test(mobile): cover the trip screens

This commit is contained in:
Maurice
2026-07-29 09:43:57 +02:00
committed by Maurice
parent 287dceb532
commit c884ddf527
58 changed files with 17878 additions and 2 deletions
@@ -1,6 +1,9 @@
import { describe, it, expect } from 'vitest'
import { filterPool, poolCounts } from './placesBrowserModel'
import type { Place } from '../../../../types'
import {
filterPool, firstPlannedDayNumbers, matchesCategoryFilter, matchesSearch,
plannedPlaceIds, poolCounts,
} from './placesBrowserModel'
import type { AssignmentsMap, Day, Place } from '../../../../types'
const mkPlace = (id: number, over: Partial<Place> = {}): Place =>
({ id, name: `Place ${id}`, lat: 0, lng: 0, address: null, category_id: null, ...over } as Place)
@@ -38,3 +41,114 @@ describe('placesBrowserModel — planned pool', () => {
expect(c.unplanned).toBe(1)
})
})
// FE-MOB-PMODEL-001 to FE-MOB-PMODEL-012
const mkAssignment = (id: number, placeId: number | null) =>
({ id, place: placeId == null ? null : { id: placeId, name: `Place ${placeId}` } })
const mkDays = (spec: [number, number | null][]): Day[] =>
spec.map(([id, dayNumber]) => ({ id, day_number: dayNumber }) as Day)
describe('placesBrowserModel — assignment lookups', () => {
it('FE-MOB-PMODEL-001: plannedPlaceIds collects every assigned place across all days', () => {
const assignments = {
'1': [mkAssignment(10, 5), mkAssignment(11, 6)],
'2': [mkAssignment(12, 5)],
} as unknown as AssignmentsMap
expect([...plannedPlaceIds(assignments)].sort()).toEqual([5, 6])
})
it('FE-MOB-PMODEL-002: plannedPlaceIds skips assignments whose place did not join', () => {
const assignments = { '1': [mkAssignment(10, null), mkAssignment(11, 7)] } as unknown as AssignmentsMap
expect([...plannedPlaceIds(assignments)]).toEqual([7])
})
it('FE-MOB-PMODEL-003: plannedPlaceIds is empty without assignments', () => {
expect(plannedPlaceIds({} as AssignmentsMap).size).toBe(0)
})
it('FE-MOB-PMODEL-004: firstPlannedDayNumbers keeps the lowest day a place appears on', () => {
const assignments = {
'30': [mkAssignment(10, 5)],
'10': [mkAssignment(11, 5)],
'20': [mkAssignment(12, 6)],
} as unknown as AssignmentsMap
const days = mkDays([[10, 1], [20, 2], [30, 3]])
const result = firstPlannedDayNumbers(assignments, days)
expect(result.get(5)).toBe(1)
expect(result.get(6)).toBe(2)
})
it('FE-MOB-PMODEL-005: firstPlannedDayNumbers falls back to the array position without a day_number', () => {
const assignments = { '20': [mkAssignment(10, 5)] } as unknown as AssignmentsMap
const days = mkDays([[10, null], [20, null]])
expect(firstPlannedDayNumbers(assignments, days).get(5)).toBe(2)
})
it('FE-MOB-PMODEL-006: firstPlannedDayNumbers ignores assignments of a day the trip no longer has', () => {
const assignments = {
'99': [mkAssignment(10, 5)],
'10': [mkAssignment(11, 6), mkAssignment(12, null)],
} as unknown as AssignmentsMap
const result = firstPlannedDayNumbers(assignments, mkDays([[10, 1]]))
expect(result.has(5)).toBe(false)
expect(result.get(6)).toBe(1)
})
})
describe('placesBrowserModel — predicates', () => {
it('FE-MOB-PMODEL-007: an empty category set matches everything', () => {
expect(matchesCategoryFilter(mkPlace(1), new Set())).toBe(true)
expect(matchesCategoryFilter(mkPlace(2, { category_id: 4 }), new Set())).toBe(true)
})
it('FE-MOB-PMODEL-008: a category set matches on the stringified id', () => {
const filters = new Set(['4'])
expect(matchesCategoryFilter(mkPlace(1, { category_id: 4 }), filters)).toBe(true)
expect(matchesCategoryFilter(mkPlace(2, { category_id: 5 }), filters)).toBe(false)
expect(matchesCategoryFilter(mkPlace(3), filters)).toBe(false)
})
it('FE-MOB-PMODEL-009: places without a category need the uncategorized entry', () => {
expect(matchesCategoryFilter(mkPlace(1), new Set(['uncategorized']))).toBe(true)
expect(matchesCategoryFilter(mkPlace(2, { category_id: 4 }), new Set(['uncategorized']))).toBe(false)
})
it('FE-MOB-PMODEL-010: search is case-insensitive over name and address', () => {
const place = mkPlace(1, { name: 'Louvre', address: 'Rue de Rivoli' })
expect(matchesSearch(place, '')).toBe(true)
expect(matchesSearch(place, 'LOUV')).toBe(true)
expect(matchesSearch(place, 'rivoli')).toBe(true)
expect(matchesSearch(place, 'eiffel')).toBe(false)
// A null address must not blow up the lookup.
expect(matchesSearch(mkPlace(2, { name: 'Eiffel' }), 'rivoli')).toBe(false)
})
})
describe('placesBrowserModel — tracks pool', () => {
const places = [
mkPlace(1, { name: 'Ridge', route_geometry: 'abc', category_id: 4 }),
mkPlace(2, { name: 'Museum', category_id: 4 }),
mkPlace(3, { name: 'River', route_geometry: 'def' }),
]
const plannedIds = new Set([1])
it('FE-MOB-PMODEL-011: the tracks filter keeps only places carrying a geometry', () => {
const out = filterPool(places, { filter: 'tracks', categoryFilters: new Set(), search: '', plannedIds })
expect(out.map(p => p.id)).toEqual([1, 3])
})
it('FE-MOB-PMODEL-012: the tracks filter still honors category and search', () => {
const out = filterPool(places, { filter: 'tracks', categoryFilters: new Set(['4']), search: 'rid', plannedIds })
expect(out.map(p => p.id)).toEqual([1])
})
it('FE-MOB-PMODEL-013: poolCounts counts tracks on the category+search base set', () => {
const c = poolCounts(places, new Set(['4']), '', plannedIds)
expect(c.all).toBe(2)
expect(c.tracks).toBe(1)
expect(c.planned).toBe(1)
expect(c.unplanned).toBe(1)
})
})
+255
View File
@@ -0,0 +1,255 @@
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<string, ReturnType<typeof vi.fn>> {
const names = [
'addCategory', 'addDayNote', 'addFile', 'addPackingContributor', 'addPackingItem',
'addPlace', 'addReservation', 'addTodoItem', 'assignPlaceToDay', 'clonePackingItem',
'deleteBudgetItem', 'deleteDayNote', 'deleteFile', 'deletePackingItem', 'deletePlace',
'deletePlacesMany', 'deleteReservation', 'deleteTodoItem', 'insertDay', 'loadBudgetItems',
'loadFiles', 'loadReservations', 'loadTrip', 'moveAssignment', 'moveDayNote', 'ratePlace',
'refreshDays', 'removeAssignment', 'removePackingContributor', 'reorderAssignments',
'reorderDays', 'setAssignments', 'setPackingItemSharing', 'setSelectedDay',
'toggleBudgetMemberPaid', 'togglePackingItem', 'toggleReservationStatus', 'toggleTodoItem',
'updateDayNote', 'updateDayTitle', 'updatePackingItem', 'updatePlace', 'updatePlacesMany',
'updateReservation', '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: [],
packingItems: [],
todoItems: [],
categories: [],
reservations: [],
budgetItems: [],
files: [],
selectedDayId: null,
isLoading: false,
tripActions: buildTripActions(),
can: vi.fn(() => true),
canUploadFiles: true,
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(),
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(),
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(),
routeProfile: 'driving',
setRouteProfile: vi.fn(),
routeVias: [],
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(),
// resolveVisibleConnectionIds returns an array, and consumers call .includes().
visibleConnections: [] 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: [],
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<number, number>(),
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> = {}): MTripShellApi {
const base: MTripShellApi = {
view: 'plan',
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,
};
return { ...base, ...overrides };
}
@@ -0,0 +1,279 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { placesApi } from '../../../../src/api/client'
import ImpFileStep from '../../../../src/mobile/screens/trip/sheets/ImpFileStep'
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { buildPlanner } from '../../../helpers/mobileTrip'
import { resetAllStores } from '../../../helpers/store'
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-IMPF-001 to FE-MOB-IMPF-021
//
// planner.t echoes its key, so the assertions check keys (with the appended
// parameter values) instead of the English copy.
function gpxFile(name = 'route.gpx') {
return new File(['<gpx/>'], name, { type: 'application/gpx+xml' })
}
function kmlFile(name = 'places.kml') {
return new File(['<kml/>'], name, { type: 'application/vnd.google-earth.kml+xml' })
}
function renderStep(plannerOverrides: Partial<TripPlanner> = {}) {
const planner = buildPlanner(plannerOverrides)
const onBack = vi.fn()
const onDone = vi.fn()
render(<ImpFileStep planner={planner} onBack={onBack} onDone={onDone} />)
return { planner, onBack, onDone }
}
function pick(files: File[]) {
const input = document.querySelector('input[type="file"]') as HTMLInputElement
fireEvent.change(input, { target: { files } })
}
const submit = () => screen.getByRole('button', { name: 'common.import' })
describe('ImpFileStep', () => {
beforeEach(() => {
resetAllStores()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('FE-MOB-IMPF-001: renders the hint and keeps the import disabled without a file', () => {
renderStep()
expect(screen.getByText('places.importFileHint')).toBeInTheDocument()
expect(screen.getByText('places.importFileDropHere')).toBeInTheDocument()
expect(submit()).toBeDisabled()
})
it('FE-MOB-IMPF-002: opens the hidden file input when the drop zone is tapped', () => {
renderStep()
const input = document.querySelector('input[type="file"]') as HTMLInputElement
const click = vi.spyOn(input, 'click')
fireEvent.click(screen.getByText('places.importFileDropHere'))
expect(click).toHaveBeenCalledTimes(1)
})
it('FE-MOB-IMPF-003: rejects an unsupported extension', () => {
renderStep()
pick([new File(['x'], 'notes.txt', { type: 'text/plain' })])
expect(screen.getByText('places.importFileUnsupported')).toBeInTheDocument()
expect(screen.getByText('places.importFileDropHere')).toBeInTheDocument()
expect(submit()).toBeDisabled()
})
it('FE-MOB-IMPF-004: rejects a file above the 10 MB limit', () => {
renderStep()
const big = gpxFile('huge.gpx')
Object.defineProperty(big, 'size', { value: 11 * 1024 * 1024 })
pick([big])
expect(screen.getByText('places.importFileTooLarge:10')).toBeInTheDocument()
})
it('FE-MOB-IMPF-005: keeps the valid files of a mixed selection and reports the first problem', () => {
renderStep()
pick([new File(['x'], 'notes.txt'), gpxFile('a.gpx'), kmlFile('b.kml')])
expect(screen.getByText('places.importFileUnsupported')).toBeInTheDocument()
expect(screen.getByText('a.gpx, b.kml')).toBeInTheDocument()
expect(submit()).toBeEnabled()
})
it('FE-MOB-IMPF-006: a GPX selection offers only the GPX entity toggles', () => {
renderStep()
pick([gpxFile()])
expect(screen.getByText('places.gpxImportTypes')).toBeInTheDocument()
expect(screen.queryByText('places.kmlImportTypes')).not.toBeInTheDocument()
for (const label of ['places.gpxImportWaypoints', 'places.gpxImportRoutes', 'places.gpxImportTracks']) {
expect(screen.getByRole('button', { name: label })).toHaveAttribute('aria-pressed', 'true')
}
})
it('FE-MOB-IMPF-007: a KMZ selection offers only the KML entity toggles', () => {
renderStep()
pick([new File(['x'], 'map.kmz')])
expect(screen.getByText('places.kmlImportTypes')).toBeInTheDocument()
expect(screen.queryByText('places.gpxImportTypes')).not.toBeInTheDocument()
})
it('FE-MOB-IMPF-008: a mixed selection shows both toggle groups', () => {
renderStep()
pick([gpxFile(), kmlFile()])
expect(screen.getByText('places.gpxImportTypes')).toBeInTheDocument()
expect(screen.getByText('places.kmlImportTypes')).toBeInTheDocument()
})
it('FE-MOB-IMPF-009: turning every GPX entity off blocks the import', () => {
renderStep()
pick([gpxFile()])
for (const label of ['places.gpxImportWaypoints', 'places.gpxImportRoutes', 'places.gpxImportTracks']) {
fireEvent.click(screen.getByRole('button', { name: label }))
}
expect(screen.getByRole('button', { name: 'places.gpxImportWaypoints' })).toHaveAttribute('aria-pressed', 'false')
expect(screen.getByText('places.gpxImportNoneSelected')).toBeInTheDocument()
expect(submit()).toBeDisabled()
})
it('FE-MOB-IMPF-010: turning every KML entity off blocks the import', () => {
renderStep()
pick([kmlFile()])
fireEvent.click(screen.getByRole('button', { name: 'places.kmlImportPoints' }))
fireEvent.click(screen.getByRole('button', { name: 'places.kmlImportPaths' }))
expect(screen.getByText('places.kmlImportNoneSelected')).toBeInTheDocument()
expect(submit()).toBeDisabled()
})
it('FE-MOB-IMPF-011: imports a GPX with the selected entities, toasts and closes', async () => {
const importGpx = vi.spyOn(placesApi, 'importGpx').mockResolvedValue({
count: 3, skipped: 0, places: [{ id: 1 }, { id: 2 }, { id: 3 }],
})
const { planner, onDone } = renderStep()
const file = gpxFile()
pick([file])
fireEvent.click(screen.getByRole('button', { name: 'places.gpxImportRoutes' }))
fireEvent.click(submit())
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1))
expect(importGpx).toHaveBeenCalledWith(1, file, { waypoints: true, routes: false, tracks: true })
expect(planner.tripActions.loadTrip).toHaveBeenCalledWith(1)
expect(planner.toast.success).toHaveBeenCalledWith('places.gpxImported:3')
expect(planner.pushUndo).toHaveBeenCalledWith('undo.importGpx', expect.any(Function))
})
it('FE-MOB-IMPF-012: the GPX undo bulk-deletes the created places and reloads', async () => {
vi.spyOn(placesApi, 'importGpx').mockResolvedValue({ count: 2, skipped: 0, places: [{ id: 7 }, { id: 8 }] })
const bulkDelete = vi.spyOn(placesApi, 'bulkDelete').mockResolvedValue({ deleted: 2 })
const { planner, onDone } = renderStep()
pick([gpxFile()])
fireEvent.click(submit())
await waitFor(() => expect(onDone).toHaveBeenCalled())
const revert = vi.mocked(planner.pushUndo).mock.calls[0][1] as () => Promise<void>
await revert()
expect(bulkDelete).toHaveBeenCalledWith(1, [7, 8])
expect(planner.tripActions.loadTrip).toHaveBeenCalledTimes(2)
})
it('FE-MOB-IMPF-013: a failing undo still reloads the trip', async () => {
vi.spyOn(placesApi, 'importGpx').mockResolvedValue({ count: 1, skipped: 0, places: [{ id: 7 }] })
vi.spyOn(placesApi, 'bulkDelete').mockRejectedValue(new Error('nope'))
const { planner, onDone } = renderStep()
pick([gpxFile()])
fireEvent.click(submit())
await waitFor(() => expect(onDone).toHaveBeenCalled())
const revert = vi.mocked(planner.pushUndo).mock.calls[0][1] as () => Promise<void>
await expect(revert()).resolves.toBeUndefined()
expect(planner.tripActions.loadTrip).toHaveBeenCalledTimes(2)
})
it('FE-MOB-IMPF-014: a KML import keeps the sheet open to show its summary', async () => {
const importMapFile = vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({
count: 4,
places: [{ id: 9 }],
summary: { totalPlacemarks: 5, createdCount: 4, skippedCount: 1, warnings: ['1 placemark had no coordinates'], errors: [] },
})
const { planner, onDone } = renderStep()
const file = kmlFile()
pick([file])
fireEvent.click(screen.getByRole('button', { name: 'places.kmlImportPaths' }))
fireEvent.click(submit())
await screen.findByText('places.kmlKmzSummaryValues:5,4,1')
expect(importMapFile).toHaveBeenCalledWith(1, file, { points: true, paths: false })
expect(screen.getByText('1 placemark had no coordinates')).toBeInTheDocument()
expect(planner.toast.success).toHaveBeenCalledWith('places.kmlKmzImported:4')
expect(planner.pushUndo).toHaveBeenCalledWith('undo.importKeyholeMarkup', expect.any(Function))
expect(onDone).not.toHaveBeenCalled()
})
it('FE-MOB-IMPF-015: merges the summaries of two KML files', async () => {
vi.spyOn(placesApi, 'importMapFile')
.mockResolvedValueOnce({
count: 2, places: [{ id: 1 }],
summary: { totalPlacemarks: 3, createdCount: 2, skippedCount: 1, warnings: ['first'], errors: [] },
})
.mockResolvedValueOnce({
count: 1, places: [{ id: 2 }],
summary: { totalPlacemarks: 2, createdCount: 1, skippedCount: 1, warnings: ['second'], errors: [] },
})
const { planner } = renderStep()
pick([kmlFile('a.kml'), kmlFile('b.kml')])
fireEvent.click(submit())
await screen.findByText('places.kmlKmzSummaryValues:5,3,2')
expect(screen.getByText('first second')).toBeInTheDocument()
expect(planner.toast.success).toHaveBeenCalledWith('places.kmlKmzImported:3')
})
it('FE-MOB-IMPF-016: a mixed import labels the undo generically and uses the GPX toast key', async () => {
vi.spyOn(placesApi, 'importGpx').mockResolvedValue({ count: 1, skipped: 0, places: [{ id: 1 }] })
vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({ count: 2, places: [{ id: 2 }] })
const { planner, onDone } = renderStep()
pick([gpxFile(), kmlFile()])
fireEvent.click(submit())
await waitFor(() => expect(onDone).toHaveBeenCalled())
expect(planner.toast.success).toHaveBeenCalledWith('places.gpxImported:3')
expect(planner.pushUndo).toHaveBeenCalledWith('undo.importKeyholeMarkup', expect.any(Function))
})
it('FE-MOB-IMPF-017: warns when everything was skipped and registers no undo', async () => {
vi.spyOn(placesApi, 'importGpx').mockResolvedValue({ count: 0, skipped: 4, places: [] })
const { planner, onDone } = renderStep()
pick([gpxFile()])
fireEvent.click(submit())
await waitFor(() => expect(onDone).toHaveBeenCalled())
expect(planner.toast.warning).toHaveBeenCalledWith('places.importAllSkipped')
expect(planner.toast.success).not.toHaveBeenCalled()
expect(planner.pushUndo).not.toHaveBeenCalled()
})
it('FE-MOB-IMPF-018: shows the server error of a single failing file and stays open', async () => {
vi.spyOn(placesApi, 'importGpx').mockRejectedValue({ response: { data: { error: 'Malformed GPX' } } })
const { planner, onDone } = renderStep()
pick([gpxFile()])
fireEvent.click(submit())
await screen.findByText('Malformed GPX')
expect(planner.toast.error).toHaveBeenCalledWith('Malformed GPX')
expect(onDone).not.toHaveBeenCalled()
expect(submit()).toBeEnabled()
})
it('FE-MOB-IMPF-019: prefixes each error with its file name on a multi-file import', async () => {
vi.spyOn(placesApi, 'importGpx').mockRejectedValue(new Error('boom'))
vi.spyOn(placesApi, 'importMapFile').mockRejectedValue({ response: { data: { error: 'bad kml' } } })
const { planner } = renderStep()
pick([gpxFile('a.gpx'), kmlFile('b.kml')])
fireEvent.click(submit())
await screen.findByText(/a\.gpx: places\.importFileError/)
expect(screen.getByText(/b\.kml: bad kml/)).toBeInTheDocument()
expect(planner.toast.error).toHaveBeenCalledWith('a.gpx: places.importFileError')
})
it('FE-MOB-IMPF-020: shows the loading label while the request is in flight', async () => {
let release: (value: unknown) => void = () => {}
vi.spyOn(placesApi, 'importGpx').mockReturnValue(new Promise(resolve => { release = resolve }))
const { onDone } = renderStep()
pick([gpxFile()])
fireEvent.click(submit())
await screen.findByRole('button', { name: 'common.loading' })
expect(screen.getByRole('button', { name: 'common.loading' })).toBeDisabled()
release({ count: 1, skipped: 0, places: [] })
await waitFor(() => expect(onDone).toHaveBeenCalled())
})
it('FE-MOB-IMPF-021: the cancel button returns to the import menu', () => {
const { onBack } = renderStep()
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
expect(onBack).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,223 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { placesApi } from '../../../../src/api/client'
import ImpListStep from '../../../../src/mobile/screens/trip/sheets/ImpListStep'
import { useAuthStore } from '../../../../src/store/authStore'
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { buildPlanner } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-IMPL-001 to FE-MOB-IMPL-017
//
// planner.t echoes its key, so the assertions below check keys (plus the
// appended parameter values) rather than the English copy.
function renderStep(plannerOverrides: Partial<TripPlanner> = {}) {
const planner = buildPlanner(plannerOverrides)
const onBack = vi.fn()
const onDone = vi.fn()
render(<ImpListStep planner={planner} onBack={onBack} onDone={onDone} />)
return { planner, onBack, onDone }
}
function typeUrl(value: string) {
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value } })
return input
}
const submit = () => screen.getByRole('button', { name: 'common.import' })
describe('ImpListStep', () => {
beforeEach(() => {
resetAllStores()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('FE-MOB-IMPL-001: starts on Google with its hint, placeholder and a disabled submit', () => {
renderStep()
expect(screen.getByText('places.importGoogleList')).toBeInTheDocument()
expect(screen.getByText('places.googleListHint')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toHaveAttribute('placeholder', 'https://maps.app.goo.gl/…')
expect(submit()).toBeDisabled()
})
it('FE-MOB-IMPL-002: switching to Naver swaps hint and placeholder', () => {
renderStep()
fireEvent.click(screen.getByText('places.importNaverList'))
expect(screen.getByText('places.naverListHint')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toHaveAttribute('placeholder', 'https://naver.me/…')
fireEvent.click(screen.getByText('places.importGoogleList'))
expect(screen.getByText('places.googleListHint')).toBeInTheDocument()
})
it('FE-MOB-IMPL-003: hides the enrichment switch without a Maps key', () => {
renderStep()
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
})
it('FE-MOB-IMPL-004: offers the enrichment switch once a Maps key is configured', () => {
seedStore(useAuthStore, { hasMapsKey: true })
renderStep()
const toggle = screen.getByRole('switch', { name: 'places.enrichOnImport' })
expect(toggle).toHaveAttribute('aria-checked', 'false')
expect(screen.getByText('places.enrichOnImportHint')).toBeInTheDocument()
fireEvent.click(toggle)
expect(toggle).toHaveAttribute('aria-checked', 'true')
})
it('FE-MOB-IMPL-005: keeps the submit disabled for whitespace-only input', () => {
renderStep()
typeUrl(' ')
expect(submit()).toBeDisabled()
})
it('FE-MOB-IMPL-006: imports a Google list, reloads the trip and toasts the count', async () => {
vi.spyOn(placesApi, 'importGoogleList').mockResolvedValue({
count: 4, skipped: 0, listName: 'Tokyo eats', places: [{ id: 71 }, { id: 72 }],
})
const { planner, onDone } = renderStep()
typeUrl(' https://maps.app.goo.gl/abc ')
fireEvent.click(submit())
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1))
expect(placesApi.importGoogleList).toHaveBeenCalledWith(1, 'https://maps.app.goo.gl/abc', false)
expect(planner.tripActions.loadTrip).toHaveBeenCalledWith(1)
expect(planner.toast.success).toHaveBeenCalledWith('places.googleListImported:4,Tokyo eats')
})
it('FE-MOB-IMPL-007: passes the enrichment flag when the user asked for it', async () => {
seedStore(useAuthStore, { hasMapsKey: true })
vi.spyOn(placesApi, 'importGoogleList').mockResolvedValue({ count: 1, skipped: 0, listName: 'L', places: [] })
const { onDone } = renderStep()
fireEvent.click(screen.getByRole('switch', { name: 'places.enrichOnImport' }))
typeUrl('https://maps.app.goo.gl/abc')
fireEvent.click(submit())
await waitFor(() => expect(onDone).toHaveBeenCalled())
expect(placesApi.importGoogleList).toHaveBeenCalledWith(1, 'https://maps.app.goo.gl/abc', true)
})
it('FE-MOB-IMPL-008: registers an undo that bulk-deletes the imported places', async () => {
vi.spyOn(placesApi, 'importGoogleList').mockResolvedValue({
count: 2, skipped: 0, listName: 'L', places: [{ id: 71 }, { id: 72 }],
})
const bulkDelete = vi.spyOn(placesApi, 'bulkDelete').mockResolvedValue({ deleted: 2 })
const { planner, onDone } = renderStep()
typeUrl('https://maps.app.goo.gl/abc')
fireEvent.click(submit())
await waitFor(() => expect(onDone).toHaveBeenCalled())
expect(planner.pushUndo).toHaveBeenCalledWith('undo.importGoogleList', expect.any(Function))
const revert = vi.mocked(planner.pushUndo).mock.calls[0][1] as () => Promise<void>
await revert()
expect(bulkDelete).toHaveBeenCalledWith(1, [71, 72])
expect(planner.tripActions.loadTrip).toHaveBeenCalledTimes(2)
})
it('FE-MOB-IMPL-009: a failing undo still reloads the trip', async () => {
vi.spyOn(placesApi, 'importGoogleList').mockResolvedValue({
count: 1, skipped: 0, listName: 'L', places: [{ id: 71 }],
})
vi.spyOn(placesApi, 'bulkDelete').mockRejectedValue(new Error('gone'))
const { planner, onDone } = renderStep()
typeUrl('https://maps.app.goo.gl/abc')
fireEvent.click(submit())
await waitFor(() => expect(onDone).toHaveBeenCalled())
const revert = vi.mocked(planner.pushUndo).mock.calls[0][1] as () => Promise<void>
await expect(revert()).resolves.toBeUndefined()
expect(planner.tripActions.loadTrip).toHaveBeenCalledTimes(2)
})
it('FE-MOB-IMPL-010: skips the undo entry when nothing was created', async () => {
vi.spyOn(placesApi, 'importGoogleList').mockResolvedValue({ count: 3, skipped: 0, listName: 'L', places: [] })
const { planner, onDone } = renderStep()
typeUrl('https://maps.app.goo.gl/abc')
fireEvent.click(submit())
await waitFor(() => expect(onDone).toHaveBeenCalled())
expect(planner.pushUndo).not.toHaveBeenCalled()
})
it('FE-MOB-IMPL-011: warns instead of celebrating when every entry was skipped', async () => {
vi.spyOn(placesApi, 'importGoogleList').mockResolvedValue({ count: 0, skipped: 5, listName: 'L', places: [] })
const { planner, onDone } = renderStep()
typeUrl('https://maps.app.goo.gl/abc')
fireEvent.click(submit())
await waitFor(() => expect(onDone).toHaveBeenCalled())
expect(planner.toast.warning).toHaveBeenCalledWith('places.importAllSkipped')
expect(planner.toast.success).not.toHaveBeenCalled()
})
it('FE-MOB-IMPL-012: imports a Naver list on the Naver endpoint', async () => {
vi.spyOn(placesApi, 'importNaverList').mockResolvedValue({
count: 2, skipped: 0, listName: 'Seoul', places: [{ id: 81 }],
})
const { planner, onDone } = renderStep()
fireEvent.click(screen.getByText('places.importNaverList'))
typeUrl('https://naver.me/xyz')
fireEvent.click(submit())
await waitFor(() => expect(onDone).toHaveBeenCalled())
expect(placesApi.importNaverList).toHaveBeenCalledWith(1, 'https://naver.me/xyz', false)
expect(planner.toast.success).toHaveBeenCalledWith('places.naverListImported:2,Seoul')
expect(planner.pushUndo).toHaveBeenCalledWith('undo.importNaverList', expect.any(Function))
})
it('FE-MOB-IMPL-013: surfaces the server error message and keeps the sheet open', async () => {
vi.spyOn(placesApi, 'importGoogleList').mockRejectedValue({ response: { data: { error: 'List is private' } } })
const { planner, onDone } = renderStep()
typeUrl('https://maps.app.goo.gl/abc')
fireEvent.click(submit())
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('List is private'))
expect(onDone).not.toHaveBeenCalled()
expect(submit()).toBeEnabled()
})
it('FE-MOB-IMPL-014: falls back to the provider error key without a server message', async () => {
vi.spyOn(placesApi, 'importNaverList').mockRejectedValue(new Error('network'))
const { planner } = renderStep()
fireEvent.click(screen.getByText('places.importNaverList'))
typeUrl('https://naver.me/xyz')
fireEvent.click(submit())
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('places.naverListError'))
})
it('FE-MOB-IMPL-015: Enter in the url field submits and ignores an empty field', async () => {
vi.spyOn(placesApi, 'importGoogleList').mockResolvedValue({ count: 1, skipped: 0, listName: 'L', places: [] })
const { onDone } = renderStep()
const input = screen.getByRole('textbox')
fireEvent.keyDown(input, { key: 'Enter' })
expect(placesApi.importGoogleList).not.toHaveBeenCalled()
fireEvent.change(input, { target: { value: 'https://maps.app.goo.gl/abc' } })
fireEvent.keyDown(input, { key: 'a' })
expect(placesApi.importGoogleList).not.toHaveBeenCalled()
fireEvent.keyDown(input, { key: 'Enter' })
await waitFor(() => expect(onDone).toHaveBeenCalled())
expect(placesApi.importGoogleList).toHaveBeenCalledTimes(1)
})
it('FE-MOB-IMPL-016: shows the loading label and refuses a second submit while in flight', async () => {
let release: (value: unknown) => void = () => {}
vi.spyOn(placesApi, 'importGoogleList').mockReturnValue(new Promise(resolve => { release = resolve }))
const { onDone } = renderStep()
const input = typeUrl('https://maps.app.goo.gl/abc')
fireEvent.click(submit())
await screen.findByRole('button', { name: 'common.loading' })
fireEvent.keyDown(input, { key: 'Enter' })
expect(placesApi.importGoogleList).toHaveBeenCalledTimes(1)
release({ count: 1, skipped: 0, listName: 'L', places: [] })
await waitFor(() => expect(onDone).toHaveBeenCalled())
})
it('FE-MOB-IMPL-017: the cancel button goes back to the import menu', () => {
const { onBack } = renderStep()
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
expect(onBack).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,331 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { accommodationsApi } from '../../../../src/api/client'
import MAccommodationSheet from '../../../../src/mobile/screens/trip/sheets/MAccommodationSheet'
import type { Accommodation, Category, Day, Place } from '../../../../src/types'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { resetAllStores } from '../../../helpers/store'
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-ACCSH-001 to FE-MOB-ACCSH-025
// The pickers have their own suites; here they only need to be addressable, so
// they render as plain controls. data-value keeps the requested value readable
// even when it is not one of the offered options.
vi.mock('../../../../src/components/shared/CustomSelect', () => ({
default: ({ value, onChange, options = [] }: {
value: string | number
onChange: (v: string | number) => void
options?: { value: string | number; label: string }[]
}) => (
<select
data-testid="cselect"
data-value={String(value ?? '')}
value={options.some(o => String(o.value) === String(value)) ? String(value) : ''}
onChange={e => {
const hit = options.find(o => String(o.value) === e.target.value)
onChange(hit ? hit.value : e.target.value)
}}
>
<option value="" />
{options.map(o => <option key={String(o.value)} value={String(o.value)}>{o.label}</option>)}
</select>
),
}))
vi.mock('../../../../src/components/shared/CustomTimePicker', () => ({
default: ({ value, onChange, placeholder }: {
value: string
onChange: (v: string) => void
placeholder?: string
}) => (
<input data-testid="ctime" placeholder={placeholder} value={value} onChange={e => onChange(e.target.value)} />
),
}))
const DAYS = [
{ id: 11, trip_id: 5, day_number: 1, date: '2026-05-01', title: null },
{ id: 12, trip_id: 5, day_number: 2, date: '2026-05-02', title: null },
{ id: 13, trip_id: 5, day_number: 3, date: null, title: 'Fly home' },
] as unknown as Day[]
const CATEGORIES = [
{ id: 1, name: 'Hotels', color: '#ff0000' },
{ id: 2, name: 'Food', color: '#00ff00' },
] as unknown as Category[]
const PLACES = [
{ id: 101, name: 'Hotel Sacher', address: 'Philharmonikerstrasse 4', category_id: 1, image_url: '/uploads/sacher.jpg' },
{ id: 102, name: 'Cafe Central', address: null, category_id: 2, image_url: null },
] as unknown as Place[]
const EXISTING = {
id: 77, trip_id: 5, place_id: 102, start_day_id: 12, end_day_id: 13,
check_in: '15:00', check_in_end: '18:00', check_out: '10:00', confirmation: 'ABC-1',
} as unknown as Accommodation
function makePlanner(overrides: Record<string, unknown> = {}) {
return buildPlanner({
tripId: 5,
days: DAYS,
places: PLACES,
categories: CATEGORIES,
tripAccommodations: [EXISTING],
...overrides,
} as never)
}
function makeShell(payload: Record<string, unknown> | null = { dayId: 11 }) {
return buildShell({ sheet: payload === null ? null : { id: 'accommodation', payload } } as never)
}
function setup(planner = makePlanner(), shell = makeShell()) {
const view = render(<MAccommodationSheet planner={planner} shell={shell} />)
return { ...view, planner, shell }
}
/** [start, end] of the day-range selects as the component holds them. */
function range(): [string, string] {
const [start, end] = screen.getAllByTestId('cselect')
return [start.getAttribute('data-value') ?? '', end.getAttribute('data-value') ?? '']
}
function pickDay(index: 0 | 1, id: number) {
fireEvent.change(screen.getAllByTestId('cselect')[index], { target: { value: String(id) } })
}
/** Both the day-range chip and the category filter are labelled "All". */
function allChips() {
return screen.getAllByRole('button', { name: 'All' })
}
function placeRow(name: string) {
return screen.getByText(name).closest('button') as HTMLButtonElement
}
describe('MAccommodationSheet', () => {
beforeEach(() => {
resetAllStores()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('FE-MOB-ACCSH-001: stays closed while another sheet is on top', () => {
setup(makePlanner(), buildShell({ sheet: { id: 'day', payload: { dayId: 11 } } } as never))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-ACCSH-002: stays closed without any sheet', () => {
setup(makePlanner(), makeShell(null))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-ACCSH-003: opens in add mode with an empty form and a disabled save', () => {
setup()
expect(screen.getByRole('dialog', { name: 'Add accommodation' })).toBeInTheDocument()
expect(screen.getByText('Apply to days')).toBeInTheDocument()
expect(screen.getByText('Select accommodation')).toBeInTheDocument()
expect(screen.getByPlaceholderText('ABC-12345')).toHaveValue('')
expect(screen.getByPlaceholderText('14:00')).toHaveValue('')
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
})
it('FE-MOB-ACCSH-004: defaults the range from the opening day to the next one', () => {
setup()
expect(range()).toEqual(['11', '12'])
})
it('FE-MOB-ACCSH-005: a day that is not in the trip keeps the range on that single day', () => {
setup(makePlanner(), makeShell({ dayId: 999 }))
expect(range()).toEqual(['999', '999'])
})
it('FE-MOB-ACCSH-006: opening the last day keeps it as its own end', () => {
setup(makePlanner(), makeShell({ dayId: 13 }))
expect(range()).toEqual(['13', '13'])
})
it('FE-MOB-ACCSH-007: an empty payload falls back to no day at all', () => {
setup(makePlanner(), makeShell({}))
expect(range()).toEqual(['0', '0'])
})
it('FE-MOB-ACCSH-008: edit mode seeds range, times, code and the linked place', () => {
setup(makePlanner(), makeShell({ dayId: 12, accId: 77 }))
expect(screen.getByRole('dialog', { name: 'Edit accommodation' })).toBeInTheDocument()
expect(range()).toEqual(['12', '13'])
expect(screen.getByPlaceholderText('14:00')).toHaveValue('15:00')
expect(screen.getByPlaceholderText('22:00')).toHaveValue('18:00')
expect(screen.getByPlaceholderText('11:00')).toHaveValue('10:00')
expect(screen.getByPlaceholderText('ABC-12345')).toHaveValue('ABC-1')
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()
})
it('FE-MOB-ACCSH-009: a stay without any times seeds empty fields', () => {
const bare = { id: 78, trip_id: 5, place_id: 101, start_day_id: 11, end_day_id: 11 } as unknown as Accommodation
setup(makePlanner({ tripAccommodations: [bare] }), makeShell({ dayId: 11, accId: 78 }))
expect(screen.getByPlaceholderText('14:00')).toHaveValue('')
expect(screen.getByPlaceholderText('ABC-12345')).toHaveValue('')
expect(range()).toEqual(['11', '11'])
})
it('FE-MOB-ACCSH-010: an unknown accommodation id falls back to add mode', () => {
setup(makePlanner(), makeShell({ dayId: 11, accId: 4242 }))
expect(screen.getByRole('dialog', { name: 'Add accommodation' })).toBeInTheDocument()
})
it('FE-MOB-ACCSH-011: cancel and the header X both return to the day sheet', () => {
const { shell } = setup()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(shell.openSheet).toHaveBeenCalledTimes(2)
expect(shell.openSheet).toHaveBeenCalledWith('day', { dayId: 11 })
})
it('FE-MOB-ACCSH-012: the All chip spans the whole trip and reaches the payload', async () => {
const create = vi.spyOn(accommodationsApi, 'create').mockResolvedValue({})
setup()
fireEvent.click(allChips()[0])
expect(range()).toEqual(['11', '13'])
fireEvent.click(placeRow('Hotel Sacher'))
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(create).toHaveBeenCalled())
expect(create.mock.calls[0][1]).toMatchObject({ start_day_id: 11, end_day_id: 13 })
})
it('FE-MOB-ACCSH-013: moving the start past the end drags the end with it', () => {
setup()
pickDay(0, 13)
expect(range()).toEqual(['13', '13'])
})
it('FE-MOB-ACCSH-014: moving the end before the start drags the start with it', () => {
setup(makePlanner(), makeShell({ dayId: 12 }))
pickDay(1, 11)
expect(range()).toEqual(['11', '11'])
})
it('FE-MOB-ACCSH-015: widening the range leaves the other end alone', () => {
setup()
pickDay(1, 13)
pickDay(0, 12)
expect(range()).toEqual(['12', '13'])
})
it('FE-MOB-ACCSH-016: the category chips filter the place list and All restores it', () => {
setup()
expect(screen.getByText('Cafe Central')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Hotels' }))
expect(screen.getByText('Hotel Sacher')).toBeInTheDocument()
expect(screen.queryByText('Cafe Central')).not.toBeInTheDocument()
fireEvent.click(allChips()[1])
expect(screen.getByText('Cafe Central')).toBeInTheDocument()
})
it('FE-MOB-ACCSH-017: a category without places shows the empty hint', () => {
setup(makePlanner({ places: [PLACES[0]] }))
fireEvent.click(screen.getByRole('button', { name: 'Food' }))
expect(screen.getByText('Add places to your trip first')).toBeInTheDocument()
})
it('FE-MOB-ACCSH-018: without categories there are no filter chips', () => {
setup(makePlanner({ categories: [] }))
expect(screen.queryByRole('button', { name: 'Hotels' })).not.toBeInTheDocument()
expect(allChips()).toHaveLength(1)
})
it('FE-MOB-ACCSH-019: renders the place thumbnail when there is one, a pin otherwise', () => {
setup()
expect(placeRow('Hotel Sacher').querySelector('img')).toHaveAttribute('src', '/uploads/sacher.jpg')
expect(screen.getByText('Philharmonikerstrasse 4')).toBeInTheDocument()
const cafe = placeRow('Cafe Central')
expect(cafe.querySelector('img')).toBeNull()
expect(cafe.querySelector('svg')).not.toBeNull()
})
it('FE-MOB-ACCSH-020: creating posts the full body, reloads the stays and returns to the day', async () => {
const create = vi.spyOn(accommodationsApi, 'create').mockResolvedValue({})
const { planner, shell } = setup()
fireEvent.change(screen.getByPlaceholderText('14:00'), { target: { value: '15:30' } })
fireEvent.change(screen.getByPlaceholderText('22:00'), { target: { value: '20:00' } })
fireEvent.change(screen.getByPlaceholderText('11:00'), { target: { value: '09:45' } })
fireEvent.change(screen.getByPlaceholderText('ABC-12345'), { target: { value: 'XYZ-9' } })
fireEvent.click(placeRow('Cafe Central'))
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(planner.loadAccommodations).toHaveBeenCalled())
expect(create).toHaveBeenCalledWith(5, {
place_id: 102,
start_day_id: 11,
end_day_id: 12,
check_in: '15:30',
check_in_end: '20:00',
check_out: '09:45',
confirmation: 'XYZ-9',
})
expect(shell.openSheet).toHaveBeenCalledWith('day', { dayId: 11 })
})
it('FE-MOB-ACCSH-021: editing updates the existing stay and nulls the cleared fields', async () => {
const update = vi.spyOn(accommodationsApi, 'update').mockResolvedValue({})
const { planner } = setup(makePlanner(), makeShell({ dayId: 12, accId: 77 }))
fireEvent.change(screen.getByPlaceholderText('22:00'), { target: { value: '' } })
fireEvent.change(screen.getByPlaceholderText('ABC-12345'), { target: { value: '' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(planner.loadAccommodations).toHaveBeenCalled())
expect(update).toHaveBeenCalledWith(5, 77, {
place_id: 102,
start_day_id: 12,
end_day_id: 13,
check_in: '15:00',
check_in_end: null,
check_out: '10:00',
confirmation: null,
})
})
it('FE-MOB-ACCSH-022: the save button locks while the request is in flight', async () => {
const deferred = { settle: () => undefined as void }
vi.spyOn(accommodationsApi, 'create').mockReturnValue(
new Promise<unknown>(res => { deferred.settle = () => res({}) }),
)
setup()
fireEvent.click(placeRow('Hotel Sacher'))
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
const saving = await screen.findByRole('button', { name: 'Saving...' })
expect(saving).toBeDisabled()
await act(async () => { deferred.settle() })
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
})
it('FE-MOB-ACCSH-024: a trip without days opens on an empty range', () => {
setup(makePlanner({ days: [] }), makeShell({}))
expect(range()).toEqual(['0', '0'])
fireEvent.click(allChips()[0])
expect(range()).toEqual(['0', '0'])
})
it('FE-MOB-ACCSH-025: a day with neither date nor title is still numbered in the range', () => {
const bareDays = [{ id: 21, trip_id: 5, day_number: 1, date: null, title: null }] as unknown as Day[]
setup(makePlanner({ days: bareDays }), makeShell({ dayId: 21 }))
expect(screen.getAllByRole('option', { name: 'Day 1' }).length).toBeGreaterThan(0)
})
it('FE-MOB-ACCSH-023: a failing save toasts, keeps the sheet open and re-enables Save', async () => {
vi.spyOn(accommodationsApi, 'create').mockRejectedValue(new Error('boom'))
const { planner, shell } = setup()
fireEvent.click(placeRow('Hotel Sacher'))
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Error'))
expect(planner.loadAccommodations).not.toHaveBeenCalled()
expect(shell.openSheet).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()
})
})
@@ -0,0 +1,250 @@
import { describe, expect, it, vi } from 'vitest'
import type { ComponentProps } from 'react'
import MBagsSheet from '../../../../src/mobile/screens/trip/tabs/MBagsSheet'
import type { PackingBag, PackingItem, TripMember } from '../../../../src/types'
import { buildPlanner } from '../../../helpers/mobileTrip'
import { fireEvent, render, screen, within } from '../../../helpers/render'
// FE-MOB-BAGS-001 to FE-MOB-BAGS-019
function bag(overrides: Partial<PackingBag> = {}): PackingBag {
return {
id: 1, trip_id: 1, name: 'Backpack', color: '#6366f1', sort_order: 0,
...overrides,
} as PackingBag
}
function item(overrides: Partial<PackingItem> = {}): PackingItem {
return {
id: 1, trip_id: 1, name: 'Shirt', checked: 0, category: 'Clothing', sort_order: 0,
...overrides,
} as PackingItem
}
const ANNA = { id: 11, username: 'anna', avatar: 'anna.png', avatar_url: null } as unknown as TripMember
const BEN = { id: 12, username: 'ben', avatar: null, avatar_url: 'https://cdn.example/ben.png' } as unknown as TripMember
const BAGS = [
bag({ id: 1, name: 'Backpack', color: '#6366f1', members: [{ user_id: 11, username: 'anna', avatar: '/uploads/avatars/anna.png' }] }),
bag({ id: 2, name: 'Suitcase', color: '#ec4899', sort_order: 1, members: [{ user_id: 12, username: 'ben', avatar: null }] }),
]
// Backpack 300 g, Suitcase 2 x 600 g, unassigned 40 g → 1540 g total.
const ITEMS = [
item({ id: 1, name: 'Shirt', bag_id: 1, weight_grams: 300 }),
item({ id: 2, name: 'Boots', bag_id: 2, weight_grams: 600, quantity: 2 }),
item({ id: 3, name: 'Charger', bag_id: null, weight_grams: 40 }),
]
function setup(overrides: Partial<ComponentProps<typeof MBagsSheet>> = {}) {
const props: ComponentProps<typeof MBagsSheet> = {
planner: buildPlanner(),
open: true,
onClose: vi.fn(),
bags: BAGS,
items: ITEMS,
tripMembers: [ANNA, BEN],
canEdit: true,
onCreateBag: vi.fn(),
onUpdateBag: vi.fn(),
onDeleteBag: vi.fn(),
onSetBagMembers: vi.fn(),
...overrides,
}
const view = render(<MBagsSheet {...props} />)
return { ...props, view }
}
/** The bag block that owns the given name button. */
function rowFor(name: string): HTMLElement {
return screen.getByRole('button', { name }).closest('div.mb-4') as HTMLElement
}
function fillBars(): HTMLElement[] {
return Array.from(document.querySelectorAll<HTMLElement>('div.h-\\[7px\\] > div'))
}
describe('MBagsSheet', () => {
it('FE-MOB-BAGS-001: stays unmounted while closed', () => {
setup({ open: false })
expect(screen.queryByRole('dialog')).toBeNull()
})
it('FE-MOB-BAGS-002: lists every bag with its weight and item count', () => {
setup()
expect(screen.getByRole('dialog')).toHaveAttribute('aria-label', 'packing.bags')
expect(within(rowFor('Backpack')).getByText('300 g')).toBeInTheDocument()
expect(within(rowFor('Backpack')).getByText('1 admin.packingTemplates.items')).toBeInTheDocument()
expect(within(rowFor('Suitcase')).getByText('1.2 kg')).toBeInTheDocument()
})
it('FE-MOB-BAGS-003: shows the unassigned pile and the grand total', () => {
setup()
expect(screen.getByText('packing.noBag')).toBeInTheDocument()
expect(screen.getByText('40 g')).toBeInTheDocument()
expect(screen.getByText('packing.totalWeight')).toBeInTheDocument()
expect(screen.getByText('1.5 kg')).toBeInTheDocument()
})
it('FE-MOB-BAGS-004: hides the unassigned pile when every item has a bag', () => {
setup({ items: ITEMS.filter(i => i.bag_id != null) })
expect(screen.queryByText('packing.noBag')).toBeNull()
})
it('FE-MOB-BAGS-005: scales the fill bar against the heaviest bag when no limit is set', () => {
setup()
// 300/1200 = 25 %, 1200/1200 = 100 %
expect(fillBars().map(b => b.style.width)).toEqual(['25%', '100%'])
})
it('FE-MOB-BAGS-006: scales against the bag limit and caps at 100 %', () => {
setup({ bags: [bag({ id: 1, name: 'Backpack', weight_limit_grams: 200 })] })
expect(fillBars()[0].style.width).toBe('100%')
})
it('FE-MOB-BAGS-007: renames a bag on Enter', () => {
const { onUpdateBag } = setup()
fireEvent.click(screen.getByRole('button', { name: 'Backpack' }))
const input = screen.getByDisplayValue('Backpack')
fireEvent.change(input, { target: { value: 'Daypack' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onUpdateBag).toHaveBeenCalledWith(1, { name: 'Daypack' })
// The row keeps showing the prop value until the parent pushes the update back.
expect(screen.getByRole('button', { name: 'Backpack' })).toBeInTheDocument()
})
it('FE-MOB-BAGS-008: renames on blur too, trimming the input', () => {
const { onUpdateBag } = setup()
fireEvent.click(screen.getByRole('button', { name: 'Suitcase' }))
const input = screen.getByDisplayValue('Suitcase')
fireEvent.change(input, { target: { value: ' Trunk ' } })
fireEvent.blur(input)
expect(onUpdateBag).toHaveBeenCalledWith(2, { name: 'Trunk' })
})
it('FE-MOB-BAGS-009: keeps the old name when the edit is blank', () => {
const { onUpdateBag } = setup()
fireEvent.click(screen.getByRole('button', { name: 'Backpack' }))
const input = screen.getByDisplayValue('Backpack')
fireEvent.change(input, { target: { value: ' ' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onUpdateBag).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: 'Backpack' })).toBeInTheDocument()
})
it('FE-MOB-BAGS-010: Escape reverts the rename', () => {
const { onUpdateBag } = setup()
fireEvent.click(screen.getByRole('button', { name: 'Backpack' }))
const input = screen.getByDisplayValue('Backpack')
fireEvent.change(input, { target: { value: 'Nope' } })
fireEvent.keyDown(input, { key: 'Escape' })
expect(onUpdateBag).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: 'Backpack' })).toBeInTheDocument()
})
it('FE-MOB-BAGS-011: deletes a bag', () => {
const { onDeleteBag } = setup()
fireEvent.click(within(rowFor('Suitcase')).getByRole('button', { name: 'common.delete' }))
expect(onDeleteBag).toHaveBeenCalledWith(2)
})
it('FE-MOB-BAGS-012: removes a member by tapping their avatar chip', () => {
const { onSetBagMembers } = setup()
fireEvent.click(within(rowFor('Backpack')).getByTitle('anna'))
expect(onSetBagMembers).toHaveBeenCalledWith(1, [])
})
it('FE-MOB-BAGS-013: falls back to the initial when a member has no avatar', () => {
setup()
expect(within(rowFor('Suitcase')).getByTitle('ben')).toHaveTextContent('B')
})
it('FE-MOB-BAGS-014: adds a member from the picker', () => {
const { onSetBagMembers } = setup()
const row = rowFor('Backpack')
fireEvent.click(within(row).getByRole('button', { name: 'common.add' }))
const imgs = Array.from(row.querySelectorAll('img')).map(i => i.getAttribute('src'))
expect(imgs).toContain('/uploads/avatars/anna.png')
expect(imgs).toContain('https://cdn.example/ben.png')
fireEvent.click(within(row).getByRole('button', { name: 'ben' }))
expect(onSetBagMembers).toHaveBeenCalledWith(1, [11, 12])
})
it('FE-MOB-BAGS-015: shows the empty hint when the trip has no members', () => {
setup({ tripMembers: [], bags: [bag({ id: 1, name: 'Backpack' })] })
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
expect(screen.getByText('packing.noMembers')).toBeInTheDocument()
})
it('FE-MOB-BAGS-016: creates a bag from the inline field', () => {
const { onCreateBag } = setup({ bags: [], items: [] })
fireEvent.click(screen.getByRole('button', { name: 'packing.addBag' }))
expect(screen.getByRole('button', { name: 'common.add' })).toBeDisabled()
fireEvent.change(screen.getByPlaceholderText('packing.bagName'), { target: { value: ' Duffel ' } })
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
expect(onCreateBag).toHaveBeenCalledWith('Duffel')
expect(screen.getByRole('button', { name: 'packing.addBag' })).toBeInTheDocument()
})
it('FE-MOB-BAGS-017: Enter submits and Escape aborts the new-bag field', () => {
const { onCreateBag } = setup({ bags: [], items: [] })
fireEvent.click(screen.getByRole('button', { name: 'packing.addBag' }))
fireEvent.keyDown(screen.getByPlaceholderText('packing.bagName'), { key: 'Enter' })
expect(onCreateBag).not.toHaveBeenCalled()
fireEvent.change(screen.getByPlaceholderText('packing.bagName'), { target: { value: 'Tote' } })
fireEvent.keyDown(screen.getByPlaceholderText('packing.bagName'), { key: 'Enter' })
expect(onCreateBag).toHaveBeenCalledWith('Tote')
fireEvent.click(screen.getByRole('button', { name: 'packing.addBag' }))
fireEvent.change(screen.getByPlaceholderText('packing.bagName'), { target: { value: 'x' } })
fireEvent.keyDown(screen.getByPlaceholderText('packing.bagName'), { key: 'Escape' })
expect(screen.getByRole('button', { name: 'packing.addBag' })).toBeInTheDocument()
expect(onCreateBag).toHaveBeenCalledTimes(1)
})
it('FE-MOB-BAGS-018: read-only mode hides delete, add-bag, picker and rename', () => {
const { onUpdateBag, onSetBagMembers } = setup({ canEdit: false })
expect(screen.queryByRole('button', { name: 'common.delete' })).toBeNull()
expect(screen.queryByRole('button', { name: 'packing.addBag' })).toBeNull()
expect(screen.queryByRole('button', { name: 'common.add' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Backpack' }))
expect(screen.queryByDisplayValue('Backpack')).toBeNull()
fireEvent.click(within(rowFor('Backpack')).getByTitle('anna'))
expect(onUpdateBag).not.toHaveBeenCalled()
expect(onSetBagMembers).not.toHaveBeenCalled()
})
it('FE-MOB-BAGS-019: closes from the header', () => {
const { onClose } = setup()
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
expect(onClose).toHaveBeenCalled()
})
})
@@ -0,0 +1,286 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MBookingsTab from '../../../../src/mobile/screens/trip/tabs/MBookingsTab'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { openFile } from '../../../../src/utils/fileDownload'
import type { Day, Reservation, TripFile } from '../../../../src/types'
import { buildSettings } from '../../../helpers/factories'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
// FE-MOB-BKTAB-001 to FE-MOB-BKTAB-018
vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({
...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()),
openFile: vi.fn(),
}))
const DAYS = [
{ id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null },
{ id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: 'Kyoto' },
] as unknown as Day[]
const MEMBERS = [
{ id: 1, username: 'Me', avatar_url: null },
{ id: 2, username: 'Ada', avatar_url: null },
]
/** Stay: the day range comes from the accommodation link, not day_id. */
const HOTEL = {
id: 201, trip_id: 7, type: 'hotel', status: 'confirmed', title: 'Hotel Granvia',
accommodation_start_day_id: 1, accommodation_end_day_id: 2,
confirmation_number: 'HG-77', location: 'Kyoto Station', notes: 'Ask for a high floor',
metadata: JSON.stringify({ check_in_time: '15:00', check_in_end_time: '18:00', check_out_time: '11:00' }),
} as unknown as Reservation
const DINNER = {
id: 202, trip_id: 7, type: 'restaurant', status: 'confirmed', title: 'Sushi Saito',
day_id: 2, reservation_time: '2026-05-02T19:30', reservation_end_time: '2026-05-02T21:00',
travelers: [{ user_id: 2, username: 'Ada', avatar: null }],
} as unknown as Reservation
/** Starts and ends on the same day — the range suffix has to stay off. */
const SHOW = {
id: 203, trip_id: 7, type: 'event', status: 'pending', title: 'Kabuki show',
day_id: 2, end_day_id: 2, needs_review: 1,
metadata: JSON.stringify({ departure_airport: 'Gion', platform: 'Gate 3', seat: 'B12', price: 40, priceCurrency: 'JPY' }),
} as unknown as Reservation
/** Endpoints present, so the airport meta cells are suppressed and only the price stays. */
const TOUR = {
id: 204, trip_id: 7, type: 'tour', status: 'pending', title: 'Bamboo walk',
metadata: JSON.stringify({ departure_airport: 'Arashiyama', arrival_airport: 'Saga', price: 30, priceCurrency: 'JPY' }),
endpoints: [
{ role: 'from', sequence: 0, name: 'Arashiyama' },
{ role: 'to', sequence: 1, name: 'Saga' },
],
} as unknown as Reservation
const FLIGHT = {
id: 205, trip_id: 7, type: 'flight', status: 'confirmed', title: 'HND to ITM', day_id: 1,
} as unknown as Reservation
const FILES = [
{ id: 301, trip_id: 7, reservation_id: 202, original_name: 'voucher.pdf', url: '/uploads/f/301' },
{ id: 302, trip_id: 7, reservation_id: 202, original_name: 'stale.pdf', url: '/uploads/f/302', deleted_at: '2026-01-01' },
] as unknown as TripFile[]
const ALL = [HOTEL, DINNER, SHOW, TOUR, FLIGHT]
function planner(overrides: Partial<TripPlanner> = {}) {
return buildPlanner({
tripId: 7,
days: DAYS,
reservations: ALL,
files: FILES,
tripMembers: MEMBERS as unknown as TripPlanner['tripMembers'],
settings: buildSettings({ time_format: '24h' }),
...overrides,
})
}
function renderTab(p: TripPlanner = planner(), shell: MTripShellApi = buildShell()) {
const view = render(<MBookingsTab planner={p} shell={shell} />)
return { ...view, planner: p, shell }
}
function cardOf(title: string): HTMLElement {
let el = screen.getByText(title).parentElement
while (el && !el.className.includes('rounded-2xl')) el = el.parentElement
if (!el) throw new Error(`no card for ${title}`)
return el
}
describe('MBookingsTab', () => {
beforeEach(() => {
vi.mocked(openFile).mockClear()
})
it('FE-MOB-BKTAB-001: keeps only the non-transport reservations, split by status', () => {
renderTab()
expect(screen.getByRole('button', { name: 'reservations.confirmed2' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'reservations.pending2' })).toBeInTheDocument()
expect(screen.queryByText('HND to ITM')).not.toBeInTheDocument()
})
it('FE-MOB-BKTAB-002: sorts chronologically and sinks the undated booking', () => {
renderTab()
const titles = screen.getAllByText(/Hotel Granvia|Sushi Saito|Kabuki show|Bamboo walk/).map(n => n.textContent)
expect(titles).toEqual(['Hotel Granvia', 'Sushi Saito', 'Kabuki show', 'Bamboo walk'])
})
it('FE-MOB-BKTAB-003: collapses a section', () => {
renderTab()
const header = screen.getByRole('button', { name: 'reservations.pending2' })
fireEvent.click(header)
expect(header).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('Kabuki show')).not.toBeInTheDocument()
expect(screen.getByText('Hotel Granvia')).toBeInTheDocument()
})
it('FE-MOB-BKTAB-004: shows the mascot empty state when only transports exist', () => {
renderTab(planner({ reservations: [FLIGHT] }))
expect(screen.getByText('mobileTrip.bookingsEmpty')).toBeInTheDocument()
})
it('FE-MOB-BKTAB-005: spans the stay over the accommodation day range', () => {
renderTab()
const card = cardOf('Hotel Granvia')
expect(within(card).getByText('dayplan.dayN:1 – Kyoto')).toBeInTheDocument()
expect(within(card).getByText('—')).toBeInTheDocument()
})
it('FE-MOB-BKTAB-006: renders check-in window and check-out for a stay', () => {
renderTab()
const card = cardOf('Hotel Granvia')
expect(within(card).getByText('reservations.meta.checkIn')).toBeInTheDocument()
expect(within(card).getByText('15:00 – 18:00')).toBeInTheDocument()
expect(within(card).getByText('reservations.meta.checkOut')).toBeInTheDocument()
expect(within(card).getByText('11:00')).toBeInTheDocument()
})
it('FE-MOB-BKTAB-007: renders location, notes and the confirmation code of a stay', () => {
renderTab()
const card = cardOf('Hotel Granvia')
expect(within(card).getByText('Kyoto Station')).toBeInTheDocument()
expect(within(card).getByText('Ask for a high floor')).toBeInTheDocument()
expect(within(card).getByText('HG-77')).toBeInTheDocument()
})
it('FE-MOB-BKTAB-008: renders the booking time range, travelers and files', () => {
renderTab()
const card = cardOf('Sushi Saito')
expect(within(card).getByText('Kyoto')).toBeInTheDocument()
expect(within(card).getByText('19:30 – 21:00')).toBeInTheDocument()
expect(within(card).getByText('reservations.travelers.label')).toBeInTheDocument()
expect(within(card).getByText('Ada')).toBeInTheDocument()
expect(within(card).getByText('voucher.pdf')).toBeInTheDocument()
expect(within(card).queryByText('stale.pdf')).not.toBeInTheDocument()
fireEvent.click(within(card).getByText('voucher.pdf'))
expect(openFile).toHaveBeenCalledWith('/uploads/f/301', 'voucher.pdf')
})
it('FE-MOB-BKTAB-009: drops the day-range suffix when start and end day are the same', () => {
renderTab()
const card = cardOf('Kabuki show')
expect(within(card).getByText('Kyoto')).toBeInTheDocument()
expect(within(card).getByText('reservations.needsReview')).toBeInTheDocument()
})
it('FE-MOB-BKTAB-010: caps the meta row at three cells', () => {
renderTab()
const card = cardOf('Kabuki show')
expect(within(card).getByText('Gion')).toBeInTheDocument()
expect(within(card).getByText('Gate 3')).toBeInTheDocument()
expect(within(card).getByText('B12')).toBeInTheDocument()
expect(within(card).queryByText('reservations.price')).not.toBeInTheDocument()
})
it('FE-MOB-BKTAB-011: suppresses the airport cells when endpoints exist and keeps the price', () => {
renderTab()
const card = cardOf('Bamboo walk')
expect(within(card).queryByText('reservations.meta.from')).not.toBeInTheDocument()
expect(within(card).getByText('reservations.price')).toBeInTheDocument()
expect(within(card).getByText('30 JPY')).toBeInTheDocument()
expect(within(card).getAllByText('—')).toHaveLength(2)
})
it('FE-MOB-BKTAB-012: opens the reservation editor from the row', () => {
const p = planner()
renderTab(p)
fireEvent.click(screen.getByText('Sushi Saito'))
expect(p.can).toHaveBeenCalledWith('reservation_edit', p.trip)
expect(p.setEditingReservation).toHaveBeenCalledWith(expect.objectContaining({ id: 202 }))
expect(p.setShowReservationModal).toHaveBeenCalledWith(true)
})
it('FE-MOB-BKTAB-013: opens the same editor from the pencil', () => {
const p = planner()
renderTab(p)
fireEvent.click(within(cardOf('Hotel Granvia')).getByRole('button', { name: 'common.edit' }))
expect(p.setEditingReservation).toHaveBeenCalledWith(expect.objectContaining({ id: 201 }))
})
it('FE-MOB-BKTAB-014: a read-only member gets no editor and no actions', () => {
const p = planner({ can: vi.fn(() => false) as unknown as TripPlanner['can'] })
renderTab(p)
fireEvent.click(screen.getByText('Sushi Saito'))
expect(p.setShowReservationModal).not.toHaveBeenCalled()
expect(screen.queryByRole('button', { name: 'common.edit' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
})
it('FE-MOB-BKTAB-015: deletes after confirming', async () => {
const p = planner()
renderTab(p)
fireEvent.click(within(cardOf('Sushi Saito')).getByRole('button', { name: 'common.delete' }))
const dialog = screen.getByRole('dialog', { name: 'reservations.confirm.deleteTitle' })
expect(within(dialog).getByText('reservations.confirm.deleteBody:Sushi Saito')).toBeInTheDocument()
fireEvent.click(within(dialog).getByText('common.delete'))
await waitFor(() => expect(p.handleDeleteReservation).toHaveBeenCalledWith(202))
expect(p.toast.error).not.toHaveBeenCalled()
})
it('FE-MOB-BKTAB-016: toasts when the delete rejects', async () => {
const p = planner({
handleDeleteReservation: vi.fn(() => Promise.reject(new Error('boom'))) as unknown as TripPlanner['handleDeleteReservation'],
})
renderTab(p)
fireEvent.click(within(cardOf('Sushi Saito')).getByRole('button', { name: 'common.delete' }))
fireEvent.click(within(screen.getByRole('dialog')).getByText('common.delete'))
await waitFor(() => expect(p.toast.error).toHaveBeenCalledWith('reservations.toast.deleteError'))
})
it('FE-MOB-BKTAB-017: compact mode drops the card bodies', () => {
renderTab(planner(), buildShell({ bookingsCompact: true }))
expect(screen.getByText('Hotel Granvia')).toBeInTheDocument()
expect(screen.queryByText('HG-77')).not.toBeInTheDocument()
expect(screen.queryByText('Kyoto Station')).not.toBeInTheDocument()
})
it('FE-MOB-BKTAB-018: blurs the confirmation code until it is tapped', () => {
renderTab(planner({ settings: buildSettings({ time_format: '24h', blur_booking_codes: true }) }))
const code = within(cardOf('Hotel Granvia')).getByText('HG-77')
expect(code).toHaveClass('blur-[4px]')
fireEvent.click(code)
expect(code).not.toHaveClass('blur-[4px]')
})
it('FE-MOB-BKTAB-019: filters the list by assigned traveler', () => {
renderTab()
const ada = screen.getByTitle('Ada')
fireEvent.click(ada)
expect(screen.getByText('Sushi Saito')).toBeInTheDocument()
expect(screen.queryByText('Hotel Granvia')).not.toBeInTheDocument()
fireEvent.click(ada)
expect(screen.getByText('Hotel Granvia')).toBeInTheDocument()
})
it('FE-MOB-BKTAB-020: cancelling the confirm keeps the booking', () => {
const p = planner()
renderTab(p)
fireEvent.click(within(cardOf('Sushi Saito')).getByRole('button', { name: 'common.delete' }))
fireEvent.click(within(screen.getByRole('dialog')).getByText('common.cancel'))
expect(p.handleDeleteReservation).not.toHaveBeenCalled()
expect(screen.getByText('Sushi Saito')).toBeInTheDocument()
})
it('FE-MOB-BKTAB-021: falls back for an unknown type and formats a dateless booking', () => {
const SPA = {
id: 206, trip_id: 7, type: 'spa', status: 'confirmed', title: 'Onsen visit',
reservation_time: '2026-06-15T09:00',
metadata: JSON.stringify({ arrival_airport: 'Hakone', check_in_time: '09:00' }),
} as unknown as Reservation
renderTab(planner({
reservations: [SPA],
files: undefined as unknown as TripPlanner['files'],
settings: { ...buildSettings(), time_format: '' },
}))
const card = cardOf('Onsen visit')
expect(within(card).getByText('reservations.type.spa')).toBeInTheDocument()
expect(within(card).getByText('Jun 15')).toBeInTheDocument()
expect(within(card).getByText('reservations.meta.to')).toBeInTheDocument()
expect(within(card).getByText('Hakone')).toBeInTheDocument()
// the check-in cell and the time field both read 09:00
expect(within(card).getAllByText('09:00')).toHaveLength(2)
})
})
@@ -0,0 +1,171 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MBrowseActionsSheet from '../../../../src/mobile/screens/trip/sheets/MBrowseActionsSheet'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { useAddonStore } from '../../../../src/store/addonStore'
import { useSaveToCollectionStore } from '../../../../src/store/saveToCollectionStore'
import type { Day, Place } from '../../../../src/types'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { fireEvent, render, screen } from '../../../helpers/render'
// FE-MOB-BRACT-001 to FE-MOB-BRACT-013
// This sheet reads its labels from the real TranslationProvider, so the
// assertions use the English copy rather than the planner's echo `t`.
const PLACE = {
id: 77, trip_id: 5, name: 'Teamlab Planets', address: '6-chome Toyosu, Koto City',
description: 'Digital art museum', lat: 35.65, lng: 139.79, category_id: 3,
} as unknown as Place
const DAYS = [
{ id: 1, trip_id: 5, day_number: 1, date: '2026-04-02', title: null },
{ id: 2, trip_id: 5, day_number: 2, date: '2026-04-03', title: 'Tokyo Bay' },
] as unknown as Day[]
function setup(plannerOverrides: Partial<TripPlanner> = {}, shellOverrides: Partial<MTripShellApi> = {}) {
const planner = buildPlanner({ places: [PLACE], days: DAYS, ...plannerOverrides })
const shell = buildShell({ sheet: { id: 'bract', payload: { placeId: 77 } }, ...shellOverrides })
const view = render(<MBrowseActionsSheet planner={planner} shell={shell} />)
return { ...view, planner, shell }
}
function enableCollections() {
seedStore(useAddonStore, { addons: [{ id: 'collections', enabled: true }], loaded: true })
}
describe('MBrowseActionsSheet', () => {
beforeEach(() => {
resetAllStores()
useSaveToCollectionStore.setState({ target: null, version: 0 })
})
it('FE-MOB-BRACT-001: renders nothing while another sheet id is active', () => {
setup({}, { sheet: { id: 'mehr' } })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-BRACT-002: renders nothing when the payload points at an unknown place', () => {
setup({ places: [] })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-BRACT-003: heads the card with the place name and address', () => {
setup()
expect(screen.getByRole('dialog', { name: 'Teamlab Planets' })).toBeInTheDocument()
expect(screen.getByText('6-chome Toyosu, Koto City')).toBeInTheDocument()
})
it('FE-MOB-BRACT-004: falls back to the description when there is no address', () => {
setup({ places: [{ ...PLACE, address: null } as unknown as Place] })
expect(screen.getByText('Digital art museum')).toBeInTheDocument()
})
it('FE-MOB-BRACT-005: omits the subline when the place carries neither', () => {
setup({ places: [{ ...PLACE, address: null, description: null } as unknown as Place] })
expect(screen.queryByText('Digital art museum')).not.toBeInTheDocument()
expect(screen.getByText('Teamlab Planets')).toBeInTheDocument()
})
it('FE-MOB-BRACT-006: view details closes the sheet and opens the place', () => {
const { planner, shell } = setup()
fireEvent.click(screen.getByRole('button', { name: /View details/ }))
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
expect(planner.handlePlaceClick).toHaveBeenCalledWith(77)
})
it('FE-MOB-BRACT-007: edit hands the place to the planner editor', () => {
const { planner, shell } = setup()
fireEvent.click(screen.getByRole('button', { name: /^Edit/ }))
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
expect(planner.openPlaceEditor).toHaveBeenCalledWith(PLACE)
})
it('FE-MOB-BRACT-008: the collection row stays hidden while the addon is off', () => {
setup()
expect(screen.queryByRole('button', { name: /Save to Collection/ })).not.toBeInTheDocument()
})
it('FE-MOB-BRACT-008b: saving to a collection hands the full place target to the picker', () => {
enableCollections()
const { shell } = setup()
fireEvent.click(screen.getByRole('button', { name: /Save to Collection/ }))
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
expect(useSaveToCollectionStore.getState().target).toMatchObject({
name: 'Teamlab Planets', source_trip_id: 5, source_place_id: 77, lat: 35.65, lng: 139.79, category_id: 3,
})
})
it('FE-MOB-BRACT-009: the day list is collapsed by default and expands on tap', () => {
setup()
const toggle = screen.getByRole('button', { name: /Add to a day\?/ })
expect(toggle).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByRole('button', { name: /Tokyo Bay/ })).not.toBeInTheDocument()
fireEvent.click(toggle)
expect(toggle).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByRole('button', { name: /Day 1/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Tokyo Bay/ })).toBeInTheDocument()
})
it('FE-MOB-BRACT-010: the quick-add payload opens with the day list already expanded and dated', () => {
const { planner, shell } = setup({}, { sheet: { id: 'bract', payload: { placeId: 77, dayPicker: true } } })
expect(screen.getByRole('button', { name: /Add to a day\?/ })).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('Thu, Apr 2')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Tokyo Bay/ }))
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
expect(planner.handleAssignToDay).toHaveBeenCalledWith(77, 2)
})
it('FE-MOB-BRACT-011: numbers an untitled, undated day by position and falls back to "?" for a zero', () => {
const days = [
{ id: 9, trip_id: 5, day_number: null, date: null, title: null },
{ id: 10, trip_id: 5, day_number: 0, date: null, title: null },
] as unknown as Day[]
setup({ days }, { sheet: { id: 'bract', payload: { placeId: 77, dayPicker: true } } })
expect(screen.getByRole('button', { name: 'Day 1' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Day ?' })).toBeInTheDocument()
})
it('FE-MOB-BRACT-012: delete routes through the planner confirm flow', () => {
const { planner, shell } = setup()
fireEvent.click(screen.getByRole('button', { name: /Delete/ }))
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
expect(planner.handleDeletePlace).toHaveBeenCalledWith(77)
})
it('FE-MOB-BRACT-013: a read-only member only keeps the view row', () => {
enableCollections()
setup({ can: vi.fn(() => false) })
expect(screen.getByRole('button', { name: /View details/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^Edit/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Delete/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Add to a day\?/ })).not.toBeInTheDocument()
// Saving to a personal collection is not a trip edit — it stays.
expect(screen.getByRole('button', { name: /Save to Collection/ })).toBeInTheDocument()
})
it('FE-MOB-BRACT-014: hides the day section for a trip without days', () => {
setup({ days: [] })
expect(screen.queryByRole('button', { name: /Add to a day\?/ })).not.toBeInTheDocument()
})
it('FE-MOB-BRACT-015: keeps the card content while the sheet plays its exit animation', () => {
const planner = buildPlanner({ places: [PLACE], days: DAYS })
const shell = buildShell({ sheet: { id: 'bract', payload: { placeId: 77 } } })
const { rerender } = render(<MBrowseActionsSheet planner={planner} shell={shell} />)
expect(screen.getByText('Teamlab Planets')).toBeInTheDocument()
// The place vanishes from the pool the moment it is deleted — the held
// snapshot keeps the card readable until the sheet has faded out.
rerender(<MBrowseActionsSheet planner={buildPlanner({ places: [], days: DAYS })} shell={shell} />)
expect(screen.getByText('Teamlab Planets')).toBeInTheDocument()
})
it('FE-MOB-BRACT-016: checks the permissions against the trip in context', () => {
const can = vi.fn(() => true)
const { planner } = setup({ can })
expect(can).toHaveBeenCalledWith('place_edit', planner.trip)
expect(can).toHaveBeenCalledWith('day_edit', planner.trip)
})
})
@@ -0,0 +1,517 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import MCollabChat from '../../../../src/mobile/screens/trip/tabs/MCollabChat'
import type { ChatMessage } from '../../../../src/mobile/screens/trip/tabs/collabModel'
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { addListener, removeListener } from '../../../../src/api/websocket'
import { useAuthStore } from '../../../../src/store/authStore'
import { buildPlanner } from '../../../helpers/mobileTrip'
import { buildUser } from '../../../helpers/factories'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { server } from '../../../helpers/msw/server'
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-CCHAT-001 to FE-MOB-CCHAT-031
const ME = 7
function chatMsg(id: number, over: Partial<ChatMessage> = {}): ChatMessage {
return {
id,
trip_id: 1,
user_id: 2,
text: `Message ${id}`,
reply_to: null,
username: 'Alice',
avatar: null,
avatar_url: null,
created_at: new Date().toISOString(),
reactions: [],
...over,
}
}
function daysAgo(n: number): string {
return new Date(Date.now() - n * 86_400_000).toISOString()
}
/** Records every GET so tests can assert the `before` cursor of load-more. */
let beforeCursors: (string | null)[] = []
let sentBodies: Record<string, unknown>[] = []
interface ServeOptions {
older?: ChatMessage[]
olderFails?: boolean
initialFails?: boolean
}
function serveMessages(initial: ChatMessage[], opts: ServeOptions = {}) {
server.use(
http.get('/api/trips/:tripId/collab/messages', ({ request }) => {
const before = new URL(request.url).searchParams.get('before')
beforeCursors.push(before)
if (before) {
if (opts.olderFails) return HttpResponse.json({ error: 'boom' }, { status: 500 })
return HttpResponse.json({ messages: opts.older || [] })
}
if (opts.initialFails) return HttpResponse.json({ error: 'boom' }, { status: 500 })
return HttpResponse.json({ messages: initial })
}),
)
}
function serveSend(reply: (body: Record<string, unknown>) => ChatMessage, fails = false) {
server.use(
http.post('/api/trips/:tripId/collab/messages', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>
sentBodies.push(body)
if (fails) return HttpResponse.json({ error: 'boom' }, { status: 500 })
return HttpResponse.json({ message: reply(body) })
}),
)
}
async function renderChat(planner: TripPlanner = buildPlanner()) {
const view = render(<MCollabChat planner={planner} />)
await waitFor(() => expect(document.querySelector('.animate-spin')).toBeNull())
return { ...view, planner }
}
/** The bubble button of the nth rendered (non-deleted) message. */
function bubble(index = 0): HTMLElement {
return screen.getAllByRole('button', { name: 'collab.chat.messageOptions' })[index]
}
/** Two taps inside DOUBLE_TAP_MS — the popover gesture that needs no timers. */
function doubleTap(el: HTMLElement, x = 120, y = 300) {
fireEvent.pointerDown(el, { clientX: x, clientY: y })
fireEvent.pointerUp(el, { clientX: x, clientY: y })
fireEvent.pointerDown(el, { clientX: x, clientY: y })
fireEvent.pointerUp(el, { clientX: x, clientY: y })
}
describe('MCollabChat', () => {
beforeEach(() => {
resetAllStores()
seedStore(useAuthStore, { user: buildUser({ id: ME, username: 'Mo' }) })
beforeCursors = []
sentBodies = []
vi.mocked(addListener).mockClear()
vi.mocked(removeListener).mockClear()
// jsdom has neither scrollTo on elements nor real layout metrics.
Element.prototype.scrollTo = vi.fn()
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it('FE-MOB-CCHAT-001: shows the spinner until the first page has landed', async () => {
serveMessages([chatMsg(1)])
render(<MCollabChat planner={buildPlanner()} />)
expect(document.querySelector('.animate-spin')).not.toBeNull()
expect(await screen.findByText('Message 1')).toBeInTheDocument()
})
it('FE-MOB-CCHAT-002: renders the mascot empty state when the trip has no messages', async () => {
serveMessages([])
await renderChat()
expect(screen.getByText('collab.chat.empty')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'collab.chat.messageOptions' })).not.toBeInTheDocument()
})
it('FE-MOB-CCHAT-003: falls back to the empty state when the first page fails', async () => {
serveMessages([], { initialFails: true })
await renderChat()
expect(screen.getByText('collab.chat.empty')).toBeInTheDocument()
})
it('FE-MOB-CCHAT-004: collapses consecutive messages of one sender into a single group', async () => {
serveMessages([
chatMsg(1, { text: 'First' }),
chatMsg(2, { text: 'Second' }),
chatMsg(3, { text: 'Mine', user_id: ME, username: 'Mo' }),
])
await renderChat()
// name + avatar initial collapse onto the first message of the group
expect(screen.getAllByText('Alice')).toHaveLength(1)
expect(screen.getAllByText('A')).toHaveLength(1)
// one trailing timestamp per group (Alice, then me)
expect(screen.getAllByText(/^\d{2}:\d{2}$/)).toHaveLength(2)
})
it('FE-MOB-CCHAT-005: separates days with today / yesterday markers', async () => {
serveMessages([
chatMsg(1, { created_at: daysAgo(1) }),
chatMsg(2, { created_at: new Date().toISOString() }),
])
await renderChat()
expect(screen.getByText('collab.chat.yesterday')).toBeInTheDocument()
expect(screen.getByText('collab.chat.today')).toBeInTheDocument()
})
it('FE-MOB-CCHAT-006: uses the sender avatar when there is one, the initial otherwise', async () => {
serveMessages([
chatMsg(1, { avatar_url: '/uploads/avatars/a.png' }),
chatMsg(2, { user_id: 3, username: 'bob' }),
])
const { container } = await renderChat()
expect(container.querySelector('img[src="/uploads/avatars/a.png"]')).not.toBeNull()
expect(screen.getByText('B')).toBeInTheDocument()
})
it('FE-MOB-CCHAT-007: renders an emoji-only message large and without a bubble', async () => {
serveMessages([chatMsg(1, { text: '🎉' }), chatMsg(2, { text: 'plain' })])
await renderChat()
expect(screen.getByText('🎉')).toHaveClass('text-[2.5rem]')
expect(screen.getByText('plain')).toHaveClass('whitespace-pre-wrap')
})
it('FE-MOB-CCHAT-008: shows the quoted message above a reply bubble', async () => {
serveMessages([
chatMsg(1, { text: 'Where do we meet?' }),
chatMsg(2, { text: 'At the station', reply_to: 1, reply_text: 'Where do we meet?', reply_username: 'Alice' }),
])
await renderChat()
const quote = screen.getAllByText('Where do we meet?')
expect(quote).toHaveLength(2)
})
it('FE-MOB-CCHAT-009: replaces a deleted message with the tombstone line', async () => {
serveMessages([chatMsg(1, { text: 'oops', deleted: 1 })])
await renderChat()
expect(screen.queryByText('oops')).not.toBeInTheDocument()
expect(screen.getByText(/Alice collab\.chat\.deletedMessage/)).toBeInTheDocument()
})
it('FE-MOB-CCHAT-010: honours the 12h time format setting', async () => {
serveMessages([chatMsg(1)])
await renderChat(buildPlanner({
settings: { time_format: '12h' } as unknown as TripPlanner['settings'],
}))
expect(screen.getByText(/^\d{1,2}:\d{2} (AM|PM)$/)).toBeInTheDocument()
})
it('FE-MOB-CCHAT-011: shows reaction badges and re-sends the emoji when one is tapped', async () => {
serveMessages([chatMsg(1, {
reactions: [
{ emoji: '👍', count: 2, users: [{ user_id: 2, username: 'Alice' }, { user_id: ME, username: 'Mo' }] },
{ emoji: '🔥', count: 1, users: [{ user_id: 2, username: 'Alice' }] },
],
})])
let reacted = ''
server.use(http.post('/api/trips/:tripId/collab/messages/:id/react', async ({ request }) => {
reacted = String(((await request.json()) as { emoji: string }).emoji)
return HttpResponse.json({ reactions: [{ emoji: '👍', count: 1, users: [{ user_id: 2, username: 'Alice' }] }] })
}))
await renderChat()
// count is only spelled out above 1
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.queryByText('1')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('🔥'))
await waitFor(() => expect(reacted).toBe('🔥'))
await waitFor(() => expect(screen.queryByText('2')).not.toBeInTheDocument())
})
it('FE-MOB-CCHAT-012: a read-only member gets the notice instead of the composer', async () => {
serveMessages([chatMsg(1, { reactions: [{ emoji: '👍', count: 1, users: [] }] })])
let reactCalls = 0
server.use(http.post('/api/trips/:tripId/collab/messages/:id/react', () => {
reactCalls++
return HttpResponse.json({ reactions: [] })
}))
await renderChat(buildPlanner({ can: (() => false) as TripPlanner['can'] }))
expect(screen.getByText('collab.chat.readOnly')).toBeInTheDocument()
expect(screen.queryByPlaceholderText('collab.chat.placeholder')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'collab.chat.messageOptions' })).not.toBeInTheDocument()
fireEvent.click(screen.getByText('👍'))
await Promise.resolve()
expect(reactCalls).toBe(0)
})
it('FE-MOB-CCHAT-013: sends the trimmed draft, appends the answer and clears the composer', async () => {
serveMessages([])
serveSend(body => chatMsg(42, { user_id: ME, username: 'Mo', text: String(body.text) }))
await renderChat()
const input = screen.getByPlaceholderText('collab.chat.placeholder')
const send = screen.getByRole('button', { name: 'collab.chat.send' })
expect(send).toBeDisabled()
fireEvent.change(input, { target: { value: ' Landed safely ' } })
expect(send).toBeEnabled()
fireEvent.click(send)
expect(await screen.findByText('Landed safely')).toBeInTheDocument()
expect(sentBodies).toEqual([{ text: 'Landed safely' }])
expect(input).toHaveValue('')
})
it('FE-MOB-CCHAT-014: keeps the draft and toasts when sending fails', async () => {
serveMessages([])
serveSend(() => chatMsg(1), true)
const { planner } = await renderChat()
const input = screen.getByPlaceholderText('collab.chat.placeholder')
fireEvent.change(input, { target: { value: 'no network' } })
fireEvent.click(screen.getByRole('button', { name: 'collab.chat.send' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.error'))
expect(input).toHaveValue('no network')
})
it('FE-MOB-CCHAT-015: auto-grows the composer and caps it at 100px', async () => {
serveMessages([])
await renderChat()
const input = screen.getByPlaceholderText('collab.chat.placeholder') as HTMLTextAreaElement
fireEvent.change(input, { target: { value: 'one line' } })
expect(input.style.overflowY).toBe('hidden')
Object.defineProperty(input, 'scrollHeight', { configurable: true, value: 340 })
fireEvent.change(input, { target: { value: 'a\nvery\ntall\ndraft' } })
expect(input.style.height).toBe('100px')
expect(input.style.overflowY).toBe('auto')
})
it('FE-MOB-CCHAT-016: offers load-more on a full page and prepends the older ones', async () => {
const page = Array.from({ length: 100 }, (_, i) => chatMsg(i + 1))
serveMessages(page, { older: [chatMsg(900, { text: 'Ancient' }), chatMsg(901, { text: 'Old' })] })
await renderChat()
const more = screen.getByRole('button', { name: /collab\.chat\.loadMore/ })
fireEvent.click(more)
expect(await screen.findByText('Ancient')).toBeInTheDocument()
expect(screen.getByText('Old')).toBeInTheDocument()
expect(beforeCursors).toEqual([null, '1'])
// a short page means there is nothing left to fetch
await waitFor(() => expect(screen.queryByRole('button', { name: /collab\.chat\.loadMore/ })).not.toBeInTheDocument())
})
it('FE-MOB-CCHAT-017: hides load-more when the older page comes back empty', async () => {
const page = Array.from({ length: 100 }, (_, i) => chatMsg(i + 1))
serveMessages(page, { older: [] })
await renderChat()
fireEvent.click(screen.getByRole('button', { name: /collab\.chat\.loadMore/ }))
await waitFor(() => expect(screen.queryByRole('button', { name: /collab\.chat\.loadMore/ })).not.toBeInTheDocument())
expect(screen.getByText('Message 1')).toBeInTheDocument()
})
it('FE-MOB-CCHAT-018: toasts and keeps the button when the older page fails', async () => {
const page = Array.from({ length: 100 }, (_, i) => chatMsg(i + 1))
serveMessages(page, { olderFails: true })
const { planner } = await renderChat()
fireEvent.click(screen.getByRole('button', { name: /collab\.chat\.loadMore/ }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.error'))
expect(screen.getByRole('button', { name: /collab\.chat\.loadMore/ })).toBeInTheDocument()
})
it('FE-MOB-CCHAT-019: a double tap opens the actions popover and a reaction from it lands', async () => {
serveMessages([chatMsg(1)])
server.use(http.post('/api/trips/:tripId/collab/messages/:id/react', () =>
HttpResponse.json({ reactions: [{ emoji: '❤️', count: 1, users: [{ user_id: ME, username: 'Mo' }] }] })))
await renderChat()
doubleTap(bubble())
expect(screen.getByRole('button', { name: 'collab.chat.reply' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '❤️' }))
await waitFor(() => expect(screen.queryByRole('button', { name: 'collab.chat.reply' })).not.toBeInTheDocument())
expect(await screen.findByText('❤️')).toBeInTheDocument()
})
it('FE-MOB-CCHAT-020: a long press opens the popover, a drag cancels it', async () => {
serveMessages([chatMsg(1)])
await renderChat()
vi.useFakeTimers()
fireEvent.pointerDown(bubble(), { clientX: 40, clientY: 90 })
fireEvent.pointerMove(bubble(), { clientX: 90, clientY: 90 })
act(() => { vi.advanceTimersByTime(600) })
expect(screen.queryByRole('button', { name: 'collab.chat.reply' })).not.toBeInTheDocument()
fireEvent.pointerDown(bubble(), { clientX: 40, clientY: 90 })
fireEvent.pointerMove(bubble(), { clientX: 42, clientY: 92 })
act(() => { vi.advanceTimersByTime(600) })
expect(screen.getByRole('button', { name: 'collab.chat.reply' })).toBeInTheDocument()
// the release after a fired long press must not toggle it again
fireEvent.pointerUp(bubble(), { clientX: 40, clientY: 90 })
expect(screen.getByRole('button', { name: 'collab.chat.reply' })).toBeInTheDocument()
})
it('FE-MOB-CCHAT-021: a pointer cancel aborts the pending long press', async () => {
serveMessages([chatMsg(1)])
await renderChat()
vi.useFakeTimers()
fireEvent.pointerDown(bubble(), { clientX: 40, clientY: 90 })
fireEvent.pointerCancel(bubble())
act(() => { vi.advanceTimersByTime(600) })
expect(screen.queryByRole('button', { name: 'collab.chat.reply' })).not.toBeInTheDocument()
})
it('FE-MOB-CCHAT-022: replying quotes the message and sends reply_to, then clears the banner', async () => {
serveMessages([chatMsg(1, { text: 'Bring sunscreen' })])
serveSend(body => chatMsg(50, { user_id: ME, username: 'Mo', text: String(body.text) }))
await renderChat()
doubleTap(bubble())
fireEvent.click(screen.getByRole('button', { name: 'collab.chat.reply' }))
// banner shows who is quoted
expect(screen.getAllByText('Alice')).toHaveLength(2)
fireEvent.change(screen.getByPlaceholderText('collab.chat.placeholder'), { target: { value: 'Got it' } })
fireEvent.click(screen.getByRole('button', { name: 'collab.chat.send' }))
await waitFor(() => expect(sentBodies).toEqual([{ text: 'Got it', reply_to: 1 }]))
await waitFor(() => expect(screen.getAllByText('Alice')).toHaveLength(1))
})
it('FE-MOB-CCHAT-023: the reply banner can be dismissed by hand', async () => {
serveMessages([chatMsg(1)])
await renderChat()
doubleTap(bubble())
fireEvent.click(screen.getByRole('button', { name: 'collab.chat.reply' }))
expect(screen.getByRole('button', { name: 'common.close' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
expect(screen.queryByRole('button', { name: 'common.close' })).not.toBeInTheDocument()
})
it('FE-MOB-CCHAT-024: delete is offered on own messages only and tombstones the bubble', async () => {
serveMessages([chatMsg(1, { text: 'Theirs' }), chatMsg(2, { text: 'Mine', user_id: ME, username: 'Mo' })])
let deleted = 0
server.use(http.delete('/api/trips/:tripId/collab/messages/:id', () => {
deleted++
return HttpResponse.json({ success: true })
}))
await renderChat()
doubleTap(bubble(0))
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
fireEvent.pointerDown(document.body)
doubleTap(bubble(1))
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
await waitFor(() => expect(deleted).toBe(1))
expect(await screen.findByText(/Mo collab\.chat\.deletedMessage/)).toBeInTheDocument()
expect(screen.getByText('Theirs')).toBeInTheDocument()
})
it('FE-MOB-CCHAT-025: toasts when deleting or reacting fails', async () => {
serveMessages([chatMsg(1, { user_id: ME, username: 'Mo', text: 'Mine' })])
server.use(
http.delete('/api/trips/:tripId/collab/messages/:id', () => HttpResponse.json({ error: 'x' }, { status: 500 })),
http.post('/api/trips/:tripId/collab/messages/:id/react', () => HttpResponse.json({ error: 'x' }, { status: 500 })),
)
const { planner } = await renderChat()
doubleTap(bubble())
fireEvent.click(screen.getByRole('button', { name: '😂' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.error'))
doubleTap(bubble())
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledTimes(2))
expect(screen.getByText('Mine')).toBeInTheDocument()
})
it('FE-MOB-CCHAT-026: a pointer down outside the popover closes it', async () => {
serveMessages([chatMsg(1)])
await renderChat()
doubleTap(bubble())
expect(screen.getByRole('button', { name: 'collab.chat.reply' })).toBeInTheDocument()
fireEvent.pointerDown(document.body)
expect(screen.queryByRole('button', { name: 'collab.chat.reply' })).not.toBeInTheDocument()
})
it('FE-MOB-CCHAT-027: a pointer down inside the popover keeps it open', async () => {
serveMessages([chatMsg(1)])
await renderChat()
doubleTap(bubble())
fireEvent.pointerDown(screen.getByRole('button', { name: '👏' }))
expect(screen.getByRole('button', { name: 'collab.chat.reply' })).toBeInTheDocument()
})
it('FE-MOB-CCHAT-028: applies remote create, delete and reaction events once each', async () => {
serveMessages([chatMsg(1)])
await renderChat()
const handler = vi.mocked(addListener).mock.calls[0][0]
act(() => handler({ type: 'collab:message:created', tripId: 1, message: chatMsg(5, { text: 'From WS' }) }))
expect(screen.getByText('From WS')).toBeInTheDocument()
act(() => handler({ type: 'collab:message:created', tripId: 1, message: chatMsg(5, { text: 'From WS' }) }))
expect(screen.getAllByText('From WS')).toHaveLength(1)
act(() => handler({
type: 'collab:message:reacted',
tripId: 1,
messageId: 5,
reactions: [{ emoji: '🎉', count: 3, users: [] }],
}))
expect(screen.getByText('🎉')).toBeInTheDocument()
expect(screen.getByText('3')).toBeInTheDocument()
act(() => handler({ type: 'collab:message:deleted', tripId: 1, messageId: 5 }))
expect(screen.queryByText('From WS')).not.toBeInTheDocument()
expect(screen.getByText(/Alice collab\.chat\.deletedMessage/)).toBeInTheDocument()
})
it('FE-MOB-CCHAT-029: ignores events addressed to another trip', async () => {
serveMessages([chatMsg(1)])
await renderChat()
const handler = vi.mocked(addListener).mock.calls[0][0]
act(() => handler({ type: 'collab:message:created', tripId: 99, message: chatMsg(5, { text: 'Other trip' }) }))
expect(screen.queryByText('Other trip')).not.toBeInTheDocument()
})
it('FE-MOB-CCHAT-030: auto-scrolls to a remote message only while the view sits at the bottom', async () => {
serveMessages([chatMsg(1)])
const { container } = await renderChat()
const scroller = container.querySelector('.overflow-y-auto') as HTMLElement
const handler = vi.mocked(addListener).mock.calls[0][0]
const metric = (name: string, value: number) =>
Object.defineProperty(scroller, name, { configurable: true, value })
// let the initial "jump to newest" pass finish before measuring
await new Promise(resolve => setTimeout(resolve, 80))
metric('scrollHeight', 1000)
metric('clientHeight', 300)
metric('scrollTop', 0)
fireEvent.scroll(scroller)
vi.mocked(Element.prototype.scrollTo).mockClear()
act(() => handler({ type: 'collab:message:created', tripId: 1, message: chatMsg(5, { text: 'Ping' }) }))
await new Promise(resolve => setTimeout(resolve, 80))
expect(Element.prototype.scrollTo).not.toHaveBeenCalled()
metric('scrollTop', 700)
fireEvent.scroll(scroller)
act(() => handler({ type: 'collab:message:created', tripId: 1, message: chatMsg(6, { text: 'Pong' }) }))
await waitFor(() => expect(Element.prototype.scrollTo).toHaveBeenCalled())
})
it('FE-MOB-CCHAT-031: detaches its websocket listener on unmount', async () => {
serveMessages([chatMsg(1)])
const { unmount } = await renderChat()
const handler = vi.mocked(addListener).mock.calls[0][0]
unmount()
expect(removeListener).toHaveBeenCalledWith(handler)
})
})
@@ -0,0 +1,517 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import MCollabNotes from '../../../../src/mobile/screens/trip/tabs/MCollabNotes'
import type { CollabNoteData } from '../../../../src/mobile/screens/trip/tabs/collabModel'
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { addListener, removeListener } from '../../../../src/api/websocket'
import { openFile } from '../../../../src/utils/fileDownload'
import { buildPlanner } from '../../../helpers/mobileTrip'
import { resetAllStores } from '../../../helpers/store'
import { server } from '../../../helpers/msw/server'
import { act, fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
// FE-MOB-CNOTE-001 to FE-MOB-CNOTE-026
// react-markdown ships as ESM-only chunks that jsdom chokes on; the note cards
// only care that the raw text reaches the renderer.
vi.mock('react-markdown', () => ({
default: ({ children }: { children: string }) => <span data-testid="md">{children}</span>,
}))
vi.mock('remark-gfm', () => ({ default: () => ({}) }))
vi.mock('remark-breaks', () => ({ default: () => ({}) }))
vi.mock('../../../../src/utils/fileDownload', () => ({
openFile: vi.fn(),
downloadFile: vi.fn(),
}))
function note(id: number, over: Partial<CollabNoteData> = {}): CollabNoteData {
return {
id,
trip_id: 1,
user_id: 2,
title: `Note ${id}`,
content: null,
category: null,
color: null,
website: null,
pinned: 0,
username: 'Alice',
avatar: null,
avatar_url: null,
created_at: '2026-06-01T10:00:00Z',
attachments: [],
...over,
}
}
let notePages: CollabNoteData[][] = []
let getCalls = 0
let createdBodies: Record<string, unknown>[] = []
let updatedBodies: { id: string; body: Record<string, unknown> }[] = []
let deletedIds: string[] = []
let deletedFiles: string[] = []
let uploadedTo: string[] = []
/** Each argument is one GET response; the last one repeats. */
function serveNotes(...pages: CollabNoteData[][]) {
notePages = pages.length > 0 ? pages : [[]]
server.use(
http.get('/api/trips/:tripId/collab/notes', () => {
getCalls++
const page = notePages.length > 1 ? notePages.shift()! : notePages[0]
return HttpResponse.json({ notes: page })
}),
)
}
function serveWrites(opts: { createFails?: boolean; updateFails?: boolean; deleteFails?: boolean; uploadFails?: boolean } = {}) {
server.use(
http.post('/api/trips/:tripId/collab/notes', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>
createdBodies.push(body)
if (opts.createFails) return HttpResponse.json({ error: 'x' }, { status: 500 })
return HttpResponse.json({ note: note(99, { title: String(body.title), category: (body.category as string) ?? null }) })
}),
http.put('/api/trips/:tripId/collab/notes/:noteId', async ({ params, request }) => {
const body = (await request.json()) as Record<string, unknown>
updatedBodies.push({ id: String(params.noteId), body })
if (opts.updateFails) return HttpResponse.json({ error: 'x' }, { status: 500 })
return HttpResponse.json({ note: { id: Number(params.noteId), ...body } })
}),
http.delete('/api/trips/:tripId/collab/notes/:noteId', ({ params }) => {
deletedIds.push(String(params.noteId))
if (opts.deleteFails) return HttpResponse.json({ error: 'x' }, { status: 500 })
return HttpResponse.json({ success: true })
}),
http.post('/api/trips/:tripId/collab/notes/:noteId/files', ({ params }) => {
uploadedTo.push(String(params.noteId))
if (opts.uploadFails) return HttpResponse.json({ error: 'x' }, { status: 500 })
return HttpResponse.json({ file: { id: 1, filename: 'f', original_name: 'f', url: '/uploads/f' } })
}),
http.delete('/api/trips/:tripId/collab/notes/:noteId/files/:fileId', ({ params }) => {
deletedFiles.push(`${params.noteId}/${params.fileId}`)
return HttpResponse.json({ success: true })
}),
)
}
async function renderNotes(planner: TripPlanner = buildPlanner()) {
const view = render(<MCollabNotes planner={planner} />)
await waitFor(() => expect(screen.queryByText('common.loading')).not.toBeInTheDocument())
return { ...view, planner }
}
function fileInput(): HTMLInputElement {
return document.querySelector('input[type="file"]') as HTMLInputElement
}
describe('MCollabNotes', () => {
beforeEach(() => {
resetAllStores()
getCalls = 0
createdBodies = []
updatedBodies = []
deletedIds = []
deletedFiles = []
uploadedTo = []
vi.mocked(addListener).mockClear()
vi.mocked(removeListener).mockClear()
vi.mocked(openFile).mockClear()
})
afterEach(() => {
vi.useRealTimers()
})
it('FE-MOB-CNOTE-001: shows the mascot loading state until the notes arrive', async () => {
serveNotes([note(1, { title: 'Visa' })])
render(<MCollabNotes planner={buildPlanner()} />)
expect(screen.getByText('common.loading')).toBeInTheDocument()
expect(await screen.findByText('Visa')).toBeInTheDocument()
})
it('FE-MOB-CNOTE-002: shows the empty state with the create affordance', async () => {
serveNotes([])
await renderNotes()
expect(screen.getByText('collab.notes.empty')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /collab\.notes\.new/ })).toBeInTheDocument()
// no categories yet, so the filter row stays out
expect(screen.queryByText('collab.notes.all')).not.toBeInTheDocument()
})
it('FE-MOB-CNOTE-003: keeps an empty list when the request fails', async () => {
server.use(http.get('/api/trips/:tripId/collab/notes', () => HttpResponse.json({ error: 'x' }, { status: 500 })))
await renderNotes()
expect(screen.getByText('collab.notes.empty')).toBeInTheDocument()
})
it('FE-MOB-CNOTE-004: sorts pinned first, then by last touch', async () => {
serveNotes([
note(1, { title: 'Oldest', created_at: '2026-06-01T10:00:00Z' }),
note(2, { title: 'Pinned', pinned: 1, created_at: '2026-01-01T10:00:00Z' }),
note(3, { title: 'Freshest', created_at: '2026-06-02T10:00:00Z', updated_at: '2026-07-09T10:00:00Z' }),
])
await renderNotes()
const titles = screen.getAllByText(/^(Oldest|Pinned|Freshest)$/).map(el => el.textContent)
expect(titles).toEqual(['Pinned', 'Freshest', 'Oldest'])
})
it('FE-MOB-CNOTE-005: renders body, author and attachment count on a card', async () => {
serveNotes([note(1, {
title: 'Packing tips',
content: '**bring** an adapter',
category: 'Gear',
username: 'alice',
attachments: [
{ id: 1, filename: 'a', original_name: 'a.pdf', url: '/uploads/a.pdf' },
{ id: 2, filename: 'b', original_name: 'b.pdf', url: '/uploads/b.pdf' },
],
})])
await renderNotes()
expect(screen.getByTestId('md')).toHaveTextContent('**bring** an adapter')
expect(screen.getAllByText('Gear').length).toBeGreaterThan(0)
expect(screen.getByText('A')).toBeInTheDocument()
expect(screen.getByText('alice')).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
})
it('FE-MOB-CNOTE-006: filters by category and clears the filter on a second tap', async () => {
serveNotes([
note(1, { title: 'Visa', category: 'Docs', color: '#ef4444' }),
note(2, { title: 'Tent', category: 'Gear', color: '#10b981' }),
])
const { container } = await renderNotes()
expect(screen.getByText('collab.notes.all')).toBeInTheDocument()
const gearPill = container.querySelectorAll('button')
const pill = Array.from(gearPill).find(b => b.textContent === 'Gear')!
fireEvent.click(pill)
expect(screen.getByText('Tent')).toBeInTheDocument()
expect(screen.queryByText('Visa')).not.toBeInTheDocument()
fireEvent.click(pill)
expect(screen.getByText('Visa')).toBeInTheDocument()
fireEvent.click(pill)
fireEvent.click(screen.getByText('collab.notes.all'))
expect(screen.getByText('Visa')).toBeInTheDocument()
})
it('FE-MOB-CNOTE-007: pins a note and flips the affordance', async () => {
serveNotes([note(1, { title: 'Visa' })])
serveWrites()
await renderNotes()
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.pin' }))
await waitFor(() => expect(updatedBodies).toEqual([{ id: '1', body: { pinned: true } }]))
expect(await screen.findByRole('button', { name: 'collab.notes.unpin' })).toBeInTheDocument()
})
it('FE-MOB-CNOTE-008: deletes only after the confirm sheet is accepted', async () => {
serveNotes([note(1, { title: 'Visa' })])
serveWrites()
await renderNotes()
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.delete' }))
expect(screen.getByText('collab.notes.confirmDeleteTitle')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
expect(deletedIds).toEqual([])
expect(screen.getByText('Visa')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.delete' }))
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
await waitFor(() => expect(deletedIds).toEqual(['1']))
expect(screen.queryByText('Visa')).not.toBeInTheDocument()
})
it('FE-MOB-CNOTE-009: toasts when a delete is rejected and keeps the card', async () => {
serveNotes([note(1, { title: 'Visa' })])
serveWrites({ deleteFails: true })
const { planner } = await renderNotes()
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.delete' }))
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.error'))
expect(screen.getByText('Visa')).toBeInTheDocument()
})
it('FE-MOB-CNOTE-010: creates a note with an existing category and prepends it', async () => {
serveNotes([note(1, { title: 'Visa', category: 'Docs', color: '#ef4444' })])
serveWrites()
await renderNotes()
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.new/ }))
const dialog = screen.getByRole('dialog', { name: 'collab.notes.new' })
expect(screen.getByRole('button', { name: 'collab.notes.create' })).toBeDisabled()
fireEvent.change(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), { target: { value: ' Insurance ' } })
fireEvent.change(screen.getByPlaceholderText('collab.notes.contentPlaceholder'), { target: { value: ' call the hotline ' } })
fireEvent.click(Array.from(dialog.querySelectorAll('button')).find(b => b.textContent === 'Docs')!)
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.create' }))
await waitFor(() => expect(createdBodies).toEqual([
{ title: 'Insurance', content: 'call the hotline', category: 'Docs', color: '#ef4444' },
]))
expect(await screen.findByText('Insurance')).toBeInTheDocument()
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
})
it('FE-MOB-CNOTE-011: a brand new category picks the first palette colour', async () => {
serveNotes([])
serveWrites()
await renderNotes()
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.new/ }))
fireEvent.change(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), { target: { value: 'Ferry' } })
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.newCategory/ }))
fireEvent.change(screen.getByPlaceholderText('collab.notes.newCategory'), { target: { value: 'Transport' } })
fireEvent.keyDown(screen.getByPlaceholderText('collab.notes.newCategory'), { key: 'Enter' })
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.create' }))
await waitFor(() => expect(createdBodies).toEqual([
{ title: 'Ferry', category: 'Transport', color: '#6366f1' },
]))
})
it('FE-MOB-CNOTE-012: the inline category input can be confirmed with the button and ignores blanks', async () => {
serveNotes([])
serveWrites()
await renderNotes()
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.new/ }))
fireEvent.change(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), { target: { value: 'Ferry' } })
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.newCategory/ }))
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
expect(screen.queryByPlaceholderText('collab.notes.newCategory')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.newCategory/ }))
fireEvent.change(screen.getByPlaceholderText('collab.notes.newCategory'), { target: { value: 'Food' } })
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.create' }))
await waitFor(() => expect(createdBodies).toEqual([
{ title: 'Ferry', category: 'Food', color: '#6366f1' },
]))
})
it('FE-MOB-CNOTE-013: a failed create toasts and keeps the sheet open', async () => {
serveNotes([])
serveWrites({ createFails: true })
const { planner } = await renderNotes()
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.new/ }))
fireEvent.change(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), { target: { value: 'Ferry' } })
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.create' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.error'))
expect(screen.getByRole('dialog', { name: 'collab.notes.new' })).toBeInTheDocument()
})
it('FE-MOB-CNOTE-014: editing prefills the sheet and saves the changed fields', async () => {
serveNotes([note(1, { title: 'Visa', content: 'apply early', category: 'Docs', color: '#ef4444' })])
serveWrites()
await renderNotes()
fireEvent.click(screen.getByText('Visa'))
expect(screen.getByRole('dialog', { name: 'collab.notes.edit' })).toBeInTheDocument()
const title = screen.getByPlaceholderText('collab.notes.titlePlaceholder')
expect(title).toHaveValue('Visa')
expect(screen.getByPlaceholderText('collab.notes.contentPlaceholder')).toHaveValue('apply early')
fireEvent.change(title, { target: { value: 'Visa (done)' } })
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.save' }))
await waitFor(() => expect(updatedBodies).toEqual([
{ id: '1', body: { title: 'Visa (done)', content: 'apply early', category: 'Docs', color: '#ef4444' } },
]))
expect(await screen.findByText('Visa (done)')).toBeInTheDocument()
})
it('FE-MOB-CNOTE-015: a failed update toasts and leaves the card untouched', async () => {
serveNotes([note(1, { title: 'Visa' })])
serveWrites({ updateFails: true })
const { planner } = await renderNotes()
fireEvent.click(screen.getByText('Visa'))
fireEvent.change(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), { target: { value: 'Nope' } })
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.save' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.error'))
expect(screen.getByText('Visa')).toBeInTheDocument()
})
it('FE-MOB-CNOTE-016: the cancel button closes the form without writing', async () => {
serveNotes([note(1, { title: 'Visa' })])
serveWrites()
await renderNotes()
fireEvent.click(screen.getByText('Visa'))
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.cancel' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(updatedBodies).toEqual([])
})
it('FE-MOB-CNOTE-017: queues picked files, drops one again and uploads the rest on save', async () => {
serveNotes(
[note(1, { title: 'Visa' })],
[note(1, { title: 'Visa', attachments: [{ id: 5, filename: 'p', original_name: 'passport.pdf', url: '/uploads/p' }] })],
)
serveWrites()
await renderNotes()
fireEvent.click(screen.getByText('Visa'))
fireEvent.change(fileInput(), {
target: { files: [new File(['a'], 'passport.pdf'), new File(['b'], 'a-really-long-attachment-name.pdf')] },
})
expect(screen.getByText('passport.pdf')).toBeInTheDocument()
// long names are shortened for the chip
const longChip = screen.getByText('a-really-long-att...')
fireEvent.click(within(longChip).getByRole('button'))
expect(screen.queryByText('a-really-long-att...')).not.toBeInTheDocument()
expect(screen.getByText('passport.pdf')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.save' }))
await waitFor(() => expect(uploadedTo).toEqual(['1']))
// the list is refetched so the new attachment shows up
await waitFor(() => expect(getCalls).toBe(2))
expect(await screen.findByText('1')).toBeInTheDocument()
})
it('FE-MOB-CNOTE-018: a failed upload toasts but still refreshes the list', async () => {
serveNotes([note(1, { title: 'Visa' })])
serveWrites({ uploadFails: true })
const { planner } = await renderNotes()
fireEvent.click(screen.getByText('Visa'))
fireEvent.change(fileInput(), { target: { files: [new File(['a'], 'x.pdf')] } })
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.save' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.error'))
await waitFor(() => expect(getCalls).toBe(2))
})
it('FE-MOB-CNOTE-019: removing an existing attachment calls the file endpoint', async () => {
serveNotes([note(1, {
title: 'Visa',
attachments: [{ id: 5, filename: 'p', original_name: 'passport.pdf', url: '/uploads/p' }],
})])
serveWrites()
await renderNotes()
fireEvent.click(screen.getByText('Visa'))
const chip = screen.getByText('passport.pdf')
fireEvent.click(within(chip).getByRole('button'))
await waitFor(() => expect(deletedFiles).toEqual(['1/5']))
expect(screen.queryByText('passport.pdf')).not.toBeInTheDocument()
})
it('FE-MOB-CNOTE-020: a new note uploads its queued files against the created id', async () => {
serveNotes([], [note(99, { title: 'Ferry', attachments: [{ id: 8, filename: 't', original_name: 't.pdf', url: '/uploads/t' }] })])
serveWrites()
await renderNotes()
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.new/ }))
fireEvent.change(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), { target: { value: 'Ferry' } })
fireEvent.change(fileInput(), { target: { files: [new File(['a'], 't.pdf')] } })
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.create' }))
await waitFor(() => expect(createdBodies).toHaveLength(1))
await waitFor(() => expect(uploadedTo).toEqual(['99']))
await waitFor(() => expect(getCalls).toBe(2))
expect(await screen.findByText('Ferry')).toBeInTheDocument()
})
it('FE-MOB-CNOTE-021: toasts when detaching an existing file is rejected', async () => {
serveNotes([note(1, {
title: 'Visa',
attachments: [{ id: 5, filename: 'p', original_name: 'passport.pdf', url: '/uploads/p' }],
})])
serveWrites()
server.use(http.delete('/api/trips/:tripId/collab/notes/:noteId/files/:fileId', () =>
HttpResponse.json({ error: 'x' }, { status: 500 })))
const { planner } = await renderNotes()
fireEvent.click(screen.getByText('Visa'))
fireEvent.click(within(screen.getByText('passport.pdf')).getByRole('button'))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.error'))
})
it('FE-MOB-CNOTE-022: the attach tile opens the hidden file picker', async () => {
serveNotes([note(1, { title: 'Visa' })])
const click = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {})
await renderNotes()
fireEvent.click(screen.getByText('Visa'))
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.attachFiles' }))
expect(click).toHaveBeenCalledTimes(1)
click.mockRestore()
})
it('FE-MOB-CNOTE-023: hides the attachment section without the upload permission', async () => {
serveNotes([note(1, { title: 'Visa' })])
const can = ((perm: string) => perm !== 'file_upload') as unknown as TripPlanner['can']
await renderNotes(buildPlanner({ can }))
fireEvent.click(screen.getByText('Visa'))
expect(screen.queryByText('collab.notes.attachFiles')).not.toBeInTheDocument()
expect(fileInput()).toBeNull()
})
it('FE-MOB-CNOTE-024: a read-only member gets the viewer sheet and can open attachments', async () => {
serveNotes([note(1, {
title: 'Visa',
content: 'apply early',
category: 'Docs',
attachments: [{ id: 5, filename: 'p', original_name: 'passport.pdf', url: '/uploads/p' }],
})])
await renderNotes(buildPlanner({ can: (() => false) as TripPlanner['can'] }))
expect(screen.queryByRole('button', { name: /collab\.notes\.new/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'collab.notes.pin' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'collab.notes.delete' })).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Visa'))
const viewer = screen.getByRole('dialog', { name: 'Visa' })
expect(viewer).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /passport\.pdf/ }))
expect(openFile).toHaveBeenCalledWith('/uploads/p', 'passport.pdf')
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
})
it('FE-MOB-CNOTE-025: applies remote create, update and delete events for this trip only', async () => {
serveNotes([note(1, { title: 'Visa' })])
await renderNotes()
const handler = vi.mocked(addListener).mock.calls[0][0]
act(() => handler({ type: 'collab:note:created', tripId: 1, note: note(2, { title: 'Remote' }) }))
expect(screen.getByText('Remote')).toBeInTheDocument()
act(() => handler({ type: 'collab:note:created', tripId: 1, note: note(2, { title: 'Remote' }) }))
expect(screen.getAllByText('Remote')).toHaveLength(1)
act(() => handler({ type: 'collab:note:updated', tripId: 1, note: note(2, { title: 'Renamed' }) }))
expect(screen.getByText('Renamed')).toBeInTheDocument()
act(() => handler({ type: 'collab:note:created', tripId: 42, note: note(3, { title: 'Elsewhere' }) }))
expect(screen.queryByText('Elsewhere')).not.toBeInTheDocument()
act(() => handler({ type: 'collab:note:deleted', tripId: 1, noteId: 2 }))
expect(screen.queryByText('Renamed')).not.toBeInTheDocument()
expect(screen.getByText('Visa')).toBeInTheDocument()
})
it('FE-MOB-CNOTE-026: detaches its websocket listener on unmount', async () => {
serveNotes([note(1)])
const { unmount } = await renderNotes()
const handler = vi.mocked(addListener).mock.calls[0][0]
unmount()
expect(removeListener).toHaveBeenCalledWith(handler)
})
})
@@ -0,0 +1,415 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { collabApi } from '../../../../src/api/client'
import { addListener, removeListener } from '../../../../src/api/websocket'
import MCollabPolls from '../../../../src/mobile/screens/trip/tabs/MCollabPolls'
import type { CollabPollData, PollVoter } from '../../../../src/mobile/screens/trip/tabs/collabModel'
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { useAuthStore } from '../../../../src/store/authStore'
import { buildUser } from '../../../helpers/factories'
import { buildPlanner, buildToast } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-POLLS-001 to FE-MOB-POLLS-024
const ME = 7
function voter(id: number, username: string, avatarUrl: string | null = null): PollVoter {
return { id, user_id: id, username, avatar: null, avatar_url: avatarUrl }
}
function poll(overrides: Partial<CollabPollData> = {}): CollabPollData {
return {
id: 1,
trip_id: 1,
user_id: ME,
question: 'Where to eat?',
options: [
{ text: 'Ramen', label: 'Ramen', voters: [] },
{ text: 'Sushi', label: 'Sushi', voters: [] },
],
multiple_choice: false,
is_closed: false,
deadline: null,
username: 'maurice',
avatar: null,
avatar_url: null,
created_at: '2026-07-01T10:00:00',
...overrides,
}
}
function setup(polls: CollabPollData[], plannerOverrides: Partial<TripPlanner> = {}) {
const toast = buildToast()
vi.spyOn(collabApi, 'getPolls').mockResolvedValue({ polls })
const planner = buildPlanner({ toast: toast as unknown as TripPlanner['toast'], ...plannerOverrides })
const view = render(<MCollabPolls planner={planner} />)
return { ...view, planner, toast }
}
/** The listener MCollabPolls registered with the (globally mocked) websocket module. */
function wsHandler() {
const calls = vi.mocked(addListener).mock.calls
return calls[calls.length - 1][0]
}
function deadlineInput(): HTMLInputElement {
return document.querySelector('input[type="datetime-local"]') as HTMLInputElement
}
describe('MCollabPolls', () => {
beforeEach(() => {
resetAllStores()
seedStore(useAuthStore, { user: buildUser({ id: ME, username: 'maurice' }) })
vi.mocked(addListener).mockClear()
vi.mocked(removeListener).mockClear()
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
it('FE-MOB-POLLS-001: shows the loading state until the request settles', async () => {
vi.spyOn(collabApi, 'getPolls').mockReturnValue(new Promise(() => {}))
render(<MCollabPolls planner={buildPlanner()} />)
expect(screen.getByText('common.loading')).toBeInTheDocument()
expect(screen.queryByText('collab.polls.empty')).not.toBeInTheDocument()
})
it('FE-MOB-POLLS-002: renders the empty state and the create button for editors', async () => {
setup([])
expect(await screen.findByText('collab.polls.empty')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'collab.polls.new' })).toBeInTheDocument()
})
it('FE-MOB-POLLS-003: keeps the empty state when loading fails', async () => {
vi.spyOn(collabApi, 'getPolls').mockRejectedValue(new Error('offline'))
render(<MCollabPolls planner={buildPlanner()} />)
expect(await screen.findByText('collab.polls.empty')).toBeInTheDocument()
})
it('FE-MOB-POLLS-004: treats a missing polls array as an empty list', async () => {
vi.spyOn(collabApi, 'getPolls').mockResolvedValue({})
render(<MCollabPolls planner={buildPlanner()} />)
expect(await screen.findByText('collab.polls.empty')).toBeInTheDocument()
})
it('FE-MOB-POLLS-005: hides every editor control without collab_edit', async () => {
setup([poll()], { can: vi.fn(() => false) as unknown as TripPlanner['can'] })
expect(await screen.findByText('Where to eat?')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'collab.polls.new' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'collab.polls.close' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'collab.polls.delete' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /Ramen/ })).toBeDisabled()
})
it('FE-MOB-POLLS-006: renders question, singular vote label and the multi-choice badge', async () => {
setup([poll({ multiple_choice: true, options: [
{ text: 'Ramen', label: 'Ramen', voters: [voter(9, 'lea')] },
{ text: 'Sushi', label: 'Sushi', voters: [] },
] })])
expect(await screen.findByText('Where to eat?')).toBeInTheDocument()
expect(screen.getByText('collab.polls.vote:1')).toBeInTheDocument()
expect(screen.getByText('collab.polls.multiChoice')).toBeInTheDocument()
})
it('FE-MOB-POLLS-007: shows the deadline countdown on an active poll', async () => {
// half a minute of slack so the floor() breakdown can't tip to 25h while the test runs
const deadline = new Date(Date.now() + 26 * 60 * 60 * 1000 + 30_000).toISOString()
setup([poll({ deadline })])
expect(await screen.findByText('collab.polls.countdownDaysHours:1,2')).toBeInTheDocument()
})
it('FE-MOB-POLLS-008: re-renders the countdown on the 30s ticker', async () => {
vi.useFakeTimers()
const deadline = new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString()
vi.spyOn(collabApi, 'getPolls').mockResolvedValue({ polls: [poll({ deadline })] })
render(<MCollabPolls planner={buildPlanner()} />)
await act(async () => { await Promise.resolve(); await Promise.resolve(); await Promise.resolve() })
expect(screen.getByText('collab.polls.countdownHoursMinutes:2,0')).toBeInTheDocument()
act(() => { vi.advanceTimersByTime(30 * 60 * 1000) })
expect(screen.getByText('collab.polls.countdownHoursMinutes:1,30')).toBeInTheDocument()
})
it('FE-MOB-POLLS-009: marks an expired poll closed and shows results with voter avatars', async () => {
setup([poll({
deadline: new Date(Date.now() - 60_000).toISOString(),
options: [
{ text: 'Ramen', label: 'Ramen', voters: [voter(9, 'lea'), voter(10, 'tom', '/uploads/avatars/tom.png')] },
{ text: 'Sushi', label: 'Sushi', voters: [voter(11, 'ada')] },
],
})])
expect(await screen.findByText('collab.polls.closed')).toBeInTheDocument()
expect(screen.getByText('collab.polls.votes:3')).toBeInTheDocument()
// 2 of 3 votes on the winning option, 1 of 3 on the other
expect(screen.getByText('67%')).toBeInTheDocument()
expect(screen.getByText('33%')).toBeInTheDocument()
expect(screen.getByText('L')).toBeInTheDocument()
expect(document.querySelector('img[src="/uploads/avatars/tom.png"]')).toBeInTheDocument()
// a closed poll can neither be voted on nor closed again
expect(screen.getByRole('button', { name: /Ramen/ })).toBeDisabled()
expect(screen.queryByRole('button', { name: 'collab.polls.close' })).not.toBeInTheDocument()
})
it('FE-MOB-POLLS-010: shows 0% for every option while nobody has voted', async () => {
setup([poll({ is_closed: true })])
expect(await screen.findByText('collab.polls.votes:0')).toBeInTheDocument()
expect(screen.getAllByText('0%')).toHaveLength(2)
})
it('FE-MOB-POLLS-011: collapses the closed section behind its header', async () => {
setup([poll({ id: 1, question: 'Active one' }), poll({ id: 2, question: 'Closed one', is_closed: true })])
expect(await screen.findByText('Closed one')).toBeInTheDocument()
const header = screen.getByRole('button', { name: /collab.polls.closedSection/ })
expect(header).toHaveAttribute('aria-expanded', 'true')
fireEvent.click(header)
expect(screen.queryByText('Closed one')).not.toBeInTheDocument()
expect(screen.getByText('Active one')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /collab.polls.closedSection/ })).toHaveAttribute('aria-expanded', 'false')
})
it('FE-MOB-POLLS-011b: deletes a poll out of the closed section', async () => {
const deletePoll = vi.spyOn(collabApi, 'deletePoll').mockResolvedValue({})
setup([poll({ id: 1, question: 'Active one' }), poll({ id: 2, question: 'Closed one', is_closed: true })])
await screen.findByText('Closed one')
// the active poll's row has the lock button in front, so the closed one owns the last trash button
const trash = screen.getAllByRole('button', { name: 'collab.polls.delete' })
fireEvent.click(trash[trash.length - 1])
fireEvent.click(await screen.findByRole('button', { name: 'common.delete' }))
await waitFor(() => expect(deletePoll).toHaveBeenCalledWith(1, 2))
await waitFor(() => expect(screen.queryByText('Closed one')).not.toBeInTheDocument())
expect(screen.getByText('Active one')).toBeInTheDocument()
})
it('FE-MOB-POLLS-012: renders closed polls without a section header when none are active', async () => {
setup([poll({ id: 2, question: 'Closed one', is_closed: true })])
expect(await screen.findByText('Closed one')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /collab.polls.closedSection/ })).not.toBeInTheDocument()
})
it('FE-MOB-POLLS-013: votes on an option and swaps in the server result', async () => {
const voted = poll({ options: [
{ text: 'Ramen', label: 'Ramen', voters: [voter(ME, 'maurice')] },
{ text: 'Sushi', label: 'Sushi', voters: [] },
] })
const votePoll = vi.spyOn(collabApi, 'votePoll').mockResolvedValue({ poll: voted })
setup([poll()])
fireEvent.click(await screen.findByRole('button', { name: /Ramen/ }))
await waitFor(() => expect(votePoll).toHaveBeenCalledWith(1, 1, 0))
expect(await screen.findByText('collab.polls.vote:1')).toBeInTheDocument()
expect(screen.getByText('100%')).toBeInTheDocument()
})
it('FE-MOB-POLLS-014: toasts when the vote request fails', async () => {
vi.spyOn(collabApi, 'votePoll').mockRejectedValue(new Error('boom'))
const { toast } = setup([poll()])
fireEvent.click(await screen.findByRole('button', { name: /Sushi/ }))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('common.error'))
})
it('FE-MOB-POLLS-015: closes a poll from the lock button', async () => {
const closePoll = vi.spyOn(collabApi, 'closePoll').mockResolvedValue({ poll: poll({ is_closed: true }) })
setup([poll()])
fireEvent.click(await screen.findByRole('button', { name: 'collab.polls.close' }))
await waitFor(() => expect(closePoll).toHaveBeenCalledWith(1, 1))
expect(await screen.findByText('collab.polls.closed')).toBeInTheDocument()
})
it('FE-MOB-POLLS-016: toasts when closing fails', async () => {
vi.spyOn(collabApi, 'closePoll').mockRejectedValue(new Error('boom'))
const { toast } = setup([poll()])
fireEvent.click(await screen.findByRole('button', { name: 'collab.polls.close' }))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('common.error'))
expect(screen.queryByText('collab.polls.closed')).not.toBeInTheDocument()
})
it('FE-MOB-POLLS-017: deletes a poll after the confirm sheet is accepted', async () => {
const deletePoll = vi.spyOn(collabApi, 'deletePoll').mockResolvedValue({})
setup([poll()])
fireEvent.click(await screen.findByRole('button', { name: 'collab.polls.delete' }))
expect(await screen.findByText('collab.polls.confirmDeleteTitle')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
await waitFor(() => expect(deletePoll).toHaveBeenCalledWith(1, 1))
await waitFor(() => expect(screen.queryByText('Where to eat?')).not.toBeInTheDocument())
})
it('FE-MOB-POLLS-018: keeps the poll when the confirm sheet is cancelled', async () => {
const deletePoll = vi.spyOn(collabApi, 'deletePoll').mockResolvedValue({})
setup([poll()])
fireEvent.click(await screen.findByRole('button', { name: 'collab.polls.delete' }))
fireEvent.click(await screen.findByRole('button', { name: 'common.cancel' }))
expect(deletePoll).not.toHaveBeenCalled()
expect(screen.getByText('Where to eat?')).toBeInTheDocument()
})
it('FE-MOB-POLLS-019: toasts when deleting fails', async () => {
vi.spyOn(collabApi, 'deletePoll').mockRejectedValue(new Error('boom'))
const { toast } = setup([poll()])
fireEvent.click(await screen.findByRole('button', { name: 'collab.polls.delete' }))
fireEvent.click(await screen.findByRole('button', { name: 'common.delete' }))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('common.error'))
expect(screen.getByText('Where to eat?')).toBeInTheDocument()
})
it('FE-MOB-POLLS-020: applies created / voted / closed / deleted websocket events', async () => {
setup([poll()])
await screen.findByText('Where to eat?')
const handler = wsHandler()
act(() => handler({ type: 'collab:poll:created', tripId: '1', poll: poll({ id: 2, question: 'Which hotel?' }) }))
expect(screen.getByText('Which hotel?')).toBeInTheDocument()
// same id again → no duplicate row
act(() => handler({ type: 'collab:poll:created', tripId: '1', poll: poll({ id: 2, question: 'Which hotel?' }) }))
expect(screen.getAllByText('Which hotel?')).toHaveLength(1)
act(() => handler({
type: 'collab:poll:voted',
tripId: '1',
poll: poll({ id: 2, question: 'Which hotel?', options: [
{ text: 'Ramen', label: 'Ramen', voters: [voter(9, 'lea')] },
{ text: 'Sushi', label: 'Sushi', voters: [] },
] }),
}))
expect(screen.getByText('collab.polls.vote:1')).toBeInTheDocument()
act(() => handler({ type: 'collab:poll:closed', tripId: '1', poll: poll({ id: 2, question: 'Which hotel?', is_closed: true }) }))
expect(screen.getByText('collab.polls.closed')).toBeInTheDocument()
act(() => handler({ type: 'collab:poll:deleted', tripId: '1', pollId: 2 }))
expect(screen.queryByText('Which hotel?')).not.toBeInTheDocument()
})
it('FE-MOB-POLLS-021: ignores websocket events for another trip and drops the listener on unmount', async () => {
const { unmount } = setup([poll()])
await screen.findByText('Where to eat?')
const handler = wsHandler()
act(() => handler({ type: 'collab:poll:created', tripId: '99', poll: poll({ id: 3, question: 'Other trip' }) }))
expect(screen.queryByText('Other trip')).not.toBeInTheDocument()
unmount()
expect(removeListener).toHaveBeenCalledWith(handler)
})
it('FE-MOB-POLLS-022: creates a poll with question, options, multi-choice and deadline', async () => {
const created = poll({ id: 5, question: 'Which hotel?' })
const createPoll = vi.spyOn(collabApi, 'createPoll').mockResolvedValue({ poll: created })
setup([])
fireEvent.click(await screen.findByRole('button', { name: 'collab.polls.new' }))
fireEvent.change(await screen.findByPlaceholderText('collab.polls.questionPlaceholder'), { target: { value: ' Which hotel? ' } })
fireEvent.change(screen.getByPlaceholderText('collab.polls.optionPlaceholder:1'), { target: { value: 'Ryokan' } })
fireEvent.change(screen.getByPlaceholderText('collab.polls.optionPlaceholder:2'), { target: { value: ' Capsule ' } })
fireEvent.click(screen.getByRole('button', { name: 'collab.polls.multiChoice' }))
fireEvent.change(deadlineInput(), { target: { value: '2026-08-01T12:00' } })
fireEvent.click(screen.getByRole('button', { name: 'collab.polls.create' }))
await waitFor(() => expect(createPoll).toHaveBeenCalledWith(1, {
question: 'Which hotel?',
options: ['Ryokan', 'Capsule'],
multiple: true,
multiple_choice: true,
deadline: new Date('2026-08-01T12:00').toISOString(),
}))
expect(await screen.findByText('Which hotel?')).toBeInTheDocument()
})
it('FE-MOB-POLLS-023: gates the submit button on a question plus two non-blank options', async () => {
setup([])
fireEvent.click(await screen.findByRole('button', { name: 'collab.polls.new' }))
const submit = await screen.findByRole('button', { name: 'collab.polls.create' })
expect(submit).toBeDisabled()
fireEvent.change(screen.getByPlaceholderText('collab.polls.questionPlaceholder'), { target: { value: ' ' } })
fireEvent.change(screen.getByPlaceholderText('collab.polls.optionPlaceholder:1'), { target: { value: 'A' } })
fireEvent.change(screen.getByPlaceholderText('collab.polls.optionPlaceholder:2'), { target: { value: 'B' } })
expect(submit).toBeDisabled()
fireEvent.change(screen.getByPlaceholderText('collab.polls.questionPlaceholder'), { target: { value: 'Q' } })
expect(submit).toBeEnabled()
// blank options don't count towards the two-option minimum
fireEvent.change(screen.getByPlaceholderText('collab.polls.optionPlaceholder:2'), { target: { value: ' ' } })
expect(submit).toBeDisabled()
})
it('FE-MOB-POLLS-024: adds, removes and clears the form fields', async () => {
setup([])
fireEvent.click(await screen.findByRole('button', { name: 'collab.polls.new' }))
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /collab.polls.addOption/ }))
expect(screen.getByPlaceholderText('collab.polls.optionPlaceholder:3')).toBeInTheDocument()
expect(screen.getAllByRole('button', { name: 'common.delete' })).toHaveLength(3)
fireEvent.click(screen.getAllByRole('button', { name: 'common.delete' })[0])
expect(screen.queryByPlaceholderText('collab.polls.optionPlaceholder:3')).not.toBeInTheDocument()
fireEvent.change(deadlineInput(), { target: { value: '2026-08-01T12:00' } })
expect(deadlineInput()).toHaveValue('2026-08-01T12:00')
fireEvent.click(screen.getByRole('button', { name: 'collab.polls.clearDeadline' }))
expect(deadlineInput()).toHaveValue('')
})
it('FE-MOB-POLLS-025: keeps the form open and toasts when creating fails', async () => {
vi.spyOn(collabApi, 'createPoll').mockRejectedValue(new Error('boom'))
const { toast } = setup([])
fireEvent.click(await screen.findByRole('button', { name: 'collab.polls.new' }))
fireEvent.change(await screen.findByPlaceholderText('collab.polls.questionPlaceholder'), { target: { value: 'Q' } })
fireEvent.change(screen.getByPlaceholderText('collab.polls.optionPlaceholder:1'), { target: { value: 'A' } })
fireEvent.change(screen.getByPlaceholderText('collab.polls.optionPlaceholder:2'), { target: { value: 'B' } })
fireEvent.click(screen.getByRole('button', { name: 'collab.polls.create' }))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('common.error'))
expect(screen.getByRole('button', { name: 'collab.polls.create' })).toBeInTheDocument()
})
it('FE-MOB-POLLS-026: resets the form fields on every reopen', async () => {
setup([])
fireEvent.click(await screen.findByRole('button', { name: 'collab.polls.new' }))
fireEvent.change(await screen.findByPlaceholderText('collab.polls.questionPlaceholder'), { target: { value: 'Draft' } })
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
fireEvent.click(screen.getByRole('button', { name: 'collab.polls.new' }))
await waitFor(() => expect(screen.getByPlaceholderText('collab.polls.questionPlaceholder')).toHaveValue(''))
})
})
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from 'vitest'
import MCollabTab from '../../../../src/mobile/screens/trip/tabs/MCollabTab'
import type { MTripCollabTab, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { render, screen } from '../../../helpers/render'
// FE-MOB-CTAB-001 to FE-MOB-CTAB-009
// The three sub-panels are self-contained (own API calls + WebSocket listener)
// and covered by their own suites — here only the routing is under test.
vi.mock('../../../../src/mobile/screens/trip/tabs/MCollabChat', () => ({
default: ({ planner }: { planner: TripPlanner }) => <div data-testid="chat">{planner.tripId}</div>,
}))
vi.mock('../../../../src/mobile/screens/trip/tabs/MCollabNotes', () => ({
default: ({ planner }: { planner: TripPlanner }) => <div data-testid="notes">{planner.tripId}</div>,
}))
vi.mock('../../../../src/mobile/screens/trip/tabs/MCollabPolls', () => ({
default: ({ planner }: { planner: TripPlanner }) => <div data-testid="polls">{planner.tripId}</div>,
}))
function renderTab(collabTab: MTripCollabTab, features: Partial<Record<'chat' | 'notes' | 'polls', boolean>> = {}) {
const planner = buildPlanner({
collabFeatures: { chat: true, notes: true, polls: true, ...features } as TripPlanner['collabFeatures'],
})
return render(<MCollabTab planner={planner} shell={buildShell({ collabTab })} />)
}
describe('MCollabTab', () => {
it('FE-MOB-CTAB-001: routes the chat sub-tab to MCollabChat', () => {
renderTab('chat')
expect(screen.getByTestId('chat')).toHaveTextContent('1')
expect(screen.queryByTestId('notes')).not.toBeInTheDocument()
expect(screen.queryByTestId('polls')).not.toBeInTheDocument()
})
it('FE-MOB-CTAB-002: routes the notes sub-tab to MCollabNotes', () => {
renderTab('notes')
expect(screen.getByTestId('notes')).toBeInTheDocument()
expect(screen.queryByTestId('chat')).not.toBeInTheDocument()
})
it('FE-MOB-CTAB-003: routes the polls sub-tab to MCollabPolls', () => {
renderTab('polls')
expect(screen.getByTestId('polls')).toBeInTheDocument()
expect(screen.queryByTestId('chat')).not.toBeInTheDocument()
})
it('FE-MOB-CTAB-004: shows the disabled notice instead of the chat panel', () => {
renderTab('chat', { chat: false })
expect(screen.getByText('mobileTrip.collabFeatureDisabled')).toBeInTheDocument()
expect(screen.queryByTestId('chat')).not.toBeInTheDocument()
})
it('FE-MOB-CTAB-005: shows the disabled notice instead of the notes panel', () => {
renderTab('notes', { notes: false })
expect(screen.getByText('mobileTrip.collabFeatureDisabled')).toBeInTheDocument()
expect(screen.queryByTestId('notes')).not.toBeInTheDocument()
})
it('FE-MOB-CTAB-006: shows the disabled notice instead of the polls panel', () => {
renderTab('polls', { polls: false })
expect(screen.getByText('mobileTrip.collabFeatureDisabled')).toBeInTheDocument()
expect(screen.queryByTestId('polls')).not.toBeInTheDocument()
})
it('FE-MOB-CTAB-007: gates each sub-tab independently', () => {
renderTab('polls', { chat: false, notes: false })
expect(screen.getByTestId('polls')).toBeInTheDocument()
expect(screen.queryByText('mobileTrip.collabFeatureDisabled')).not.toBeInTheDocument()
})
it('FE-MOB-CTAB-008: renders nothing for a sub-tab it does not handle', () => {
const { container } = renderTab('whatsnext' as MTripCollabTab)
expect(container).toBeEmptyDOMElement()
expect(screen.queryByText('mobileTrip.collabFeatureDisabled')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,525 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import MCostSheet from '../../../../src/mobile/screens/trip/sheets/MCostSheet'
import type { TripMember } from '../../../../src/components/Budget/BudgetPanelMemberChips'
import { clearExchangeRateCache } from '../../../../src/hooks/useExchangeRates'
import { useTripStore, type TripStoreState } from '../../../../src/store/tripStore'
import type { BudgetItem, BudgetItemMember } from '../../../../src/types'
import { buildBudgetItem } from '../../../helpers/factories'
import { resetAllStores } from '../../../helpers/store'
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-COSTSH-001 to FE-MOB-COSTSH-030
// The sheet reads its copy from useTranslation(), so assertions are English.
// EUR formats de-DE style: displayed amounts and inputs use a comma.
const PEOPLE = [
{ id: 1, username: 'alice', avatar_url: null },
{ id: 2, username: 'bob', avatar_url: null },
] as TripMember[]
const TODAY = new Date().toISOString().slice(0, 10)
function member(user_id: number, amount: number | null): BudgetItemMember {
return { user_id, paid: 0, username: user_id === 1 ? 'alice' : 'bob', amount }
}
let addBudgetItem: ReturnType<typeof vi.fn>
let updateBudgetItem: ReturnType<typeof vi.fn>
let deleteBudgetItem: ReturnType<typeof vi.fn>
let addToast: ReturnType<typeof vi.fn>
interface SheetOverrides {
people?: TripMember[]
me?: number
base?: string
editing?: BudgetItem | null
prefill?: { name?: string; category?: string; amount?: number; reservationId?: number }
}
function renderSheet(overrides: SheetOverrides = {}) {
const onClose = vi.fn()
const onSaved = vi.fn()
const view = render(
<MCostSheet
tripId={1}
base={overrides.base ?? 'EUR'}
people={overrides.people ?? PEOPLE}
me={overrides.me ?? 1}
editing={overrides.editing ?? null}
prefill={overrides.prefill}
onClose={onClose}
onSaved={onSaved}
/>,
)
return { ...view, onClose, onSaved }
}
const nameField = () => screen.getByPlaceholderText('e.g. Dinner, souvenirs, gas…')
const totalField = () => screen.getAllByPlaceholderText('0,00')[0]
const submit = () => screen.getByRole('button', { name: 'Add' })
const saveBtn = () => screen.getByRole('button', { name: 'Save' })
function fillBasics(name: string, total: string) {
fireEvent.change(nameField(), { target: { value: name } })
fireEvent.change(totalField(), { target: { value: total } })
}
describe('MCostSheet', () => {
beforeEach(() => {
resetAllStores()
clearExchangeRateCache()
// Seeded FX cache, fresh enough that the hook never reaches the network.
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.25 }, ts: Date.now() }))
addBudgetItem = vi.fn(async () => buildBudgetItem({ id: 9 }))
updateBudgetItem = vi.fn(async () => buildBudgetItem({ id: 9 }))
deleteBudgetItem = vi.fn(async () => undefined)
useTripStore.setState(
{ addBudgetItem, updateBudgetItem, deleteBudgetItem } as unknown as Partial<TripStoreState>,
)
addToast = vi.fn()
;(window as unknown as { __addToast: unknown }).__addToast = addToast
})
afterEach(() => {
delete (window as unknown as { __addToast?: unknown }).__addToast
})
it('FE-MOB-COSTSH-001: opens in add mode with everyone in an equal split and a blocked submit', () => {
renderSheet()
expect(screen.getByRole('dialog', { name: 'Add expense' })).toBeInTheDocument()
expect(nameField()).toHaveValue('')
expect(screen.getByRole('button', { name: 'Food & drink' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'Y You' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'B bob' })).toBeInTheDocument()
expect(submit()).toBeDisabled()
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument()
})
it('FE-MOB-COSTSH-002: splits the typed total equally and saves the expense', async () => {
const { onSaved } = renderSheet()
// A comma is normalised to a dot before it reaches the state.
fillBasics('Dinner', '85,50')
expect(screen.getAllByText('€42.75')).toHaveLength(2)
expect(screen.getByText('Split 2 ways · €42.75 each')).toBeInTheDocument()
fireEvent.click(submit())
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
expect(addBudgetItem).toHaveBeenCalledWith(1, {
name: 'Dinner',
category: 'food',
currency: 'EUR',
payers: [{ user_id: 1, amount: 85.5 }],
members: [{ user_id: 1, amount: null }, { user_id: 2, amount: null }],
member_ids: [1, 2],
expense_date: TODAY,
total_price: 85.5,
note: null,
})
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1))
})
it('FE-MOB-COSTSH-003: adopts the prefill and carries the reservation link into the payload', async () => {
renderSheet({ prefill: { name: 'Ryokan', category: 'accommodation', amount: 240, reservationId: 77 } })
expect(nameField()).toHaveValue('Ryokan')
expect(totalField()).toHaveValue('240')
expect(screen.getByRole('button', { name: 'Accommodation' })).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(submit())
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
name: 'Ryokan', category: 'accommodation', total_price: 240, reservation_id: 77,
}))
})
it('FE-MOB-COSTSH-004: dropping a participant re-splits and shrinks the member list', async () => {
renderSheet()
fillBasics('Taxi', '30')
fireEvent.click(screen.getByRole('button', { name: 'B bob' }))
expect(screen.getByText('Split 1 ways · €30.00 each')).toBeInTheDocument()
expect(screen.getAllByText('Tap to include')).toHaveLength(1)
fireEvent.click(submit())
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
members: [{ user_id: 1, amount: null }],
member_ids: [1],
payers: [{ user_id: 1, amount: 30 }],
}))
})
it('FE-MOB-COSTSH-005: an expense nobody shares is saved without payers or members', async () => {
renderSheet()
fillBasics('Souvenir', '12')
fireEvent.click(screen.getByRole('button', { name: 'Y You' }))
fireEvent.click(screen.getByRole('button', { name: 'B bob' }))
fireEvent.click(submit())
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({ payers: [], members: [], member_ids: [] }))
})
it('FE-MOB-COSTSH-006: switching the category updates the pressed pill and the payload', async () => {
renderSheet()
fillBasics('Metro pass', '20')
fireEvent.click(screen.getByRole('button', { name: 'Transport' }))
expect(screen.getByRole('button', { name: 'Transport' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'Food & drink' })).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(submit())
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({ category: 'transport' })))
})
it('FE-MOB-COSTSH-007: a foreign currency shows the live conversion into the base', () => {
renderSheet()
fillBasics('Hotel', '100')
expect(screen.queryByText(/live rate/)).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'EUR €' }))
fireEvent.change(screen.getByPlaceholderText('...'), { target: { value: 'USD' } })
fireEvent.click(screen.getByRole('button', { name: 'USD $' }))
expect(screen.getByText('$100.00')).toBeInTheDocument()
// 100 USD at 1.25 USD per EUR = 80 EUR.
expect(screen.getByRole('dialog').textContent).toContain('80,00')
expect(screen.getByText(/live rate/)).toBeInTheDocument()
})
it('FE-MOB-COSTSH-008: "No one paid yet" saves a planning entry without payers', async () => {
renderSheet()
fillBasics('Tickets', '60')
fireEvent.click(screen.getByRole('button', { name: 'You' }))
fireEvent.click(screen.getByRole('button', { name: 'No one paid yet' }))
fireEvent.click(submit())
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({ payers: [] })))
})
it('FE-MOB-COSTSH-009: multiple payers start out balanced on the current payer', () => {
renderSheet()
fillBasics('Group ticket', '100')
fireEvent.click(screen.getByRole('button', { name: 'Multiple people paid' }))
const amounts = screen.getAllByPlaceholderText('0,00')
// Total field plus the single active payer row.
expect(amounts).toHaveLength(2)
expect(amounts[1]).toHaveValue('100,00')
// The hint next to an inactive payer is a second way into the same toggle.
fireEvent.click(screen.getByRole('button', { name: 'Tap to include' }))
expect(screen.getAllByPlaceholderText('0,00')[2]).toHaveValue('50,00')
})
it('FE-MOB-COSTSH-010: adding a second payer spreads the total and saves both', async () => {
renderSheet()
fillBasics('Group ticket', '100')
fireEvent.click(screen.getByRole('button', { name: 'Multiple people paid' }))
fireEvent.click(screen.getAllByRole('button', { name: 'B bob' })[0])
const amounts = screen.getAllByPlaceholderText('0,00')
expect(amounts[1]).toHaveValue('50,00')
expect(amounts[2]).toHaveValue('50,00')
fireEvent.click(submit())
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
payers: [{ user_id: 1, amount: 50 }, { user_id: 2, amount: 50 }],
})))
})
it('FE-MOB-COSTSH-011: a typed payer amount is pinned and the rest absorbs the remainder', () => {
renderSheet()
fillBasics('Group ticket', '100')
fireEvent.click(screen.getByRole('button', { name: 'Multiple people paid' }))
fireEvent.click(screen.getAllByRole('button', { name: 'B bob' })[0])
fireEvent.change(screen.getAllByPlaceholderText('0,00')[1], { target: { value: '30' } })
const amounts = screen.getAllByPlaceholderText('0,00')
expect(amounts[1]).toHaveValue('30')
expect(amounts[2]).toHaveValue('70,00')
})
it('FE-MOB-COSTSH-012: payers that do not add up block the save and explain why', () => {
renderSheet()
fillBasics('Group ticket', '100')
fireEvent.click(screen.getByRole('button', { name: 'Multiple people paid' }))
fireEvent.click(screen.getAllByRole('button', { name: 'B bob' })[0])
fireEvent.change(screen.getAllByPlaceholderText('0,00')[1], { target: { value: '30' } })
fireEvent.change(screen.getAllByPlaceholderText('0,00')[2], { target: { value: '30' } })
expect(screen.getByText(/Payer amounts must add up to/)).toBeInTheDocument()
expect(submit()).toBeDisabled()
})
it('FE-MOB-COSTSH-013: going back to a single payer keeps the remaining one selected', () => {
renderSheet()
fillBasics('Group ticket', '100')
fireEvent.click(screen.getByRole('button', { name: 'Multiple people paid' }))
fireEvent.click(screen.getAllByRole('button', { name: 'B bob' })[0])
fireEvent.click(screen.getAllByRole('button', { name: 'Y You' })[0])
fireEvent.click(screen.getByRole('button', { name: 'One person paid' }))
expect(screen.getByRole('button', { name: 'bob' })).toBeInTheDocument()
expect(screen.queryByText(/Payer amounts must add up to/)).not.toBeInTheDocument()
})
it('FE-MOB-COSTSH-014: a custom split must match the total before it can be saved', async () => {
renderSheet()
fillBasics('Dinner', '100')
fireEvent.click(screen.getByRole('button', { name: 'Custom' }))
const shares = screen.getAllByPlaceholderText('50,00')
expect(shares).toHaveLength(2)
fireEvent.change(shares[0], { target: { value: '30' } })
expect(screen.getByText('€30.00 / €100.00')).toBeInTheDocument()
expect(submit()).toBeDisabled()
// The remaining share now suggests what is left of the total.
fireEvent.change(screen.getByPlaceholderText('70,00'), { target: { value: '70' } })
expect(screen.getByText('Split 2 ways · €50.00 each')).toBeInTheDocument()
fireEvent.click(submit())
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
members: [{ user_id: 1, amount: 30 }, { user_id: 2, amount: 70 }],
})))
})
it('FE-MOB-COSTSH-015: a custom share rejects a third decimal and is cleared when the member drops out', () => {
renderSheet()
fillBasics('Dinner', '100')
fireEvent.click(screen.getByRole('button', { name: 'Custom' }))
const share = screen.getAllByPlaceholderText('50,00')[0]
fireEvent.change(share, { target: { value: '30.123' } })
expect(share).toHaveValue('')
fireEvent.change(share, { target: { value: '30,5' } })
expect(screen.getByDisplayValue('30,5')).toBe(share)
fireEvent.click(screen.getByRole('button', { name: 'Y You' }))
fireEvent.click(screen.getByRole('button', { name: 'Tap to include' }))
expect(screen.getAllByPlaceholderText('50,00')[0]).toHaveValue('')
})
it('FE-MOB-COSTSH-016: ticket mode derives the total and the per-person shares from the items', async () => {
renderSheet()
fireEvent.change(nameField(), { target: { value: 'Izakaya' } })
fireEvent.click(screen.getByRole('button', { name: 'Ticket' }))
expect(totalField()).toBeDisabled()
fireEvent.click(screen.getAllByRole('button', { name: 'Add' })[0])
fireEvent.change(screen.getByPlaceholderText('What was it for?'), { target: { value: 'Ramen' } })
fireEvent.change(screen.getAllByPlaceholderText('0,00')[1], { target: { value: '20' } })
expect(totalField()).toHaveValue('20,00')
expect(screen.getAllByText('€10.00')).toHaveLength(2)
fireEvent.click(screen.getAllByRole('button', { name: 'Add' })[1])
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
total_price: 20,
members: [{ user_id: 1, amount: 10 }, { user_id: 2, amount: 10 }],
note: 'TICKETJSON:{"items":[{"name":"Ramen","price":"20","parts":[1,2]}]}',
}))
})
it('FE-MOB-COSTSH-017: a ticket item without a name or a participant blocks the save', () => {
renderSheet()
fireEvent.change(nameField(), { target: { value: 'Izakaya' } })
fireEvent.click(screen.getByRole('button', { name: 'Ticket' }))
fireEvent.click(screen.getAllByRole('button', { name: 'Add' })[0])
const footerSubmit = () => screen.getAllByRole('button', { name: 'Add' })[1]
fireEvent.change(screen.getAllByPlaceholderText('0,00')[1], { target: { value: '20' } })
expect(footerSubmit()).toBeDisabled()
fireEvent.change(screen.getByPlaceholderText('What was it for?'), { target: { value: 'Ramen' } })
expect(footerSubmit()).toBeEnabled()
// Un-ticking everyone makes the item unsplittable again.
fireEvent.click(screen.getByRole('button', { name: 'Y You' }))
fireEvent.click(screen.getByRole('button', { name: 'B bob' }))
expect(footerSubmit()).toBeDisabled()
})
it('FE-MOB-COSTSH-018: removing the last ticket item drops the share breakdown', () => {
renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Ticket' }))
fireEvent.click(screen.getAllByRole('button', { name: 'Add' })[0])
fireEvent.change(screen.getByPlaceholderText('What was it for?'), { target: { value: 'Ramen' } })
expect(screen.getAllByText('Split')).toHaveLength(2)
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
expect(screen.queryByPlaceholderText('What was it for?')).not.toBeInTheDocument()
expect(screen.getAllByText('Split')).toHaveLength(1)
})
it('FE-MOB-COSTSH-019: editing loads name, currency, legacy category and the custom shares', () => {
const editing = buildBudgetItem({
id: 5, name: 'Flight to Osaka', category: 'Flight', currency: 'usd', total_price: 60,
expense_date: '2026-03-04',
members: [member(1, 20), member(2, 40)],
payers: [{ user_id: 2, amount: 60 }],
})
renderSheet({ editing })
expect(screen.getByRole('dialog', { name: 'Edit expense' })).toBeInTheDocument()
expect(nameField()).toHaveValue('Flight to Osaka')
expect(screen.getByRole('button', { name: 'Flights' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'USD $' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'bob' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Custom' })).toHaveClass('bg-m-act')
// USD renders with a dot, so the total and both shares share this placeholder.
const amounts = screen.getAllByPlaceholderText('0.00')
expect(amounts[0]).toHaveValue('60')
expect(amounts[1]).toHaveValue('20')
expect(amounts[2]).toHaveValue('40')
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument()
})
it('FE-MOB-COSTSH-020: saving an edit goes through updateBudgetItem', async () => {
const editing = buildBudgetItem({
id: 5, name: 'Dinner', category: 'food', currency: 'EUR', total_price: 60,
members: [member(1, null), member(2, null)],
payers: [{ user_id: 1, amount: 60 }],
})
const { onSaved } = renderSheet({ editing })
fireEvent.change(nameField(), { target: { value: 'Dinner & drinks' } })
fireEvent.click(saveBtn())
await waitFor(() => expect(updateBudgetItem).toHaveBeenCalledTimes(1))
expect(updateBudgetItem).toHaveBeenCalledWith(1, 5, expect.objectContaining({
name: 'Dinner & drinks',
total_price: 60,
members: [{ user_id: 1, amount: null }, { user_id: 2, amount: null }],
}))
expect(addBudgetItem).not.toHaveBeenCalled()
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1))
})
it('FE-MOB-COSTSH-021: an expense with two payers reopens in multi-payer mode', () => {
const editing = buildBudgetItem({
id: 6, name: 'Rental car', category: 'transport', currency: 'EUR', total_price: 100,
members: [member(1, null), member(2, null)],
payers: [{ user_id: 1, amount: 40 }, { user_id: 2, amount: 60 }],
})
renderSheet({ editing })
expect(screen.getByRole('button', { name: 'One person paid' })).toBeInTheDocument()
const amounts = screen.getAllByPlaceholderText('0,00')
expect(amounts[1]).toHaveValue('40')
expect(amounts[2]).toHaveValue('60')
expect(screen.queryByText(/Payer amounts must add up to/)).not.toBeInTheDocument()
})
it('FE-MOB-COSTSH-022: an expense nobody paid reopens on the placeholder payer', () => {
const editing = buildBudgetItem({
id: 7, name: 'Museum', category: 'sightseeing', currency: 'EUR', total_price: 0,
members: [], payers: [],
})
renderSheet({ editing })
expect(screen.getByRole('button', { name: 'No one paid yet' })).toBeInTheDocument()
expect(totalField()).toHaveValue('')
expect(saveBtn()).toBeDisabled()
})
it('FE-MOB-COSTSH-023: a stored ticket note reopens as editable ticket rows', () => {
const editing = buildBudgetItem({
id: 8, name: 'Izakaya', category: 'food', currency: 'EUR', total_price: 20,
members: [member(1, 10), member(2, 10)],
note: 'TICKETJSON:{"items":[{"name":"Ramen","price":"20","parts":[1,2]}]}',
})
renderSheet({ editing })
expect(screen.getByRole('button', { name: 'Ticket' })).toHaveClass('bg-m-act')
expect(screen.getByPlaceholderText('What was it for?')).toHaveValue('Ramen')
expect(screen.getAllByPlaceholderText('0,00')[1]).toHaveValue('20')
expect(totalField()).toHaveValue('20,00')
})
it('FE-MOB-COSTSH-024: a corrupt ticket note still opens the sheet with an empty item list', () => {
const editing = buildBudgetItem({
id: 9, name: 'Izakaya', category: 'food', currency: 'EUR', total_price: 20,
members: [], note: 'TICKETJSON:{not json',
})
renderSheet({ editing })
expect(screen.getByRole('button', { name: 'Ticket' })).toHaveClass('bg-m-act')
expect(screen.queryByPlaceholderText('What was it for?')).not.toBeInTheDocument()
expect(saveBtn()).toBeDisabled()
})
it('FE-MOB-COSTSH-025: deleting takes two taps and warns in between', async () => {
const editing = buildBudgetItem({ id: 5, name: 'Dinner', category: 'food', currency: 'EUR', total_price: 60, members: [] })
const { onSaved } = renderSheet({ editing })
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
expect(addToast).toHaveBeenCalledWith('Tap again to delete', 'warning', undefined)
expect(deleteBudgetItem).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: 'Delete' }).className).toContain('--m-st-danger')
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
await waitFor(() => expect(deleteBudgetItem).toHaveBeenCalledWith(1, 5))
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1))
})
it('FE-MOB-COSTSH-026: a failing delete disarms the button and reports the error', async () => {
deleteBudgetItem.mockRejectedValueOnce(new Error('locked'))
const editing = buildBudgetItem({ id: 5, name: 'Dinner', category: 'food', currency: 'EUR', total_price: 60, members: [] })
const { onSaved } = renderSheet({ editing })
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
expect(onSaved).not.toHaveBeenCalled()
await waitFor(() => expect(screen.getByRole('button', { name: 'Delete' }).className).not.toContain('--m-st-danger'))
})
it('FE-MOB-COSTSH-027: a failing save reports the error and lets the user retry', async () => {
addBudgetItem.mockRejectedValueOnce(new Error('offline'))
const { onSaved } = renderSheet()
fillBasics('Dinner', '20')
fireEvent.click(submit())
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
expect(onSaved).not.toHaveBeenCalled()
await waitFor(() => expect(submit()).toBeEnabled())
})
it('FE-MOB-COSTSH-028: cancel reports the close only after the exit animation', () => {
vi.useFakeTimers()
try {
const { onClose } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onClose).not.toHaveBeenCalled()
act(() => { vi.advanceTimersByTime(280) })
expect(onClose).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('FE-MOB-COSTSH-029: renders member avatars and flags guests', () => {
const people = [
{ id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.png' },
{ id: 2, username: 'bob', avatar_url: null, is_guest: true },
] as TripMember[]
renderSheet({ people })
// The sheet lives in a portal, so query from the document root.
expect(document.querySelector('img[src="/uploads/avatars/alice.png"]')).not.toBeNull()
expect(screen.getByText('Guest')).toBeInTheDocument()
})
it('FE-MOB-COSTSH-030: a currency without a known symbol falls back to its code', () => {
localStorage.setItem('trek_fx_XTS', JSON.stringify({ rates: { XTS: 1 }, ts: Date.now() }))
renderSheet({ base: 'XTS' })
fireEvent.change(nameField(), { target: { value: 'Test purchase' } })
fireEvent.change(screen.getByPlaceholderText('0.00'), { target: { value: '10' } })
expect(screen.getAllByText('XTS 5.00')).toHaveLength(2)
expect(screen.getByText('Split 2 ways · XTS 5.00 each')).toBeInTheDocument()
})
})
@@ -0,0 +1,557 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import MCostsTab from '../../../../src/mobile/screens/trip/tabs/MCostsTab'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { budgetApi } from '../../../../src/api/client'
import { clearExchangeRateCache } from '../../../../src/hooks/useExchangeRates'
import { useAuthStore } from '../../../../src/store/authStore'
import { useSettingsStore } from '../../../../src/store/settingsStore'
import type { BudgetItem, Day, Trip } from '../../../../src/types'
import { buildTrip, buildUser } from '../../../helpers/factories'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { server } from '../../../helpers/msw/server'
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
// FE-MOB-COSTT-001 to FE-MOB-COSTT-037
// The add/edit expense sheet is the shared desktop-sized form; the panel only
// owns when it opens and what happens on save, so it is stubbed here.
vi.mock('../../../../src/mobile/screens/trip/sheets/MCostSheet', () => ({
default: ({ base, editing, onClose, onSaved }: {
base: string
editing: { id: number } | null
onClose: () => void
onSaved: () => void
}) => (
<div>
<span>{editing ? `sheet editing ${editing.id}` : `sheet new ${base}`}</span>
<button type="button" onClick={onSaved}>sheet save</button>
<button type="button" onClick={onClose}>sheet close</button>
</div>
),
}))
const TRIP = buildTrip({ id: 7, title: 'Tokyo Trip', currency: 'USD' }) as Trip
const DAYS = [
{ id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null },
{ id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: 'Kyoto' },
] as unknown as Day[]
const MEMBERS = [
{ id: 1, username: 'Me', avatar_url: null },
{ id: 2, username: 'Ada', avatar_url: '/uploads/avatars/ada.png' },
{ id: 3, username: 'Bob', avatar_url: null },
]
const RAMEN = {
id: 11, trip_id: 7, name: 'Ramen', category: 'food', total_price: 80, currency: 'USD',
expense_date: '2026-05-02', note: null,
members: [
{ user_id: 1, paid: 1, username: 'Me', amount: null, avatar_url: null },
{ user_id: 2, paid: 0, username: 'Ada', amount: null, avatar_url: '/uploads/avatars/ada.png' },
],
payers: [{ user_id: 1, amount: 80, username: 'Me' }],
} as unknown as BudgetItem
/** Foreign currency + explicit custom shares — exercises the conversion line. */
const MUSEUM = {
id: 12, trip_id: 7, name: 'Museum', category: 'activities', total_price: 30, currency: 'GBP',
expense_date: '2026-04-30', note: 'Tickets; adult',
members: [
{ user_id: 1, paid: 0, username: 'Me', amount: 10, avatar_url: null },
{ user_id: 3, paid: 0, username: 'Bob', amount: 20, avatar_url: null },
],
payers: [{ user_id: 3, amount: 30, username: 'Bob' }],
} as unknown as BudgetItem
/** No currency (falls back to the trip's), no members, no payer → the "unfinished" case. */
const SOUVENIR = {
id: 13, trip_id: 7, name: 'Souvenirs', category: 'shopping', total_price: 25, currency: null,
expense_date: null, note: null, payers: [],
} as unknown as BudgetItem
/** I paid exactly my own share — counts as "mine" but never as "owed". */
const TAXI = {
id: 14, trip_id: 7, name: 'Taxi', category: 'transport', total_price: 20, currency: 'USD',
expense_date: '2026-05-02', note: 'TICKETJSON:{"items":[]}',
members: [{ user_id: 1, paid: 1, username: 'Me', amount: 20, avatar_url: null }],
payers: [{ user_id: 1, amount: 20, username: 'Me' }],
} as unknown as BudgetItem
const ITEMS = [RAMEN, MUSEUM, SOUVENIR, TAXI]
const SETTLEMENT = {
balances: [
{ user_id: 1, username: 'Me', avatar_url: null, balance: 12.5 },
{ user_id: 2, username: 'Ada', avatar_url: '/uploads/avatars/ada.png', balance: -7.25 },
],
flows: [
{ from: { user_id: 1, username: 'Me' }, to: { user_id: 2, username: 'Ada' }, amount: 40 },
{ from: { user_id: 3, username: 'Bob' }, to: { user_id: 1, username: 'Me' }, amount: 15 },
{ from: { user_id: 99, username: 'Ghost' }, to: { user_id: 3, username: 'Bob' }, amount: 5 },
],
}
let settlementBases: string[] = []
function serveSettlement(body: unknown = SETTLEMENT, fail = false) {
server.use(
http.get('/api/trips/:id/budget/settlement', ({ request }) => {
settlementBases.push(new URL(request.url).searchParams.get('base') ?? '')
return fail ? HttpResponse.error() : HttpResponse.json(body)
}),
)
}
function planner(overrides: Partial<TripPlanner> = {}) {
return buildPlanner({
tripId: 7,
trip: TRIP,
days: DAYS,
budgetItems: ITEMS,
tripMembers: MEMBERS as unknown as TripPlanner['tripMembers'],
...overrides,
})
}
/** Renders and waits until the settlement response has landed in state. */
async function renderTab(p: TripPlanner = planner(), shell: MTripShellApi = buildShell()) {
const view = render(<MCostsTab planner={p} shell={shell} />)
await screen.findByText('+$12.50')
return { ...view, planner: p, shell }
}
/** Walks up `n` levels — the panel's blocks are plain divs without test ids. */
function up(el: HTMLElement, n: number): HTMLElement {
let node: HTMLElement = el
for (let i = 0; i < n; i++) node = node.parentElement as HTMLElement
return node
}
/** The expense card of a row, found from its name. */
function cardOf(name: string): HTMLElement {
let el = screen.getByText(name).parentElement
while (el && !el.className.includes('rounded-2xl')) el = el.parentElement
if (!el) throw new Error(`no card for ${name}`)
return el
}
/** Card plus the edit/delete stack that sits next to it. */
function rowOf(name: string): HTMLElement {
return up(cardOf(name), 1)
}
describe('MCostsTab', () => {
beforeEach(() => {
resetAllStores()
clearExchangeRateCache()
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'Me' }) })
localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, GBP: 0.5 }, ts: Date.now() }))
localStorage.setItem('trek_fx_GBP', JSON.stringify({ rates: { GBP: 1, USD: 2 }, ts: Date.now() }))
settlementBases = []
serveSettlement()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('FE-MOB-COSTT-001: loads the items and the settlement for the display currency', async () => {
const { planner: p } = await renderTab()
expect(p.tripActions.loadBudgetItems).toHaveBeenCalledWith(7)
expect(settlementBases).toEqual(['USD'])
})
it('FE-MOB-COSTT-002: sums the hero totals across currencies', async () => {
await renderTab()
const hero = up(screen.getByText('costs.totalSpend'), 1)
expect(hero.textContent).toContain('$185.00')
expect(hero.textContent).toContain('costs.yourShare')
expect(hero.textContent).toContain('$80.00')
expect(hero.textContent).toContain('costs.youPaid')
expect(hero.textContent).toContain('$100.00')
})
it('FE-MOB-COSTT-003: derives owe / owed from the settlement flows', async () => {
await renderTab()
expect(up(screen.getByText('costs.youOwe'), 1).textContent).toContain('$40.00')
expect(up(screen.getByText('costs.youreOwed'), 1).textContent).toContain('$15.00')
})
it('FE-MOB-COSTT-004: reports the unpaid expenses as outstanding', async () => {
await renderTab()
const block = up(screen.getByText('costs.outstanding'), 2)
expect(block.textContent).toContain('1')
expect(block.textContent).toContain('$25.00')
})
it('FE-MOB-COSTT-005: hides the outstanding block when everything is paid', async () => {
await renderTab(planner({ budgetItems: [RAMEN, TAXI] }))
expect(screen.queryByText('costs.outstanding')).not.toBeInTheDocument()
})
it('FE-MOB-COSTT-006: lists the settle-up flows and names the participants', async () => {
await renderTab()
const settle = up(screen.getByText('costs.settleUp'), 2)
expect(within(settle).getByText('$40.00')).toBeInTheDocument()
expect(within(settle).getByText('$15.00')).toBeInTheDocument()
expect(within(settle).getByText('$5.00')).toBeInTheDocument()
expect(within(settle).getByText('?')).toBeInTheDocument()
expect(within(settle).getAllByText('costs.you')).toHaveLength(3)
expect(within(settle).getAllByText('Ada')).toHaveLength(2)
})
it('FE-MOB-COSTT-007: shows each member balance with its sign, zero for unknown members', async () => {
await renderTab()
const settle = up(screen.getByText('costs.settleUp'), 2)
expect(within(settle).getByText('+$12.50')).toBeInTheDocument()
expect(within(settle).getByText('−$7.25')).toBeInTheDocument()
expect(within(settle).getByText('$0.00')).toBeInTheDocument()
expect(within(settle).getByAltText('')).toHaveAttribute('src', '/uploads/avatars/ada.png')
})
it('FE-MOB-COSTT-008: collapses and reopens the settle-up card by click and by keyboard', async () => {
await renderTab()
const head = screen.getByRole('button', { expanded: true })
fireEvent.click(head)
expect(screen.queryByText('costs.balances')).not.toBeInTheDocument()
fireEvent.keyDown(head, { key: 'Enter' })
expect(screen.getByText('costs.balances')).toBeInTheDocument()
fireEvent.keyDown(head, { key: ' ' })
expect(screen.queryByText('costs.balances')).not.toBeInTheDocument()
})
it('FE-MOB-COSTT-009: celebrates a settlement without open flows', async () => {
serveSettlement({ balances: SETTLEMENT.balances, flows: [] })
await renderTab()
expect(screen.getByText('costs.everyoneSquare')).toBeInTheDocument()
expect(screen.getByText('costs.nothingOutstanding')).toBeInTheDocument()
expect(up(screen.getByText('costs.youOwe'), 1).textContent).toContain('$0.00')
})
it('FE-MOB-COSTT-010: survives a failing settlement request', async () => {
serveSettlement(null, true)
render(<MCostsTab planner={planner()} shell={buildShell()} />)
await waitFor(() => expect(settlementBases).toEqual(['USD']))
expect(screen.getByText('costs.everyoneSquare')).toBeInTheDocument()
const settle = up(screen.getByText('costs.settleUp'), 2)
expect(within(settle).getAllByText('$0.00')).toHaveLength(3)
})
it('FE-MOB-COSTT-011: breaks the spend down by category, largest bar first', async () => {
await renderTab()
const block = up(screen.getByText('costs.byCategory'), 1)
const labels = within(block).getAllByText(/^costs\.cat\./).map(n => n.textContent)
expect(labels).toEqual(['costs.cat.food', 'costs.cat.activities', 'costs.cat.shopping', 'costs.cat.transport'])
expect(within(block).getByText('$60.00')).toBeInTheDocument()
expect(within(block).getByText('$20.00')).toBeInTheDocument()
})
it('FE-MOB-COSTT-012: shows the empty panel without any expense', async () => {
await renderTab(planner({ budgetItems: [] }))
expect(screen.getByText('costs.noCategories')).toBeInTheDocument()
expect(screen.getByText('costs.emptyText')).toBeInTheDocument()
expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('$0.00')
})
it('FE-MOB-COSTT-013: groups the expenses by day with a per-day total', async () => {
await renderTab()
expect(screen.getByText('Sat, May 2')).toBeInTheDocument()
expect(screen.getByText('Thu, Apr 30')).toBeInTheDocument()
expect(screen.getByText('costs.noDate')).toBeInTheDocument()
expect(screen.getByText('costs.spent:$100.00')).toBeInTheDocument()
expect(screen.getByText('costs.spent:$60.00')).toBeInTheDocument()
expect(screen.getByText('costs.spent:$25.00')).toBeInTheDocument()
})
it('FE-MOB-COSTT-014: converts a foreign expense and shows both amounts', async () => {
await renderTab()
const card = cardOf('Museum')
expect(within(card).getByText('£30.00 → $60.00')).toBeInTheDocument()
expect(within(card).getByText('costs.cat.activities')).toBeInTheDocument()
expect(within(card).getByText('$40.00')).toBeInTheDocument()
})
it('FE-MOB-COSTT-015: marks an expense nobody paid as unfinished', async () => {
await renderTab()
expect(within(cardOf('Souvenirs')).getByText('costs.unfinished')).toBeInTheDocument()
expect(within(cardOf('Ramen')).queryByText('costs.unfinished')).not.toBeInTheDocument()
})
it('FE-MOB-COSTT-016: renders the member chips with their share and paid state', async () => {
await renderTab()
const card = cardOf('Ramen')
const chips = within(card).getAllByRole('button')
expect(chips).toHaveLength(2)
expect(chips[0]).toHaveAttribute('aria-pressed', 'true')
expect(chips[1]).toHaveAttribute('aria-pressed', 'false')
expect(chips.map(c => c.textContent)).toEqual(['costs.youShort$40.00', '$40.00'])
})
it('FE-MOB-COSTT-017: toggles a member chip to paid', async () => {
const p = planner()
await renderTab(p)
fireEvent.click(within(cardOf('Ramen')).getByRole('button', { pressed: false }))
await waitFor(() => expect(p.tripActions.toggleBudgetMemberPaid).toHaveBeenCalledWith(7, 11, 2, true))
})
it('FE-MOB-COSTT-018: toasts when the paid toggle fails', async () => {
const p = planner()
vi.mocked(p.tripActions.toggleBudgetMemberPaid).mockRejectedValueOnce(new Error('offline'))
await renderTab(p)
fireEvent.click(within(cardOf('Ramen')).getByRole('button', { pressed: false }))
await waitFor(() => expect(p.toast.error).toHaveBeenCalledWith('common.unknownError'))
})
it('FE-MOB-COSTT-019: filters the list by the search field', async () => {
await renderTab()
const search = screen.getByLabelText('costs.searchPlaceholder')
fireEvent.change(search, { target: { value: 'muse' } })
expect(screen.getByText('Museum')).toBeInTheDocument()
expect(screen.queryByText('Ramen')).not.toBeInTheDocument()
fireEvent.change(search, { target: { value: 'zzz' } })
expect(screen.getByText('costs.noMatch')).toBeInTheDocument()
})
it('FE-MOB-COSTT-020: segments the list into mine and owed', async () => {
await renderTab()
const mine = screen.getByRole('button', { name: 'costs.filter.mine' })
fireEvent.click(mine)
expect(mine).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByText('Ramen')).toBeInTheDocument()
expect(screen.getByText('Taxi')).toBeInTheDocument()
expect(screen.queryByText('Museum')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'costs.filter.owed' }))
expect(screen.getByText('Ramen')).toBeInTheDocument()
expect(screen.queryByText('Taxi')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'costs.filter.all' }))
expect(screen.getByText('Museum')).toBeInTheDocument()
})
it('FE-MOB-COSTT-021: filters by a category that is actually in use', async () => {
await renderTab()
const trigger = screen.getByText('costs.filter.allCategories').closest('button') as HTMLElement
fireEvent.click(trigger)
const menu = up(trigger, 1).nextElementSibling as HTMLElement
const options = within(menu).getAllByRole('button').map(b => b.textContent)
expect(options).toEqual([
'costs.filter.allCategories', 'costs.cat.food', 'costs.cat.transport', 'costs.cat.activities', 'costs.cat.shopping',
])
fireEvent.click(within(menu).getByText('costs.cat.activities'))
expect(screen.getByText('Museum')).toBeInTheDocument()
expect(screen.queryByText('Ramen')).not.toBeInTheDocument()
expect(trigger).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(trigger)
fireEvent.click(within(up(trigger, 1).nextElementSibling as HTMLElement).getByText('costs.filter.allCategories'))
expect(screen.getByText('Ramen')).toBeInTheDocument()
})
it('FE-MOB-COSTT-022: filters by expense day, labelling known days with their number', async () => {
await renderTab()
const trigger = screen.getByText('costs.filter.allDays').closest('button') as HTMLElement
fireEvent.click(trigger)
const menu = up(trigger, 1).nextElementSibling as HTMLElement
expect(within(menu).getAllByRole('button').map(b => b.textContent)).toEqual([
'costs.filter.allDays', 'Apr 30', 'dayplan.dayN:2 · May 2',
])
fireEvent.click(within(menu).getByText('dayplan.dayN:2 · May 2'))
expect(screen.getByText('Ramen')).toBeInTheDocument()
expect(screen.getByText('Taxi')).toBeInTheDocument()
expect(screen.queryByText('Museum')).not.toBeInTheDocument()
expect(trigger.textContent).toBe('dayplan.dayN:2 · May 2')
})
it('FE-MOB-COSTT-023: only one filter menu is open at a time', async () => {
await renderTab()
const catTrigger = screen.getByText('costs.filter.allCategories').closest('button') as HTMLElement
const dayTrigger = screen.getByText('costs.filter.allDays').closest('button') as HTMLElement
fireEvent.click(catTrigger)
fireEvent.click(dayTrigger)
expect(catTrigger).toHaveAttribute('aria-expanded', 'false')
expect(dayTrigger).toHaveAttribute('aria-expanded', 'true')
})
it('FE-MOB-COSTT-024: opens the edit sheet from a row and reloads on save', async () => {
const p = planner()
await renderTab(p)
fireEvent.click(within(rowOf('Ramen')).getByRole('button', { name: 'common.edit' }))
expect(screen.getByText('sheet editing 11')).toBeInTheDocument()
fireEvent.click(screen.getByText('sheet save'))
expect(screen.queryByText('sheet editing 11')).not.toBeInTheDocument()
expect(p.tripActions.loadBudgetItems).toHaveBeenCalledTimes(2)
await waitFor(() => expect(settlementBases).toEqual(['USD', 'USD']))
})
it('FE-MOB-COSTT-025: deletes an expense after confirming and refreshes the settlement', async () => {
const p = planner()
await renderTab(p)
fireEvent.click(within(rowOf('Ramen')).getByRole('button', { name: 'common.delete' }))
const dialog = screen.getByRole('dialog', { name: 'costs.confirm.deleteTitle' })
expect(within(dialog).getByText('costs.confirm.deleteBody:Ramen')).toBeInTheDocument()
fireEvent.click(within(dialog).getByText('common.delete'))
await waitFor(() => expect(p.tripActions.deleteBudgetItem).toHaveBeenCalledWith(7, 11))
await waitFor(() => expect(settlementBases).toHaveLength(2))
})
it('FE-MOB-COSTT-026: toasts when the delete fails', async () => {
const p = planner()
vi.mocked(p.tripActions.deleteBudgetItem).mockRejectedValueOnce(new Error('offline'))
await renderTab(p)
fireEvent.click(within(rowOf('Ramen')).getByRole('button', { name: 'common.delete' }))
fireEvent.click(within(screen.getByRole('dialog')).getByText('common.delete'))
await waitFor(() => expect(p.toast.error).toHaveBeenCalledWith('common.unknownError'))
})
it('FE-MOB-COSTT-027: opens a blank expense sheet when the header raises the add signal', async () => {
const p = planner()
const { rerender } = await renderTab(p)
expect(screen.queryByText('sheet new USD')).not.toBeInTheDocument()
rerender(<MCostsTab planner={p} shell={buildShell({ addExpenseSignal: 1 })} />)
expect(screen.getByText('sheet new USD')).toBeInTheDocument()
fireEvent.click(screen.getByText('sheet close'))
expect(screen.queryByText('sheet new USD')).not.toBeInTheDocument()
})
it('FE-MOB-COSTT-028: exports the CSV when the header raises the export signal', async () => {
const blobs: Blob[] = []
const createObjectURL = vi.fn((b: Blob) => { blobs.push(b); return 'blob:costs' })
const revokeObjectURL = vi.fn()
Object.defineProperty(URL, 'createObjectURL', { writable: true, configurable: true, value: createObjectURL })
Object.defineProperty(URL, 'revokeObjectURL', { writable: true, configurable: true, value: revokeObjectURL })
let downloadName = ''
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
downloadName = this.download
})
const p = planner()
const { rerender } = await renderTab(p)
expect(createObjectURL).not.toHaveBeenCalled()
rerender(<MCostsTab planner={p} shell={buildShell({ exportCostsCsvSignal: 1 })} />)
expect(downloadName).toBe('costs-Tokyo Trip.csv')
expect(revokeObjectURL).toHaveBeenCalledWith('blob:costs')
const csv = await blobs[0].text()
const lines = csv.replace('', '').split('\r\n')
expect(lines[0]).toBe('Date;Name;Category;Amount;Currency;Amount (USD);Note')
expect(lines[1]).toBe(';Souvenirs;costs.cat.shopping;25.00;USD;25.00;')
expect(lines[2]).toBe('04/30/2026;Museum;costs.cat.activities;30.00;GBP;60.00;"Tickets; adult"')
// The ticket JSON note never leaves the app as a "note".
expect(lines[4]).toBe('05/02/2026;Taxi;costs.cat.transport;20.00;USD;20.00;')
})
it('FE-MOB-COSTT-029: records a manual payment and refreshes the settlement', async () => {
const create = vi.spyOn(budgetApi, 'createSettlement').mockResolvedValue({})
await renderTab()
fireEvent.click(screen.getByRole('button', { name: 'costs.addPayment' }))
const dialog = screen.getByRole('dialog', { name: 'costs.addPayment' })
const submit = within(dialog).getByRole('button', { name: 'costs.addPayment' })
expect(submit).toBeDisabled()
fireEvent.change(within(dialog).getByPlaceholderText('0.00'), { target: { value: '12,5' } })
expect(submit).toBeEnabled()
fireEvent.click(submit)
expect(create).toHaveBeenCalledWith(7, { from_user_id: 1, to_user_id: 2, amount: 12.5, currency: 'USD' })
await waitFor(() => expect(settlementBases).toHaveLength(2))
})
it('FE-MOB-COSTT-030: refuses a payment to yourself and toasts a failed one', async () => {
const create = vi.spyOn(budgetApi, 'createSettlement').mockRejectedValue(new Error('nope'))
const p = planner()
await renderTab(p)
fireEvent.click(screen.getByRole('button', { name: 'costs.addPayment' }))
const dialog = screen.getByRole('dialog', { name: 'costs.addPayment' })
const submit = within(dialog).getByRole('button', { name: 'costs.addPayment' })
fireEvent.change(within(dialog).getByPlaceholderText('0.00'), { target: { value: '5' } })
const toGroup = within(dialog).getByText('costs.to').nextElementSibling as HTMLElement
fireEvent.click(within(toGroup).getByText('costs.you'))
expect(submit).toBeDisabled()
fireEvent.click(within(within(dialog).getByText('costs.to').nextElementSibling as HTMLElement).getByText('Ada'))
expect(submit).toBeEnabled()
fireEvent.click(submit)
await waitFor(() => expect(p.toast.error).toHaveBeenCalledWith('common.unknownError'))
expect(create).toHaveBeenCalledTimes(1)
})
it('FE-MOB-COSTT-031: a solo trip can never send a payment', async () => {
await renderTab(planner({ tripMembers: [MEMBERS[0]] as unknown as TripPlanner['tripMembers'] }))
fireEvent.click(screen.getByRole('button', { name: 'costs.addPayment' }))
const dialog = screen.getByRole('dialog', { name: 'costs.addPayment' })
fireEvent.change(within(dialog).getByPlaceholderText('0.00'), { target: { value: '5' } })
expect(within(dialog).getByRole('button', { name: 'costs.addPayment' })).toBeDisabled()
})
it('FE-MOB-COSTT-032: a read-only member gets no add, edit or delete affordances', async () => {
await renderTab(planner({ can: vi.fn(() => false) as unknown as TripPlanner['can'] }))
expect(screen.queryByRole('button', { name: 'costs.addPayment' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.edit' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
expect(within(cardOf('Ramen')).getByRole('button', { pressed: false })).toBeDisabled()
})
it('FE-MOB-COSTT-033: resets the day filter back to all days', async () => {
await renderTab()
const trigger = screen.getByText('costs.filter.allDays').closest('button') as HTMLElement
fireEvent.click(trigger)
fireEvent.click(within(up(trigger, 1).nextElementSibling as HTMLElement).getByText('Apr 30'))
expect(screen.queryByText('Ramen')).not.toBeInTheDocument()
fireEvent.click(trigger)
fireEvent.click(within(up(trigger, 1).nextElementSibling as HTMLElement).getByText('costs.filter.allDays'))
expect(screen.getByText('Ramen')).toBeInTheDocument()
expect(trigger.textContent).toBe('costs.filter.allDays')
})
it('FE-MOB-COSTT-034: dismisses the payment sheet without recording anything', async () => {
const create = vi.spyOn(budgetApi, 'createSettlement')
await renderTab()
fireEvent.click(screen.getByRole('button', { name: 'costs.addPayment' }))
const dialog = screen.getByRole('dialog', { name: 'costs.addPayment' })
const fromGroup = within(dialog).getByText('costs.from').nextElementSibling as HTMLElement
fireEvent.click(within(fromGroup).getByText('Bob'))
fireEvent.click(within(dialog).getByRole('button', { name: 'common.cancel' }))
expect(create).not.toHaveBeenCalled()
expect(settlementBases).toHaveLength(1)
})
it('FE-MOB-COSTT-035: cancelling the confirm keeps the expense', async () => {
const p = planner()
await renderTab(p)
fireEvent.click(within(rowOf('Ramen')).getByRole('button', { name: 'common.delete' }))
fireEvent.click(within(screen.getByRole('dialog')).getByText('common.cancel'))
expect(p.tripActions.deleteBudgetItem).not.toHaveBeenCalled()
expect(screen.getByText('Ramen')).toBeInTheDocument()
})
it('FE-MOB-COSTT-036: falls back to EUR and a neutral avatar without a user or trip currency', async () => {
seedStore(useAuthStore, { user: null })
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1 }, ts: Date.now() }))
const solo = [{ id: 1, username: '', avatar_url: null }] as unknown as TripPlanner['tripMembers']
render(<MCostsTab planner={planner({ trip: null as unknown as TripPlanner['trip'], tripMembers: solo })} shell={buildShell()} />)
await screen.findByText('+12,50 €')
expect(settlementBases).toEqual(['EUR'])
// Nameless member → the avatar and the flow names both fall back to '?'.
expect(screen.getAllByText('?').length).toBeGreaterThan(1)
})
it('FE-MOB-COSTT-037: follows the user display currency instead of the trip currency', async () => {
seedStore(useSettingsStore, {
settings: { ...useSettingsStore.getState().settings, default_currency: 'GBP' },
})
render(<MCostsTab planner={planner()} shell={buildShell()} />)
await screen.findByText('+£12.50')
expect(settlementBases).toEqual(['GBP'])
expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('£92.50')
})
})
@@ -0,0 +1,392 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WeatherResult } from '@trek/shared'
import { weatherApi } from '../../../../src/api/client'
import MDaySheet from '../../../../src/mobile/screens/trip/sheets/MDaySheet'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { usePluginStore } from '../../../../src/store/pluginStore'
import { useSettingsStore } from '../../../../src/store/settingsStore'
import { useTripStore } from '../../../../src/store/tripStore'
import type { Accommodation, Assignment, Day, DayNote, Reservation } from '../../../../src/types'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-DAYSH-001 to FE-MOB-DAYSH-027
const DAYS = [
{ id: 1, trip_id: 5, day_number: 1, date: '2026-05-01', title: null },
{ id: 2, trip_id: 5, day_number: 2, date: '2026-05-02', title: 'Old Town' },
{ id: 3, trip_id: 5, day_number: 3, date: '2026-05-03', title: null },
] as unknown as Day[]
function assignment(id: number, placeId: number, name: string, order: number, lat: number, lng: number) {
return {
id,
day_id: 2,
place_id: placeId,
order_index: order,
place: { id: placeId, name, lat, lng },
} as unknown as Assignment
}
const MUSEUM = assignment(11, 101, 'Museum', 0, 48.21, 16.36)
const PARK = assignment(12, 102, 'Park', 1, 48.22, 16.37)
const CAFE = assignment(13, 103, 'Cafe', 2, 48.23, 16.38)
// Deliberately out of order — the sheet sorts by order_index.
const ASSIGNMENTS: Record<string, Assignment[]> = { '2': [CAFE, MUSEUM, PARK] }
const RESERVATIONS = [
{
id: 21, trip_id: 5, type: 'restaurant', title: 'Dinner at Figlmueller', day_id: 2,
reservation_time: '2026-05-02T19:30', reservation_end_time: '2026-05-02T21:00',
},
{
id: 22, trip_id: 5, type: 'train', title: 'Train to Salzburg', day_id: 2,
reservation_time: '2026-05-02T08:00',
},
{
id: 23, trip_id: 5, type: 'hotel', title: 'Hotel Sacher booking', day_id: 2,
accommodation_id: 31, status: 'confirmed', confirmation_number: 'X9',
},
{ id: 24, trip_id: 5, type: 'event', title: 'Opera (other day)', day_id: 3 },
] as unknown as Reservation[]
const ACCOMMODATIONS = [
{
id: 31, trip_id: 5, place_id: 55, start_day_id: 2, end_day_id: 3,
check_in: '15:00', check_in_end: '18:00', check_out: '11:00', confirmation: 'ABC123',
place_name: 'Hotel Sacher', place_address: 'Philharmonikerstrasse 4',
place_lat: 48.2, place_lng: 16.35,
},
] as unknown as Accommodation[]
const DAY_NOTES = [
{ id: 42, day_id: 2, text: 'Pack the day bag', time: 'before leaving', sort_order: 0 },
{ id: 41, day_id: 2, text: 'Buy museum tickets', time: '09:30 at the kiosk', sort_order: 1 },
] as unknown as DayNote[]
const FORECAST: WeatherResult = {
temp: 20, temp_max: 24, temp_min: 12, main: 'Rain', description: 'light rain', type: 'forecast',
}
function makeShell(overrides: Record<string, unknown> = {}) {
return {
view: 'plan',
mode: 'go',
trTab: 'plan',
sheet: { id: 'day', payload: { dayId: 2 } },
openSheet: vi.fn(),
closeSheet: vi.fn(),
setTrTab: vi.fn(),
toggleView: vi.fn(),
setTravelMode: vi.fn(),
...overrides,
} as unknown as MTripShellApi
}
function makePlanner(overrides: Record<string, unknown> = {}) {
return {
tripId: 5,
trip: { id: 5, title: 'Vienna', start_date: '2026-05-01', end_date: '2026-05-03' },
language: 'en',
days: DAYS,
places: [],
assignments: ASSIGNMENTS,
reservations: RESERVATIONS,
tripAccommodations: ACCOMMODATIONS,
selectedDayId: 2,
routeShown: false,
setRouteShown: vi.fn(),
routeProfile: 'driving',
setRouteProfile: vi.fn(),
handleSelectDay: vi.fn(),
handleUpdateDayTitle: vi.fn(),
handleReorder: vi.fn(),
handlePlaceClick: vi.fn(),
setEditingTransport: vi.fn(),
setTransportModalDayId: vi.fn(),
setShowTransportModal: vi.fn(),
setEditingReservation: vi.fn(),
setShowReservationModal: vi.fn(),
setTransitPrefill: vi.fn(),
setTransportModalAutomated: vi.fn(),
TRANSPORT_TYPES: new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']),
can: vi.fn(() => true),
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
...overrides,
} as unknown as TripPlanner
}
/** Renders and settles the weather request so no state update lands outside act(). */
async function renderSheet(planner: TripPlanner = makePlanner(), shell: MTripShellApi = makeShell()) {
const view = render(<MDaySheet planner={planner} shell={shell} />)
await act(async () => { await Promise.resolve() })
await waitFor(() => expect(document.querySelector('.animate-pulse')).toBeNull())
return { ...view, planner, shell }
}
describe('MDaySheet', () => {
beforeEach(() => {
resetAllStores()
usePluginStore.setState({ plugins: [] })
seedStore(useTripStore, { dayNotes: { '2': DAY_NOTES } })
vi.spyOn(weatherApi, 'getDetailed').mockResolvedValue(FORECAST)
vi.spyOn(window, 'open').mockImplementation(() => null)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('FE-MOB-DAYSH-001: stays closed while another sheet id is active', () => {
render(<MDaySheet planner={makePlanner()} shell={makeShell({ sheet: { id: 'mehr' } })} />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(weatherApi.getDetailed).not.toHaveBeenCalled()
})
it('FE-MOB-DAYSH-002: falls back to the selected day when the payload carries no dayId', async () => {
await renderSheet(makePlanner({ selectedDayId: 1 }), makeShell({ sheet: { id: 'day' } }))
expect(screen.getByRole('dialog', { name: 'Day 1' })).toBeInTheDocument()
expect(screen.getByText('Friday, May 1')).toBeInTheDocument()
})
it('FE-MOB-DAYSH-003: renders the day title and its formatted date', async () => {
await renderSheet()
expect(screen.getByRole('dialog', { name: 'Old Town' })).toBeInTheDocument()
expect(screen.getByText('Old Town')).toBeInTheDocument()
expect(screen.getByText('Saturday, May 2')).toBeInTheDocument()
})
it('FE-MOB-DAYSH-004: requests the forecast for the first located stop and shows it', async () => {
await renderSheet()
expect(weatherApi.getDetailed).toHaveBeenCalledWith(48.21, 16.36, '2026-05-02', 'en')
expect(screen.getByText('20°')).toBeInTheDocument()
expect(screen.getByRole('dialog').textContent).toContain('24° / 12° ·')
expect(screen.getByText('light rain')).toBeInTheDocument()
})
it('FE-MOB-DAYSH-005: converts to Fahrenheit when the user asked for it', async () => {
seedStore(useSettingsStore, {
settings: { ...useSettingsStore.getState().settings, temperature_unit: 'fahrenheit' },
})
await renderSheet()
expect(screen.getByText('68°')).toBeInTheDocument()
})
it('FE-MOB-DAYSH-006: marks a climate fallback with the average sign and the hint', async () => {
vi.mocked(weatherApi.getDetailed).mockResolvedValue({ ...FORECAST, type: 'climate' })
await renderSheet()
expect(screen.getByText('Ø 20°')).toBeInTheDocument()
expect(screen.getByText(/Historical averages/)).toBeInTheDocument()
})
it('FE-MOB-DAYSH-007: shows the empty state when the service reports an error', async () => {
vi.mocked(weatherApi.getDetailed).mockResolvedValue({ ...FORECAST, error: 'no_forecast' })
await renderSheet()
expect(screen.getByText('No weather data available. Add a place with coordinates.')).toBeInTheDocument()
})
it('FE-MOB-DAYSH-008: shows the empty state when the request rejects', async () => {
vi.mocked(weatherApi.getDetailed).mockRejectedValue(new Error('offline'))
await renderSheet()
expect(screen.getByText('No weather data available. Add a place with coordinates.')).toBeInTheDocument()
})
it('FE-MOB-DAYSH-009: skips the weather block entirely without coordinates', async () => {
await renderSheet(makePlanner({ assignments: {}, places: [] }))
expect(weatherApi.getDetailed).not.toHaveBeenCalled()
expect(screen.queryByText('No weather data available. Add a place with coordinates.')).not.toBeInTheDocument()
})
it('FE-MOB-DAYSH-010: renames the day inline and commits the trimmed title on Enter', async () => {
const { planner } = await renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Rename day' }))
const input = screen.getByPlaceholderText('Day 2')
expect(input).toHaveValue('Old Town')
fireEvent.change(input, { target: { value: ' Ringstrasse ' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(planner.handleUpdateDayTitle).toHaveBeenCalledWith(2, 'Ringstrasse')
})
it('FE-MOB-DAYSH-011: Escape leaves the rename without saving', async () => {
const { planner } = await renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Rename day' }))
const input = screen.getByPlaceholderText('Day 2')
fireEvent.change(input, { target: { value: 'Nope' } })
fireEvent.keyDown(input, { key: 'Escape' })
expect(planner.handleUpdateDayTitle).not.toHaveBeenCalled()
expect(screen.queryByPlaceholderText('Day 2')).not.toBeInTheDocument()
})
it('FE-MOB-DAYSH-012: toggles the route for the already selected day', async () => {
const { planner } = await renderSheet()
const routeBtn = screen.getByRole('button', { name: 'Route' })
expect(routeBtn).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(routeBtn)
expect(planner.setRouteShown).toHaveBeenCalledTimes(1)
expect(planner.handleSelectDay).not.toHaveBeenCalled()
})
it('FE-MOB-DAYSH-013: selects the day first when the route is toggled from another one', async () => {
const planner = makePlanner({ selectedDayId: 1, routeShown: false })
await renderSheet(planner)
fireEvent.click(screen.getByRole('button', { name: 'Route' }))
expect(planner.handleSelectDay).toHaveBeenCalledWith(2, true)
expect(planner.setRouteShown).toHaveBeenCalledWith(true)
})
it('FE-MOB-DAYSH-014: marks the route pill active when it is drawn for this day', async () => {
await renderSheet(makePlanner({ routeShown: true }))
expect(screen.getByRole('button', { name: 'Route' })).toHaveAttribute('aria-pressed', 'true')
})
it('FE-MOB-DAYSH-015: offers plugin route profiles next to the built-ins', async () => {
usePluginStore.setState({
plugins: [{
id: 'brouter', name: 'BRouter', type: 'integration', icon: null,
routeProfiles: [{ id: 'bike', label: 'Bike' }],
}],
})
const { planner } = await renderSheet()
expect(screen.getByRole('button', { name: 'Driving' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'Walking' })).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(screen.getByRole('button', { name: 'Bike' }))
expect(planner.setRouteProfile).toHaveBeenCalledWith('plugin:brouter/bike')
})
it('FE-MOB-DAYSH-016: exports the day stops plus the evening hotel to Google Maps', async () => {
await renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Open in Google Maps' }))
expect(window.open).toHaveBeenCalledTimes(1)
const url = String(vi.mocked(window.open).mock.calls[0][0])
expect(url).toContain('https://www.google.com/maps/dir/48.21,16.36/48.22,16.37/48.23,16.38')
expect(url).toContain('/48.2,16.35')
})
it('FE-MOB-DAYSH-017: optimizes the day order and toasts the hotel variant', async () => {
const { planner } = await renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Optimize' }))
expect(planner.handleReorder).toHaveBeenCalledTimes(1)
const [dayId, order] = vi.mocked(planner.handleReorder).mock.calls[0]
expect(dayId).toBe(2)
expect([...(order as unknown as number[])].sort()).toEqual([11, 12, 13])
expect(planner.toast.success).toHaveBeenCalledWith('Route optimized from your accommodation')
})
it('FE-MOB-DAYSH-018: hides Optimize below three stops but keeps the route pill', async () => {
await renderSheet(makePlanner({ assignments: { '2': [MUSEUM, PARK] } }))
expect(screen.queryByRole('button', { name: 'Optimize' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Route' })).toBeInTheDocument()
})
it('FE-MOB-DAYSH-019: opens the automated transport modal for public transit', async () => {
const { planner, shell } = await renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Public transit' }))
expect(planner.setTransportModalDayId).toHaveBeenCalledWith(2)
expect(planner.setEditingTransport).toHaveBeenCalledWith(null)
expect(planner.setTransitPrefill).toHaveBeenCalledWith(null)
expect(planner.setTransportModalAutomated).toHaveBeenCalledWith(true)
expect(planner.setShowTransportModal).toHaveBeenCalledWith(true)
expect(shell.closeSheet).toHaveBeenCalled()
})
it('FE-MOB-DAYSH-020: lists the day bookings without hotels and opens the matching editor', async () => {
const { planner, shell } = await renderSheet()
expect(screen.getByText('Dinner at Figlmueller')).toBeInTheDocument()
expect(screen.getByText('19:30 – 21:00')).toBeInTheDocument()
expect(screen.queryByText('Opera (other day)')).not.toBeInTheDocument()
expect(screen.queryByText('Hotel Sacher booking')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Train to Salzburg'))
expect(planner.setEditingTransport).toHaveBeenCalledWith(expect.objectContaining({ id: 22 }))
expect(planner.setShowTransportModal).toHaveBeenCalledWith(true)
fireEvent.click(screen.getByText('Dinner at Figlmueller'))
expect(planner.setEditingReservation).toHaveBeenCalledWith(expect.objectContaining({ id: 21 }))
expect(planner.setShowReservationModal).toHaveBeenCalledWith(true)
expect(shell.closeSheet).toHaveBeenCalledTimes(2)
})
it('FE-MOB-DAYSH-021: a booking row does nothing without the matching permission', async () => {
const planner = makePlanner({ can: (perm: string) => perm !== 'day_edit' && perm !== 'reservation_edit' })
const { shell } = await renderSheet(planner)
fireEvent.click(screen.getByText('Dinner at Figlmueller'))
fireEvent.click(screen.getByText('Train to Salzburg'))
expect(planner.setShowReservationModal).not.toHaveBeenCalled()
expect(planner.setShowTransportModal).not.toHaveBeenCalled()
expect(shell.closeSheet).not.toHaveBeenCalled()
})
it('FE-MOB-DAYSH-022: renders notes sorted, splits a leading time off the detail line, and routes taps', async () => {
const { shell } = await renderSheet()
const notes = screen.getAllByText(/Pack the day bag|Buy museum tickets/)
expect(notes.map(n => n.textContent)).toEqual(['Pack the day bag', 'Buy museum tickets'])
expect(screen.getByText('at the kiosk')).toBeInTheDocument()
expect(screen.getByText('09:30')).toBeInTheDocument()
expect(screen.getByText('before leaving')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Add Note' }))
expect(shell.openSheet).toHaveBeenCalledWith('note', { dayId: 2 })
fireEvent.click(screen.getByText('Buy museum tickets'))
expect(shell.openSheet).toHaveBeenCalledWith('note', { dayId: 2, note: expect.objectContaining({ id: 41 }) })
})
it('FE-MOB-DAYSH-023: shows the stay with its times, code and linked booking status', async () => {
const { planner, shell } = await renderSheet()
expect(screen.getByText('Hotel Sacher')).toBeInTheDocument()
expect(screen.getByText('Philharmonikerstrasse 4')).toBeInTheDocument()
expect(screen.getByText('15:00 – 18:00')).toBeInTheDocument()
expect(screen.getByText('11:00')).toBeInTheDocument()
expect(screen.getByText('ABC123')).toBeInTheDocument()
expect(screen.getByText('Confirmed · #X9')).toBeInTheDocument()
// start_day_id is this day, end_day_id a later one → check-in badge plus the stat label.
expect(screen.getAllByText('Check-in')).toHaveLength(2)
fireEvent.click(screen.getByText('Hotel Sacher'))
expect(shell.closeSheet).toHaveBeenCalled()
expect(planner.handlePlaceClick).toHaveBeenCalledWith(55)
fireEvent.click(screen.getByRole('button', { name: /Add accommodation/ }))
expect(shell.openSheet).toHaveBeenCalledWith('accommodation', { dayId: 2 })
})
it('FE-MOB-DAYSH-024: labels a stay that both starts and ends on this day', async () => {
const acc = [{ ...ACCOMMODATIONS[0], end_day_id: 2 }] as unknown as Accommodation[]
await renderSheet(makePlanner({ tripAccommodations: acc }))
expect(screen.getByText('Check-in & Check-out')).toBeInTheDocument()
})
it('FE-MOB-DAYSH-025: labels the check-out day and a plain mid-stay day', async () => {
const checkOutDay = [{ ...ACCOMMODATIONS[0], start_day_id: 1, end_day_id: 2 }] as unknown as Accommodation[]
const { unmount } = await renderSheet(makePlanner({ tripAccommodations: checkOutDay }))
// Badge plus the check-out stat label.
expect(screen.getAllByText('Check-out')).toHaveLength(2)
expect(screen.queryByText('Stay')).not.toBeInTheDocument()
unmount()
const midStay = [{ ...ACCOMMODATIONS[0], start_day_id: 1, end_day_id: 3 }] as unknown as Accommodation[]
await renderSheet(makePlanner({ tripAccommodations: midStay }))
expect(screen.getByText('Stay')).toBeInTheDocument()
})
it('FE-MOB-DAYSH-026: falls back to any trip place with coordinates as the weather anchor', async () => {
await renderSheet(makePlanner({
assignments: {},
places: [{ id: 200, name: 'Prater', lat: 48.31, lng: 16.41 }],
}))
expect(weatherApi.getDetailed).toHaveBeenCalledWith(48.31, 16.41, '2026-05-02', 'en')
expect(screen.getByText('20°')).toBeInTheDocument()
})
it('FE-MOB-DAYSH-027: hides every editing affordance for a read-only member', async () => {
await renderSheet(makePlanner({ can: () => false }))
expect(screen.queryByRole('button', { name: 'Rename day' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Optimize' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Public transit' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Add Note' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Add accommodation/ })).not.toBeInTheDocument()
// The route pill is a view toggle, not an edit — it survives.
expect(screen.getByRole('button', { name: 'Route' })).toBeInTheDocument()
})
})
@@ -0,0 +1,115 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MDaysSheet from '../../../../src/mobile/screens/trip/sheets/MDaysSheet'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import type { Day } from '../../../../src/types'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { resetAllStores } from '../../../helpers/store'
import { fireEvent, render, screen } from '../../../helpers/render'
// FE-MOB-DAYSS-001 to FE-MOB-DAYSS-012
//
// The sheet reads its copy from the real TranslationProvider (useTranslation),
// not from planner.t — assertions therefore go against the English strings.
// Deliberately unsorted: the sheet orders by day_number.
const DAYS = [
{ id: 3, trip_id: 1, day_number: 3, date: '2026-05-03', title: null },
{ id: 1, trip_id: 1, day_number: 1, date: '2026-05-01', title: null },
{ id: 2, trip_id: 1, day_number: 2, date: '2026-05-02', title: 'Old Town' },
] as unknown as Day[]
function renderSheet(plannerOverrides: Partial<TripPlanner> = {}, shellOverrides: Partial<MTripShellApi> = {}) {
const planner = buildPlanner({ days: DAYS, ...plannerOverrides })
const shell = buildShell({ sheet: { id: 'days' }, ...shellOverrides })
render(<MDaysSheet planner={planner} shell={shell} />)
return { planner, shell }
}
describe('MDaysSheet', () => {
beforeEach(() => {
resetAllStores()
})
it('FE-MOB-DAYSS-001: stays closed while another sheet id is active', () => {
renderSheet({}, { sheet: { id: 'mehr' } })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-DAYSS-002: stays closed when no sheet is open at all', () => {
renderSheet({}, { sheet: null })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-DAYSS-003: renders the reorder header with its hint', () => {
renderSheet()
expect(screen.getByRole('dialog', { name: 'Reorder days' })).toBeInTheDocument()
expect(screen.getByText("A day's places, notes and bookings move with it.")).toBeInTheDocument()
})
it('FE-MOB-DAYSS-004: lists the days in day_number order and numbers the rows', () => {
renderSheet()
const positions = screen.getAllByText(/^[123]$/).map(el => el.textContent)
expect(positions).toEqual(['1', '2', '3'])
const labels = ['Fri, May 1', 'Old Town', 'Sun, May 3']
for (const label of labels) expect(screen.getByText(label)).toBeInTheDocument()
})
it('FE-MOB-DAYSS-005: falls back to "Day n" when neither title nor a usable date exists', () => {
const days = [
{ id: 7, trip_id: 1, day_number: 1, date: null, title: null },
{ id: 8, trip_id: 1, day_number: 2, date: 'not-a-date', title: '' },
] as unknown as Day[]
renderSheet({ days })
expect(screen.getByText('Day 1')).toBeInTheDocument()
expect(screen.getByText('Day 2')).toBeInTheDocument()
})
it('FE-MOB-DAYSS-006: numbers a day without day_number by its list position', () => {
const days = [{ id: 9, trip_id: 1, day_number: null, date: null, title: null }] as unknown as Day[]
renderSheet({ days })
expect(screen.getByText('Day 1')).toBeInTheDocument()
})
it('FE-MOB-DAYSS-007: moving a day up sends the whole reordered id list', () => {
const { planner } = renderSheet()
fireEvent.click(screen.getAllByRole('button', { name: 'Move up' })[1])
expect(planner.handleReorderDays).toHaveBeenCalledWith([2, 1, 3])
})
it('FE-MOB-DAYSS-008: moving a day down sends the whole reordered id list', () => {
const { planner } = renderSheet()
fireEvent.click(screen.getAllByRole('button', { name: 'Move down' })[0])
expect(planner.handleReorderDays).toHaveBeenCalledWith([2, 1, 3])
})
it('FE-MOB-DAYSS-009: disables the moves that would leave the list', () => {
renderSheet()
const up = screen.getAllByRole('button', { name: 'Move up' })
const down = screen.getAllByRole('button', { name: 'Move down' })
expect(up[0]).toBeDisabled()
expect(up[2]).toBeEnabled()
expect(down[2]).toBeDisabled()
expect(down[0]).toBeEnabled()
})
it('FE-MOB-DAYSS-010: appends a day', () => {
const { planner } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Add day' }))
expect(planner.handleAddDay).toHaveBeenCalledTimes(1)
})
it('FE-MOB-DAYSS-011: hides every edit affordance for a read-only member', () => {
const { planner } = renderSheet({ can: vi.fn(() => false) })
expect(planner.can).toHaveBeenCalledWith('day_edit', planner.trip)
expect(screen.queryByRole('button', { name: 'Move up' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Add day' })).not.toBeInTheDocument()
// The list itself stays readable.
expect(screen.getByText('Old Town')).toBeInTheDocument()
})
it('FE-MOB-DAYSS-012: the header close button closes the sheet', () => {
const { shell } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,191 @@
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import { http, HttpResponse } from 'msw'
import MExportSheet from '../../../../src/mobile/screens/trip/sheets/MExportSheet'
import { downloadTripPDF } from '../../../../src/components/PDF/TripPDF'
import { useTripStore } from '../../../../src/store/tripStore'
import type { Day, DayNote } from '../../../../src/types'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { server } from '../../../helpers/msw/server'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-EXPSH-001 to FE-MOB-EXPSH-013
// This sheet reads its copy from useTranslation(), so the assertions are English.
vi.mock('../../../../src/components/PDF/TripPDF', () => ({ downloadTripPDF: vi.fn() }))
vi.mock('../../../../src/components/Planner/IcsSubscribeModal', () => ({
IcsSubscribeModal: ({ endpoint, onClose }: { endpoint: string; onClose: () => void }) => (
<div data-testid="ics-subscribe" data-endpoint={endpoint}>
<button type="button" onClick={onClose}>dismiss subscribe</button>
</div>
),
}))
const DAYS = [{ id: 2, trip_id: 1, day_number: 1, date: '2026-05-01', title: null }] as unknown as Day[]
const NOTES: Record<string, DayNote[]> = {
'2': [{ id: 41, day_id: 2, text: 'Pack the day bag' }] as unknown as DayNote[],
'3': [{ id: 42, day_id: 3, text: 'Return the keys' }] as unknown as DayNote[],
}
function renderSheet(plannerOverrides: Record<string, unknown> = {}, sheetId: string | null = 'export') {
const planner = buildPlanner({ days: DAYS, ...plannerOverrides })
const shell = buildShell({ sheet: sheetId ? { id: sheetId } : null })
const view = render(<MExportSheet planner={planner} shell={shell} />)
return { ...view, planner, shell }
}
describe('MExportSheet', () => {
// The download anchor is never attached to the DOM, so the click spy's
// recorded `this` is the only handle on it.
let clickSpy: MockInstance<() => void>
const clickedAnchor = () => clickSpy.mock.contexts[0] as HTMLAnchorElement
beforeEach(() => {
resetAllStores()
seedStore(useTripStore, { dayNotes: NOTES })
vi.mocked(downloadTripPDF).mockReset().mockResolvedValue(undefined)
clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:trek/ics')
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('FE-MOB-EXPSH-001: stays closed while another sheet id is active', () => {
renderSheet({}, 'mehr')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-EXPSH-002: lists the three export routes and names the ics after the trip', () => {
renderSheet()
expect(screen.getByRole('dialog', { name: 'Export Calendar' })).toBeInTheDocument()
expect(screen.getByText('PDF')).toBeInTheDocument()
expect(screen.getByText('Export day plan as PDF')).toBeInTheDocument()
expect(screen.getByText('Download .ics')).toBeInTheDocument()
expect(screen.getByText('Japan 2026.ics')).toBeInTheDocument()
expect(screen.getByText('Subscribe to calendar')).toBeInTheDocument()
expect(screen.getByText('Auto-updates in your calendar app')).toBeInTheDocument()
})
it('FE-MOB-EXPSH-003: hands the planner slices and the flattened day notes to the PDF export', async () => {
const { planner } = renderSheet({
places: [{ id: 9, name: 'Fushimi Inari' }],
categories: [{ id: 3, name: 'Sights' }],
reservations: [{ id: 4, title: 'Ryokan' }],
})
fireEvent.click(screen.getByText('PDF'))
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
const arg = vi.mocked(downloadTripPDF).mock.calls[0][0]
expect(arg.trip).toBe(planner.trip)
expect(arg.days).toBe(planner.days)
expect(arg.places).toBe(planner.places)
expect(arg.reservations).toBe(planner.reservations)
expect(arg.categories).toBe(planner.categories)
// dayNotes come out of the store keyed by day, flattened with a numeric day_id.
expect(arg.dayNotes).toEqual([
{ id: 41, day_id: 2, text: 'Pack the day bag' },
{ id: 42, day_id: 3, text: 'Return the keys' },
])
expect(typeof arg.t).toBe('function')
})
it('FE-MOB-EXPSH-004: shows the busy label and refuses a second export while one runs', async () => {
let release: () => void = () => {}
vi.mocked(downloadTripPDF).mockImplementation(() => new Promise<void>(res => { release = () => res() }))
renderSheet()
fireEvent.click(screen.getByText('PDF'))
const busy = await screen.findByText('Loading...')
fireEvent.click(busy)
expect(downloadTripPDF).toHaveBeenCalledTimes(1)
release()
await waitFor(() => expect(screen.getByText('PDF')).toBeInTheDocument())
})
it('FE-MOB-EXPSH-005: reports a failing PDF export with the underlying message', async () => {
vi.mocked(downloadTripPDF).mockRejectedValue(new Error('font missing'))
const { planner } = renderSheet()
fireEvent.click(screen.getByText('PDF'))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Failed to export PDF: font missing'))
// The busy flag is released in the finally branch.
expect(screen.getByText('PDF')).toBeInTheDocument()
})
it('FE-MOB-EXPSH-006: stringifies a non-Error rejection in the toast', async () => {
vi.mocked(downloadTripPDF).mockRejectedValue('renderer gone')
const { planner } = renderSheet()
fireEvent.click(screen.getByText('PDF'))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Failed to export PDF: renderer gone'))
})
it('FE-MOB-EXPSH-007: does not export without a loaded trip', () => {
renderSheet({ trip: null })
expect(screen.getByText('trip.ics')).toBeInTheDocument()
fireEvent.click(screen.getByText('PDF'))
expect(downloadTripPDF).not.toHaveBeenCalled()
})
it('FE-MOB-EXPSH-008: downloads the ics through a named anchor and closes the sheet', async () => {
server.use(http.get('/api/trips/1/export.ics', () => HttpResponse.text('BEGIN:VCALENDAR')))
const { shell } = renderSheet()
fireEvent.click(screen.getByText('Download .ics'))
await waitFor(() => expect(clickSpy).toHaveBeenCalledTimes(1))
expect(clickedAnchor().getAttribute('href')).toBe('blob:trek/ics')
expect(clickedAnchor().download).toBe('Japan 2026.ics')
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:trek/ics')
await waitFor(() => expect(shell.closeSheet).toHaveBeenCalledTimes(1))
})
it('FE-MOB-EXPSH-009: falls back to a generic filename without a trip title', async () => {
server.use(http.get('/api/trips/1/export.ics', () => HttpResponse.text('BEGIN:VCALENDAR')))
renderSheet({ trip: null })
fireEvent.click(screen.getByText('Download .ics'))
await waitFor(() => expect(clickSpy).toHaveBeenCalledTimes(1))
expect(clickedAnchor().download).toBe('trip.ics')
})
it('FE-MOB-EXPSH-010: toasts and keeps the sheet open when the ics endpoint errors', async () => {
server.use(http.get('/api/trips/1/export.ics', () => new HttpResponse(null, { status: 500 })))
const { planner, shell } = renderSheet()
fireEvent.click(screen.getByText('Download .ics'))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('ICS export failed'))
expect(shell.closeSheet).not.toHaveBeenCalled()
expect(clickSpy).not.toHaveBeenCalled()
})
it('FE-MOB-EXPSH-011: toasts when the ics request never reaches the server', async () => {
server.use(http.get('/api/trips/1/export.ics', () => HttpResponse.error()))
const { planner } = renderSheet()
fireEvent.click(screen.getByText('Download .ics'))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('ICS export failed'))
})
it('FE-MOB-EXPSH-012: opens the subscribe dialog on the trip feed endpoint and dismisses it again', () => {
renderSheet()
expect(screen.queryByTestId('ics-subscribe')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Subscribe to calendar'))
expect(screen.getByTestId('ics-subscribe')).toHaveAttribute('data-endpoint', '/api/trips/1/feed')
fireEvent.click(screen.getByRole('button', { name: 'dismiss subscribe' }))
expect(screen.queryByTestId('ics-subscribe')).not.toBeInTheDocument()
})
it('FE-MOB-EXPSH-013: the header close hands the dismissal back to the shell', () => {
const { shell } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,208 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import MFileLightbox from '../../../../src/mobile/screens/trip/tabs/MFileLightbox'
import { getAuthUrl } from '../../../../src/api/authUrl'
import { downloadFile, openFile } from '../../../../src/utils/fileDownload'
import type { TranslationFn, TripFile } from '../../../../src/types'
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-FLBOX-001 to FE-MOB-FLBOX-017
vi.mock('../../../../src/api/authUrl', () => ({
getAuthUrl: vi.fn(async (url: string) => `${url}?token=t1`),
}))
vi.mock('../../../../src/utils/fileDownload', () => ({
downloadFile: vi.fn(async () => undefined),
openFile: vi.fn(async () => undefined),
}))
vi.mock('../../../../src/components/Journey/VideoPlayer', () => ({
default: ({ src }: { src: string }) => <div data-testid="video-player" data-src={src} />,
}))
const t: TranslationFn = key => key
function file(id: number, name: string, mime = 'image/jpeg'): TripFile {
return {
id, trip_id: 1, filename: name, original_name: name, mime_type: mime,
url: `/api/trips/1/files/${id}/download`, created_at: '2026-05-01T10:00:00.000Z',
} as unknown as TripFile
}
const FILES = [file(1, 'beach.jpg'), file(2, 'castle.png', 'image/png'), file(3, 'clip.mp4', 'video/mp4')]
function renderBox(index: number, overrides: { files?: TripFile[] } = {}) {
const onIndexChange = vi.fn()
const onClose = vi.fn()
const view = render(
<MFileLightbox
files={overrides.files ?? FILES}
index={index}
onIndexChange={onIndexChange}
onClose={onClose}
t={t}
/>,
)
return { ...view, onIndexChange, onClose }
}
describe('MFileLightbox', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
document.body.style.overflow = ''
document.getElementById('m-sheet-root')?.remove()
})
it('FE-MOB-FLBOX-001: shows the file name, the position counter and the signed image url', async () => {
renderBox(0)
expect(screen.getByText('beach.jpg')).toBeInTheDocument()
expect(screen.getByText('1 / 3')).toBeInTheDocument()
expect(getAuthUrl).toHaveBeenCalledWith('/api/trips/1/files/1/download', 'download')
await waitFor(() =>
expect(screen.getByAltText('beach.jpg')).toHaveAttribute('src', '/api/trips/1/files/1/download?token=t1'),
)
})
it('FE-MOB-FLBOX-002: renders nothing when the index points past the list', () => {
const { container } = renderBox(5)
expect(container).toBeEmptyDOMElement()
expect(document.querySelector('.m-root')).toBeNull()
expect(getAuthUrl).not.toHaveBeenCalled()
})
it('FE-MOB-FLBOX-003: hides the prev arrow on the first file and the next arrow on the last', () => {
renderBox(0)
expect(screen.queryByRole('button', { name: 'mobileTrip.filesPrev' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'mobileTrip.filesNext' })).toBeInTheDocument()
})
it('FE-MOB-FLBOX-004: steps forward and back without bubbling the close click', () => {
const { onIndexChange, onClose } = renderBox(1)
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.filesNext' }))
expect(onIndexChange).toHaveBeenCalledWith(2)
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.filesPrev' }))
expect(onIndexChange).toHaveBeenCalledWith(0)
expect(onClose).not.toHaveBeenCalled()
})
it('FE-MOB-FLBOX-005: arrow keys navigate and Escape closes', () => {
const { onIndexChange, onClose } = renderBox(1)
fireEvent.keyDown(window, { key: 'ArrowRight' })
expect(onIndexChange).toHaveBeenCalledWith(2)
fireEvent.keyDown(window, { key: 'ArrowLeft' })
expect(onIndexChange).toHaveBeenCalledWith(0)
fireEvent.keyDown(window, { key: 'Escape' })
expect(onClose).toHaveBeenCalledTimes(1)
})
it('FE-MOB-FLBOX-006: arrow keys stop at both ends', () => {
const { onIndexChange, unmount } = renderBox(0)
fireEvent.keyDown(window, { key: 'ArrowLeft' })
expect(onIndexChange).not.toHaveBeenCalled()
unmount()
const last = renderBox(FILES.length - 1)
fireEvent.keyDown(window, { key: 'ArrowRight' })
expect(last.onIndexChange).not.toHaveBeenCalled()
})
it('FE-MOB-FLBOX-007: unregisters the key handler on unmount', () => {
const { onClose, unmount } = renderBox(0)
unmount()
fireEvent.keyDown(window, { key: 'Escape' })
expect(onClose).not.toHaveBeenCalled()
})
it('FE-MOB-FLBOX-008: a swipe to the right goes back, a swipe to the left goes forward', () => {
const { onIndexChange } = renderBox(1)
const overlay = document.querySelector('.m-root') as HTMLElement
fireEvent.touchStart(overlay, { touches: [{ clientX: 200 }] })
fireEvent.touchEnd(overlay, { changedTouches: [{ clientX: 300 }] })
expect(onIndexChange).toHaveBeenLastCalledWith(0)
fireEvent.touchStart(overlay, { touches: [{ clientX: 300 }] })
fireEvent.touchEnd(overlay, { changedTouches: [{ clientX: 200 }] })
expect(onIndexChange).toHaveBeenLastCalledWith(2)
})
it('FE-MOB-FLBOX-009: ignores a short drag and a touchend without a start', () => {
const { onIndexChange } = renderBox(1)
const overlay = document.querySelector('.m-root') as HTMLElement
fireEvent.touchEnd(overlay, { changedTouches: [{ clientX: 900 }] })
expect(onIndexChange).not.toHaveBeenCalled()
fireEvent.touchStart(overlay, { touches: [{ clientX: 200 }] })
fireEvent.touchEnd(overlay, { changedTouches: [{ clientX: 210 }] })
expect(onIndexChange).not.toHaveBeenCalled()
})
it('FE-MOB-FLBOX-010: tapping the backdrop closes, tapping the header does not', () => {
const { onClose } = renderBox(0)
const overlay = document.querySelector('.m-root') as HTMLElement
fireEvent.click(screen.getByText('beach.jpg'))
expect(onClose).not.toHaveBeenCalled()
fireEvent.click(overlay)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('FE-MOB-FLBOX-011: tapping the empty media area closes but tapping the image does not', async () => {
const { onClose } = renderBox(0)
const img = await screen.findByAltText('beach.jpg')
fireEvent.click(img)
expect(onClose).not.toHaveBeenCalled()
fireEvent.click(img.parentElement as HTMLElement)
expect(onClose).toHaveBeenCalled()
})
it('FE-MOB-FLBOX-012: the close button closes', () => {
const { onClose } = renderBox(0)
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('FE-MOB-FLBOX-013: opens and downloads through the protected file helpers', () => {
renderBox(1)
fireEvent.click(screen.getByRole('button', { name: 'files.openTab' }))
expect(openFile).toHaveBeenCalledWith('/api/trips/1/files/2/download', 'castle.png')
fireEvent.click(screen.getByRole('button', { name: 'files.download' }))
expect(downloadFile).toHaveBeenCalledWith('/api/trips/1/files/2/download', 'castle.png')
})
it('FE-MOB-FLBOX-014: swallows a failing open or download', async () => {
vi.mocked(openFile).mockRejectedValueOnce(new Error('offline'))
vi.mocked(downloadFile).mockRejectedValueOnce(new Error('offline'))
renderBox(0)
fireEvent.click(screen.getByRole('button', { name: 'files.openTab' }))
fireEvent.click(screen.getByRole('button', { name: 'files.download' }))
await waitFor(() => expect(downloadFile).toHaveBeenCalled())
expect(screen.getByText('beach.jpg')).toBeInTheDocument()
})
it('FE-MOB-FLBOX-015: a video plays in the player instead of resolving a signed image url', () => {
renderBox(2)
expect(screen.getByTestId('video-player')).toHaveAttribute('data-src', '/api/trips/1/files/3/download')
expect(screen.queryByAltText('clip.mp4')).not.toBeInTheDocument()
expect(getAuthUrl).not.toHaveBeenCalled()
})
it('FE-MOB-FLBOX-016: locks the body scroll while open and mounts into the sheet root', () => {
const root = document.createElement('div')
root.id = 'm-sheet-root'
document.body.appendChild(root)
document.body.style.overflow = 'auto'
const { unmount } = renderBox(0)
expect(document.body.style.overflow).toBe('hidden')
expect(root.querySelector('.m-root')).not.toBeNull()
unmount()
expect(document.body.style.overflow).toBe('auto')
})
it('FE-MOB-FLBOX-017: tapping the player does not dismiss the viewer', () => {
const { onClose } = renderBox(2)
fireEvent.click(screen.getByTestId('video-player').parentElement as HTMLElement)
expect(onClose).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,195 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import MFileLinkSheet from '../../../../src/mobile/screens/trip/tabs/MFileLinkSheet'
import { filesApi } from '../../../../src/api/client'
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import type { Place, Reservation, TripFile } from '../../../../src/types'
import { buildPlanner } from '../../../helpers/mobileTrip'
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-FLINK-001 to FE-MOB-FLINK-018
const PLACES = [
{ id: 11, name: 'Fushimi Inari' },
{ id: 12, name: 'Nara Park' },
] as unknown as Place[]
const RESERVATIONS = [
{ id: 21, type: 'hotel', title: 'Hotel Granvia' },
{ id: 22, type: 'train', title: 'Shinkansen Nozomi 21' },
] as unknown as Reservation[]
function file(overrides: Partial<TripFile> = {}): TripFile {
return {
id: 7, trip_id: 1, filename: 'ticket.pdf', original_name: 'ticket.pdf',
mime_type: 'application/pdf', url: '/api/trips/1/files/7/download',
created_at: '2026-05-01T12:00:00.000Z',
...overrides,
} as unknown as TripFile
}
function renderSheet(fileArg: TripFile | null = file(), plannerOverrides: Partial<TripPlanner> = {}) {
const planner = buildPlanner({
places: PLACES, reservations: RESERVATIONS, ...plannerOverrides,
} as unknown as Partial<TripPlanner>)
const onClose = vi.fn()
const view = render(<MFileLinkSheet planner={planner} file={fileArg} onClose={onClose} />)
return { ...view, planner, onClose }
}
describe('MFileLinkSheet', () => {
beforeEach(() => {
vi.spyOn(filesApi, 'update').mockResolvedValue({})
vi.spyOn(filesApi, 'addLink').mockResolvedValue({})
vi.spyOn(filesApi, 'removeLink').mockResolvedValue({})
vi.spyOn(filesApi, 'getLinks').mockResolvedValue({ links: [] })
})
afterEach(() => {
vi.restoreAllMocks()
})
it('FE-MOB-FLINK-001: stays closed without a file', () => {
renderSheet(null)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-FLINK-002: heads the sheet with the file name and lists both sections', () => {
renderSheet()
expect(screen.getByRole('dialog', { name: 'files.linkTitle' })).toBeInTheDocument()
expect(screen.getByText('ticket.pdf')).toBeInTheDocument()
expect(screen.getByText('files.assignPlace')).toBeInTheDocument()
expect(screen.getByText('files.assignBooking')).toBeInTheDocument()
expect(screen.getByText('files.assignTransport')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Fushimi Inari' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Hotel Granvia' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Shinkansen Nozomi 21' })).toBeInTheDocument()
})
it('FE-MOB-FLINK-003: shows the empty hint when the trip has nothing to link to', () => {
renderSheet(file(), { places: [], reservations: [] } as unknown as Partial<TripPlanner>)
expect(screen.getByText('files.linkEmpty')).toBeInTheDocument()
expect(screen.queryByText('files.assignPlace')).not.toBeInTheDocument()
})
it('FE-MOB-FLINK-004: marks the primary and the extra links as active', () => {
renderSheet(file({ place_id: 11, linked_place_ids: [12], reservation_id: 21, linked_reservation_ids: [22] }))
for (const name of ['Fushimi Inari', 'Nara Park', 'Hotel Granvia', 'Shinkansen Nozomi 21']) {
expect(screen.getByRole('button', { name }).querySelector('.lucide-check')).not.toBeNull()
}
})
it('FE-MOB-FLINK-005: the first place link goes into place_id', async () => {
const { planner } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Fushimi Inari' }))
expect(filesApi.update).toHaveBeenCalledWith(1, 7, { place_id: 11 })
await waitFor(() => expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1))
expect(filesApi.addLink).not.toHaveBeenCalled()
})
it('FE-MOB-FLINK-006: a further place becomes a file_link record', async () => {
renderSheet(file({ place_id: 11 }))
fireEvent.click(screen.getByRole('button', { name: 'Nara Park' }))
await waitFor(() => expect(filesApi.addLink).toHaveBeenCalledWith(1, 7, { place_id: 12 }))
expect(filesApi.update).not.toHaveBeenCalled()
})
it('FE-MOB-FLINK-007: unlinking the primary place clears place_id', async () => {
renderSheet(file({ place_id: 11 }))
fireEvent.click(screen.getByRole('button', { name: 'Fushimi Inari' }))
await waitFor(() => expect(filesApi.update).toHaveBeenCalledWith(1, 7, { place_id: null }))
})
it('FE-MOB-FLINK-008: unlinking an extra place removes its link record', async () => {
vi.mocked(filesApi.getLinks).mockResolvedValue({ links: [{ id: 99, place_id: '12' }] })
renderSheet(file({ place_id: 11, linked_place_ids: [12] }))
fireEvent.click(screen.getByRole('button', { name: 'Nara Park' }))
await waitFor(() => expect(filesApi.removeLink).toHaveBeenCalledWith(1, 7, 99))
expect(filesApi.update).not.toHaveBeenCalled()
})
it('FE-MOB-FLINK-009: a missing link record is a no-op that still refreshes', async () => {
vi.mocked(filesApi.getLinks).mockResolvedValue({})
const { planner } = renderSheet(file({ place_id: 11, linked_place_ids: [12] }))
fireEvent.click(screen.getByRole('button', { name: 'Nara Park' }))
await waitFor(() => expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1))
expect(filesApi.removeLink).not.toHaveBeenCalled()
})
it('FE-MOB-FLINK-010: the first reservation link goes into reservation_id', async () => {
const { planner } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Hotel Granvia' }))
expect(filesApi.update).toHaveBeenCalledWith(1, 7, { reservation_id: 21 })
await waitFor(() => expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1))
})
it('FE-MOB-FLINK-011: a further reservation becomes a file_link record', async () => {
renderSheet(file({ reservation_id: 21 }))
fireEvent.click(screen.getByRole('button', { name: 'Shinkansen Nozomi 21' }))
await waitFor(() => expect(filesApi.addLink).toHaveBeenCalledWith(1, 7, { reservation_id: 22 }))
})
it('FE-MOB-FLINK-012: unlinking the primary reservation clears reservation_id', async () => {
renderSheet(file({ reservation_id: 21 }))
fireEvent.click(screen.getByRole('button', { name: 'Hotel Granvia' }))
await waitFor(() => expect(filesApi.update).toHaveBeenCalledWith(1, 7, { reservation_id: null }))
})
it('FE-MOB-FLINK-013: unlinking an extra reservation removes its link record', async () => {
vi.mocked(filesApi.getLinks).mockResolvedValue({ links: [{ id: 77, reservation_id: 22 }] })
renderSheet(file({ reservation_id: 21, linked_reservation_ids: [22] }))
fireEvent.click(screen.getByRole('button', { name: 'Shinkansen Nozomi 21' }))
await waitFor(() => expect(filesApi.removeLink).toHaveBeenCalledWith(1, 7, 77))
})
it('FE-MOB-FLINK-014: a failing toggle surfaces the assign error for both sections', async () => {
vi.mocked(filesApi.update).mockRejectedValue(new Error('boom'))
const { planner } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Fushimi Inari' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('files.toast.assignError'))
expect(planner.tripActions.loadFiles).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Hotel Granvia' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledTimes(2))
})
it('FE-MOB-FLINK-015: one toggle at a time — the row spins and the others are ignored', async () => {
let release: () => void = () => {}
vi.mocked(filesApi.update).mockImplementation(
() => new Promise(resolve => { release = () => resolve({}) }),
)
renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Fushimi Inari' }))
const row = screen.getByRole('button', { name: 'Fushimi Inari' })
await waitFor(() => expect(row).toBeDisabled())
expect(row.querySelector('.animate-spin')).not.toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Nara Park' }))
fireEvent.click(screen.getByRole('button', { name: 'Hotel Granvia' }))
expect(filesApi.update).toHaveBeenCalledTimes(1)
await act(async () => { release() })
await waitFor(() => expect(screen.getByRole('button', { name: 'Fushimi Inari' })).not.toBeDisabled())
})
it('FE-MOB-FLINK-016: skips null entries in the linked id arrays', () => {
renderSheet(file({ linked_place_ids: [null, 12], linked_reservation_ids: [null, 22] }))
expect(screen.getByRole('button', { name: 'Nara Park' }).querySelector('.lucide-check')).not.toBeNull()
expect(screen.getByRole('button', { name: 'Shinkansen Nozomi 21' }).querySelector('.lucide-check')).not.toBeNull()
expect(screen.getByRole('button', { name: 'Fushimi Inari' }).querySelector('.lucide-check')).toBeNull()
})
it('FE-MOB-FLINK-017: a links response without a links array is treated as empty', async () => {
vi.mocked(filesApi.getLinks).mockResolvedValue({})
const { planner } = renderSheet(file({ reservation_id: 21, linked_reservation_ids: [22] }))
fireEvent.click(screen.getByRole('button', { name: 'Shinkansen Nozomi 21' }))
await waitFor(() => expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1))
expect(filesApi.removeLink).not.toHaveBeenCalled()
})
it('FE-MOB-FLINK-018: keeps the file mounted through the exit animation', () => {
const planner = buildPlanner({ places: PLACES, reservations: RESERVATIONS } as unknown as Partial<TripPlanner>)
const { rerender } = render(<MFileLinkSheet planner={planner} file={file()} onClose={vi.fn()} />)
rerender(<MFileLinkSheet planner={planner} file={null} onClose={vi.fn()} />)
expect(screen.getByText('ticket.pdf')).toBeInTheDocument()
})
})
@@ -0,0 +1,177 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import MFileMenuSheet from '../../../../src/mobile/screens/trip/tabs/MFileMenuSheet'
import { filesApi } from '../../../../src/api/client'
import { downloadFile } from '../../../../src/utils/fileDownload'
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import type { TripFile } from '../../../../src/types'
import { buildPlanner, buildTripActions } from '../../../helpers/mobileTrip'
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
// FE-MOB-FMENU-001 to FE-MOB-FMENU-015
vi.mock('../../../../src/utils/fileDownload', () => ({
downloadFile: vi.fn(async () => undefined),
openFile: vi.fn(async () => undefined),
}))
function file(overrides: Partial<TripFile> = {}): TripFile {
return {
id: 7, trip_id: 1, filename: 'ticket.pdf', original_name: 'ticket.pdf',
mime_type: 'application/pdf', file_size: 2048, description: 'Seat 14A',
url: '/api/trips/1/files/7/download', created_at: '2026-05-01T12:00:00.000Z',
...overrides,
} as unknown as TripFile
}
function renderSheet(fileArg: TripFile | null = file(), plannerOverrides: Partial<TripPlanner> = {}) {
const planner = buildPlanner(plannerOverrides)
const onClose = vi.fn()
const onOpenLinks = vi.fn()
const view = render(
<MFileMenuSheet planner={planner} file={fileArg} onClose={onClose} onOpenLinks={onOpenLinks} />,
)
return { ...view, planner, onClose, onOpenLinks }
}
describe('MFileMenuSheet', () => {
beforeEach(() => {
vi.spyOn(filesApi, 'update').mockResolvedValue({})
vi.mocked(downloadFile).mockClear()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('FE-MOB-FMENU-001: stays closed while no file is selected', () => {
renderSheet(null)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-FMENU-002: heads the sheet with the file name, size and date', () => {
renderSheet()
const dialog = screen.getByRole('dialog', { name: 'ticket.pdf' })
expect(within(dialog).getByText('ticket.pdf')).toBeInTheDocument()
expect(within(dialog).getByText(/2\.0 KB · May 1/)).toBeInTheDocument()
})
it('FE-MOB-FMENU-003: seeds the note field from the file description', () => {
renderSheet()
expect(screen.getByPlaceholderText('files.notePlaceholder')).toHaveValue('Seat 14A')
})
it('FE-MOB-FMENU-004: saves a changed note on blur and refreshes the store', async () => {
const { planner } = renderSheet()
const input = screen.getByPlaceholderText('files.notePlaceholder')
fireEvent.change(input, { target: { value: ' Seat 22C ' } })
fireEvent.blur(input)
expect(filesApi.update).toHaveBeenCalledWith(1, 7, { description: 'Seat 22C' })
await waitFor(() => expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1))
})
it('FE-MOB-FMENU-005: Enter commits the note by blurring the field', () => {
renderSheet()
const input = screen.getByPlaceholderText('files.notePlaceholder')
input.focus()
fireEvent.change(input, { target: { value: 'Gate B' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(filesApi.update).toHaveBeenCalledWith(1, 7, { description: 'Gate B' })
})
it('FE-MOB-FMENU-006: an unchanged note is not sent', () => {
renderSheet()
const input = screen.getByPlaceholderText('files.notePlaceholder')
fireEvent.change(input, { target: { value: ' Seat 14A ' } })
fireEvent.blur(input)
expect(filesApi.update).not.toHaveBeenCalled()
})
it('FE-MOB-FMENU-007: reports a failed note save', async () => {
vi.mocked(filesApi.update).mockRejectedValue(new Error('boom'))
const { planner } = renderSheet()
fireEvent.change(screen.getByPlaceholderText('files.notePlaceholder'), { target: { value: 'x' } })
fireEvent.blur(screen.getByPlaceholderText('files.notePlaceholder'))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('files.toast.assignError'))
expect(planner.tripActions.loadFiles).not.toHaveBeenCalled()
})
it('FE-MOB-FMENU-008: an empty description starts blank and stays unsent while untouched', () => {
renderSheet(file({ description: null }))
const input = screen.getByPlaceholderText('files.notePlaceholder')
expect(input).toHaveValue('')
fireEvent.keyDown(input, { key: 'a' })
fireEvent.blur(input)
expect(filesApi.update).not.toHaveBeenCalled()
})
it('FE-MOB-FMENU-009: downloads through the cookie-authenticated helper', () => {
renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'files.download' }))
expect(downloadFile).toHaveBeenCalledWith('/api/trips/1/files/7/download', 'ticket.pdf')
})
it('FE-MOB-FMENU-010: hands the file to the link picker', () => {
const { onOpenLinks } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'files.link' }))
expect(onOpenLinks).toHaveBeenCalledWith(expect.objectContaining({ id: 7 }))
})
it('FE-MOB-FMENU-011: deleting asks first, then trashes the file and closes', async () => {
const { planner, onClose } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
const confirm = screen.getByRole('dialog', { name: 'common.delete' })
expect(within(confirm).getByText('files.confirm.delete')).toBeInTheDocument()
fireEvent.click(within(confirm).getByRole('button', { name: 'common.delete' }))
expect(planner.tripActions.deleteFile).toHaveBeenCalledWith(1, 7)
await waitFor(() => expect(planner.toast.success).toHaveBeenCalledWith('files.toast.trashed'))
expect(onClose).toHaveBeenCalled()
})
it('FE-MOB-FMENU-012: reports a failed delete and keeps the sheet open', async () => {
const tripActions = buildTripActions()
tripActions.deleteFile.mockRejectedValue(new Error('nope'))
const { planner, onClose } = renderSheet(file(), { tripActions } as unknown as Partial<TripPlanner>)
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
const confirm = screen.getByRole('dialog', { name: 'common.delete' })
fireEvent.click(within(confirm).getByRole('button', { name: 'common.delete' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('files.toast.deleteError'))
expect(onClose).not.toHaveBeenCalled()
})
it('FE-MOB-FMENU-013: cancelling the confirm leaves the file alone', () => {
const { planner } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
const confirm = screen.getByRole('dialog', { name: 'common.delete' })
fireEvent.click(within(confirm).getByRole('button', { name: 'common.cancel' }))
expect(planner.tripActions.deleteFile).not.toHaveBeenCalled()
})
it('FE-MOB-FMENU-014: a read-only member only sees the download row', () => {
renderSheet(file(), { can: () => false } as unknown as Partial<TripPlanner>)
expect(screen.getByRole('button', { name: 'files.download' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'files.link' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('files.notePlaceholder')).not.toBeInTheDocument()
})
it('FE-MOB-FMENU-015: keeps the last file mounted through the exit animation and re-seeds on a new one', () => {
const planner = buildPlanner()
const { rerender } = render(
<MFileMenuSheet planner={planner} file={file()} onClose={vi.fn()} onOpenLinks={vi.fn()} />,
)
rerender(<MFileMenuSheet planner={planner} file={null} onClose={vi.fn()} onOpenLinks={vi.fn()} />)
expect(screen.getByText('ticket.pdf')).toBeInTheDocument()
rerender(
<MFileMenuSheet
planner={planner}
file={file({ id: 8, original_name: 'map.png', description: 'Old town' })}
onClose={vi.fn()}
onOpenLinks={vi.fn()}
/>,
)
expect(screen.getByText('map.png')).toBeInTheDocument()
expect(screen.getByPlaceholderText('files.notePlaceholder')).toHaveValue('Old town')
})
})
@@ -0,0 +1,199 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import MFileTrashSheet from '../../../../src/mobile/screens/trip/tabs/MFileTrashSheet'
import { filesApi } from '../../../../src/api/client'
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import type { TripFile } from '../../../../src/types'
import { buildPlanner } from '../../../helpers/mobileTrip'
import { act, fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
// FE-MOB-FTRASH-001 to FE-MOB-FTRASH-016
const TRASHED = [
{
id: 31, trip_id: 1, filename: 'old.pdf', original_name: 'old.pdf', mime_type: 'application/pdf',
file_size: 1024, url: '/api/trips/1/files/31/download', created_at: '2026-05-01T12:00:00.000Z',
},
{
id: 32, trip_id: 1, filename: 'shot.png', original_name: 'shot.png', mime_type: 'image/png',
url: '/api/trips/1/files/32/download', created_at: '2026-05-02T12:00:00.000Z',
},
] as unknown as TripFile[]
function renderSheet(open = true, plannerOverrides: Partial<TripPlanner> = {}) {
const planner = buildPlanner(plannerOverrides)
const onClose = vi.fn()
const view = render(<MFileTrashSheet planner={planner} open={open} onClose={onClose} />)
return { ...view, planner, onClose }
}
/** Waits for the lazy trash fetch to settle. */
async function settled() {
await waitFor(() => expect(document.querySelector('.animate-spin')).toBeNull())
}
describe('MFileTrashSheet', () => {
beforeEach(() => {
vi.spyOn(filesApi, 'list').mockResolvedValue({ files: TRASHED })
vi.spyOn(filesApi, 'restore').mockResolvedValue({})
vi.spyOn(filesApi, 'permanentDelete').mockResolvedValue({})
vi.spyOn(filesApi, 'emptyTrash').mockResolvedValue({})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('FE-MOB-FTRASH-001: fetches nothing while closed', () => {
renderSheet(false)
expect(filesApi.list).not.toHaveBeenCalled()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-FTRASH-002: loads the trashed files on open and lists them with size and date', async () => {
renderSheet()
expect(filesApi.list).toHaveBeenCalledWith(1, true)
expect(document.querySelector('.animate-spin')).not.toBeNull()
await settled()
expect(screen.getByRole('dialog', { name: 'files.trash' })).toBeInTheDocument()
expect(screen.getByText('old.pdf')).toBeInTheDocument()
expect(screen.getByText('1.0 KB · May 1')).toBeInTheDocument()
// No file_size → only the date remains.
expect(screen.getByText('May 2')).toBeInTheDocument()
})
it('FE-MOB-FTRASH-003: shows the empty hint for an empty trash', async () => {
vi.mocked(filesApi.list).mockResolvedValue({ files: [] })
renderSheet()
await settled()
expect(screen.getByText('files.trashEmpty')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'files.emptyTrash' })).not.toBeInTheDocument()
})
it('FE-MOB-FTRASH-004: survives a failing fetch', async () => {
vi.mocked(filesApi.list).mockRejectedValue(new Error('offline'))
renderSheet()
await settled()
expect(screen.getByText('files.trashEmpty')).toBeInTheDocument()
})
it('FE-MOB-FTRASH-005: tolerates a response without a files array', async () => {
vi.mocked(filesApi.list).mockResolvedValue({})
renderSheet()
await settled()
expect(screen.getByText('files.trashEmpty')).toBeInTheDocument()
})
it('FE-MOB-FTRASH-006: restores a file, drops the row and refreshes the store', async () => {
const { planner } = renderSheet()
await settled()
fireEvent.click(screen.getAllByRole('button', { name: 'files.restore' })[0])
expect(filesApi.restore).toHaveBeenCalledWith(1, 31)
await waitFor(() => expect(screen.queryByText('old.pdf')).not.toBeInTheDocument())
expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1)
expect(planner.toast.success).toHaveBeenCalledWith('files.toast.restored')
})
it('FE-MOB-FTRASH-007: reports a failed restore and keeps the row', async () => {
vi.mocked(filesApi.restore).mockRejectedValue(new Error('nope'))
const { planner } = renderSheet()
await settled()
fireEvent.click(screen.getAllByRole('button', { name: 'files.restore' })[0])
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('files.toast.restoreError'))
expect(screen.getByText('old.pdf')).toBeInTheDocument()
})
it('FE-MOB-FTRASH-008: permanently deletes only after the confirm', async () => {
const { planner } = renderSheet()
await settled()
fireEvent.click(screen.getAllByRole('button', { name: 'common.delete' })[0])
expect(filesApi.permanentDelete).not.toHaveBeenCalled()
const confirm = screen.getByRole('dialog', { name: 'common.delete' })
expect(within(confirm).getByText('files.confirm.permanentDelete')).toBeInTheDocument()
fireEvent.click(within(confirm).getByRole('button', { name: 'common.delete' }))
expect(filesApi.permanentDelete).toHaveBeenCalledWith(1, 31)
await waitFor(() => expect(screen.queryByText('old.pdf')).not.toBeInTheDocument())
expect(planner.toast.success).toHaveBeenCalledWith('files.toast.deleted')
expect(planner.tripActions.loadFiles).not.toHaveBeenCalled()
})
it('FE-MOB-FTRASH-009: cancelling the confirm keeps the file', async () => {
renderSheet()
await settled()
fireEvent.click(screen.getAllByRole('button', { name: 'common.delete' })[0])
const confirm = screen.getByRole('dialog', { name: 'common.delete' })
fireEvent.click(within(confirm).getByRole('button', { name: 'common.cancel' }))
expect(filesApi.permanentDelete).not.toHaveBeenCalled()
expect(screen.getByText('old.pdf')).toBeInTheDocument()
})
it('FE-MOB-FTRASH-010: reports a failed permanent delete', async () => {
vi.mocked(filesApi.permanentDelete).mockRejectedValue(new Error('nope'))
const { planner } = renderSheet()
await settled()
fireEvent.click(screen.getAllByRole('button', { name: 'common.delete' })[0])
const confirm = screen.getByRole('dialog', { name: 'common.delete' })
fireEvent.click(within(confirm).getByRole('button', { name: 'common.delete' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('files.toast.deleteError'))
expect(screen.getByText('old.pdf')).toBeInTheDocument()
})
it('FE-MOB-FTRASH-011: empties the whole trash after the confirm', async () => {
const { planner } = renderSheet()
await settled()
fireEvent.click(screen.getByRole('button', { name: 'files.emptyTrash' }))
const confirm = screen.getByRole('dialog', { name: 'files.emptyTrash' })
expect(within(confirm).getByText('files.confirm.emptyTrash')).toBeInTheDocument()
fireEvent.click(within(confirm).getByRole('button', { name: 'files.emptyTrash' }))
expect(filesApi.emptyTrash).toHaveBeenCalledWith(1)
await waitFor(() => expect(screen.getByText('files.trashEmpty')).toBeInTheDocument())
expect(planner.toast.success).toHaveBeenCalledWith('files.toast.trashEmptied')
})
it('FE-MOB-FTRASH-012: reports a failed empty', async () => {
vi.mocked(filesApi.emptyTrash).mockRejectedValue(new Error('nope'))
const { planner } = renderSheet()
await settled()
fireEvent.click(screen.getByRole('button', { name: 'files.emptyTrash' }))
const confirm = screen.getByRole('dialog', { name: 'files.emptyTrash' })
fireEvent.click(within(confirm).getByRole('button', { name: 'files.emptyTrash' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('files.toast.deleteError'))
expect(screen.getByText('old.pdf')).toBeInTheDocument()
})
it('FE-MOB-FTRASH-013: a member without file_delete only gets to look', async () => {
renderSheet(true, { can: (perm: string) => perm !== 'file_delete' } as unknown as Partial<TripPlanner>)
await settled()
expect(screen.getByText('old.pdf')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'files.restore' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'files.emptyTrash' })).not.toBeInTheDocument()
})
it('FE-MOB-FTRASH-014: closes through the header button', async () => {
const { onClose } = renderSheet()
await settled()
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('FE-MOB-FTRASH-015: a fetch that lands after unmount is dropped', async () => {
let release: (data: { files: TripFile[] }) => void = () => {}
vi.mocked(filesApi.list).mockImplementation(() => new Promise(resolve => { release = resolve }))
const { unmount } = renderSheet()
unmount()
await act(async () => { release({ files: TRASHED }) })
expect(screen.queryByText('old.pdf')).not.toBeInTheDocument()
})
it('FE-MOB-FTRASH-016: cancelling the empty-trash confirm keeps everything', async () => {
renderSheet()
await settled()
fireEvent.click(screen.getByRole('button', { name: 'files.emptyTrash' }))
const confirm = screen.getByRole('dialog', { name: 'files.emptyTrash' })
fireEvent.click(within(confirm).getByRole('button', { name: 'common.cancel' }))
expect(filesApi.emptyTrash).not.toHaveBeenCalled()
expect(screen.getByText('old.pdf')).toBeInTheDocument()
})
})
@@ -0,0 +1,375 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import MFilesTab from '../../../../src/mobile/screens/trip/tabs/MFilesTab'
import { filesApi } from '../../../../src/api/client'
import { openFile } from '../../../../src/utils/fileDownload'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import type { Place, TripFile } from '../../../../src/types'
import { buildPlanner, buildShell, buildTripActions } from '../../../helpers/mobileTrip'
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-FTAB-001 to FE-MOB-FTAB-025
vi.mock('../../../../src/utils/fileDownload', () => ({
downloadFile: vi.fn(async () => undefined),
openFile: vi.fn(async () => undefined),
}))
// The four overlays are covered by their own suites — stubbed here so this one
// asserts the tab's own wiring.
vi.mock('../../../../src/mobile/screens/trip/tabs/MFileMenuSheet', () => ({
default: ({ file, onClose, onOpenLinks }: {
file: TripFile | null
onClose: () => void
onOpenLinks: (f: TripFile) => void
}) => file ? (
<div data-testid="menu-sheet" data-file={String(file.id)}>
<button type="button" onClick={onClose}>menu-close</button>
<button type="button" onClick={() => onOpenLinks(file)}>menu-links</button>
</div>
) : null,
}))
vi.mock('../../../../src/mobile/screens/trip/tabs/MFileLinkSheet', () => ({
default: ({ file, onClose }: { file: TripFile | null; onClose: () => void }) => file ? (
<div data-testid="link-sheet" data-file={String(file.id)}>
<button type="button" onClick={onClose}>link-close</button>
</div>
) : null,
}))
vi.mock('../../../../src/mobile/screens/trip/tabs/MFileTrashSheet', () => ({
default: ({ open, onClose }: { open: boolean; onClose: () => void }) => open ? (
<div data-testid="trash-sheet">
<button type="button" onClick={onClose}>trash-close</button>
</div>
) : null,
}))
vi.mock('../../../../src/mobile/screens/trip/tabs/MFileLightbox', () => ({
default: ({ files, index, onIndexChange, onClose }: {
files: TripFile[]
index: number
onIndexChange: (i: number) => void
onClose: () => void
}) => (
<div data-testid="lightbox" data-index={String(index)} data-count={String(files.length)}>
<span>{files[index]?.original_name}</span>
<button type="button" onClick={() => onIndexChange(index + 1)}>lb-next</button>
<button type="button" onClick={onClose}>lb-close</button>
</div>
),
}))
const PLACES = [{ id: 11, name: 'Fushimi Inari' }] as unknown as Place[]
function tripFile(overrides: Partial<TripFile>): TripFile {
return {
trip_id: 1, filename: 'f', original_name: 'f', mime_type: 'application/octet-stream',
url: '/api/trips/1/files/0/download', created_at: '2026-05-01T12:00:00.000Z',
...overrides,
} as unknown as TripFile
}
const GUIDE = tripFile({
id: 1, filename: 'guide.pdf', original_name: 'guide.pdf', mime_type: 'application/pdf',
file_size: 2048, uploaded_by_name: 'ada', place_id: 11, url: '/api/trips/1/files/1/download',
})
const BEACH = tripFile({
id: 2, filename: 'beach.jpg', original_name: 'beach.jpg', mime_type: 'image/jpeg',
starred: 1, url: '/api/trips/1/files/2/download',
})
const NOTES = tripFile({ id: 3, filename: 'notes.txt', original_name: 'notes.txt', mime_type: 'text/plain' })
const PASS = tripFile({ id: 4, filename: 'board.pkpass', original_name: 'board.pkpass', mime_type: '' })
const IDEA = tripFile({
id: 5, filename: 'idea.png', original_name: 'idea.png', mime_type: 'image/png', note_id: 9,
})
const FILES = [GUIDE, BEACH, NOTES, PASS, IDEA]
const NAME_RE = /^(guide\.pdf|beach\.jpg|notes\.txt|board\.pkpass|idea\.png)$/
function renderTab(plannerOverrides: Partial<TripPlanner> = {}, shellOverrides: Partial<MTripShellApi> = {}) {
const planner = buildPlanner({ files: FILES, places: PLACES, ...plannerOverrides } as unknown as Partial<TripPlanner>)
const shell = buildShell(shellOverrides)
const view = render(<MFilesTab planner={planner} shell={shell} />)
const rerenderWith = (nextShell: Partial<MTripShellApi>) =>
view.rerender(<MFilesTab planner={planner} shell={buildShell({ ...shellOverrides, ...nextShell })} />)
return { ...view, planner, shell, rerenderWith }
}
function tile(labelKey: string): HTMLButtonElement {
return screen.getByText(labelKey).closest('button') as HTMLButtonElement
}
function rowNames(): string[] {
return screen.queryAllByText(NAME_RE).map(n => n.textContent as string)
}
function hiddenInput(container: HTMLElement): HTMLInputElement {
return container.querySelector('input[type="file"]') as HTMLInputElement
}
function pasteTarget(container: HTMLElement): HTMLElement {
return container.querySelector('[tabindex="-1"]') as HTMLElement
}
function makeFile(name: string, type: string, size = 10): File {
const f = new File(['x'], name, { type })
Object.defineProperty(f, 'size', { value: size })
return f
}
describe('MFilesTab', () => {
beforeEach(() => {
vi.spyOn(filesApi, 'toggleStar').mockResolvedValue({})
vi.mocked(openFile).mockClear()
vi.mocked(openFile).mockResolvedValue(undefined)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('FE-MOB-FTAB-001: shows the mascot empty state and no filter grid without files', () => {
renderTab({ files: [] } as unknown as Partial<TripPlanner>)
expect(screen.getByText('mobileTrip.filesEmpty')).toBeInTheDocument()
expect(screen.queryByText('files.filterAll')).not.toBeInTheDocument()
})
it('FE-MOB-FTAB-002: counts every filter tile over the whole file list', () => {
renderTab()
expect(tile('files.filterAll').textContent).toBe('files.filterAll5')
expect(tile('files.filterPdf').textContent).toBe('files.filterPdf1')
expect(tile('files.filterImages').textContent).toBe('files.filterImages2')
expect(tile('files.filterDocs').textContent).toBe('files.filterDocs2')
expect(tile('files.filterStarred').textContent).toBe('files.filterStarred1')
expect(tile('files.filterCollab').textContent).toBe('files.filterCollab1')
})
it('FE-MOB-FTAB-003: drops the collab tile when no file came from a note', () => {
renderTab({ files: [GUIDE, BEACH] } as unknown as Partial<TripPlanner>)
expect(screen.queryByText('files.filterCollab')).not.toBeInTheDocument()
expect(screen.getByText('files.filterAll')).toBeInTheDocument()
})
it('FE-MOB-FTAB-004: sorts starred files to the top', () => {
renderTab()
expect(rowNames()).toEqual(['beach.jpg', 'guide.pdf', 'notes.txt', 'board.pkpass', 'idea.png'])
})
it('FE-MOB-FTAB-005: a filter narrows the list to its bucket', () => {
renderTab()
fireEvent.click(tile('files.filterDocs'))
expect(rowNames()).toEqual(['notes.txt', 'board.pkpass'])
fireEvent.click(tile('files.filterImages'))
expect(rowNames()).toEqual(['beach.jpg', 'idea.png'])
fireEvent.click(tile('files.filterStarred'))
expect(rowNames()).toEqual(['beach.jpg'])
fireEvent.click(tile('files.filterCollab'))
expect(rowNames()).toEqual(['idea.png'])
})
it('FE-MOB-FTAB-006: an empty filter result keeps the grid and shows the hint', () => {
renderTab({ files: [BEACH] } as unknown as Partial<TripPlanner>)
fireEvent.click(tile('files.filterPdf'))
expect(rowNames()).toEqual([])
expect(screen.getByText('mobileTrip.filesEmpty')).toBeInTheDocument()
expect(screen.getByText('files.filterPdf')).toBeInTheDocument()
})
it('FE-MOB-FTAB-007: a row carries size, date, uploader and its link labels', () => {
renderTab()
expect(screen.getByText('2.0 KB')).toBeInTheDocument()
expect(screen.getAllByText('May 1').length).toBeGreaterThan(0)
expect(screen.getByText('ada')).toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
expect(screen.getByText('files.sourcePlan · Fushimi Inari')).toBeInTheDocument()
expect(screen.getByText('files.sourceCollab')).toBeInTheDocument()
})
it('FE-MOB-FTAB-008: starring calls the api and refreshes the store', async () => {
const { planner } = renderTab()
expect(screen.getByRole('button', { name: 'files.unstar' })).toBeInTheDocument()
fireEvent.click(screen.getAllByRole('button', { name: 'files.star' })[0])
expect(filesApi.toggleStar).toHaveBeenCalledWith(1, 1)
await waitFor(() => expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1))
})
it('FE-MOB-FTAB-009: a failing star toggle is reported', async () => {
vi.mocked(filesApi.toggleStar).mockRejectedValue(new Error('boom'))
const { planner } = renderTab()
fireEvent.click(screen.getAllByRole('button', { name: 'files.star' })[0])
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('files.toast.assignError'))
})
it('FE-MOB-FTAB-010: opening a media row puts it in the lightbox at its media index', () => {
renderTab()
expect(screen.queryByTestId('lightbox')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('idea.png'))
const box = screen.getByTestId('lightbox')
expect(box).toHaveAttribute('data-index', '1')
expect(box).toHaveAttribute('data-count', '2')
fireEvent.click(screen.getByText('lb-close'))
expect(screen.queryByTestId('lightbox')).not.toBeInTheDocument()
})
it('FE-MOB-FTAB-011: the lightbox index is clamped to the media it is given', () => {
renderTab()
fireEvent.click(screen.getByText('beach.jpg'))
expect(screen.getByTestId('lightbox')).toHaveAttribute('data-index', '0')
fireEvent.click(screen.getByText('lb-next'))
expect(screen.getByTestId('lightbox')).toHaveAttribute('data-index', '1')
// Past the end the panel clamps instead of unmounting the viewer.
fireEvent.click(screen.getByText('lb-next'))
expect(screen.getByTestId('lightbox')).toHaveAttribute('data-index', '1')
})
it('FE-MOB-FTAB-012: a non-media row opens through the browser-native helper', () => {
renderTab()
fireEvent.click(screen.getByText('guide.pdf'))
expect(openFile).toHaveBeenCalledWith('/api/trips/1/files/1/download', 'guide.pdf')
expect(screen.queryByTestId('lightbox')).not.toBeInTheDocument()
})
it('FE-MOB-FTAB-013: a failing open is reported', async () => {
vi.mocked(openFile).mockRejectedValue(new Error('offline'))
const { planner } = renderTab()
fireEvent.click(screen.getByText('guide.pdf'))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('files.openError'))
})
it('FE-MOB-FTAB-014: the kebab opens the menu sheet and hands over to the link picker', () => {
renderTab()
fireEvent.click(screen.getAllByRole('button', { name: 'files.menu' })[0])
expect(screen.getByTestId('menu-sheet')).toHaveAttribute('data-file', '2')
fireEvent.click(screen.getByText('menu-links'))
expect(screen.queryByTestId('menu-sheet')).not.toBeInTheDocument()
expect(screen.getByTestId('link-sheet')).toHaveAttribute('data-file', '2')
fireEvent.click(screen.getByText('link-close'))
expect(screen.queryByTestId('link-sheet')).not.toBeInTheDocument()
})
it('FE-MOB-FTAB-015: closing the menu sheet clears the selection', () => {
renderTab()
fireEvent.click(screen.getAllByRole('button', { name: 'files.menu' })[1])
expect(screen.getByTestId('menu-sheet')).toHaveAttribute('data-file', '1')
fireEvent.click(screen.getByText('menu-close'))
expect(screen.queryByTestId('menu-sheet')).not.toBeInTheDocument()
})
it('FE-MOB-FTAB-016: the header upload signal opens the file picker', () => {
const click = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {})
const { rerenderWith } = renderTab()
expect(click).not.toHaveBeenCalled()
rerenderWith({ uploadFilesSignal: 1 })
expect(click).toHaveBeenCalledTimes(1)
// Re-render without a new signal must not re-open the picker.
rerenderWith({ uploadFilesSignal: 1 })
expect(click).toHaveBeenCalledTimes(1)
})
it('FE-MOB-FTAB-017: uploads every picked file and reports the count', async () => {
const { container, planner } = renderTab()
const input = hiddenInput(container)
fireEvent.change(input, {
target: { files: [makeFile('a.pdf', 'application/pdf'), makeFile('b.pdf', 'application/pdf')] },
})
await waitFor(() => expect(planner.toast.success).toHaveBeenCalledWith('files.uploaded:2'))
expect(planner.tripActions.addFile).toHaveBeenCalledTimes(2)
expect(planner.tripActions.addFile).toHaveBeenCalledWith(1, expect.any(FormData))
expect(input.value).toBe('')
})
it('FE-MOB-FTAB-018: an empty pick does nothing', () => {
const { container, planner } = renderTab()
fireEvent.change(hiddenInput(container), { target: { files: [] } })
expect(planner.tripActions.addFile).not.toHaveBeenCalled()
})
it('FE-MOB-FTAB-019: a file over the 50 MB cap is rejected outright', () => {
const { container, planner } = renderTab()
fireEvent.change(hiddenInput(container), {
target: { files: [makeFile('huge.pdf', 'application/pdf', 60 * 1024 * 1024)] },
})
expect(planner.toast.error).toHaveBeenCalledWith('files.uploadErrorSize')
expect(planner.tripActions.addFile).not.toHaveBeenCalled()
})
it('FE-MOB-FTAB-020: an oversized file in a batch is skipped, the rest still uploads', async () => {
const { container, planner } = renderTab()
fireEvent.change(hiddenInput(container), {
target: {
files: [
makeFile('huge.pdf', 'application/pdf', 60 * 1024 * 1024),
makeFile('small.pdf', 'application/pdf'),
],
},
})
expect(planner.toast.error).toHaveBeenCalledWith('files.uploadErrorSize')
await waitFor(() => expect(planner.toast.success).toHaveBeenCalledWith('files.uploaded:1'))
expect(planner.tripActions.addFile).toHaveBeenCalledTimes(1)
})
it('FE-MOB-FTAB-021: a rejected upload falls back to the generic upload error', async () => {
const tripActions = buildTripActions()
tripActions.addFile.mockRejectedValue(new Error('server-500'))
const { container, planner } = renderTab({ tripActions } as unknown as Partial<TripPlanner>)
fireEvent.change(hiddenInput(container), { target: { files: [makeFile('a.pdf', 'application/pdf')] } })
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('files.uploadError'))
expect(planner.toast.success).not.toHaveBeenCalled()
})
it('FE-MOB-FTAB-022: the uploading pill shows while the request is in flight', async () => {
let release: () => void = () => {}
const tripActions = buildTripActions()
tripActions.addFile.mockImplementation(() => new Promise(resolve => { release = () => resolve(undefined) }))
const { container } = renderTab({ tripActions } as unknown as Partial<TripPlanner>)
fireEvent.change(hiddenInput(container), { target: { files: [makeFile('a.pdf', 'application/pdf')] } })
await waitFor(() => expect(screen.getByText('files.uploading')).toBeInTheDocument())
await act(async () => { release() })
await waitFor(() => expect(screen.queryByText('files.uploading')).not.toBeInTheDocument())
})
it('FE-MOB-FTAB-023: pasted files upload, and nothing happens without the permission', async () => {
const { container, planner } = renderTab()
const pasted = makeFile('screenshot.png', 'image/png')
fireEvent.paste(pasteTarget(container), {
clipboardData: {
items: [
{ kind: 'file', getAsFile: () => pasted },
{ kind: 'file', getAsFile: () => null },
{ kind: 'string', getAsFile: () => null },
],
},
})
await waitFor(() => expect(planner.tripActions.addFile).toHaveBeenCalledTimes(1))
const readOnly = renderTab({ canUploadFiles: false } as unknown as Partial<TripPlanner>)
fireEvent.paste(pasteTarget(readOnly.container), {
clipboardData: { items: [{ kind: 'file', getAsFile: () => pasted }] },
})
expect(readOnly.planner.tripActions.addFile).not.toHaveBeenCalled()
})
it('FE-MOB-FTAB-024: a paste that carries no file at all is left to the browser', () => {
const { container, planner } = renderTab()
fireEvent.paste(pasteTarget(container), { clipboardData: { items: [{ kind: 'string', getAsFile: () => null }] } })
expect(planner.tripActions.addFile).not.toHaveBeenCalled()
// A paste event without clipboardData at all must not throw either.
fireEvent.paste(pasteTarget(container))
expect(planner.tripActions.addFile).not.toHaveBeenCalled()
})
it('FE-MOB-FTAB-025: the header trash signal opens the trash sheet', () => {
const { rerenderWith } = renderTab()
expect(screen.queryByTestId('trash-sheet')).not.toBeInTheDocument()
rerenderWith({ openFilesTrashSignal: 1 })
expect(screen.getByTestId('trash-sheet')).toBeInTheDocument()
fireEvent.click(screen.getByText('trash-close'))
expect(screen.queryByTestId('trash-sheet')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,113 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { placesApi } from '../../../../src/api/client'
import MImportSheet from '../../../../src/mobile/screens/trip/sheets/MImportSheet'
import { buildPlanner } from '../../../helpers/mobileTrip'
import { resetAllStores } from '../../../helpers/store'
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-IMPS-001 to FE-MOB-IMPS-010
//
// planner.t echoes its key — assertions go against keys, not English copy.
function renderSheet(open = true) {
const planner = buildPlanner()
const onClose = vi.fn()
const view = render(<MImportSheet planner={planner} open={open} onClose={onClose} />)
return { ...view, planner, onClose }
}
const menuRow = (title: string) => screen.getByRole('button', { name: new RegExp(`^${title}`) })
describe('MImportSheet', () => {
beforeEach(() => {
resetAllStores()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('FE-MOB-IMPS-001: renders nothing while closed', () => {
renderSheet(false)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-IMPS-002: opens on the menu with both import options', () => {
renderSheet()
const dialog = screen.getByRole('dialog', { name: 'mobileTrip.importPlaces' })
expect(dialog).toBeInTheDocument()
expect(menuRow('places.importFile')).toHaveTextContent('GPX · KML · KMZ')
expect(menuRow('places.importList')).toHaveTextContent('mobileTrip.importListSub')
expect(screen.queryByRole('button', { name: 'common.back' })).not.toBeInTheDocument()
})
it('FE-MOB-IMPS-003: the close button reports the sheet closed', () => {
const { onClose } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('FE-MOB-IMPS-004: the file option opens the file step under its own title', () => {
renderSheet()
fireEvent.click(menuRow('places.importFile'))
expect(screen.getByText('places.importFileHint')).toBeInTheDocument()
expect(screen.getByText('places.importFileDropHere')).toBeInTheDocument()
expect(screen.queryByText('mobileTrip.importListSub')).not.toBeInTheDocument()
})
it('FE-MOB-IMPS-005: the list option opens the shared-list step', () => {
renderSheet()
fireEvent.click(menuRow('places.importList'))
expect(screen.getByText('places.googleListHint')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('FE-MOB-IMPS-006: the header back button returns to the menu', () => {
renderSheet()
fireEvent.click(menuRow('places.importList'))
fireEvent.click(screen.getByRole('button', { name: 'common.back' }))
expect(menuRow('places.importFile')).toBeInTheDocument()
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('FE-MOB-IMPS-007: the footer cancel of either step returns to the menu', () => {
renderSheet()
fireEvent.click(menuRow('places.importFile'))
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
expect(menuRow('places.importList')).toBeInTheDocument()
fireEvent.click(menuRow('places.importList'))
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
expect(menuRow('places.importFile')).toBeInTheDocument()
})
it('FE-MOB-IMPS-008: reopening the sheet resets it to the menu', () => {
const { rerender, planner, onClose } = renderSheet()
fireEvent.click(menuRow('places.importList'))
expect(screen.getByRole('textbox')).toBeInTheDocument()
rerender(<MImportSheet planner={planner} open={false} onClose={onClose} />)
rerender(<MImportSheet planner={planner} open onClose={onClose} />)
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(menuRow('places.importFile')).toBeInTheDocument()
})
it('FE-MOB-IMPS-009: a finished list import closes the whole sheet', async () => {
vi.spyOn(placesApi, 'importGoogleList').mockResolvedValue({ count: 2, skipped: 0, listName: 'L', places: [] })
const { onClose } = renderSheet()
fireEvent.click(menuRow('places.importList'))
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'https://maps.app.goo.gl/abc' } })
fireEvent.click(screen.getByRole('button', { name: 'common.import' }))
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1))
})
it('FE-MOB-IMPS-010: a finished file import closes the whole sheet', async () => {
vi.spyOn(placesApi, 'importGpx').mockResolvedValue({ count: 1, skipped: 0, places: [] })
const { onClose } = renderSheet()
fireEvent.click(menuRow('places.importFile'))
const input = document.querySelector('input[type="file"]') as HTMLInputElement
fireEvent.change(input, { target: { files: [new File(['<gpx/>'], 'route.gpx')] } })
fireEvent.click(screen.getByRole('button', { name: 'common.import' }))
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1))
})
})
@@ -0,0 +1,136 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { FolderOpen, Users } from 'lucide-react'
import MMehrSheet from '../../../../src/mobile/screens/trip/sheets/MMehrSheet'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import type { TripFile, TripMember } from '../../../../src/types'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { resetAllStores } from '../../../helpers/store'
import { fireEvent, render, screen } from '../../../helpers/render'
// FE-MOB-MEHR-001 to FE-MOB-MEHR-012
//
// The sheet takes its copy from the real TranslationProvider, so the visible
// strings are asserted in English.
const TABS = [
{ id: 'plan', label: 'Plan', icon: FolderOpen },
{ id: 'transports', label: 'Transport', icon: FolderOpen },
{ id: 'buchungen', label: 'Bookings', icon: FolderOpen },
{ id: 'finanzplan', label: 'Budget', icon: FolderOpen },
{ id: 'listen', label: 'Lists', icon: FolderOpen },
{ id: 'dateien', label: 'Files', icon: FolderOpen },
{ id: 'collab', label: 'Collaboration', icon: Users },
]
const FILES = [
{ id: 1, trip_id: 1, filename: 'a.pdf', deleted_at: null },
{ id: 2, trip_id: 1, filename: 'b.pdf', deleted_at: null },
{ id: 3, trip_id: 1, filename: 'gone.pdf', deleted_at: '2026-05-01T10:00:00Z' },
] as unknown as TripFile[]
const MEMBERS = [
{ user_id: 1, username: 'maurice', role: 'owner' },
{ user_id: 2, username: 'julien', role: 'editor' },
{ user_id: 3, username: 'guest', role: 'viewer' },
] as unknown as TripMember[]
function renderSheet(plannerOverrides: Partial<TripPlanner> = {}, shellOverrides: Partial<MTripShellApi> = {}) {
const planner = buildPlanner({
TRIP_TABS: TABS as TripPlanner['TRIP_TABS'],
files: FILES,
tripMembers: MEMBERS,
...plannerOverrides,
})
const shell = buildShell({ sheet: { id: 'mehr' }, ...shellOverrides })
render(<MMehrSheet planner={planner} shell={shell} />)
return { planner, shell }
}
describe('MMehrSheet', () => {
beforeEach(() => {
resetAllStores()
})
it('FE-MOB-MEHR-001: stays closed while another sheet id is active', () => {
renderSheet({}, { sheet: { id: 'days' } })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-MEHR-002: opens as the "More" panel', () => {
renderSheet()
expect(screen.getByRole('dialog', { name: 'More' })).toBeInTheDocument()
})
it('FE-MOB-MEHR-003: only tiles the sections that are not in the dock', () => {
renderSheet()
expect(screen.getByRole('button', { name: /Files/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Collaboration/ })).toBeInTheDocument()
for (const dockLabel of ['Plan', 'Transport', 'Bookings', 'Budget', 'Lists']) {
expect(screen.queryByRole('button', { name: new RegExp(`^${dockLabel}`) })).not.toBeInTheDocument()
}
})
it('FE-MOB-MEHR-004: counts only the files that are not in the trash', () => {
renderSheet()
expect(screen.getByText('2 files')).toBeInTheDocument()
})
it('FE-MOB-MEHR-005: counts the trip members on the collab tile', () => {
renderSheet()
expect(screen.getByText('3 people')).toBeInTheDocument()
})
it('FE-MOB-MEHR-006: a plugin tile gets the neutral tint and no stat line', () => {
renderSheet({
TRIP_TABS: [...TABS, { id: 'plugin:todos', label: 'Trip To-Dos', icon: FolderOpen }] as TripPlanner['TRIP_TABS'],
})
const tile = screen.getByRole('button', { name: /Trip To-Dos/ })
expect(tile.textContent).toBe('Trip To-Dos')
expect(tile.querySelector('[style]')).toHaveStyle({ color: '#68686F' })
})
it('FE-MOB-MEHR-007: survives a tab entry without an icon component', () => {
renderSheet({ TRIP_TABS: [{ id: 'plugin:bare', label: 'Bare Plugin', icon: undefined }] as unknown as TripPlanner['TRIP_TABS'] })
const tile = screen.getByRole('button', { name: 'Bare Plugin' })
expect(tile.querySelector('svg')).toBeNull()
})
it('FE-MOB-MEHR-008: opening a section closes the sheet and switches the trip tab', () => {
const { shell } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: /Files/ }))
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
expect(shell.setTrTab).toHaveBeenCalledWith('dateien')
})
it('FE-MOB-MEHR-009: the action rows open the share, export and edit sheets', () => {
const { shell } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Share Trip' }))
fireEvent.click(screen.getByRole('button', { name: 'Export Calendar' }))
fireEvent.click(screen.getByRole('button', { name: 'Edit Trip' }))
expect(shell.openSheet).toHaveBeenNthCalledWith(1, 'members')
expect(shell.openSheet).toHaveBeenNthCalledWith(2, 'export')
expect(shell.openSheet).toHaveBeenNthCalledWith(3, 'tripedit')
})
it('FE-MOB-MEHR-010: drops the edit row without the trip_edit permission', () => {
const { planner } = renderSheet({ can: vi.fn(() => false) })
expect(planner.can).toHaveBeenCalledWith('trip_edit', planner.trip)
expect(screen.queryByRole('button', { name: 'Edit Trip' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Share Trip' })).toBeInTheDocument()
})
it('FE-MOB-MEHR-011: renders only the action rows when every section sits in the dock', () => {
renderSheet({
TRIP_TABS: TABS.filter(tab => tab.id !== 'dateien' && tab.id !== 'collab') as TripPlanner['TRIP_TABS'],
})
expect(screen.queryByText(/files$/)).not.toBeInTheDocument()
const rows = screen.getByRole('button', { name: 'Share Trip' }).parentElement
expect(rows).not.toHaveClass('mt-2')
})
it('FE-MOB-MEHR-012: separates the action rows from the tile grid when both are present', () => {
renderSheet()
const rows = screen.getByRole('button', { name: 'Share Trip' }).parentElement
expect(rows).toHaveClass('mt-2')
})
})
@@ -0,0 +1,245 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MNoteSheet, { type MNoteSheetPayload } from '../../../../src/mobile/screens/trip/sheets/MNoteSheet'
import { useTripStore } from '../../../../src/store/tripStore'
import type { Assignment, DayNote } from '../../../../src/types'
import { buildPlanner } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-NOTESH-001 to FE-MOB-NOTESH-018
// planner.t echoes the key, so every assertion here is against keys.
const NOTE = {
id: 88,
day_id: 7,
text: 'Buy museum tickets',
time: '09:30 at the kiosk',
icon: 'Ticket',
sort_order: 2,
} as unknown as DayNote
function renderSheet(overrides: {
planner?: ReturnType<typeof buildPlanner>
open?: boolean
payload?: MNoteSheetPayload
} = {}) {
const planner = overrides.planner ?? buildPlanner({ tripId: 3, selectedDayId: 7 })
const onClose = vi.fn()
const payload = 'payload' in overrides ? overrides.payload : { dayId: 7 }
const view = render(
<MNoteSheet planner={planner} open={overrides.open ?? true} payload={payload} onClose={onClose} />,
)
return { ...view, planner, onClose, payload }
}
function typeTitle(value: string) {
fireEvent.change(screen.getByPlaceholderText('dayplan.noteTitle *'), { target: { value } })
}
describe('MNoteSheet', () => {
beforeEach(() => {
resetAllStores()
})
it('FE-MOB-NOTESH-001: renders nothing while the sheet is closed', () => {
renderSheet({ open: false })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-NOTESH-002: opens in create mode with the default icon and no delete action', () => {
renderSheet()
expect(screen.getByRole('dialog', { name: 'dayplan.noteAdd' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.add' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'FileText' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'Train' })).toHaveAttribute('aria-pressed', 'false')
})
it('FE-MOB-NOTESH-003: picking an icon moves the pressed state', () => {
renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Coffee' }))
expect(screen.getByRole('button', { name: 'Coffee' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'FileText' })).toHaveAttribute('aria-pressed', 'false')
})
it('FE-MOB-NOTESH-004: keeps the submit disabled until a non-blank title is typed', () => {
renderSheet()
const submit = screen.getByRole('button', { name: 'common.add' })
expect(submit).toBeDisabled()
typeTitle(' ')
expect(submit).toBeDisabled()
typeTitle('Ferry tickets')
expect(submit).toBeEnabled()
})
it('FE-MOB-NOTESH-005: appends the new note behind the last assignment and note of the day', async () => {
seedStore(useTripStore, {
assignments: { '7': [{ order_index: 3 }, { order_index: 1 }] as unknown as Assignment[] },
dayNotes: { '7': [{ sort_order: 5 }] as unknown as DayNote[] },
})
const { planner, onClose } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Plane' }))
typeTitle(' Check in online ')
fireEvent.change(screen.getByPlaceholderText('dayplan.noteSubtitle'), { target: { value: '18:00 at the gate' } })
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
await waitFor(() => expect(planner.tripActions.addDayNote).toHaveBeenCalledTimes(1))
expect(planner.tripActions.addDayNote).toHaveBeenCalledWith(3, 7, {
text: 'Check in online',
time: '18:00 at the gate',
icon: 'Plane',
sort_order: 6,
})
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1))
})
it('FE-MOB-NOTESH-006: starts at sort order 0 on an empty day and stores an empty detail as null', async () => {
const { planner } = renderSheet()
typeTitle('Sunrise walk')
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
await waitFor(() => expect(planner.tripActions.addDayNote).toHaveBeenCalledTimes(1))
expect(planner.tripActions.addDayNote).toHaveBeenCalledWith(3, 7, {
text: 'Sunrise walk',
time: null,
icon: 'FileText',
sort_order: 0,
})
})
it('FE-MOB-NOTESH-007: falls back to the selected day when the payload carries no dayId', async () => {
const planner = buildPlanner({ tripId: 3, selectedDayId: 12 })
renderSheet({ planner, payload: {} })
typeTitle('Laundry')
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
await waitFor(() => expect(planner.tripActions.addDayNote).toHaveBeenCalledTimes(1))
expect(planner.tripActions.addDayNote).toHaveBeenCalledWith(3, 12, expect.objectContaining({ text: 'Laundry' }))
})
it('FE-MOB-NOTESH-008: does not save without any day to attach the note to', () => {
const planner = buildPlanner({ tripId: 3, selectedDayId: null })
renderSheet({ planner, payload: undefined })
typeTitle('Orphan note')
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
expect(planner.tripActions.addDayNote).not.toHaveBeenCalled()
})
it('FE-MOB-NOTESH-009: prefills icon, title and detail when editing an existing note', () => {
renderSheet({ payload: { dayId: 7, note: NOTE } })
expect(screen.getByRole('dialog', { name: 'dayplan.noteEdit' })).toBeInTheDocument()
expect(screen.getByPlaceholderText('dayplan.noteTitle *')).toHaveValue('Buy museum tickets')
expect(screen.getByPlaceholderText('dayplan.noteSubtitle')).toHaveValue('09:30 at the kiosk')
expect(screen.getByRole('button', { name: 'Ticket' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'common.save' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.delete' })).toBeInTheDocument()
})
it('FE-MOB-NOTESH-010: updates the existing note instead of creating a second one', async () => {
const { planner, onClose } = renderSheet({ payload: { dayId: 7, note: NOTE } })
typeTitle('Buy museum tickets online')
fireEvent.click(screen.getByRole('button', { name: 'common.save' }))
await waitFor(() => expect(planner.tripActions.updateDayNote).toHaveBeenCalledTimes(1))
expect(planner.tripActions.updateDayNote).toHaveBeenCalledWith(3, 7, 88, {
text: 'Buy museum tickets online',
time: '09:30 at the kiosk',
icon: 'Ticket',
})
expect(planner.tripActions.addDayNote).not.toHaveBeenCalled()
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1))
})
it('FE-MOB-NOTESH-011: deletes the note and closes the sheet', async () => {
const { planner, onClose } = renderSheet({ payload: { dayId: 7, note: NOTE } })
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
await waitFor(() => expect(planner.tripActions.deleteDayNote).toHaveBeenCalledWith(3, 7, 88))
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1))
})
it('FE-MOB-NOTESH-012: surfaces the save error and keeps the sheet open', async () => {
const planner = buildPlanner({ tripId: 3, selectedDayId: 7 })
vi.mocked(planner.tripActions.addDayNote).mockRejectedValueOnce(new Error('day is locked'))
const { onClose } = renderSheet({ planner })
typeTitle('Sunrise walk')
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('day is locked'))
expect(onClose).not.toHaveBeenCalled()
// isSaving is released again, so a retry is possible.
expect(screen.getByRole('button', { name: 'common.add' })).toBeEnabled()
})
it('FE-MOB-NOTESH-013: falls back to the generic message when the rejection is not an Error', async () => {
const planner = buildPlanner({ tripId: 3, selectedDayId: 7 })
vi.mocked(planner.tripActions.addDayNote).mockRejectedValueOnce('nope')
renderSheet({ planner })
typeTitle('Sunrise walk')
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.unknownError'))
})
it('FE-MOB-NOTESH-014: surfaces a failing delete without closing', async () => {
const planner = buildPlanner({ tripId: 3, selectedDayId: 7 })
vi.mocked(planner.tripActions.deleteDayNote).mockRejectedValueOnce(new Error('gone already'))
const { onClose } = renderSheet({ planner, payload: { dayId: 7, note: NOTE } })
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('gone already'))
expect(onClose).not.toHaveBeenCalled()
})
it('FE-MOB-NOTESH-015: counts the detail characters and warns near the limit', () => {
renderSheet()
const detail = screen.getByPlaceholderText('dayplan.noteSubtitle')
expect(screen.getByText('0/250')).toBeInTheDocument()
fireEvent.change(detail, { target: { value: 'a'.repeat(100) } })
const relaxed = screen.getByText('100/250')
expect(relaxed.className).toContain('text-m-faint')
fireEvent.change(detail, { target: { value: 'a'.repeat(241) } })
const warned = screen.getByText('241/250')
expect(warned.className).toContain('--m-st-pending')
})
it('FE-MOB-NOTESH-016: cancel and the header close both report the close', () => {
const { onClose } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
expect(onClose).toHaveBeenCalledTimes(2)
})
it('FE-MOB-NOTESH-017: keeps the opened payload while the sheet animates out', () => {
const planner = buildPlanner({ tripId: 3, selectedDayId: 7 })
const onClose = vi.fn()
const payload: MNoteSheetPayload = { dayId: 7, note: NOTE }
const { rerender } = render(
<MNoteSheet planner={planner} open payload={payload} onClose={onClose} />,
)
rerender(<MNoteSheet planner={planner} open={false} payload={undefined} onClose={onClose} />)
// The parent drops the payload with the sheet id, but the exit animation
// still shows the edit chrome.
expect(screen.getByRole('button', { name: 'common.delete' })).toBeInTheDocument()
expect(screen.getByRole('dialog', { name: 'dayplan.noteEdit' })).toBeInTheDocument()
})
it('FE-MOB-NOTESH-018: reopening on another note replaces the form state', () => {
const planner = buildPlanner({ tripId: 3, selectedDayId: 7 })
const onClose = vi.fn()
const first: MNoteSheetPayload = { dayId: 7, note: NOTE }
const second: MNoteSheetPayload = {
dayId: 9,
note: { id: 90, day_id: 9, text: 'Return the car', time: '', icon: 'Car' } as unknown as DayNote,
}
const { rerender } = render(
<MNoteSheet planner={planner} open payload={first} onClose={onClose} />,
)
rerender(<MNoteSheet planner={planner} open payload={second} onClose={onClose} />)
expect(screen.getByPlaceholderText('dayplan.noteTitle *')).toHaveValue('Return the car')
expect(screen.getByPlaceholderText('dayplan.noteSubtitle')).toHaveValue('')
expect(screen.getByRole('button', { name: 'Car' })).toHaveAttribute('aria-pressed', 'true')
})
})
@@ -0,0 +1,239 @@
import { describe, expect, it, vi } from 'vitest'
import type { ComponentProps } from 'react'
import MPackItemSheet from '../../../../src/mobile/screens/trip/tabs/MPackItemSheet'
import type { PackingItem, TripMember } from '../../../../src/types'
import { buildPlanner } from '../../../helpers/mobileTrip'
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-PACKITEM-001 to FE-MOB-PACKITEM-020
const ME = 7
const ANNA = { id: 11, username: 'anna', avatar: 'anna.png', avatar_url: null } as unknown as TripMember
const BEN = { id: 12, username: 'ben', avatar: null, avatar_url: 'https://cdn.example/ben.png' } as unknown as TripMember
const OWNER = { id: 21, username: 'owner', avatar: null, avatar_url: null } as unknown as TripMember
function packItem(overrides: Partial<PackingItem> = {}): PackingItem {
return {
id: 1, trip_id: 3, name: 'Rain jacket', checked: 0, category: 'Clothing', sort_order: 0,
quantity: 2, weight_grams: 450, is_private: 0, owner_id: ME,
...overrides,
} as PackingItem
}
function setup(overrides: Partial<ComponentProps<typeof MPackItemSheet>> = {}, items: PackingItem[] = [packItem()]) {
const planner = buildPlanner({ tripId: 3, packingItems: items })
const props: ComponentProps<typeof MPackItemSheet> = {
planner,
open: true,
itemId: 1,
bagTrackingEnabled: true,
tripMembers: [ANNA, BEN],
currentUserId: ME,
onClose: vi.fn(),
...overrides,
}
const view = render(<MPackItemSheet {...props} />)
return { ...props, planner, view }
}
describe('MPackItemSheet', () => {
it('FE-MOB-PACKITEM-001: renders nothing without a resolvable item', () => {
setup({ itemId: null })
expect(screen.queryByRole('dialog')).toBeNull()
})
it('FE-MOB-PACKITEM-002: renders nothing when the id is unknown', () => {
setup({ itemId: 99 })
expect(screen.queryByRole('dialog')).toBeNull()
})
it('FE-MOB-PACKITEM-003: prefills name, quantity and weight', () => {
setup()
expect(screen.getByRole('dialog')).toHaveAttribute('aria-label', 'packing.editItem')
expect(screen.getByText('Clothing')).toBeInTheDocument()
expect(screen.getByDisplayValue('Rain jacket')).toBeInTheDocument()
expect(screen.getByDisplayValue('2')).toBeInTheDocument()
expect(screen.getByDisplayValue('450')).toBeInTheDocument()
})
it('FE-MOB-PACKITEM-004: falls back to the default category in the subtitle', () => {
setup({}, [packItem({ category: null })])
expect(screen.getByText('packing.defaultCategory')).toBeInTheDocument()
})
it('FE-MOB-PACKITEM-005: hides the weight field when bag tracking is off', () => {
setup({ bagTrackingEnabled: false })
expect(screen.queryByText('packing.itemWeight')).toBeNull()
expect(screen.queryByDisplayValue('450')).toBeNull()
})
it('FE-MOB-PACKITEM-006: starts a placeholder row with an empty name and no sharing block', () => {
setup({}, [packItem({ name: '...', quantity: 1, weight_grams: null })])
const name = screen.getByPlaceholderText('packing.addItemPlaceholder')
expect(name).toHaveValue('')
expect(screen.getByRole('button', { name: 'common.save' })).toBeDisabled()
expect(screen.queryByText('packing.share')).toBeNull()
})
it('FE-MOB-PACKITEM-007: strips non-digits from quantity and weight', () => {
setup()
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '1a2' } })
fireEvent.change(screen.getByDisplayValue('450'), { target: { value: '4x5' } })
expect(screen.getByDisplayValue('12')).toBeInTheDocument()
expect(screen.getByDisplayValue('45')).toBeInTheDocument()
})
it('FE-MOB-PACKITEM-008: saves name, clamped quantity and weight', async () => {
const { planner, onClose } = setup()
fireEvent.change(screen.getByDisplayValue('Rain jacket'), { target: { value: ' Poncho ' } })
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '1500' } })
fireEvent.click(screen.getByRole('button', { name: 'common.save' }))
await waitFor(() => expect(onClose).toHaveBeenCalled())
expect(planner.tripActions.updatePackingItem).toHaveBeenCalledWith(3, 1, {
name: 'Poncho', quantity: 999, weight_grams: 450,
})
})
it('FE-MOB-PACKITEM-009: treats an empty quantity as 1 and an empty weight as null', async () => {
const { planner } = setup()
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '' } })
fireEvent.change(screen.getByDisplayValue('450'), { target: { value: '' } })
fireEvent.click(screen.getByRole('button', { name: 'common.save' }))
await waitFor(() => expect(planner.tripActions.updatePackingItem).toHaveBeenCalledWith(3, 1, {
name: 'Rain jacket', quantity: 1, weight_grams: null,
}))
})
it('FE-MOB-PACKITEM-010: omits weight entirely without bag tracking', async () => {
const { planner } = setup({ bagTrackingEnabled: false })
fireEvent.click(screen.getByRole('button', { name: 'common.save' }))
await waitFor(() => expect(planner.tripActions.updatePackingItem).toHaveBeenCalledWith(3, 1, {
name: 'Rain jacket', quantity: 2,
}))
})
it('FE-MOB-PACKITEM-011: reports a failed save and keeps the sheet open', async () => {
const { planner, onClose } = setup()
const update = planner.tripActions.updatePackingItem as unknown as ReturnType<typeof vi.fn>
update.mockRejectedValueOnce(new Error('nope'))
fireEvent.click(screen.getByRole('button', { name: 'common.save' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('packing.toast.saveError'))
expect(onClose).not.toHaveBeenCalled()
})
it('FE-MOB-PACKITEM-012: disables save for a blank name', () => {
setup()
fireEvent.change(screen.getByDisplayValue('Rain jacket'), { target: { value: ' ' } })
expect(screen.getByRole('button', { name: 'common.save' })).toBeDisabled()
})
it('FE-MOB-PACKITEM-013: keeps rendering the last item after it disappears from the store', () => {
const { view, planner, ...props } = setup()
const drained = buildPlanner({ tripId: 3, packingItems: [], t: planner.t })
view.rerender(<MPackItemSheet {...props} planner={drained} />)
expect(screen.getByDisplayValue('Rain jacket')).toBeInTheDocument()
})
it('FE-MOB-PACKITEM-014: owner switches the item to personal and back to common', () => {
const { planner } = setup()
fireEvent.click(screen.getByRole('button', { name: 'packing.tierPersonal' }))
fireEvent.click(screen.getByRole('button', { name: 'packing.viewCommon' }))
expect(planner.tripActions.setPackingItemSharing).toHaveBeenNthCalledWith(1, 3, 1, 'personal', [])
expect(planner.tripActions.setPackingItemSharing).toHaveBeenNthCalledWith(2, 3, 1, 'common', [])
})
it('FE-MOB-PACKITEM-015: owner adds and removes a share recipient', () => {
const item = packItem({ is_private: 1, recipients: [{ user_id: 11, username: 'anna' }] })
const { planner } = setup({}, [item])
fireEvent.click(screen.getByRole('button', { name: 'ben' }))
expect(planner.tripActions.setPackingItemSharing).toHaveBeenCalledWith(3, 1, 'shared', [11, 12])
fireEvent.click(screen.getByRole('button', { name: 'anna' }))
expect(planner.tripActions.setPackingItemSharing).toHaveBeenCalledWith(3, 1, 'shared', [])
})
it('FE-MOB-PACKITEM-016: renders member avatars and initials in the share picker', () => {
const { view } = setup()
const srcs = Array.from(view.baseElement.querySelectorAll('img')).map(i => i.getAttribute('src'))
expect(srcs).toContain('/uploads/avatars/anna.png')
expect(srcs).toContain('https://cdn.example/ben.png')
})
it('FE-MOB-PACKITEM-017: tells the owner when there is nobody to share with', () => {
setup({ tripMembers: [] })
expect(screen.getByText('packing.noOneToShare')).toBeInTheDocument()
})
it('FE-MOB-PACKITEM-018: a non-owner can pledge to bring a common item too', () => {
const item = packItem({ owner_id: 21, owner_username: 'owner' })
const { planner } = setup({ tripMembers: [ANNA, BEN, OWNER] }, [item])
expect(screen.getByText('packing.broughtBy:owner')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'packing.alsoBring' }))
expect(planner.tripActions.addPackingContributor).toHaveBeenCalledWith(3, 1)
})
it('FE-MOB-PACKITEM-019: a pledged contributor can withdraw and can clone the item', () => {
const item = packItem({
owner_id: 21, owner_username: 'owner',
contributors: [{ user_id: ME, username: 'me', status: 'pledged' }],
})
const { planner, onClose } = setup({}, [item])
fireEvent.click(screen.getByRole('button', { name: 'packing.alsoBringingStop' }))
expect(planner.tripActions.removePackingContributor).toHaveBeenCalledWith(3, 1, ME)
fireEvent.click(screen.getByRole('button', { name: 'packing.cloneToMine' }))
expect(planner.tripActions.clonePackingItem).toHaveBeenCalledWith(3, 1)
expect(onClose).toHaveBeenCalled()
})
it('FE-MOB-PACKITEM-020: a recipient sees a read-only note instead of the tier pills', () => {
const item = packItem({ is_private: 1, owner_id: 21, owner_username: 'owner' })
setup({}, [item])
expect(screen.getByText('packing.takenCareOf:owner')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'packing.tierPersonal' })).toBeNull()
})
it('FE-MOB-PACKITEM-021: falls back to the generic hint when the owner is unknown', () => {
const item = packItem({ is_private: 1, owner_id: 21, owner_username: null })
setup({}, [item])
expect(screen.getByText('packing.tierPersonalHint')).toBeInTheDocument()
})
it('FE-MOB-PACKITEM-022: an unowned item is treated as the viewer\'s own', () => {
const item = packItem({ owner_id: null })
setup({}, [item])
expect(screen.getByRole('button', { name: 'packing.tierPersonal' })).toBeInTheDocument()
})
it('FE-MOB-PACKITEM-023: closes from the header and the cancel button', () => {
const { onClose } = setup()
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
expect(onClose).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,144 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { packingApi } from '../../../../src/api/client'
import MPackingImportSheet from '../../../../src/mobile/screens/trip/tabs/MPackingImportSheet'
import { useTripStore } from '../../../../src/store/tripStore'
import type { PackingItem } from '../../../../src/types'
import { buildPlanner } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-PACKIMP-001 to FE-MOB-PACKIMP-012
function packItem(overrides: Partial<PackingItem> = {}): PackingItem {
return {
id: 1, trip_id: 1, name: 'Passport', checked: 0, category: 'Documents', sort_order: 0,
...overrides,
} as PackingItem
}
function setup(text?: string) {
const onClose = vi.fn()
const planner = buildPlanner({ tripId: 7 })
const view = render(<MPackingImportSheet planner={planner} open onClose={onClose} />)
if (text != null) {
fireEvent.change(screen.getByPlaceholderText('packing.importPlaceholder'), { target: { value: text } })
}
return { onClose, planner, view }
}
describe('MPackingImportSheet', () => {
beforeEach(() => {
resetAllStores()
seedStore(useTripStore, { packingItems: [packItem()] })
})
afterEach(() => {
vi.restoreAllMocks()
})
it('FE-MOB-PACKIMP-001: stays unmounted while closed', () => {
const planner = buildPlanner()
render(<MPackingImportSheet planner={planner} open={false} onClose={vi.fn()} />)
expect(screen.queryByRole('dialog')).toBeNull()
})
it('FE-MOB-PACKIMP-002: renders the hint, textarea and a disabled zero-count submit', () => {
setup()
expect(screen.getByRole('dialog')).toHaveAttribute('aria-label', 'packing.importTitle')
expect(screen.getByText('packing.importHint')).toBeInTheDocument()
expect(screen.getByPlaceholderText('packing.importPlaceholder')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'packing.importAction:0' })).toBeDisabled()
})
it('FE-MOB-PACKIMP-003: counts the parsed lines in the submit label', () => {
setup('Documents, Passport, 30\nClothing, Socks\n\n')
expect(screen.getByRole('button', { name: 'packing.importAction:2' })).toBeEnabled()
})
it('FE-MOB-PACKIMP-004: warns when text parses to nothing', () => {
setup(',')
expect(screen.getByText('packing.importEmpty')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'packing.importAction:0' })).toBeDisabled()
})
it('FE-MOB-PACKIMP-005: keeps the warning hidden for blank input', () => {
setup(' ')
expect(screen.queryByText('packing.importEmpty')).toBeNull()
})
it('FE-MOB-PACKIMP-006: posts the parsed items and appends the response to the store', async () => {
const imported = [packItem({ id: 50, name: 'Socks', category: 'Clothing' })]
const spy = vi.spyOn(packingApi, 'bulkImport').mockResolvedValue({ items: imported, count: 1 })
const { onClose, planner } = setup('Clothing, Socks, 120, Backpack, checked')
fireEvent.click(screen.getByRole('button', { name: 'packing.importAction:1' }))
await waitFor(() => expect(onClose).toHaveBeenCalled())
expect(spy).toHaveBeenCalledWith(7, [
{ name: 'Socks', category: 'Clothing', weight_grams: '120', bag: 'Backpack', checked: true },
])
expect(planner.toast.success).toHaveBeenCalledWith('packing.importSuccess:1')
expect(useTripStore.getState().packingItems.map(i => i.id)).toEqual([1, 50])
})
it('FE-MOB-PACKIMP-007: clears the textarea after a successful import', async () => {
vi.spyOn(packingApi, 'bulkImport').mockResolvedValue({ items: [], count: 0 })
setup('Socks')
fireEvent.click(screen.getByRole('button', { name: 'packing.importAction:1' }))
await waitFor(() => expect(screen.getByPlaceholderText('packing.importPlaceholder')).toHaveValue(''))
})
it('FE-MOB-PACKIMP-008: reports a failed import and keeps the sheet open', async () => {
vi.spyOn(packingApi, 'bulkImport').mockRejectedValue(new Error('boom'))
const { onClose, planner } = setup('Socks')
fireEvent.click(screen.getByRole('button', { name: 'packing.importAction:1' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('packing.importError'))
expect(onClose).not.toHaveBeenCalled()
expect(screen.getByPlaceholderText('packing.importPlaceholder')).toHaveValue('Socks')
})
it('FE-MOB-PACKIMP-009: opens the file picker from the CSV button', () => {
const click = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {})
setup()
fireEvent.click(screen.getByRole('button', { name: 'packing.importCsv' }))
expect(click).toHaveBeenCalled()
})
it('FE-MOB-PACKIMP-010: fills the textarea from a picked file', async () => {
const { view } = setup()
const input = view.container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement
const file = new File(['Clothing, Socks\nClothing, Shirt'], 'list.csv', { type: 'text/csv' })
fireEvent.change(input, { target: { files: [file] } })
await waitFor(() =>
expect(screen.getByPlaceholderText('packing.importPlaceholder')).toHaveValue('Clothing, Socks\nClothing, Shirt'),
)
expect(screen.getByRole('button', { name: 'packing.importAction:2' })).toBeEnabled()
})
it('FE-MOB-PACKIMP-011: ignores a change event without a file', () => {
const { view } = setup()
const input = view.container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement
fireEvent.change(input, { target: { files: [] } })
expect(screen.getByPlaceholderText('packing.importPlaceholder')).toHaveValue('')
})
it('FE-MOB-PACKIMP-012: closes on cancel without importing', () => {
const spy = vi.spyOn(packingApi, 'bulkImport')
const { onClose } = setup('Socks')
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
expect(onClose).toHaveBeenCalled()
expect(spy).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,891 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RenderResult } from '@testing-library/react'
import { packingApi } from '../../../../src/api/client'
import MPackingListTab from '../../../../src/mobile/screens/trip/tabs/MPackingListTab'
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { useAddonStore } from '../../../../src/store/addonStore'
import { useAuthStore } from '../../../../src/store/authStore'
import { useTripStore } from '../../../../src/store/tripStore'
import type { PackingBag, PackingItem, TripMember } from '../../../../src/types'
import { buildUser } from '../../../helpers/factories'
import { buildPlanner } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { act, fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
// FE-MOB-PACKTAB-001 to FE-MOB-PACKTAB-046
const ME = 7
const ANNA = { id: 11, username: 'anna', avatar: null, avatar_url: 'https://cdn.example/anna.png' } as unknown as TripMember
const BEN = { id: 12, username: 'ben', avatar: null, avatar_url: null } as unknown as TripMember
function packItem(overrides: Partial<PackingItem> = {}): PackingItem {
return {
id: 1, trip_id: 3, name: 'Passport', checked: 0, category: 'Documents', sort_order: 0,
is_private: 0, owner_id: null,
...overrides,
} as PackingItem
}
const ITEMS: PackingItem[] = [
packItem({ id: 1, name: 'Passport', category: 'Documents', checked: 1 }),
packItem({ id: 2, name: 'Visa', category: 'Documents' }),
packItem({ id: 3, name: 'Socks', category: 'Clothing', quantity: 3, weight_grams: 120 }),
packItem({ id: 4, name: 'Shirt', category: 'Clothing', checked: 1 }),
packItem({ id: 5, name: 'Diary', category: 'Private', is_private: 1, owner_id: ME }),
]
const BAGS: PackingBag[] = [
{ id: 71, trip_id: 3, name: 'Backpack', color: '#6366f1', sort_order: 0, members: [] } as PackingBag,
]
const TEMPLATES = [{ id: 91, name: 'Beach trip', item_count: 12 }]
/** The tab appends this zero-width space to keep a duplicate category name distinguishable. */
const ZERO_WIDTH = '​'
interface SetupOptions {
items?: PackingItem[]
planner?: Partial<TripPlanner>
admin?: boolean
bagTracking?: boolean
}
async function setup(opts: SetupOptions = {}) {
seedStore(useAuthStore, { user: buildUser({ id: ME, role: opts.admin ? 'admin' : 'user' }) })
if (opts.bagTracking) seedStore(useAddonStore, { bagTracking: true })
const planner = buildPlanner({ tripId: 3, packingItems: opts.items ?? ITEMS, ...opts.planner })
let view!: RenderResult
await act(async () => { view = render(<MPackingListTab planner={planner} />) })
return { planner, view }
}
/** The category card that owns the given title. */
function card(category: string): HTMLElement {
return screen.getByText(category).closest('div.rounded-2xl') as HTMLElement
}
/** The row of an item, found via its checkbox (aria-label = item name). */
function itemRow(name: string): HTMLElement {
return screen.getByRole('button', { name }).parentElement as HTMLElement
}
function openActions() {
fireEvent.click(screen.getByRole('button', { name: 'packing.actions' }))
}
function enterEditMode() {
fireEvent.click(screen.getByRole('button', { name: /common.edit/ }))
}
function openCategoryMenu(category: string) {
fireEvent.click(within(card(category)).getByRole('button', { name: 'packing.categoryOptions' }))
}
function asMock(fn: unknown): ReturnType<typeof vi.fn> {
return fn as ReturnType<typeof vi.fn>
}
describe('MPackingListTab', () => {
beforeEach(() => {
resetAllStores()
seedStore(useTripStore, { packingItems: [] })
vi.spyOn(packingApi, 'listBags').mockResolvedValue({ bags: BAGS })
vi.spyOn(packingApi, 'listTemplates').mockResolvedValue({ templates: TEMPLATES })
vi.spyOn(packingApi, 'getCategoryAssignees').mockResolvedValue({ assignees: {} })
})
afterEach(() => {
vi.restoreAllMocks()
})
// ── Progress card + chrome ────────────────────────────────────────────
it('FE-MOB-PACKTAB-001: shows checked/total and the percentage of the common list', async () => {
await setup()
expect(screen.getByText('2/4')).toBeInTheDocument()
expect(screen.getByText('50%')).toBeInTheDocument()
})
it('FE-MOB-PACKTAB-002: hides the bags button while bag tracking is off', async () => {
await setup()
expect(screen.queryByRole('button', { name: 'packing.bags' })).toBeNull()
expect(packingApi.listBags).not.toHaveBeenCalled()
})
it('FE-MOB-PACKTAB-003: loads the bags and opens the bags sheet', async () => {
await setup({ bagTracking: true })
expect(packingApi.listBags).toHaveBeenCalledWith(3)
fireEvent.click(screen.getAllByRole('button', { name: 'packing.bags' })[0])
const dialog = screen.getByRole('dialog')
expect(dialog).toHaveAttribute('aria-label', 'packing.bags')
expect(within(dialog).getByText('Backpack')).toBeInTheDocument()
})
it('FE-MOB-PACKTAB-004: toggles edit mode', async () => {
await setup()
const toggle = screen.getByRole('button', { name: 'common.edit' })
expect(toggle).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(toggle)
expect(screen.getByRole('button', { name: 'packing.editDone' })).toHaveAttribute('aria-pressed', 'true')
})
it('FE-MOB-PACKTAB-005: read-only members get neither edit nor the action menu', async () => {
await setup({ planner: { can: vi.fn(() => false) } as Partial<TripPlanner> })
expect(screen.queryByRole('button', { name: 'common.edit' })).toBeNull()
expect(screen.queryByRole('button', { name: 'packing.actions' })).toBeNull()
expect(screen.queryByRole('button', { name: 'packing.addCategory' })).toBeNull()
})
it('FE-MOB-PACKTAB-006: survives failing mount requests', async () => {
vi.mocked(packingApi.listBags).mockRejectedValue(new Error('x'))
vi.mocked(packingApi.listTemplates).mockRejectedValue(new Error('x'))
vi.mocked(packingApi.getCategoryAssignees).mockRejectedValue(new Error('x'))
await setup({ bagTracking: true })
openActions()
expect(screen.getByText('2/4')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'packing.import' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'packing.applyTemplate' })).toBeNull()
})
// ── Action menu ───────────────────────────────────────────────────────
it('FE-MOB-PACKTAB-007: the action menu offers clear/apply/import to a non-admin', async () => {
await setup()
openActions()
expect(screen.getByRole('button', { name: 'packing.clearChecked:2' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'packing.applyTemplate' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'packing.import' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'packing.saveAsTemplate' })).toBeNull()
})
it('FE-MOB-PACKTAB-008: admins additionally get save-as-template', async () => {
await setup({ admin: true })
openActions()
expect(screen.getByRole('button', { name: 'packing.saveAsTemplate' })).toBeInTheDocument()
})
it('FE-MOB-PACKTAB-009: hides clear + apply when nothing is checked and no template exists', async () => {
vi.mocked(packingApi.listTemplates).mockResolvedValue({ templates: [] })
await setup({ items: [packItem({ id: 1, name: 'Visa' })] })
openActions()
expect(screen.queryByRole('button', { name: /packing.clearChecked/ })).toBeNull()
expect(screen.queryByRole('button', { name: 'packing.applyTemplate' })).toBeNull()
expect(screen.getByRole('button', { name: 'packing.import' })).toBeInTheDocument()
})
it('FE-MOB-PACKTAB-010: clearing checked items deletes each of them after confirmation', async () => {
const { planner } = await setup()
openActions()
fireEvent.click(screen.getByRole('button', { name: 'packing.clearChecked:2' }))
const dialog = screen.getByRole('dialog')
expect(within(dialog).getByText('packing.confirm.clearChecked:2')).toBeInTheDocument()
fireEvent.click(within(dialog).getByRole('button', { name: 'common.delete' }))
await waitFor(() => expect(planner.tripActions.deletePackingItem).toHaveBeenCalledTimes(2))
expect(planner.tripActions.deletePackingItem).toHaveBeenCalledWith(3, 1)
expect(planner.tripActions.deletePackingItem).toHaveBeenCalledWith(3, 4)
})
it('FE-MOB-PACKTAB-011: a failed clear surfaces the delete error', async () => {
const { planner } = await setup()
asMock(planner.tripActions.deletePackingItem).mockRejectedValueOnce(new Error('x'))
openActions()
fireEvent.click(screen.getByRole('button', { name: 'packing.clearChecked:2' }))
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'common.delete' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('packing.toast.deleteError'))
})
it('FE-MOB-PACKTAB-012: cancelling the clear dialog deletes nothing', async () => {
const { planner } = await setup()
openActions()
fireEvent.click(screen.getByRole('button', { name: 'packing.clearChecked:2' }))
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'common.cancel' }))
expect(planner.tripActions.deletePackingItem).not.toHaveBeenCalled()
})
it('FE-MOB-PACKTAB-013: applies a template and appends its items to the store', async () => {
const applied = [packItem({ id: 30, name: 'Towel', category: 'Beach' })]
vi.spyOn(packingApi, 'applyTemplate').mockResolvedValue({ items: applied, count: 1 })
const { planner } = await setup()
openActions()
fireEvent.click(screen.getByRole('button', { name: 'packing.applyTemplate' }))
expect(screen.getByText('12 admin.packingTemplates.items')).toBeInTheDocument()
await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Beach trip/ })) })
expect(packingApi.applyTemplate).toHaveBeenCalledWith(3, 91)
expect(planner.toast.success).toHaveBeenCalledWith('packing.templateApplied:1')
expect(useTripStore.getState().packingItems.map(i => i.id)).toEqual([30])
expect(screen.queryByRole('button', { name: /Beach trip/ })).toBeNull()
})
it('FE-MOB-PACKTAB-014: reports a failing template', async () => {
vi.spyOn(packingApi, 'applyTemplate').mockRejectedValue(new Error('x'))
const { planner } = await setup()
openActions()
fireEvent.click(screen.getByRole('button', { name: 'packing.applyTemplate' }))
await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Beach trip/ })) })
expect(planner.toast.error).toHaveBeenCalledWith('packing.templateError')
})
it('FE-MOB-PACKTAB-015: saves the list as a template and reloads the template list', async () => {
vi.spyOn(packingApi, 'saveAsTemplate').mockResolvedValue({ ok: true })
const { planner } = await setup({ admin: true })
openActions()
fireEvent.click(screen.getByRole('button', { name: 'packing.saveAsTemplate' }))
expect(screen.getByRole('button', { name: 'common.save' })).toBeDisabled()
fireEvent.change(screen.getByPlaceholderText('packing.templateName'), { target: { value: ' Summer ' } })
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'common.save' })) })
expect(packingApi.saveAsTemplate).toHaveBeenCalledWith(3, 'Summer')
expect(planner.toast.success).toHaveBeenCalledWith('packing.templateSaved')
expect(packingApi.listTemplates).toHaveBeenCalledTimes(2)
})
it('FE-MOB-PACKTAB-016: Enter submits the template name, blank input does nothing', async () => {
vi.spyOn(packingApi, 'saveAsTemplate').mockResolvedValue({ ok: true })
await setup({ admin: true })
openActions()
fireEvent.click(screen.getByRole('button', { name: 'packing.saveAsTemplate' }))
const input = screen.getByPlaceholderText('packing.templateName')
fireEvent.keyDown(input, { key: 'Enter' })
expect(packingApi.saveAsTemplate).not.toHaveBeenCalled()
fireEvent.change(input, { target: { value: 'Winter' } })
await act(async () => { fireEvent.keyDown(input, { key: 'Enter' }) })
expect(packingApi.saveAsTemplate).toHaveBeenCalledWith(3, 'Winter')
})
it('FE-MOB-PACKTAB-017: reports a failing template save', async () => {
vi.spyOn(packingApi, 'saveAsTemplate').mockRejectedValue(new Error('x'))
const { planner } = await setup({ admin: true })
openActions()
fireEvent.click(screen.getByRole('button', { name: 'packing.saveAsTemplate' }))
fireEvent.change(screen.getByPlaceholderText('packing.templateName'), { target: { value: 'Summer' } })
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'common.save' })) })
expect(planner.toast.error).toHaveBeenCalledWith('common.error')
})
it('FE-MOB-PACKTAB-018: the import row opens the import sheet', async () => {
await setup()
openActions()
fireEvent.click(screen.getByRole('button', { name: 'packing.import' }))
expect(screen.getByRole('dialog')).toHaveAttribute('aria-label', 'packing.importTitle')
})
// ── Filters + empty states ────────────────────────────────────────────
it('FE-MOB-PACKTAB-019: the personal view shows only private items', async () => {
await setup()
expect(screen.queryByText('Diary')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'packing.viewPersonal' }))
expect(screen.getByText('Diary')).toBeInTheDocument()
expect(screen.queryByText('Passport')).toBeNull()
expect(screen.getByText('0%')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'packing.viewCommon' }))
expect(screen.getByText('Passport')).toBeInTheDocument()
expect(screen.queryByText('Diary')).toBeNull()
})
it('FE-MOB-PACKTAB-020: an empty personal list shows its own hint', async () => {
await setup({ items: ITEMS.filter(i => !i.is_private) })
fireEvent.click(screen.getByRole('button', { name: 'packing.viewPersonal' }))
expect(screen.getByText('packing.personalEmptyHint')).toBeInTheDocument()
})
it('FE-MOB-PACKTAB-021: the open/done filters narrow the categories', async () => {
await setup()
fireEvent.click(screen.getByRole('button', { name: 'packing.filterDone' }))
expect(screen.getByText('Passport')).toBeInTheDocument()
expect(screen.queryByText('Visa')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'packing.filterOpen' }))
expect(screen.getByText('Visa')).toBeInTheDocument()
expect(screen.queryByText('Passport')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'packing.filterAll' }))
expect(screen.getByText('Passport')).toBeInTheDocument()
})
it('FE-MOB-PACKTAB-022: a filter that matches nothing shows the filtered-empty hint', async () => {
await setup({ items: [packItem({ id: 1, name: 'Visa' })] })
fireEvent.click(screen.getByRole('button', { name: 'packing.filterDone' }))
expect(screen.getByText('packing.emptyFiltered')).toBeInTheDocument()
})
it('FE-MOB-PACKTAB-023: an empty list shows the mascot empty state and no filters', async () => {
await setup({ items: [] })
expect(screen.getByText('packing.emptyTitle')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'packing.filterAll' })).toBeNull()
expect(screen.getByRole('button', { name: 'packing.addCategory' })).toBeInTheDocument()
})
// ── Categories ────────────────────────────────────────────────────────
it('FE-MOB-PACKTAB-024: adds a category as a placeholder item', async () => {
const { planner } = await setup({ items: [] })
fireEvent.click(screen.getByRole('button', { name: 'packing.addCategory' }))
expect(screen.getByRole('button', { name: 'common.add' })).toBeDisabled()
fireEvent.change(screen.getByPlaceholderText('packing.newCategoryPlaceholder'), { target: { value: ' Gear ' } })
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'common.add' })) })
expect(planner.tripActions.addPackingItem).toHaveBeenCalledWith(3, {
name: '...', category: 'Gear', visibility: 'common',
})
expect(screen.getByRole('button', { name: 'packing.addCategory' })).toBeInTheDocument()
})
it('FE-MOB-PACKTAB-025: a duplicate category name is disambiguated', async () => {
const { planner } = await setup()
enterEditMode()
fireEvent.click(screen.getByRole('button', { name: 'packing.addCategory' }))
const input = screen.getByPlaceholderText('packing.newCategoryPlaceholder')
fireEvent.change(input, { target: { value: 'Documents' } })
await act(async () => { fireEvent.keyDown(input, { key: 'Enter' }) })
expect(planner.tripActions.addPackingItem).toHaveBeenCalledWith(3, {
name: '...', category: `Documents${ZERO_WIDTH}`, visibility: 'common',
})
})
it('FE-MOB-PACKTAB-026: a category added from the personal view is private', async () => {
const { planner } = await setup()
enterEditMode()
fireEvent.click(screen.getByRole('button', { name: 'packing.viewPersonal' }))
fireEvent.click(screen.getByRole('button', { name: 'packing.addCategory' }))
fireEvent.change(screen.getByPlaceholderText('packing.newCategoryPlaceholder'), { target: { value: 'Books' } })
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'common.add' })) })
expect(planner.tripActions.addPackingItem).toHaveBeenCalledWith(3, {
name: '...', category: 'Books', visibility: 'personal',
})
})
it('FE-MOB-PACKTAB-027: Escape aborts the new-category field and errors are reported', async () => {
const { planner } = await setup({ items: [] })
asMock(planner.tripActions.addPackingItem).mockRejectedValueOnce(new Error('x'))
fireEvent.click(screen.getByRole('button', { name: 'packing.addCategory' }))
fireEvent.change(screen.getByPlaceholderText('packing.newCategoryPlaceholder'), { target: { value: 'Gear' } })
await act(async () => { fireEvent.keyDown(screen.getByPlaceholderText('packing.newCategoryPlaceholder'), { key: 'Enter' }) })
expect(planner.toast.error).toHaveBeenCalledWith('packing.toast.addError')
fireEvent.keyDown(screen.getByPlaceholderText('packing.newCategoryPlaceholder'), { key: 'Escape' })
expect(screen.getByRole('button', { name: 'packing.addCategory' })).toBeInTheDocument()
})
it('FE-MOB-PACKTAB-028: collapses a category by click and by keyboard', async () => {
await setup()
const header = within(card('Documents')).getByRole('button', { expanded: true })
fireEvent.click(header)
expect(screen.queryByText('Passport')).toBeNull()
fireEvent.keyDown(header, { key: 'Enter' })
expect(screen.getByText('Passport')).toBeInTheDocument()
fireEvent.keyDown(header, { key: ' ' })
expect(screen.queryByText('Passport')).toBeNull()
fireEvent.keyDown(header, { key: 'a' })
expect(screen.queryByText('Passport')).toBeNull()
})
it('FE-MOB-PACKTAB-029: renames a category across all its items', async () => {
const { planner } = await setup()
enterEditMode()
openCategoryMenu('Documents')
fireEvent.click(screen.getByRole('button', { name: 'packing.menuRename' }))
const input = screen.getByDisplayValue('Documents')
fireEvent.click(input)
fireEvent.change(input, { target: { value: 'Papers' } })
await act(async () => { fireEvent.keyDown(input, { key: 'Enter' }) })
expect(planner.tripActions.updatePackingItem).toHaveBeenCalledWith(3, 1, { category: 'Papers' })
expect(planner.tripActions.updatePackingItem).toHaveBeenCalledWith(3, 2, { category: 'Papers' })
})
it('FE-MOB-PACKTAB-030: an unchanged or blank rename is dropped, Escape reverts', async () => {
const { planner } = await setup()
enterEditMode()
openCategoryMenu('Documents')
fireEvent.click(screen.getByRole('button', { name: 'packing.menuRename' }))
fireEvent.blur(screen.getByDisplayValue('Documents'))
expect(planner.tripActions.updatePackingItem).not.toHaveBeenCalled()
openCategoryMenu('Documents')
fireEvent.click(screen.getByRole('button', { name: 'packing.menuRename' }))
const input = screen.getByDisplayValue('Documents')
fireEvent.change(input, { target: { value: 'Nope' } })
fireEvent.keyDown(input, { key: 'Escape' })
expect(planner.tripActions.updatePackingItem).not.toHaveBeenCalled()
expect(screen.getByText('Documents')).toBeInTheDocument()
})
it('FE-MOB-PACKTAB-031: a failing rename surfaces the rename error', async () => {
const { planner } = await setup()
asMock(planner.tripActions.updatePackingItem).mockRejectedValueOnce(new Error('x'))
enterEditMode()
openCategoryMenu('Documents')
fireEvent.click(screen.getByRole('button', { name: 'packing.menuRename' }))
const input = screen.getByDisplayValue('Documents')
fireEvent.change(input, { target: { value: 'Papers' } })
await act(async () => { fireEvent.keyDown(input, { key: 'Enter' }) })
expect(planner.toast.error).toHaveBeenCalledWith('packing.toast.renameError')
})
it('FE-MOB-PACKTAB-032: checks and unchecks a whole category', async () => {
const { planner } = await setup()
enterEditMode()
openCategoryMenu('Documents')
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'packing.menuCheckAll' })) })
expect(planner.tripActions.togglePackingItem).toHaveBeenCalledTimes(1)
expect(planner.tripActions.togglePackingItem).toHaveBeenCalledWith(3, 2, true)
openCategoryMenu('Documents')
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'packing.menuUncheckAll' })) })
expect(planner.tripActions.togglePackingItem).toHaveBeenCalledWith(3, 1, false)
})
it('FE-MOB-PACKTAB-033: a failing bulk check surfaces the save error', async () => {
const { planner } = await setup()
asMock(planner.tripActions.togglePackingItem).mockRejectedValueOnce(new Error('x'))
enterEditMode()
openCategoryMenu('Documents')
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'packing.menuCheckAll' })) })
expect(planner.toast.error).toHaveBeenCalledWith('packing.toast.saveError')
})
it('FE-MOB-PACKTAB-034: a failing bulk uncheck surfaces the save error', async () => {
const { planner } = await setup()
asMock(planner.tripActions.togglePackingItem).mockRejectedValueOnce(new Error('x'))
enterEditMode()
openCategoryMenu('Documents')
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'packing.menuUncheckAll' })) })
expect(planner.toast.error).toHaveBeenCalledWith('packing.toast.saveError')
})
it('FE-MOB-PACKTAB-035: deleting a category removes every item after confirmation', async () => {
const { planner } = await setup()
asMock(planner.tripActions.deletePackingItem).mockRejectedValueOnce(new Error('x'))
enterEditMode()
openCategoryMenu('Documents')
fireEvent.click(screen.getByRole('button', { name: 'packing.menuDeleteCat' }))
const dialog = screen.getByRole('dialog')
expect(within(dialog).getByText('packing.confirm.deleteCat:Documents,2')).toBeInTheDocument()
fireEvent.click(within(dialog).getByRole('button', { name: 'common.delete' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('packing.toast.deleteError'))
expect(planner.tripActions.deletePackingItem).toHaveBeenCalledTimes(2)
})
it('FE-MOB-PACKTAB-036: cancelling the category delete keeps the items', async () => {
const { planner } = await setup()
enterEditMode()
openCategoryMenu('Documents')
fireEvent.click(screen.getByRole('button', { name: 'packing.menuDeleteCat' }))
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'common.cancel' }))
expect(planner.tripActions.deletePackingItem).not.toHaveBeenCalled()
})
// ── Category assignees ────────────────────────────────────────────────
it('FE-MOB-PACKTAB-037: assigns and unassigns members of a category', async () => {
const both = [{ user_id: 11, username: 'anna' }, { user_id: 12, username: 'ben' }]
vi.mocked(packingApi.getCategoryAssignees).mockResolvedValue({ assignees: { Documents: [both[0]] } })
const setAssignees = vi.spyOn(packingApi, 'setCategoryAssignees').mockResolvedValue({ assignees: both })
await setup({ planner: { tripMembers: [ANNA, BEN] } as Partial<TripPlanner> })
enterEditMode()
fireEvent.click(within(card('Documents')).getByRole('button', { name: 'packing.assignMembers' }))
await act(async () => { fireEvent.click(within(card('Documents')).getByRole('button', { name: 'ben' })) })
expect(setAssignees).toHaveBeenCalledWith(3, 'Documents', [11, 12])
// The response made both members assigned — tapping anna now removes her.
await act(async () => { fireEvent.click(within(card('Documents')).getByRole('button', { name: 'anna' })) })
expect(setAssignees).toHaveBeenLastCalledWith(3, 'Documents', [12])
})
it('FE-MOB-PACKTAB-038: a failing assignee update surfaces the save error', async () => {
vi.spyOn(packingApi, 'setCategoryAssignees').mockRejectedValue(new Error('x'))
const { planner } = await setup({ planner: { tripMembers: [ANNA] } as Partial<TripPlanner> })
enterEditMode()
fireEvent.click(within(card('Documents')).getByRole('button', { name: 'packing.assignMembers' }))
await act(async () => { fireEvent.click(within(card('Documents')).getByRole('button', { name: 'anna' })) })
expect(planner.toast.error).toHaveBeenCalledWith('packing.toast.saveError')
})
it('FE-MOB-PACKTAB-039: a trip without members shows the assign hint', async () => {
await setup()
enterEditMode()
fireEvent.click(within(card('Documents')).getByRole('button', { name: 'packing.assignMembers' }))
expect(screen.getByText('packing.noMembers')).toBeInTheDocument()
})
// ── Items ─────────────────────────────────────────────────────────────
it('FE-MOB-PACKTAB-040: adds an item to a category', async () => {
const { planner } = await setup()
enterEditMode()
fireEvent.click(within(card('Clothing')).getByRole('button', { name: 'packing.addItem' }))
const input = within(card('Clothing')).getByPlaceholderText('packing.addItemPlaceholder')
expect(within(card('Clothing')).getByRole('button', { name: 'common.add' })).toBeDisabled()
fireEvent.change(input, { target: { value: ' Hat ' } })
await act(async () => { fireEvent.click(within(card('Clothing')).getByRole('button', { name: 'common.add' })) })
expect(planner.tripActions.addPackingItem).toHaveBeenCalledWith(3, {
name: 'Hat', category: 'Clothing', visibility: 'common',
})
})
it('FE-MOB-PACKTAB-041: a category placeholder is renamed instead of adding a second row', async () => {
const items = [packItem({ id: 1, name: 'Passport', category: 'Documents' }), packItem({ id: 9, name: '...', category: 'Gear' })]
const { planner } = await setup({ items })
enterEditMode()
fireEvent.click(within(card('Gear')).getByRole('button', { name: 'packing.addItem' }))
const input = within(card('Gear')).getByPlaceholderText('packing.addItemPlaceholder')
fireEvent.change(input, { target: { value: 'Rope' } })
await act(async () => { fireEvent.keyDown(input, { key: 'Enter' }) })
expect(planner.tripActions.updatePackingItem).toHaveBeenCalledWith(3, 9, { name: 'Rope' })
expect(planner.tripActions.addPackingItem).not.toHaveBeenCalled()
})
it('FE-MOB-PACKTAB-042: Escape aborts the add-item field and failures are reported', async () => {
const { planner } = await setup()
asMock(planner.tripActions.addPackingItem).mockRejectedValueOnce(new Error('x'))
enterEditMode()
fireEvent.click(within(card('Clothing')).getByRole('button', { name: 'packing.addItem' }))
const input = within(card('Clothing')).getByPlaceholderText('packing.addItemPlaceholder')
fireEvent.keyDown(input, { key: 'Enter' })
expect(planner.tripActions.addPackingItem).not.toHaveBeenCalled()
fireEvent.change(input, { target: { value: 'Hat' } })
await act(async () => { fireEvent.keyDown(input, { key: 'Enter' }) })
expect(planner.toast.error).toHaveBeenCalledWith('packing.toast.addError')
fireEvent.keyDown(input, { key: 'Escape' })
expect(within(card('Clothing')).getByRole('button', { name: 'packing.addItem' })).toBeInTheDocument()
})
it('FE-MOB-PACKTAB-043: toggles an item and shows its quantity badge', async () => {
const { planner } = await setup()
fireEvent.click(screen.getByRole('button', { name: 'Socks' }))
expect(planner.tripActions.togglePackingItem).toHaveBeenCalledWith(3, 3, true)
fireEvent.click(screen.getByRole('button', { name: 'Passport' }))
expect(planner.tripActions.togglePackingItem).toHaveBeenCalledWith(3, 1, false)
expect(within(itemRow('Socks')).getByText('3×')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Passport' })).toHaveAttribute('aria-pressed', 'true')
})
it('FE-MOB-PACKTAB-044: shows the placeholder label instead of the sentinel name', async () => {
await setup({ items: [packItem({ id: 9, name: '...', category: 'Gear' })] })
expect(screen.getByText('packing.addItemPlaceholder')).toBeInTheDocument()
})
it('FE-MOB-PACKTAB-045: shows weights in edit mode while bag tracking is on', async () => {
await setup({ bagTracking: true })
enterEditMode()
// The row shows the unit weight, not quantity x weight.
expect(within(itemRow('Socks')).getByText('120 g')).toBeInTheDocument()
expect(within(itemRow('Shirt')).getByText('— g')).toBeInTheDocument()
})
it('FE-MOB-PACKTAB-046: opens the item editor from the row', async () => {
await setup()
enterEditMode()
fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'common.edit' }))
expect(screen.getByRole('dialog')).toHaveAttribute('aria-label', 'packing.editItem')
})
it('FE-MOB-PACKTAB-047: deletes a normal item outright', async () => {
const { planner } = await setup()
enterEditMode()
await act(async () => { fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'common.delete' })) })
expect(planner.tripActions.deletePackingItem).toHaveBeenCalledWith(3, 3)
})
it('FE-MOB-PACKTAB-048: the last item of a custom category is reset to a placeholder', async () => {
const items = [
packItem({ id: 1, name: 'Passport', category: 'Documents' }),
packItem({ id: 8, name: 'Rope', category: 'Gear', checked: 1, quantity: 2, weight_grams: 90 }),
]
const { planner } = await setup({ items })
enterEditMode()
await act(async () => { fireEvent.click(within(itemRow('Rope')).getByRole('button', { name: 'common.delete' })) })
expect(planner.tripActions.togglePackingItem).toHaveBeenCalledWith(3, 8, false)
expect(planner.tripActions.updatePackingItem).toHaveBeenCalledWith(3, 8, {
name: '...', weight_grams: null, bag_id: null, quantity: 1,
})
expect(planner.tripActions.deletePackingItem).not.toHaveBeenCalled()
})
it('FE-MOB-PACKTAB-049: a failing item delete surfaces the delete error', async () => {
const { planner } = await setup()
asMock(planner.tripActions.deletePackingItem).mockRejectedValueOnce(new Error('x'))
enterEditMode()
await act(async () => { fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'common.delete' })) })
expect(planner.toast.error).toHaveBeenCalledWith('packing.toast.deleteError')
})
// ── Sharing badges ────────────────────────────────────────────────────
it('FE-MOB-PACKTAB-050: badges an item somebody else takes care of', async () => {
const items = [packItem({ id: 1, name: 'Tent', is_private: 1, owner_id: 21, owner_username: 'owner' })]
await setup({ items })
fireEvent.click(screen.getByRole('button', { name: 'packing.viewPersonal' }))
expect(screen.getByText('packing.takenCareOf:owner')).toBeInTheDocument()
})
it('FE-MOB-PACKTAB-051: badges how many people my shared item covers', async () => {
const items = [packItem({
id: 1, name: 'Tent', is_private: 1, owner_id: ME, owner_username: 'me',
recipients: [{ user_id: 11, username: 'anna' }, { user_id: 12, username: 'ben' }],
})]
await setup({ items })
fireEvent.click(screen.getByRole('button', { name: 'packing.viewPersonal' }))
expect(screen.getByText('packing.sharedWithCount:2')).toBeInTheDocument()
})
it('FE-MOB-PACKTAB-052: shows the owner avatar and the contributor count on a common item', async () => {
const items = [packItem({
id: 1, name: 'Tent', owner_id: 11, owner_username: 'anna',
contributors: [{ user_id: 12, username: 'ben', status: 'pledged' }],
})]
const member = { ...ANNA, avatar_url: 'https://cdn.example/anna.png' } as unknown as TripMember
await setup({ items, planner: { tripMembers: [member] } as Partial<TripPlanner> })
expect(within(itemRow('Tent')).getByAltText('anna')).toHaveAttribute('src', 'https://cdn.example/anna.png')
expect(within(itemRow('Tent')).getByText('+1')).toBeInTheDocument()
})
it('FE-MOB-PACKTAB-053: falls back to the owner initial when there is no avatar', async () => {
const items = [packItem({ id: 1, name: 'Tent', owner_id: 99, owner_username: 'zoe' })]
await setup({ items })
expect(within(itemRow('Tent')).getByText('Z')).toBeInTheDocument()
})
// ── Bag picker on the row ─────────────────────────────────────────────
it('FE-MOB-PACKTAB-054: assigns and clears the bag of an item', async () => {
const { planner } = await setup({ bagTracking: true })
fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'packing.bags' }))
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Backpack' })) })
expect(planner.tripActions.updatePackingItem).toHaveBeenCalledWith(3, 3, { bag_id: 71 })
fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'packing.bags' }))
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'packing.noBag' })) })
expect(planner.tripActions.updatePackingItem).toHaveBeenCalledWith(3, 3, { bag_id: null })
})
it('FE-MOB-PACKTAB-055: a failing bag assignment surfaces the save error', async () => {
const { planner } = await setup({ bagTracking: true })
asMock(planner.tripActions.updatePackingItem).mockRejectedValueOnce(new Error('x'))
fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'packing.bags' }))
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Backpack' })) })
expect(planner.toast.error).toHaveBeenCalledWith('packing.toast.saveError')
})
it('FE-MOB-PACKTAB-056: creates a bag from the row picker and assigns it right away', async () => {
const created = { id: 72, trip_id: 3, name: 'Duffel', color: '#ec4899', sort_order: 1 } as PackingBag
vi.spyOn(packingApi, 'createBag').mockResolvedValue({ bag: created })
const { planner } = await setup({ bagTracking: true })
fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'packing.bags' }))
fireEvent.click(screen.getByRole('button', { name: 'packing.addBag' }))
const input = screen.getByPlaceholderText('packing.bagName')
fireEvent.keyDown(input, { key: 'Enter' })
expect(packingApi.createBag).not.toHaveBeenCalled()
fireEvent.change(input, { target: { value: 'Duffel' } })
await act(async () => { fireEvent.keyDown(input, { key: 'Enter' }) })
expect(packingApi.createBag).toHaveBeenCalledWith(3, { name: 'Duffel', color: '#ec4899' })
expect(planner.tripActions.updatePackingItem).toHaveBeenCalledWith(3, 3, { bag_id: 72 })
})
it('FE-MOB-PACKTAB-057: a failing bag creation is reported and assigns nothing', async () => {
vi.spyOn(packingApi, 'createBag').mockRejectedValue(new Error('x'))
const { planner } = await setup({ bagTracking: true })
fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'packing.bags' }))
fireEvent.click(screen.getByRole('button', { name: 'packing.addBag' }))
fireEvent.change(screen.getByPlaceholderText('packing.bagName'), { target: { value: 'Duffel' } })
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'common.add' })) })
expect(planner.toast.error).toHaveBeenCalledWith('packing.toast.saveError')
expect(planner.tripActions.updatePackingItem).not.toHaveBeenCalled()
})
it('FE-MOB-PACKTAB-058: Escape closes the inline bag field', async () => {
await setup({ bagTracking: true })
fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'packing.bags' }))
fireEvent.click(screen.getByRole('button', { name: 'packing.addBag' }))
fireEvent.change(screen.getByPlaceholderText('packing.bagName'), { target: { value: 'x' } })
fireEvent.keyDown(screen.getByPlaceholderText('packing.bagName'), { key: 'Escape' })
expect(screen.getByRole('button', { name: 'packing.addBag' })).toBeInTheDocument()
})
// ── Bags sheet plumbing ───────────────────────────────────────────────
it('FE-MOB-PACKTAB-059: renames, deletes and re-crews a bag through the sheet', async () => {
vi.spyOn(packingApi, 'updateBag').mockResolvedValue({ bag: { ...BAGS[0], name: 'Daypack' } })
vi.spyOn(packingApi, 'setBagMembers').mockResolvedValue({ members: [{ user_id: 11, username: 'anna', avatar: null }] })
vi.spyOn(packingApi, 'deleteBag').mockResolvedValue({ ok: true })
await setup({ bagTracking: true, planner: { tripMembers: [ANNA] } as Partial<TripPlanner> })
fireEvent.click(screen.getAllByRole('button', { name: 'packing.bags' })[0])
const dialog = screen.getByRole('dialog')
fireEvent.click(within(dialog).getByRole('button', { name: 'Backpack' }))
const nameInput = within(dialog).getByDisplayValue('Backpack')
fireEvent.change(nameInput, { target: { value: 'Daypack' } })
await act(async () => { fireEvent.keyDown(nameInput, { key: 'Enter' }) })
expect(packingApi.updateBag).toHaveBeenCalledWith(3, 71, { name: 'Daypack' })
expect(within(dialog).getByRole('button', { name: 'Daypack' })).toBeInTheDocument()
fireEvent.click(within(dialog).getByRole('button', { name: 'common.add' }))
await act(async () => { fireEvent.click(within(dialog).getByRole('button', { name: 'anna' })) })
expect(packingApi.setBagMembers).toHaveBeenCalledWith(3, 71, [11])
await act(async () => { fireEvent.click(within(dialog).getByRole('button', { name: 'common.delete' })) })
expect(packingApi.deleteBag).toHaveBeenCalledWith(3, 71)
expect(within(dialog).queryByRole('button', { name: 'Daypack' })).toBeNull()
})
it('FE-MOB-PACKTAB-060: reports failures of the bag sheet mutations', async () => {
vi.spyOn(packingApi, 'updateBag').mockRejectedValue(new Error('x'))
vi.spyOn(packingApi, 'setBagMembers').mockRejectedValue(new Error('x'))
vi.spyOn(packingApi, 'deleteBag').mockRejectedValue(new Error('x'))
const { planner } = await setup({ bagTracking: true, planner: { tripMembers: [ANNA] } as Partial<TripPlanner> })
fireEvent.click(screen.getAllByRole('button', { name: 'packing.bags' })[0])
const dialog = screen.getByRole('dialog')
fireEvent.click(within(dialog).getByRole('button', { name: 'Backpack' }))
const nameInput = within(dialog).getByDisplayValue('Backpack')
fireEvent.change(nameInput, { target: { value: 'Daypack' } })
await act(async () => { fireEvent.keyDown(nameInput, { key: 'Enter' }) })
fireEvent.click(within(dialog).getByRole('button', { name: 'common.add' }))
await act(async () => { fireEvent.click(within(dialog).getByRole('button', { name: 'anna' })) })
await act(async () => { fireEvent.click(within(dialog).getByRole('button', { name: 'common.delete' })) })
expect(planner.toast.error).toHaveBeenCalledWith('common.error')
expect(planner.toast.error).toHaveBeenCalledWith('packing.toast.deleteError')
})
it('FE-MOB-PACKTAB-061: creates a bag from the sheet with the next palette colour', async () => {
const created = { id: 72, trip_id: 3, name: 'Duffel', color: '#ec4899', sort_order: 1 } as PackingBag
vi.spyOn(packingApi, 'createBag').mockResolvedValue({ bag: created })
await setup({ bagTracking: true })
fireEvent.click(screen.getAllByRole('button', { name: 'packing.bags' })[0])
const dialog = screen.getByRole('dialog')
fireEvent.click(within(dialog).getByRole('button', { name: 'packing.addBag' }))
const field = within(dialog).getByPlaceholderText('packing.bagName')
fireEvent.change(field, { target: { value: 'Duffel' } })
const addRow = field.parentElement as HTMLElement
await act(async () => { fireEvent.click(within(addRow).getByRole('button', { name: 'common.add' })) })
expect(packingApi.createBag).toHaveBeenCalledWith(3, { name: 'Duffel', color: '#ec4899' })
expect(within(dialog).getByRole('button', { name: 'Duffel' })).toBeInTheDocument()
})
it('FE-MOB-PACKTAB-062: every sheet the tab owns can be dismissed again', async () => {
await setup({ bagTracking: true })
fireEvent.click(screen.getAllByRole('button', { name: 'packing.bags' })[0])
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'common.close' }))
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
openActions()
fireEvent.click(screen.getByRole('button', { name: 'packing.import' }))
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'common.cancel' }))
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
enterEditMode()
fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'common.edit' }))
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'common.close' }))
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
})
})
@@ -0,0 +1,393 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { HttpResponse, http } from 'msw'
import MPlaceEditSheet from '../../../../src/mobile/screens/trip/sheets/MPlaceEditSheet'
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import type { Assignment, Category, Place } from '../../../../src/types'
import { buildPlanner } from '../../../helpers/mobileTrip'
import { server } from '../../../helpers/msw/server'
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-PLEDIT-001 to FE-MOB-PLEDIT-024
// planner.t echoes the key, so every label/placeholder is asserted as its key.
const CATEGORIES = [
{ id: 3, name: 'Food', color: '#ef4444', icon: 'Coffee', user_id: 1 },
] as unknown as Category[]
const EXISTING = {
id: 41, trip_id: 1, name: 'Ueno Park', address: 'Taito City', lat: 35.715, lng: 139.773,
google_place_id: 'ChIJ_ueno',
} as unknown as Place
const EDITED = {
id: 42, trip_id: 1, name: 'Senso-ji', description: 'Oldest temple', address: '2-3-1 Asakusa',
lat: 35.7148, lng: 139.7967, category_id: 3, notes: 'Go early', website: 'https://senso-ji.jp',
place_time: '08:00', end_time: '09:30', transport_mode: 'transit',
} as unknown as Place
function setup(overrides: Partial<TripPlanner> = {}) {
const planner = buildPlanner({ showPlaceForm: true, categories: CATEGORIES, ...overrides })
const view = render(<MPlaceEditSheet planner={planner} />)
return { ...view, planner }
}
const nameField = () => screen.getByPlaceholderText('places.formNamePlaceholder')
const submit = () => screen.getByRole('button', { name: /^(common\.add|common\.save|common\.saving|places\.addAnyway)$/ })
describe('MPlaceEditSheet', () => {
beforeEach(() => {
server.use(
http.post('/api/maps/search', () => HttpResponse.json({
source: 'osm',
places: [{ name: 'Ueno Koen', address: 'Taito', lat: 35.7, lng: 139.7, google_place_id: 'ChIJ_ueno' }],
})),
http.post('/api/maps/autocomplete', () => HttpResponse.json({ source: 'osm', suggestions: [] })),
)
})
it('FE-MOB-PLEDIT-001: stays unmounted while the planner has no editor open', () => {
setup({ showPlaceForm: false })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-PLEDIT-002: opens blank in add mode', () => {
setup()
expect(screen.getByRole('dialog', { name: 'places.addPlace' })).toBeInTheDocument()
expect(screen.getAllByText('places.addPlace').length).toBeGreaterThan(0)
expect(nameField()).toHaveValue('')
expect(submit()).toHaveTextContent('common.add')
// No place in context — no delete affordance and no time fields.
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
expect(screen.queryByText('places.startTime')).not.toBeInTheDocument()
})
it('FE-MOB-PLEDIT-003: prefills every field from the place under edit', () => {
setup({ editingPlace: EDITED })
expect(screen.getByRole('dialog', { name: 'places.editPlace' })).toBeInTheDocument()
expect(nameField()).toHaveValue('Senso-ji')
expect(screen.getByPlaceholderText('places.formDescriptionPlaceholder')).toHaveValue('Oldest temple')
expect(screen.getByPlaceholderText('places.formNotesPlaceholder')).toHaveValue('Go early')
expect(screen.getByPlaceholderText('places.formAddressPlaceholder')).toHaveValue('2-3-1 Asakusa')
expect(screen.getByPlaceholderText('places.formLat')).toHaveValue('35.7148')
expect(screen.getByPlaceholderText('places.formLng')).toHaveValue('139.7967')
expect(screen.getByPlaceholderText('https://')).toHaveValue('https://senso-ji.jp')
expect(screen.getByRole('button', { name: /Food/ }).className).toContain('bg-m-act')
expect(submit()).toHaveTextContent('common.save')
})
it('FE-MOB-PLEDIT-004: leaves optional fields blank when the place has none', () => {
const bare = { id: 43, trip_id: 1, name: 'Nameless' } as unknown as Place
setup({ editingPlace: bare })
expect(nameField()).toHaveValue('Nameless')
expect(screen.getByPlaceholderText('places.formLat')).toHaveValue('')
expect(screen.getByPlaceholderText('places.formNotesPlaceholder')).toHaveValue('')
})
it('FE-MOB-PLEDIT-005: reads the times off the in-context assignment, not off the place', () => {
const assignment = {
id: 7, day_id: 2, place_id: 42, order_index: 0,
place: { ...EDITED, place_time: '14:00', end_time: '15:00' },
} as unknown as Assignment
setup({ editingPlace: EDITED, editingAssignmentId: 7, assignments: { '2': [assignment] } })
expect(screen.getByText('places.startTime')).toBeInTheDocument()
const times = screen.getAllByDisplayValue(/^1[45]:00$/)
expect(times.map(el => (el as HTMLInputElement).value)).toEqual(['14:00', '15:00'])
})
it('FE-MOB-PLEDIT-006: prefills from a map long-press / POI pick', () => {
setup({
prefillCoords: {
lat: 35.6586, lng: 139.7454, name: 'Tokyo Tower', address: '4-2-8 Shibakoen',
website: 'https://tokyotower.co.jp', phone: '+81 3', osm_id: 'W123',
},
})
expect(nameField()).toHaveValue('Tokyo Tower')
expect(screen.getByPlaceholderText('places.formLat')).toHaveValue('35.6586')
expect(screen.getByPlaceholderText('places.formLng')).toHaveValue('139.7454')
expect(screen.getByPlaceholderText('places.formAddressPlaceholder')).toHaveValue('4-2-8 Shibakoen')
expect(screen.getByPlaceholderText('https://')).toHaveValue('https://tokyotower.co.jp')
})
it('FE-MOB-PLEDIT-007: falls back to bare coordinates when the pick carries no metadata', () => {
setup({ prefillCoords: { lat: 1.5, lng: 2.5 } })
expect(nameField()).toHaveValue('')
expect(screen.getByPlaceholderText('places.formLat')).toHaveValue('1.5')
})
it('FE-MOB-PLEDIT-008: refuses to save without a name', () => {
const { planner } = setup()
fireEvent.change(nameField(), { target: { value: ' ' } })
fireEvent.click(submit())
expect(planner.toast.error).toHaveBeenCalledWith('places.nameRequired')
expect(planner.handleSavePlace).not.toHaveBeenCalled()
})
it('FE-MOB-PLEDIT-009: saves the parsed form and clears every editor flag', async () => {
const { planner } = setup()
fireEvent.change(nameField(), { target: { value: 'Nakamise' } })
fireEvent.change(screen.getByPlaceholderText('places.formLat'), { target: { value: '35.71' } })
fireEvent.change(screen.getByPlaceholderText('places.formLng'), { target: { value: '139.79' } })
fireEvent.click(screen.getByRole('button', { name: /Food/ }))
fireEvent.click(submit())
await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1))
expect(planner.handleSavePlace).toHaveBeenCalledWith(expect.objectContaining({
name: 'Nakamise', lat: 35.71, lng: 139.79, category_id: '3', _pendingFiles: undefined,
}))
expect(planner.setShowPlaceForm).toHaveBeenCalledWith(false)
expect(planner.setEditingPlace).toHaveBeenCalledWith(null)
expect(planner.setEditingAssignmentId).toHaveBeenCalledWith(null)
expect(planner.setPrefillCoords).toHaveBeenCalledWith(null)
})
it('FE-MOB-PLEDIT-009b: carries the free-text fields into the save payload', async () => {
const { planner } = setup()
fireEvent.change(nameField(), { target: { value: 'Nakamise' } })
fireEvent.change(screen.getByPlaceholderText('places.formDescriptionPlaceholder'), { target: { value: 'Shopping street' } })
fireEvent.change(screen.getByPlaceholderText('places.formNotesPlaceholder'), { target: { value: 'Cash only' } })
fireEvent.change(screen.getByPlaceholderText('places.formAddressPlaceholder'), { target: { value: '1-36-3 Asakusa' } })
fireEvent.change(screen.getByPlaceholderText('https://'), { target: { value: 'https://asakusa.example' } })
fireEvent.click(submit())
await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1))
expect(planner.handleSavePlace).toHaveBeenCalledWith(expect.objectContaining({
description: 'Shopping street',
notes: 'Cash only',
address: '1-36-3 Asakusa',
website: 'https://asakusa.example',
}))
})
it('FE-MOB-PLEDIT-010: sends null coordinates and no category when the fields stay empty', async () => {
const { planner } = setup()
fireEvent.change(nameField(), { target: { value: 'Somewhere' } })
fireEvent.click(submit())
await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1))
expect(planner.handleSavePlace).toHaveBeenCalledWith(expect.objectContaining({ lat: null, lng: null, category_id: null }))
})
it('FE-MOB-PLEDIT-011: strips non-numeric characters from the coordinate fields', () => {
setup()
const lat = screen.getByPlaceholderText('places.formLat')
fireEvent.change(lat, { target: { value: '3a5.7N' } })
expect(lat).toHaveValue('35.7')
})
it('FE-MOB-PLEDIT-012: a pasted "lat, lng" pair fills both coordinate fields', () => {
setup()
const lat = screen.getByPlaceholderText('places.formLat')
fireEvent.paste(lat, { clipboardData: { getData: () => ' 35.6895, 139.6917 ' } })
expect(lat).toHaveValue('35.6895')
expect(screen.getByPlaceholderText('places.formLng')).toHaveValue('139.6917')
})
it('FE-MOB-PLEDIT-013: an unparseable paste is left to the browser', () => {
setup()
const lat = screen.getByPlaceholderText('places.formLat')
fireEvent.paste(lat, { clipboardData: { getData: () => 'Tokyo Tower' } })
expect(lat).toHaveValue('')
})
it('FE-MOB-PLEDIT-014: warns once about a duplicate name and saves on the second tap', async () => {
const { planner } = setup({ places: [EXISTING] })
fireEvent.change(nameField(), { target: { value: ' ueno park ' } })
fireEvent.click(submit())
expect(planner.toast.warning).toHaveBeenCalledWith('places.duplicateExists:Ueno Park')
expect(planner.handleSavePlace).not.toHaveBeenCalled()
expect(submit()).toHaveTextContent('places.addAnyway')
fireEvent.click(submit())
await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1))
})
it('FE-MOB-PLEDIT-015: treats near-identical coordinates as a duplicate', () => {
const { planner } = setup({ places: [EXISTING], prefillCoords: { lat: 35.71505, lng: 139.77305 } })
fireEvent.change(nameField(), { target: { value: 'Park entrance' } })
fireEvent.click(submit())
expect(planner.toast.warning).toHaveBeenCalledWith('places.duplicateExists:Ueno Park')
})
it('FE-MOB-PLEDIT-016: a maps pick that shares the Google Place ID counts as a duplicate', async () => {
const { planner } = setup({ places: [EXISTING] })
fireEvent.change(screen.getByPlaceholderText('places.mapsSearchPlaceholder'), { target: { value: 'ueno koen' } })
fireEvent.click(screen.getByRole('button', { name: 'common.search' }))
fireEvent.click(await screen.findByText('Ueno Koen'))
expect(nameField()).toHaveValue('Ueno Koen')
fireEvent.click(submit())
expect(planner.toast.warning).toHaveBeenCalledWith('places.duplicateExists:Ueno Park')
})
it('FE-MOB-PLEDIT-017: an existing place never triggers the duplicate guard', async () => {
const { planner } = setup({ places: [EXISTING], editingPlace: { ...EXISTING, id: 99 } as unknown as Place })
fireEvent.click(submit())
await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1))
expect(planner.toast.warning).not.toHaveBeenCalled()
})
it('FE-MOB-PLEDIT-018: surfaces the server message and keeps the sheet open when the save fails', async () => {
const handleSavePlace = vi.fn().mockRejectedValue(new Error('Trip is locked'))
const { planner } = setup({ handleSavePlace })
fireEvent.change(nameField(), { target: { value: 'Nakamise' } })
fireEvent.click(submit())
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Trip is locked'))
expect(planner.setShowPlaceForm).not.toHaveBeenCalled()
expect(submit()).not.toBeDisabled()
})
it('FE-MOB-PLEDIT-019: falls back to the generic message for a non-Error rejection', async () => {
const handleSavePlace = vi.fn().mockRejectedValue('nope')
const { planner } = setup({ handleSavePlace })
fireEvent.change(nameField(), { target: { value: 'Nakamise' } })
fireEvent.click(submit())
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('places.saveError'))
})
it('FE-MOB-PLEDIT-020: shows the saving label and blocks the button while the save runs', async () => {
let release: () => void = () => {}
const handleSavePlace = vi.fn(() => new Promise<void>(resolve => { release = resolve }))
setup({ handleSavePlace })
fireEvent.change(nameField(), { target: { value: 'Nakamise' } })
fireEvent.click(submit())
await waitFor(() => expect(submit()).toHaveTextContent('common.saving'))
expect(submit()).toBeDisabled()
release()
await waitFor(() => expect(handleSavePlace).toHaveBeenCalledTimes(1))
})
it('FE-MOB-PLEDIT-021: deletes on the second tap and stages the id on the first', async () => {
const { planner } = setup({ editingPlace: EDITED })
const del = screen.getByRole('button', { name: 'common.delete' })
fireEvent.click(del)
expect(planner.setDeletePlaceId).toHaveBeenCalledWith(42)
expect(planner.toast.warning).toHaveBeenCalledWith('mobileTrip.tapAgainToDelete')
expect(planner.confirmDeletePlace).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
await waitFor(() => expect(planner.confirmDeletePlace).toHaveBeenCalledTimes(1))
expect(planner.setShowPlaceForm).toHaveBeenCalledWith(false)
})
it('FE-MOB-PLEDIT-022: cancelling an armed delete un-stages the id', () => {
const { planner } = setup({ editingPlace: EDITED })
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
expect(planner.setDeletePlaceId).toHaveBeenLastCalledWith(null)
expect(planner.setShowPlaceForm).toHaveBeenCalledWith(false)
})
it('FE-MOB-PLEDIT-023: the header close leaves the staged id alone when nothing was armed', () => {
const { planner } = setup({ editingPlace: EDITED })
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
expect(planner.setShowPlaceForm).toHaveBeenCalledWith(false)
expect(planner.setDeletePlaceId).not.toHaveBeenCalled()
})
it('FE-MOB-PLEDIT-024: blocks the save while the end time is not after the start', () => {
const assignment = { id: 7, day_id: 2, place_id: 42, order_index: 0, place: EDITED } as unknown as Assignment
setup({ editingPlace: EDITED, editingAssignmentId: 7, assignments: { '2': [assignment] } })
const [start, end] = screen.getAllByDisplayValue(/^0[89]:[0-3]0$/) as HTMLInputElement[]
expect(submit()).not.toBeDisabled()
fireEvent.change(end, { target: { value: '07:00' } })
expect(start).toHaveValue('08:00')
expect(screen.getByText('places.endTimeBeforeStart')).toBeInTheDocument()
expect(submit()).toBeDisabled()
})
it('FE-MOB-PLEDIT-025: attaches pasted images and sends them with the save', async () => {
const { planner } = setup()
const file = new File(['x'], 'ticket.png', { type: 'image/png' })
fireEvent.paste(screen.getByPlaceholderText('places.formAddressPlaceholder'), {
clipboardData: { items: [{ type: 'text/plain', getAsFile: () => null }, { type: 'image/png', getAsFile: () => file }] },
})
expect(await screen.findByText('ticket.png')).toBeInTheDocument()
fireEvent.change(nameField(), { target: { value: 'Nakamise' } })
fireEvent.click(submit())
await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1))
expect(planner.handleSavePlace).toHaveBeenCalledWith(expect.objectContaining({ _pendingFiles: [file] }))
})
it('FE-MOB-PLEDIT-025b: attaches files chosen through the picker', async () => {
const { planner } = setup()
const file = new File(['x'], 'map.pdf', { type: 'application/pdf' })
const picker = document.querySelector('input[type="file"]') as HTMLInputElement
fireEvent.change(picker, { target: { files: [file] } })
expect(await screen.findByText('map.pdf')).toBeInTheDocument()
fireEvent.change(nameField(), { target: { value: 'Nakamise' } })
fireEvent.click(submit())
await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledWith(expect.objectContaining({ _pendingFiles: [file] })))
})
it('FE-MOB-PLEDIT-026: a clipboard item without a file is ignored', () => {
setup()
fireEvent.paste(screen.getByPlaceholderText('places.formAddressPlaceholder'), {
clipboardData: { items: [{ type: 'application/pdf', getAsFile: () => null }] },
})
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
})
it('FE-MOB-PLEDIT-027: drops a pending attachment again', async () => {
setup()
const file = new File(['x'], 'voucher.pdf', { type: 'application/pdf' })
fireEvent.paste(screen.getByPlaceholderText('places.formAddressPlaceholder'), {
clipboardData: { items: [{ type: 'application/pdf', getAsFile: () => file }] },
})
expect(await screen.findByText('voucher.pdf')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
expect(screen.queryByText('voucher.pdf')).not.toBeInTheDocument()
})
it('FE-MOB-PLEDIT-028: hides the file row and ignores pastes without the upload permission', () => {
setup({ canUploadFiles: false })
expect(screen.queryByText('files.title')).not.toBeInTheDocument()
const file = new File(['x'], 'ticket.png', { type: 'image/png' })
fireEvent.paste(screen.getByPlaceholderText('places.formAddressPlaceholder'), {
clipboardData: { items: [{ type: 'image/png', getAsFile: () => file }] },
})
expect(screen.queryByText('ticket.png')).not.toBeInTheDocument()
})
/** Types into the search row and returns the bias the autocomplete request carried. */
async function capturedBias(places: Place[]) {
const bodies: Record<string, unknown>[] = []
server.use(http.post('/api/maps/autocomplete', async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({ source: 'osm', suggestions: [] })
}))
setup({ places })
fireEvent.change(screen.getByPlaceholderText('places.mapsSearchPlaceholder'), { target: { value: 'ueno' } })
await waitFor(() => expect(bodies).toHaveLength(1))
return bodies[0].locationBias
}
it('FE-MOB-PLEDIT-029: biases the maps search on the trip bounding box', async () => {
const places = [
{ id: 1, name: 'A', lat: 35.6, lng: 139.7 },
{ id: 2, name: 'B', lat: 35.8, lng: 139.9 },
{ id: 3, name: 'C', lat: null, lng: null },
] as unknown as Place[]
expect(await capturedBias(places)).toEqual({ low: { lat: 35.6, lng: 139.7 }, high: { lat: 35.8, lng: 139.9 } })
})
it('FE-MOB-PLEDIT-030: drops the bias for a trip that spans more than 500 km', async () => {
const places = [
{ id: 1, name: 'Tokyo', lat: 35.68, lng: 139.76 },
{ id: 2, name: 'Sapporo', lat: 43.06, lng: 141.35 },
] as unknown as Place[]
expect(await capturedBias(places)).toBeUndefined()
})
it('FE-MOB-PLEDIT-031: sends no bias when no place has usable coordinates', async () => {
const places = [{ id: 1, name: 'Broken', lat: 'north', lng: 'east' }] as unknown as Place[]
expect(await capturedBias(places)).toBeUndefined()
})
it('FE-MOB-PLEDIT-032: sends no bias for a trip without any located place', async () => {
expect(await capturedBias([])).toBeUndefined()
})
})
@@ -0,0 +1,410 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { assignmentsApi } from '../../../../src/api/client'
import MPlaceSheet from '../../../../src/mobile/screens/trip/sheets/MPlaceSheet'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { useAddonStore } from '../../../../src/store/addonStore'
import { useSaveToCollectionStore } from '../../../../src/store/saveToCollectionStore'
import { useTripStore } from '../../../../src/store/tripStore'
import { openFile } from '../../../../src/utils/fileDownload'
import type { Assignment, Category, Day, Place, TripFile, TripMember } from '../../../../src/types'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-PLSH-001 to FE-MOB-PLSH-025
vi.mock('../../../../src/utils/fileDownload', () => ({ openFile: vi.fn() }))
const DAYS = [
{ id: 1, trip_id: 5, day_number: 1, date: '2026-05-01', title: null },
{ id: 2, trip_id: 5, day_number: 2, date: '2026-05-02', title: 'Old Town' },
{ id: 3, trip_id: 5, day_number: 3, date: '2026-05-03', title: null },
] as unknown as Day[]
const CATEGORIES = [{ id: 9, name: 'Museum', icon: 'Landmark', color: '#ff0000' }] as unknown as Category[]
const PLACE = {
id: 101,
trip_id: 5,
name: 'Kunsthistorisches Museum',
address: 'Maria-Theresien-Platz',
phone: '+43 1 525240',
website: 'https://khm.at',
description: 'Habsburg collections',
notes: 'Book the skip-the-line ticket',
category_id: 9,
lat: 48.2038,
lng: 16.3616,
image_url: '/uploads/places/khm.jpg',
ratings: [],
rating_avg: null,
} as unknown as Place
const MEMBERS = [
{ id: 1, username: 'ann', avatar: null, avatar_url: null },
{ id: 2, username: 'bob', avatar: null, avatar_url: null },
{ id: 3, username: 'cara', avatar: null, avatar_url: null },
] as unknown as TripMember[]
function dayAssignment(id: number, dayId: number, participants: { user_id: number; username: string }[] = []) {
return {
id,
day_id: dayId,
place_id: PLACE.id,
order_index: 0,
participants,
place: { id: PLACE.id, name: PLACE.name, lat: PLACE.lat, lng: PLACE.lng },
} as unknown as Assignment
}
const FILES = [
{ id: 71, trip_id: 5, place_id: 101, original_name: 'ticket.pdf', url: '/uploads/files/ticket.pdf', mime_type: 'application/pdf', filename: 'a', created_at: '' },
{ id: 72, trip_id: 5, place_id: null, linked_place_ids: [101], original_name: 'map.png', url: '/uploads/files/map.png', mime_type: 'image/png', filename: 'b', created_at: '' },
{ id: 73, trip_id: 5, place_id: 101, original_name: 'deleted.pdf', url: '/x', mime_type: 'application/pdf', filename: 'c', created_at: '', deleted_at: '2026-01-01' },
{ id: 74, trip_id: 5, place_id: 999, original_name: 'other.pdf', url: '/y', mime_type: 'application/pdf', filename: 'd', created_at: '' },
] as unknown as TripFile[]
function makeShell(overrides: Record<string, unknown> = {}) {
return {
view: 'plan',
mode: 'go',
trTab: 'plan',
sheet: null,
openSheet: vi.fn(),
closeSheet: vi.fn(),
setTrTab: vi.fn(),
toggleView: vi.fn(),
setTravelMode: vi.fn(),
...overrides,
} as unknown as MTripShellApi
}
function makePlanner(overrides: Record<string, unknown> = {}) {
return {
tripId: 5,
trip: { id: 5, title: 'Vienna' },
language: 'en',
days: DAYS,
categories: CATEGORIES,
assignments: { '2': [dayAssignment(11, 2)] },
files: FILES,
canUploadFiles: true,
selectedPlace: PLACE,
selectedDayId: 2,
selectedAssignmentId: null,
setSelectedPlaceId: vi.fn(),
tripMembers: MEMBERS,
setFitKey: vi.fn(),
openPlaceEditor: vi.fn(),
handleDeletePlace: vi.fn(),
handleAssignToDay: vi.fn(),
handleRemoveAssignment: vi.fn(),
tripActions: {
uploadPlaceImage: vi.fn().mockResolvedValue(undefined),
updatePlace: vi.fn().mockResolvedValue(undefined),
addFile: vi.fn().mockResolvedValue(undefined),
ratePlace: vi.fn().mockResolvedValue(undefined),
},
can: vi.fn(() => true),
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
...overrides,
} as unknown as TripPlanner
}
function renderSheet(planner: TripPlanner = makePlanner(), shell: MTripShellApi = makeShell()) {
const view = render(<MPlaceSheet planner={planner} shell={shell} />)
return { ...view, planner, shell }
}
/** jsdom keeps input.files read-only, so the selection has to be planted. */
function selectFiles(input: HTMLInputElement, files: File[]) {
Object.defineProperty(input, 'files', { value: files, configurable: true, writable: true })
fireEvent.change(input)
}
describe('MPlaceSheet', () => {
beforeEach(() => {
resetAllStores()
seedStore(useAddonStore, { addons: [{ id: 'collections', name: 'Collections', type: 'feature', icon: '', enabled: true }] })
vi.spyOn(window, 'open').mockImplementation(() => null)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('FE-MOB-PLSH-001: stays closed without a selected place', () => {
renderSheet(makePlanner({ selectedPlace: null }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-PLSH-002: renders name, address, phone link, category and image', () => {
renderSheet()
expect(screen.getByRole('dialog', { name: PLACE.name })).toBeInTheDocument()
expect(screen.getByText(PLACE.name)).toBeInTheDocument()
expect(screen.getByText('Maria-Theresien-Platz')).toBeInTheDocument()
expect(screen.getByRole('link', { name: '+43 1 525240' })).toHaveAttribute('href', 'tel:+43 1 525240')
expect(screen.getByText('Museum')).toBeInTheDocument()
expect(screen.getByText('Habsburg collections')).toBeInTheDocument()
expect(screen.getByText('Book the skip-the-line ticket')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Change image' })).toBeInTheDocument()
})
it('FE-MOB-PLSH-003: clears the place selection when closed', () => {
const { planner } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(planner.setSelectedPlaceId).toHaveBeenCalledWith(null)
})
it('FE-MOB-PLSH-004: casts a star rating through the trip actions', async () => {
const { planner } = renderSheet()
expect(screen.getByText('Not rated yet')).toBeInTheDocument()
fireEvent.click(screen.getByRole('radio', { name: '4' }))
await waitFor(() => expect(planner.tripActions.ratePlace).toHaveBeenCalledWith(5, 101, 4))
})
it('FE-MOB-PLSH-005: reports a failing rating as a toast', async () => {
const planner = makePlanner()
vi.mocked(planner.tripActions.ratePlace).mockRejectedValue(new Error('rate limited'))
renderSheet(planner)
fireEvent.click(screen.getByRole('radio', { name: '2' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('rate limited'))
})
it('FE-MOB-PLSH-006: hides the track colour section without a route geometry', () => {
renderSheet()
expect(screen.queryByText('Track color')).not.toBeInTheDocument()
})
it('FE-MOB-PLSH-007: picks a track colour for a place that carries a track', async () => {
const place = { ...PLACE, route_geometry: '{"type":"LineString"}', route_color: null } as unknown as Place
const planner = makePlanner({ selectedPlace: place })
renderSheet(planner)
expect(screen.getByText('Track color')).toBeInTheDocument()
const toggle = screen.getByRole('button', { name: /Automatic color/ })
expect(toggle).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(toggle)
expect(toggle).toHaveAttribute('aria-expanded', 'true')
fireEvent.click(screen.getByRole('button', { name: '#ea580c' }))
await waitFor(() => expect(planner.tripActions.updatePlace).toHaveBeenCalledWith(5, 101, { route_color: '#ea580c' }))
})
it('FE-MOB-PLSH-008: reports a failing track colour update', async () => {
const place = { ...PLACE, route_geometry: 'x', route_color: '#1d4ed8' } as unknown as Place
const planner = makePlanner({ selectedPlace: place })
vi.mocked(planner.tripActions.updatePlace).mockRejectedValue(new Error('conflict'))
renderSheet(planner)
fireEvent.click(screen.getByRole('button', { name: /#1d4ed8/ }))
fireEvent.click(screen.getAllByRole('button', { name: 'Automatic color' })[0])
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Unknown error'))
})
it('FE-MOB-PLSH-009: lists the assigned days and removes an assignment', () => {
const { planner } = renderSheet()
expect(screen.getByText('Old Town')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Remove from Day' }))
expect(planner.handleRemoveAssignment).toHaveBeenCalledWith(2, 11)
})
it('FE-MOB-PLSH-010: assigns the place to a day from the picker', () => {
const { planner } = renderSheet()
const add = screen.getByRole('button', { name: 'Add to Day' })
expect(add).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(add)
expect(screen.getByText('May 1')).toBeInTheDocument()
fireEvent.click(screen.getByText('Day 3'))
expect(planner.handleAssignToDay).toHaveBeenCalledWith(101, 3)
expect(screen.queryByText('May 1')).not.toBeInTheDocument()
})
it('FE-MOB-PLSH-011: treats an assignment without participants as everyone joined', async () => {
vi.spyOn(assignmentsApi, 'setParticipants').mockResolvedValue({ participants: [{ user_id: 2, username: 'bob' }] })
renderSheet()
expect(screen.getByText('Participants')).toBeInTheDocument()
expect(screen.getByText('ann')).toBeInTheDocument()
expect(screen.getByText('cara')).toBeInTheDocument()
fireEvent.click(screen.getByText('ann'))
await waitFor(() => expect(assignmentsApi.setParticipants).toHaveBeenCalledWith(5, 11, [2, 3]))
})
it('FE-MOB-PLSH-012: writes the new participant list back into the trip store', async () => {
vi.spyOn(assignmentsApi, 'setParticipants').mockResolvedValue({ participants: [{ user_id: 1, username: 'ann' }, { user_id: 2, username: 'bob' }] })
seedStore(useTripStore, { assignments: { '2': [dayAssignment(11, 2, [{ user_id: 1, username: 'ann' }])] } })
const planner = makePlanner({ assignments: { '2': [dayAssignment(11, 2, [{ user_id: 1, username: 'ann' }])] } })
renderSheet(planner)
expect(screen.getByText('ann')).toBeInTheDocument()
expect(screen.queryByText('bob')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
fireEvent.click(screen.getByText('bob'))
await waitFor(() => expect(assignmentsApi.setParticipants).toHaveBeenCalledWith(5, 11, [1, 2]))
await waitFor(() =>
expect(useTripStore.getState().assignments['2'][0].participants).toEqual([
{ user_id: 1, username: 'ann' },
{ user_id: 2, username: 'bob' },
]),
)
})
it('FE-MOB-PLSH-013: reports a failing participant update', async () => {
vi.spyOn(assignmentsApi, 'setParticipants').mockRejectedValue(new Error('forbidden'))
const { planner } = renderSheet()
fireEvent.click(screen.getByText('bob'))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('forbidden'))
})
it('FE-MOB-PLSH-014: prefers the explicitly selected assignment for the participant row', async () => {
vi.spyOn(assignmentsApi, 'setParticipants').mockResolvedValue({ participants: [] })
const planner = makePlanner({
selectedAssignmentId: 12,
assignments: { '2': [dayAssignment(11, 2), dayAssignment(12, 2)] },
})
renderSheet(planner)
fireEvent.click(screen.getByText('ann'))
await waitFor(() => expect(assignmentsApi.setParticipants).toHaveBeenCalledWith(5, 12, [2, 3]))
})
it('FE-MOB-PLSH-015: hides participants when the trip has a single member', () => {
renderSheet(makePlanner({ tripMembers: [MEMBERS[0]] }))
expect(screen.queryByText('Participants')).not.toBeInTheDocument()
})
it('FE-MOB-PLSH-016: counts only this place\'s live files and opens one', () => {
renderSheet()
expect(screen.getByText('2 files')).toBeInTheDocument()
fireEvent.click(screen.getByText('2 files'))
expect(screen.getByText('ticket.pdf')).toBeInTheDocument()
expect(screen.getByText('map.png')).toBeInTheDocument()
expect(screen.queryByText('deleted.pdf')).not.toBeInTheDocument()
expect(screen.queryByText('other.pdf')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('ticket.pdf'))
expect(openFile).toHaveBeenCalledWith('/uploads/files/ticket.pdf', 'ticket.pdf')
})
it('FE-MOB-PLSH-017: uploads every picked file against the place and expands the list', async () => {
const planner = makePlanner({ files: [] })
renderSheet(planner)
expect(screen.getByText('Files')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Upload/ }))
const input = document.querySelector('input[type="file"][multiple]') as HTMLInputElement
selectFiles(input, [new File(['a'], 'a.pdf'), new File(['b'], 'b.pdf')])
await waitFor(() => expect(planner.tripActions.addFile).toHaveBeenCalledTimes(2))
const fd = vi.mocked(planner.tripActions.addFile).mock.calls[0][1] as FormData
expect(fd.get('place_id')).toBe('101')
expect((fd.get('file') as File).name).toBe('a.pdf')
})
it('FE-MOB-PLSH-018: reports a failing file upload', async () => {
const planner = makePlanner()
vi.mocked(planner.tripActions.addFile).mockRejectedValue(new Error('too large'))
renderSheet(planner)
const input = document.querySelector('input[type="file"][multiple]') as HTMLInputElement
selectFiles(input, [new File(['a'], 'a.pdf')])
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Upload failed'))
})
it('FE-MOB-PLSH-019: uploads and removes the custom place image', async () => {
const { planner } = renderSheet()
const input = document.querySelector('input[type="file"][accept*="image"]') as HTMLInputElement
const openPicker = vi.spyOn(input, 'click')
fireEvent.click(screen.getByRole('button', { name: 'Change image' }))
expect(openPicker).toHaveBeenCalledTimes(1)
const picture = new File(['x'], 'front.png', { type: 'image/png' })
selectFiles(input, [picture])
await waitFor(() => expect(planner.tripActions.uploadPlaceImage).toHaveBeenCalledWith(5, 101, picture))
fireEvent.click(screen.getByRole('button', { name: 'Remove image' }))
await waitFor(() => expect(planner.tripActions.updatePlace).toHaveBeenCalledWith(5, 101, { image_url: null }))
})
it('FE-MOB-PLSH-020: reports a failing image upload and offers upload on a place without one', async () => {
const planner = makePlanner({ selectedPlace: { ...PLACE, image_url: null } })
vi.mocked(planner.tripActions.uploadPlaceImage).mockRejectedValue(new Error('unsupported'))
renderSheet(planner)
expect(screen.getByRole('button', { name: 'Upload image' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Remove image' })).not.toBeInTheDocument()
const input = document.querySelector('input[type="file"][accept*="image"]') as HTMLInputElement
selectFiles(input, [new File(['x'], 'front.png', { type: 'image/png' })])
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Could not upload image'))
})
it('FE-MOB-PLSH-021: blocks the picker while an image request runs and reports its failure', async () => {
const planner = makePlanner()
let rejectUpdate: (err: Error) => void = () => {}
vi.mocked(planner.tripActions.updatePlace).mockReturnValue(new Promise((_res, rej) => { rejectUpdate = rej }))
renderSheet(planner)
fireEvent.click(screen.getByRole('button', { name: 'Remove image' }))
const input = document.querySelector('input[type="file"][accept*="image"]') as HTMLInputElement
const openPicker = vi.spyOn(input, 'click')
fireEvent.click(screen.getByRole('button', { name: 'Change image' }))
expect(openPicker).not.toHaveBeenCalled()
await act(async () => { rejectUpdate(new Error('nope')) })
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Could not upload image'))
})
it('FE-MOB-PLSH-022: hands the place to the save-to-collection picker', () => {
renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Save to Collection' }))
expect(useSaveToCollectionStore.getState().target).toMatchObject({
name: PLACE.name,
source_trip_id: 5,
source_place_id: 101,
lat: 48.2038,
lng: 16.3616,
})
})
it('FE-MOB-PLSH-023: opens directions, the website and the map view', async () => {
const shell = makeShell({ trTab: 'dateien', view: 'plan' })
const { planner } = renderSheet(makePlanner(), shell)
fireEvent.click(screen.getByRole('button', { name: 'Google Maps' }))
expect(window.open).toHaveBeenCalledWith(
'https://www.google.com/maps/search/?api=1&query=48.2038,16.3616', '_blank', 'noopener,noreferrer',
)
fireEvent.click(screen.getByRole('button', { name: 'Open Website' }))
expect(window.open).toHaveBeenCalledWith('https://khm.at', '_blank', 'noopener,noreferrer')
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Show on map' })) })
expect(planner.setSelectedPlaceId).toHaveBeenCalledWith(null)
expect(shell.setTrTab).toHaveBeenCalledWith('plan')
expect(shell.toggleView).toHaveBeenCalledTimes(1)
expect(planner.setFitKey).toHaveBeenCalledTimes(1)
})
it('FE-MOB-PLSH-024: edits and deletes the place, both closing the sheet', () => {
const { planner } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
expect(planner.openPlaceEditor).toHaveBeenCalledWith(PLACE, 11)
expect(planner.setSelectedPlaceId).toHaveBeenCalledWith(null)
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
expect(planner.handleDeletePlace).toHaveBeenCalledWith(101)
expect(planner.setSelectedPlaceId).toHaveBeenCalledTimes(2)
})
it('FE-MOB-PLSH-025: strips every editing affordance for a read-only member', () => {
seedStore(useAddonStore, { addons: [] })
renderSheet(makePlanner({ can: () => false, canUploadFiles: false }))
expect(screen.queryByRole('button', { name: 'Change image' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Remove from Day' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Add to Day' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Upload/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Save to Collection' })).not.toBeInTheDocument()
// Read-only members still get the navigation actions.
expect(screen.getByRole('button', { name: 'Google Maps' })).toBeInTheDocument()
})
})
@@ -0,0 +1,360 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import MPlacesBrowser from '../../../../src/mobile/screens/trip/places/MPlacesBrowser'
import { collectionsApi } from '../../../../src/api/collections'
import type { CollectionListResponse } from '@trek/shared'
import { useAddonStore } from '../../../../src/store/addonStore'
import { useTripStore } from '../../../../src/store/tripStore'
import { resetAllStores, seedStore } from '../../../helpers/store'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import type { AssignmentsMap, Category, Day, Place } from '../../../../src/types'
// FE-MOB-PBROW-001 to FE-MOB-PBROW-029
const CATEGORIES = [
{ id: 1, name: 'Sights', color: '#123456', icon: 'landmark' },
// No colour — the panel and the row fall back to the muted token.
{ id: 2, name: 'Food', color: '', icon: 'utensils' },
] as unknown as Category[]
const DAYS = [
{ id: 21, trip_id: 1, day_number: 1, date: '2026-05-01' },
{ id: 22, trip_id: 1, day_number: 2, date: '2026-05-02' },
] as unknown as Day[]
function place(over: Partial<Place>): Place {
return {
id: 0, trip_id: 1, name: '', address: null, description: null, category_id: null,
lat: 48.2, lng: 16.3, image_url: null, google_place_id: null, osm_id: null,
route_geometry: null, route_color: null,
...over,
} as unknown as Place
}
const LOUVRE = place({ id: 1, name: 'Louvre', address: 'Rue de Rivoli', category_id: 1 })
const EIFFEL = place({ id: 2, name: 'Eiffel Tower', description: 'Iron lady' })
const SEINE = place({ id: 3, name: 'Seine Track', category_id: 2, route_geometry: 'abc', route_color: '#ff0000' })
const PLACES = [LOUVRE, EIFFEL, SEINE]
// Louvre is planned on day 22 (day_number 2), the rest is still in the pool.
const ASSIGNMENTS: AssignmentsMap = {
'22': [{ id: 90, day_id: 22, place_id: 1, order_index: 0, place: { id: 1, name: 'Louvre' } }],
} as unknown as AssignmentsMap
function makePlanner(overrides: Partial<TripPlanner> = {}): TripPlanner {
return buildPlanner({
places: PLACES,
categories: CATEGORIES,
assignments: ASSIGNMENTS,
days: DAYS,
...overrides,
} as Partial<TripPlanner>)
}
function renderBrowser(planner: TripPlanner = makePlanner(), shell: MTripShellApi = buildShell()) {
const view = render(<MPlacesBrowser planner={planner} shell={shell} />)
return { ...view, planner, shell }
}
/** Row button of a place — the whole left side of the row is one button. */
const row = (name: string) => screen.getByRole('button', { name: new RegExp(name) })
describe('MPlacesBrowser', () => {
beforeEach(() => {
resetAllStores()
seedStore(useAddonStore, { addons: [{ id: 'collections', enabled: true }] })
vi.spyOn(collectionsApi, 'list').mockResolvedValue({ collections: [], incomingInvites: [] } as CollectionListResponse)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('FE-MOB-PBROW-001: lists every place with its count divider', () => {
renderBrowser()
expect(screen.getByText('places.count:3')).toBeInTheDocument()
expect(screen.getByText('Louvre')).toBeInTheDocument()
expect(screen.getByText('Rue de Rivoli')).toBeInTheDocument()
// No address → the description takes the sub line.
expect(screen.getByText('Iron lady')).toBeInTheDocument()
})
it('FE-MOB-PBROW-002: badges a planned place with its first day and offers no quick-add', () => {
renderBrowser()
expect(screen.getByText('planner.dayN:2')).toBeInTheDocument()
// Two unplanned places → two quick-add buttons.
expect(screen.getAllByRole('button', { name: 'places.assignToDay' })).toHaveLength(2)
})
it('FE-MOB-PBROW-003: quick-add opens the place-actions sheet with the day picker', () => {
const { shell } = renderBrowser()
fireEvent.click(screen.getAllByRole('button', { name: 'places.assignToDay' })[0])
expect(shell.openSheet).toHaveBeenCalledWith('bract', { placeId: 2, dayPicker: true })
})
it('FE-MOB-PBROW-004: a row tap opens the place-actions sheet without the day picker', () => {
const { shell } = renderBrowser()
fireEvent.click(row('Louvre'))
expect(shell.openSheet).toHaveBeenCalledWith('bract', { placeId: 1, dayPicker: false })
})
it('FE-MOB-PBROW-005: the filter chips write the pool filter to the trip store', () => {
renderBrowser()
fireEvent.click(screen.getByRole('button', { name: 'places.unplanned' }))
expect(useTripStore.getState().placesFilter).toBe('unplanned')
expect(screen.getByText('places.count:2')).toBeInTheDocument()
expect(screen.queryByText('Louvre')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'places.planned' }))
expect(useTripStore.getState().placesFilter).toBe('planned')
expect(screen.getByText('places.countSingular')).toBeInTheDocument()
expect(screen.getByText('Louvre')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'places.all' }))
expect(useTripStore.getState().placesFilter).toBe('all')
})
it('FE-MOB-PBROW-006: the tracks chip only appears with a track and filters to it', () => {
renderBrowser()
fireEvent.click(screen.getByRole('button', { name: 'places.filterTracks' }))
expect(screen.getByText('places.countSingular')).toBeInTheDocument()
expect(screen.getByText('Seine Track')).toBeInTheDocument()
expect(screen.queryByText('Louvre')).not.toBeInTheDocument()
})
it('FE-MOB-PBROW-007: hides the tracks chip and falls back to all when the last track is gone', () => {
seedStore(useTripStore, { placesFilter: 'tracks' })
renderBrowser(makePlanner({ places: [LOUVRE, EIFFEL] } as Partial<TripPlanner>))
expect(screen.queryByRole('button', { name: 'places.filterTracks' })).not.toBeInTheDocument()
expect(useTripStore.getState().placesFilter).toBe('all')
expect(screen.getByText('places.count:2')).toBeInTheDocument()
})
it('FE-MOB-PBROW-008: entering the browser from the edit segment seeds the unplanned pool', () => {
renderBrowser(makePlanner(), buildShell({ browseFromEdit: true }))
expect(useTripStore.getState().placesFilter).toBe('unplanned')
expect(screen.queryByText('Louvre')).not.toBeInTheDocument()
})
it('FE-MOB-PBROW-009: the search box matches name and address', () => {
renderBrowser()
const input = screen.getByPlaceholderText('places.search')
fireEvent.change(input, { target: { value: 'rivoli' } })
expect(screen.getByText('Louvre')).toBeInTheDocument()
expect(screen.queryByText('Seine Track')).not.toBeInTheDocument()
fireEvent.change(input, { target: { value: 'seine' } })
expect(screen.getByText('Seine Track')).toBeInTheDocument()
expect(screen.queryByText('Louvre')).not.toBeInTheDocument()
})
it('FE-MOB-PBROW-010: the category panel toggles the shared filter set and badges its size', () => {
renderBrowser()
const catBtn = screen.getByRole('button', { name: 'places.allCategories' })
expect(catBtn).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(catBtn)
expect(catBtn).toHaveAttribute('aria-expanded', 'true')
fireEvent.click(screen.getByRole('checkbox', { name: 'Sights' }))
expect([...useTripStore.getState().placesCategoryFilter]).toEqual(['1'])
expect(screen.getByRole('checkbox', { name: 'Sights' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByText('Louvre')).toBeInTheDocument()
expect(screen.queryByText('Eiffel Tower')).not.toBeInTheDocument()
expect(catBtn).toHaveTextContent('1')
fireEvent.click(screen.getByRole('checkbox', { name: 'Sights' }))
expect(useTripStore.getState().placesCategoryFilter.size).toBe(0)
})
it('FE-MOB-PBROW-011: the uncategorized row filters the places without a category', () => {
renderBrowser()
fireEvent.click(screen.getByRole('button', { name: 'places.allCategories' }))
fireEvent.click(screen.getByRole('checkbox', { name: 'places.noCategory' }))
expect([...useTripStore.getState().placesCategoryFilter]).toEqual(['uncategorized'])
expect(screen.getByText('Eiffel Tower')).toBeInTheDocument()
expect(screen.queryByText('Louvre')).not.toBeInTheDocument()
})
it('FE-MOB-PBROW-012: the uncategorized row is dropped when every place has a category', () => {
const planner = makePlanner({ places: [LOUVRE, SEINE] } as Partial<TripPlanner>)
renderBrowser(planner)
fireEvent.click(screen.getByRole('button', { name: 'places.allCategories' }))
expect(screen.queryByRole('checkbox', { name: 'places.noCategory' })).not.toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: 'Food' })).toBeInTheDocument()
})
it('FE-MOB-PBROW-013: select mode picks rows and reports the count', () => {
renderBrowser()
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
expect(screen.getByText('places.selectionCount:0')).toBeInTheDocument()
fireEvent.click(row('Louvre'))
fireEvent.click(row('Eiffel Tower'))
expect(screen.getByText('places.selectionCount:2')).toBeInTheDocument()
fireEvent.click(row('Louvre'))
expect(screen.getByText('places.selectionCount:1')).toBeInTheDocument()
// Row taps no longer open the actions sheet while selecting.
expect(screen.queryByRole('button', { name: 'places.assignToDay' })).not.toBeInTheDocument()
})
it('FE-MOB-PBROW-014: select all / deselect all covers exactly the visible pool', () => {
renderBrowser()
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
fireEvent.click(screen.getByRole('button', { name: 'common.selectAll' }))
expect(screen.getByText('places.selectionCount:3')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.deselectAll' }))
expect(screen.getByText('places.selectionCount:0')).toBeInTheDocument()
})
it('FE-MOB-PBROW-015: switching the pool filter or searching clears the selection', () => {
renderBrowser()
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
fireEvent.click(screen.getByRole('button', { name: 'common.selectAll' }))
fireEvent.click(screen.getByRole('button', { name: 'places.unplanned' }))
expect(screen.getByText('places.selectionCount:0')).toBeInTheDocument()
fireEvent.click(row('Eiffel Tower'))
expect(screen.getByText('places.selectionCount:1')).toBeInTheDocument()
fireEvent.change(screen.getByPlaceholderText('places.search'), { target: { value: 'ei' } })
expect(screen.getByText('places.selectionCount:0')).toBeInTheDocument()
})
it('FE-MOB-PBROW-016: leaving select mode drops the selection', () => {
renderBrowser()
const toggle = screen.getByRole('button', { name: 'common.select' })
fireEvent.click(toggle)
fireEvent.click(row('Louvre'))
expect(toggle).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(toggle)
expect(toggle).toHaveAttribute('aria-pressed', 'false')
expect(screen.queryByText(/places.selectionCount/)).not.toBeInTheDocument()
})
it('FE-MOB-PBROW-017: the bulk actions stay disabled until something is selected', () => {
renderBrowser()
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
expect(screen.getByRole('button', { name: 'places.changeCategory' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'inspector.saveToCollection' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'places.deleteSelected' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'common.selectAll' })).toBeEnabled()
})
it('FE-MOB-PBROW-018: the bulk category sheet applies the pick and leaves select mode', () => {
const { planner } = renderBrowser()
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
fireEvent.click(row('Louvre'))
fireEvent.click(screen.getByRole('button', { name: 'places.changeCategory' }))
expect(screen.getByRole('dialog', { name: 'Change category' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Food' }))
expect(planner.confirmChangeCategory).toHaveBeenCalledWith([1], 2)
expect(screen.queryByText(/places.selectionCount/)).not.toBeInTheDocument()
})
it('FE-MOB-PBROW-019: dismissing the category sheet keeps the selection untouched', async () => {
const { planner } = renderBrowser()
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
fireEvent.click(row('Louvre'))
fireEvent.click(screen.getByRole('button', { name: 'places.changeCategory' }))
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Change category' })).not.toBeInTheDocument())
expect(planner.confirmChangeCategory).not.toHaveBeenCalled()
expect(screen.getByText('places.selectionCount:1')).toBeInTheDocument()
})
it('FE-MOB-PBROW-020: the delete confirmation removes the selection in one call', () => {
const { planner } = renderBrowser()
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
fireEvent.click(row('Louvre'))
fireEvent.click(row('Seine Track'))
fireEvent.click(screen.getByRole('button', { name: 'places.deleteSelected' }))
expect(screen.getByText('trip.confirm.deletePlaces:2')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
expect(planner.confirmDeletePlaces).toHaveBeenCalledWith([1, 3])
expect(screen.queryByText(/places.selectionCount/)).not.toBeInTheDocument()
})
it('FE-MOB-PBROW-021: cancelling the delete keeps the selection intact', () => {
const { planner } = renderBrowser()
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
fireEvent.click(row('Louvre'))
fireEvent.click(screen.getByRole('button', { name: 'places.deleteSelected' }))
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
expect(planner.confirmDeletePlaces).not.toHaveBeenCalled()
expect(screen.getByText('places.selectionCount:1')).toBeInTheDocument()
})
it('FE-MOB-PBROW-022: the save-to-collection action opens the list picker', async () => {
renderBrowser()
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
fireEvent.click(row('Louvre'))
fireEvent.click(screen.getByRole('button', { name: 'inspector.saveToCollection' }))
await waitFor(() => expect(collectionsApi.list).toHaveBeenCalled())
expect(screen.getByRole('dialog', { name: 'Save 1 to a list' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Save 1 to a list' })).not.toBeInTheDocument())
expect(screen.getByText('places.selectionCount:1')).toBeInTheDocument()
})
it('FE-MOB-PBROW-023: without the collections addon neither the action nor the sheet exists', () => {
seedStore(useAddonStore, { addons: [] })
renderBrowser()
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
expect(screen.queryByRole('button', { name: 'inspector.saveToCollection' })).not.toBeInTheDocument()
expect(collectionsApi.list).not.toHaveBeenCalled()
})
it('FE-MOB-PBROW-024: the add button opens a blank place form', () => {
const { planner } = renderBrowser()
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
expect(planner.setEditingPlace).toHaveBeenCalledWith(null)
expect(planner.setEditingAssignmentId).toHaveBeenCalledWith(null)
expect(planner.setPrefillCoords).toHaveBeenCalledWith(null)
expect(planner.setShowPlaceForm).toHaveBeenCalledWith(true)
})
it('FE-MOB-PBROW-025: the ellipsis opens the import sheet', () => {
const { shell } = renderBrowser()
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.importPlaces' }))
expect(shell.openSheet).toHaveBeenCalledWith('import')
})
it('FE-MOB-PBROW-026: a read-only member keeps the filters but loses every mutation', () => {
renderBrowser(makePlanner({ can: vi.fn(() => false) } as Partial<TripPlanner>))
expect(screen.getByRole('button', { name: 'places.all' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'mobileTrip.importPlaces' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.select' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.add' })).not.toBeInTheDocument()
})
it('FE-MOB-PBROW-027: the empty unplanned pool celebrates instead of reporting no matches', () => {
renderBrowser(makePlanner({ places: [LOUVRE] } as Partial<TripPlanner>), buildShell({ browseFromEdit: true }))
expect(screen.getByText('places.allPlanned')).toBeInTheDocument()
expect(screen.queryByText('places.noneFound')).not.toBeInTheDocument()
})
it('FE-MOB-PBROW-028: a search without hits reports no matches', () => {
renderBrowser()
fireEvent.change(screen.getByPlaceholderText('places.search'), { target: { value: 'zzz' } })
expect(screen.getByText('places.noneFound')).toBeInTheDocument()
expect(screen.getByText('places.count:0')).toBeInTheDocument()
})
it('FE-MOB-PBROW-029: a deleted place drops out of the selection count', async () => {
const shell = buildShell()
const { rerender } = renderBrowser(makePlanner(), shell)
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
fireEvent.click(screen.getByRole('button', { name: 'common.selectAll' }))
expect(screen.getByText('places.selectionCount:3')).toBeInTheDocument()
await act(async () => {
rerender(<MPlacesBrowser planner={makePlanner({ places: [LOUVRE, EIFFEL] } as Partial<TripPlanner>)} shell={shell} />)
})
expect(screen.getByText('places.selectionCount:2')).toBeInTheDocument()
})
})
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '../../../helpers/render'
import MPlacesBulkCategorySheet from '../../../../src/mobile/screens/trip/places/MPlacesBulkCategorySheet'
import type { Category } from '../../../../src/types'
// FE-MOB-PBULK-001 to FE-MOB-PBULK-006
const CATEGORIES = [
{ id: 1, name: 'Sights', color: '#123456', icon: 'landmark' },
{ id: 2, name: 'Food', color: '', icon: 'unknown-icon' },
] as unknown as Category[]
function renderSheet(over: { open?: boolean; count?: number; categories?: Category[] } = {}) {
const onPick = vi.fn()
const onClose = vi.fn()
const view = render(
<MPlacesBulkCategorySheet
open={over.open ?? true}
count={over.count ?? 3}
categories={over.categories ?? CATEGORIES}
onPick={onPick}
onClose={onClose}
/>,
)
return { ...view, onPick, onClose }
}
describe('MPlacesBulkCategorySheet', () => {
it('FE-MOB-PBULK-001: titles the sheet and reports how many places the pick applies to', () => {
renderSheet()
expect(screen.getByRole('dialog', { name: 'Change category' })).toBeInTheDocument()
expect(screen.getByText('3 selected')).toBeInTheDocument()
})
it('FE-MOB-PBULK-002: lists every category plus the clear-category row', () => {
renderSheet()
expect(screen.getByRole('button', { name: 'Sights' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Food' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'No Category' })).toBeInTheDocument()
})
it('FE-MOB-PBULK-003: hands the picked category id back', () => {
const { onPick } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Sights' }))
expect(onPick).toHaveBeenCalledWith(1)
})
it('FE-MOB-PBULK-004: the clear row picks null', () => {
const { onPick } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'No Category' }))
expect(onPick).toHaveBeenCalledWith(null)
})
it('FE-MOB-PBULK-005: the close button dismisses without picking', () => {
const { onPick, onClose } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(onClose).toHaveBeenCalled()
expect(onPick).not.toHaveBeenCalled()
})
it('FE-MOB-PBULK-006: without categories only the clear row remains and it loses its divider', () => {
renderSheet({ categories: [], count: 1 })
expect(screen.getByText('1 selected')).toBeInTheDocument()
const clear = screen.getByRole('button', { name: 'No Category' })
expect(clear.className).not.toContain('border-t')
expect(screen.queryByRole('button', { name: 'Sights' })).not.toBeInTheDocument()
})
it('FE-MOB-PBULK-007: stays unmounted while closed', () => {
renderSheet({ open: false })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,204 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Collection, CollectionListResponse } from '@trek/shared'
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
import MPlacesSaveToCollectionSheet from '../../../../src/mobile/screens/trip/places/MPlacesSaveToCollectionSheet'
import { collectionsApi } from '../../../../src/api/collections'
// FE-MOB-PSAVE-001 to FE-MOB-PSAVE-015
const listResponse = (collections: Collection[]): CollectionListResponse => ({ collections, incomingInvites: [] })
function list(over: Partial<Collection>): Collection {
return { id: 1, owner_id: 7, name: 'List', color: null, place_count: 0, ...over } as Collection
}
const FAVORITES = list({ id: 1, name: 'Favorites', color: '#ff0000', place_count: 4 })
const WISHLIST = list({ id: 2, name: 'Wishlist' })
let addToast: ReturnType<typeof vi.fn>
function renderSheet(props: Partial<Parameters<typeof MPlacesSaveToCollectionSheet>[0]> = {}) {
const onClose = vi.fn()
const onDone = vi.fn()
const view = render(
<MPlacesSaveToCollectionSheet
open
tripId={5}
placeIds={[11, 12]}
onClose={onClose}
onDone={onDone}
{...props}
/>,
)
return { ...view, onClose, onDone }
}
describe('MPlacesSaveToCollectionSheet', () => {
beforeEach(() => {
addToast = vi.fn()
window.__addToast = addToast as unknown as typeof window.__addToast
})
afterEach(() => {
vi.restoreAllMocks()
delete window.__addToast
})
it('FE-MOB-PSAVE-001: lists the collections the user owns with their place counts', async () => {
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES, WISHLIST]))
renderSheet()
expect(await screen.findByText('Favorites')).toBeInTheDocument()
expect(screen.getByRole('dialog', { name: 'Save 2 to a list' })).toBeInTheDocument()
expect(screen.getByText('4 places')).toBeInTheDocument()
expect(screen.getByText('0 places')).toBeInTheDocument()
expect(screen.getByText('Duplicates are skipped automatically')).toBeInTheDocument()
})
it('FE-MOB-PSAVE-002: drops lists shared with the user — they cannot be written to', async () => {
// A list the server sent without a count must still render a countable row.
const untracked = { id: 4, owner_id: 7, name: 'Untracked' } as Collection
vi.spyOn(collectionsApi, 'list').mockResolvedValue(
listResponse([FAVORITES, list({ id: 3, name: 'Shared trip ideas', is_owner: false }), untracked]),
)
renderSheet()
expect(await screen.findByText('Favorites')).toBeInTheDocument()
expect(screen.queryByText('Shared trip ideas')).not.toBeInTheDocument()
expect(screen.getByText('Untracked')).toBeInTheDocument()
expect(screen.getAllByText('0 places')).toHaveLength(1)
})
it('FE-MOB-PSAVE-003: shows the spinner until the lists arrive', async () => {
let resolve!: (v: CollectionListResponse) => void
vi.spyOn(collectionsApi, 'list').mockReturnValue(new Promise(r => { resolve = r }))
renderSheet()
expect(document.querySelector('.animate-spin')).not.toBeNull()
expect(screen.queryByText('You have no lists yet')).not.toBeInTheDocument()
resolve(listResponse([FAVORITES]))
expect(await screen.findByText('Favorites')).toBeInTheDocument()
})
it('FE-MOB-PSAVE-004: reports the empty state when the response carries no collections', async () => {
vi.spyOn(collectionsApi, 'list').mockResolvedValue({ incomingInvites: [] } as unknown as CollectionListResponse)
renderSheet()
expect(await screen.findByText('You have no lists yet')).toBeInTheDocument()
})
it('FE-MOB-PSAVE-005: a failing request falls back to the empty state instead of breaking', async () => {
vi.spyOn(collectionsApi, 'list').mockRejectedValue(new Error('offline'))
renderSheet()
expect(await screen.findByText('You have no lists yet')).toBeInTheDocument()
})
it('FE-MOB-PSAVE-006: requests nothing while the sheet is closed', () => {
const spy = vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([]))
renderSheet({ open: false })
expect(spy).not.toHaveBeenCalled()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-PSAVE-007: the search box appears above five lists and filters by name', async () => {
const many = [1, 2, 3, 4, 5, 6].map(id => list({ id, name: `List ${id}` }))
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse(many))
renderSheet()
const search = await screen.findByPlaceholderText('Search')
fireEvent.change(search, { target: { value: 'list 4' } })
expect(screen.getByText('List 4')).toBeInTheDocument()
expect(screen.queryByText('List 5')).not.toBeInTheDocument()
fireEvent.change(search, { target: { value: 'nope' } })
expect(screen.getByText('You have no lists yet')).toBeInTheDocument()
})
it('FE-MOB-PSAVE-008: five lists or fewer need no search box', async () => {
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES, WISHLIST]))
renderSheet()
await screen.findByText('Favorites')
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
})
it('FE-MOB-PSAVE-009: picking a list copies every selected place and reports the count', async () => {
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES]))
const save = vi.spyOn(collectionsApi, 'saveFromTripMany').mockResolvedValue({ copied: 2, skipped: [] })
const { onClose, onDone } = renderSheet()
fireEvent.click(await screen.findByText('Favorites'))
await waitFor(() => expect(onDone).toHaveBeenCalled())
expect(save).toHaveBeenCalledWith(1, 5, [11, 12])
expect(addToast).toHaveBeenCalledWith('Saved 2 to Favorites', 'success', undefined)
expect(onClose).toHaveBeenCalled()
})
it('FE-MOB-PSAVE-010: duplicates the server skipped are reported separately', async () => {
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES]))
vi.spyOn(collectionsApi, 'saveFromTripMany').mockResolvedValue({
copied: 1, skipped: [{ id: 12, name: 'Louvre' }],
})
renderSheet()
fireEvent.click(await screen.findByText('Favorites'))
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Skipped 1 duplicates', 'info', undefined))
expect(addToast).toHaveBeenCalledWith('Saved 1 to Favorites', 'success', undefined)
})
it('FE-MOB-PSAVE-011: a no-op save says so instead of staying silent', async () => {
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES]))
vi.spyOn(collectionsApi, 'saveFromTripMany').mockResolvedValue({ copied: 0, skipped: [] })
renderSheet()
fireEvent.click(await screen.findByText('Favorites'))
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Nothing to copy', 'info', undefined))
})
it('FE-MOB-PSAVE-012: a failed save surfaces the server message and keeps the sheet open', async () => {
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES]))
vi.spyOn(collectionsApi, 'saveFromTripMany').mockRejectedValue({
response: { data: { error: 'List is full' } },
})
const { onClose, onDone } = renderSheet()
fireEvent.click(await screen.findByText('Favorites'))
await waitFor(() => expect(addToast).toHaveBeenCalledWith('List is full', 'error', undefined))
expect(onDone).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
})
it('FE-MOB-PSAVE-013: a second tap is ignored while a save is still running', async () => {
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES, WISHLIST]))
let resolve!: (v: { copied: number; skipped: { id: number; name: string }[] }) => void
const save = vi.spyOn(collectionsApi, 'saveFromTripMany')
.mockReturnValue(new Promise(r => { resolve = r }))
const { onDone } = renderSheet()
fireEvent.click(await screen.findByText('Favorites'))
fireEvent.click(screen.getByText('Wishlist'))
expect(save).toHaveBeenCalledTimes(1)
resolve({ copied: 2, skipped: [] })
await waitFor(() => expect(onDone).toHaveBeenCalled())
})
it('FE-MOB-PSAVE-014: a response that lands after the sheet is gone is dropped', async () => {
let resolve!: (v: CollectionListResponse) => void
const spy = vi.spyOn(collectionsApi, 'list').mockReturnValue(new Promise(r => { resolve = r }))
const first = renderSheet()
first.unmount()
resolve(listResponse([FAVORITES]))
await Promise.resolve()
expect(screen.queryByText('Favorites')).not.toBeInTheDocument()
let reject!: (e: Error) => void
spy.mockReturnValue(new Promise((_, r) => { reject = r }))
const second = renderSheet()
second.unmount()
reject(new Error('offline'))
await Promise.resolve()
expect(screen.queryByText('You have no lists yet')).not.toBeInTheDocument()
})
it('FE-MOB-PSAVE-015: an empty selection never reaches the server, and close hands back', async () => {
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES]))
const save = vi.spyOn(collectionsApi, 'saveFromTripMany')
const { onClose } = renderSheet({ placeIds: [] })
fireEvent.click(await screen.findByText('Favorites'))
expect(save).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(onClose).toHaveBeenCalled()
})
})
@@ -0,0 +1,560 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, within } from '../../../helpers/render'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import type { PluginDaySchedule } from '../../../../src/components/Plugins/PluginDaySchedule'
import type { MPlanTimelineController } from '../../../../src/mobile/screens/trip/plan/useMPlanTimeline'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import type { PlanRow, TransitMeta, TransportEntry } from '../../../../src/mobile/screens/trip/plan/planTimelineModel'
import type { MergedItem } from '../../../../src/utils/dayMerge'
import type { Assignment, Day, DayNote, Place, RouteSegment } from '../../../../src/types'
import MPlanTimeline from '../../../../src/mobile/screens/trip/plan/MPlanTimeline'
// FE-MOB-PLTL-001 to FE-MOB-PLTL-038
const mocks = vi.hoisted(() => ({
tl: {} as Record<string, unknown>,
schedule: {} as PluginDaySchedule,
}))
vi.mock('../../../../src/mobile/screens/trip/plan/useMPlanTimeline', () => ({
useMPlanTimeline: () => mocks.tl,
}))
vi.mock('../../../../src/components/Plugins/PluginDaySchedule', async importOriginal => {
const actual = await importOriginal<typeof import('../../../../src/components/Plugins/PluginDaySchedule')>()
return { ...actual, usePluginDaySchedule: () => mocks.schedule }
})
const DAY = { id: 2, trip_id: 1, day_number: 2, date: '2026-05-02', title: 'Tokyo → Kyoto' } as unknown as Day
const MUSEUM = {
id: 11, day_id: 2, place_id: 101, order_index: 0,
place: { id: 101, name: 'Museum', place_time: '09:30', address: 'Museum Rd 1', lat: 35.71, lng: 139.79 },
} as unknown as Assignment
const PARK = {
id: 12, day_id: 2, place_id: 102, order_index: 1,
place: { id: 102, name: 'Ueno Park', lat: 35.72, lng: 139.77 },
} as unknown as Assignment
const NOTE = {
id: 41, day_id: 2, text: 'Buy museum tickets', time: '08:00', icon: 'Ticket', sort_order: 0,
} as unknown as DayNote
const FLIGHT = {
id: 21, type: 'flight', title: 'LH 714 to Tokyo', day_id: 2,
reservation_time: '2026-05-02T08:15',
} as unknown as TransportEntry
const TRANSIT_RES = {
id: 22, type: 'transit', title: 'Shibuya → Asakusa', day_id: 2,
reservation_time: '2026-05-02T10:05',
} as unknown as TransportEntry
const TRANSIT: TransitMeta = {
legs: [{
mode: 'SUBWAY', line: 'G', line_color: '#FF9500', duration: 900,
from: { name: 'Shibuya Sta.' }, to: { name: 'Asakusa Sta.' },
}],
}
const SEG: RouteSegment = {
mid: [35.7, 139.7], from: [35.71, 139.79], to: [35.72, 139.77],
distance: 2100, duration: 600,
walkingText: '26 min', drivingText: '7 min', distanceText: '2.1 km',
}
const M_PLACE: MergedItem = { type: 'place', sortKey: 0, data: MUSEUM }
const M_FLIGHT: MergedItem = { type: 'transport', sortKey: 1, data: FLIGHT }
const M_TRANSIT: MergedItem = { type: 'transport', sortKey: 2, data: TRANSIT_RES }
const M_NOTE: MergedItem = { type: 'note', sortKey: 3, data: NOTE }
const M_PLACE2: MergedItem = { type: 'place', sortKey: 4, data: PARK }
const MERGED = [M_PLACE, M_FLIGHT, M_TRANSIT, M_NOTE, M_PLACE2]
const ROWS: PlanRow[] = [
{ key: 'pl-11', kind: 'place', item: M_PLACE, assignment: MUSEUM, linkedRes: null },
{ key: 'conn-pl-11', kind: 'conn', seg: SEG, assignmentId: 11 },
{ key: 'tr-21', kind: 'transport', item: M_FLIGHT, res: FLIGHT },
{ key: 'tr-22', kind: 'transit', item: M_TRANSIT, res: TRANSIT_RES, transit: TRANSIT },
{ key: 'note-41', kind: 'note', item: M_NOTE, note: NOTE },
{ key: 'pl-12', kind: 'place', item: M_PLACE2, assignment: PARK, linkedRes: null },
{ key: 'conn-orphan', kind: 'conn', seg: { ...SEG, mode: 'walking' } },
]
const EMPTY_SCHEDULE: PluginDaySchedule = {
byAssignment: {}, byReservation: {}, byPosition: {}, minutesByDay: {},
}
function buildTl(over: Record<string, unknown> = {}): MPlanTimelineController {
return {
day: DAY,
rows: ROWS,
merged: MERGED,
hotelLegs: { top: null, bottom: null },
hotelChips: [],
weather: null,
weatherTemp: null,
upNext: { assignment: MUSEUM, minutesUntil: 45 },
language: 'en',
timeFormat: '24h',
openTransitKeys: new Set<string>(),
toggleTransit: vi.fn(),
moveRow: vi.fn(),
removeAssignment: vi.fn(),
editAssignment: vi.fn(),
editTransport: vi.fn(),
openTransitJourney: vi.fn(),
addPlace: vi.fn(),
addBooking: vi.fn(),
addTransport: vi.fn(),
optimize: vi.fn(async () => undefined),
exportGoogleMaps: vi.fn(),
renameDay: vi.fn(),
fullPlaceOf: vi.fn(() => undefined as Place | undefined),
routeModeOptions: [
{ key: 'driving', label: 'Driving' },
{ key: 'walking', label: 'Walking' },
{ key: 'plugin:ev/fastest', label: 'EV fastest' },
],
setLegMode: vi.fn(),
...over,
} as unknown as MPlanTimelineController
}
/** The scrollable timeline card — its inline top offset encodes the layout mode. */
const card = (container: HTMLElement) => container.querySelector('.overflow-y-auto') as HTMLElement
function renderTimeline(
tlOver: Record<string, unknown> = {},
plannerOver: Partial<TripPlanner> = {},
shellOver: Partial<MTripShellApi> = {},
) {
mocks.tl = buildTl(tlOver) as unknown as Record<string, unknown>
const planner = buildPlanner(plannerOver)
const shell = buildShell(shellOver)
return { planner, shell, ...render(<MPlanTimeline planner={planner} shell={shell} />) }
}
describe('MPlanTimeline', () => {
beforeEach(() => {
mocks.schedule = EMPTY_SCHEDULE
})
describe('go mode — up next card', () => {
it('FE-MOB-PLTL-001: shows the next stop with its time, subtitle and countdown', () => {
renderTimeline()
expect(screen.getByText('mobileTrip.upNext')).toBeInTheDocument()
expect(screen.getByText('mobileTrip.inCountdown:transit.min:45')).toBeInTheDocument()
expect(screen.getAllByText('Museum').length).toBeGreaterThan(0)
expect(screen.getAllByText('09:30').length).toBeGreaterThan(0)
expect(screen.getAllByText('Museum Rd 1').length).toBeGreaterThan(0)
})
it('FE-MOB-PLTL-002: tapping the card selects the place and its assignment', () => {
const { planner } = renderTimeline()
fireEvent.click(screen.getByText('mobileTrip.upNext'))
expect(planner.handlePlaceClick).toHaveBeenCalledWith(101, 11)
})
it('FE-MOB-PLTL-003: hides the countdown, time chip and subtitle when the stop has none', () => {
const bare = { ...MUSEUM, place: { id: 101, name: 'Museum' } } as unknown as Assignment
renderTimeline({ upNext: { assignment: bare, minutesUntil: null }, rows: [], merged: [] })
expect(screen.queryByText(/mobileTrip.inCountdown/)).not.toBeInTheDocument()
expect(screen.queryByText('09:30')).not.toBeInTheDocument()
expect(screen.queryByText('Museum Rd 1')).not.toBeInTheDocument()
})
it('FE-MOB-PLTL-004: collapses the reserved space when the day has no up next', () => {
const { container, unmount } = renderTimeline()
expect(card(container).style.top).toContain('216px')
unmount()
const empty = renderTimeline({ upNext: null, rows: [], merged: [] })
expect(screen.queryByText('mobileTrip.upNext')).not.toBeInTheDocument()
expect(card(empty.container).style.top).toContain('102px')
})
})
describe('timeline header', () => {
it('FE-MOB-PLTL-005: renders the accommodation chips and the weather chip', () => {
renderTimeline({
hotelChips: [
{ key: 'out-1', variant: 'checkout', name: 'Hotel Sacher', time: '11:00:00' },
{ key: 'in-2', variant: 'checkin', name: 'Ryokan Kyoto', time: '15:00:00' },
{ key: 'stay-3', variant: 'stay', name: 'Capsule Tokyo', time: null },
],
weather: { main: 'Rain', temp: 17 },
weatherTemp: 17,
})
expect(screen.getByText(/Hotel Sacher/)).toHaveTextContent('Hotel Sacher · 11:00')
expect(screen.getByText(/Ryokan Kyoto/)).toHaveTextContent('Ryokan Kyoto · 15:00')
expect(screen.getByText('Capsule Tokyo')).toBeInTheDocument()
expect(screen.getByText('17°')).toBeInTheDocument()
})
it('FE-MOB-PLTL-006: a chip opens the day sheet', () => {
const { shell } = renderTimeline({
hotelChips: [{ key: 'stay-3', variant: 'stay', name: 'Capsule Tokyo', time: null }],
})
fireEvent.click(screen.getByText('Capsule Tokyo'))
expect(shell.openSheet).toHaveBeenCalledWith('day', { dayId: 2 })
})
it('FE-MOB-PLTL-007: is left out entirely without chips and without weather', () => {
const { container } = renderTimeline()
expect(container.querySelector('.border-b')).toBeNull()
})
it('FE-MOB-PLTL-008: draws the hotel bookend legs above and below the rows', () => {
renderTimeline({
hotelLegs: {
top: { seg: SEG, name: 'Hotel Sacher' },
bottom: { seg: SEG, name: 'Ryokan Kyoto' },
},
})
expect(screen.getByText('Hotel Sacher')).toBeInTheDocument()
expect(screen.getByText('Ryokan Kyoto')).toBeInTheDocument()
})
})
describe('rows', () => {
it('FE-MOB-PLTL-009: renders one row per kind', () => {
renderTimeline()
expect(screen.getAllByText('Museum').length).toBeGreaterThan(0)
expect(screen.getByText('Ueno Park')).toBeInTheDocument()
expect(screen.getByText('LH 714 to Tokyo')).toBeInTheDocument()
expect(screen.getByText('Shibuya → Asakusa')).toBeInTheDocument()
expect(screen.getByText('Buy museum tickets')).toBeInTheDocument()
expect(screen.getByText('7 min')).toBeInTheDocument()
})
it('FE-MOB-PLTL-010: a place row selects its place', () => {
const { planner } = renderTimeline()
fireEvent.click(screen.getByText('Ueno Park'))
expect(planner.handlePlaceClick).toHaveBeenCalledWith(102, 12)
})
it('FE-MOB-PLTL-011: a place row without a place still reports the assignment', () => {
const orphan = { id: 13, day_id: 2, place_id: 103, order_index: 2, place: null } as unknown as Assignment
const rows: PlanRow[] = [{ key: 'pl-13', kind: 'place', item: M_PLACE, assignment: orphan, linkedRes: null }]
const { planner, container } = renderTimeline({ rows })
fireEvent.click(container.querySelector('.cursor-pointer.items-center') as HTMLElement)
expect(planner.handlePlaceClick).toHaveBeenCalledWith(null, 13)
})
it('FE-MOB-PLTL-012: a transport row opens the transport sheet in go mode', () => {
const { shell } = renderTimeline()
fireEvent.click(screen.getByText('LH 714 to Tokyo'))
expect(shell.openSheet).toHaveBeenCalledWith('transport', { reservationId: 21 })
})
it('FE-MOB-PLTL-013: a transport row opens the editor in edit mode', () => {
const { shell } = renderTimeline({}, {}, { mode: 'edit' })
fireEvent.click(screen.getByText('LH 714 to Tokyo'))
expect(mocks.tl.editTransport).toHaveBeenCalledWith(FLIGHT)
expect(shell.openSheet).not.toHaveBeenCalledWith('transport', { reservationId: 21 })
})
it('FE-MOB-PLTL-014: a transit row toggles by its row key and renders its legs when open', () => {
const { unmount } = renderTimeline()
fireEvent.click(screen.getByText('Shibuya → Asakusa'))
expect(mocks.tl.toggleTransit).toHaveBeenCalledWith('tr-22')
expect(screen.queryByText('Shibuya Sta. → Asakusa Sta.')).not.toBeInTheDocument()
unmount()
renderTimeline({ openTransitKeys: new Set(['tr-22']) })
expect(screen.getByText('Shibuya Sta. → Asakusa Sta.')).toBeInTheDocument()
expect(screen.getByText('transit.min:15')).toBeInTheDocument()
})
it('FE-MOB-PLTL-015: a note row opens the note sheet with its note in edit mode', () => {
const { shell } = renderTimeline({}, {}, { mode: 'edit' })
fireEvent.click(screen.getByText('Buy museum tickets'))
expect(shell.openSheet).toHaveBeenCalledWith('note', { dayId: 2, note: NOTE })
})
it('FE-MOB-PLTL-016: the reorder arrows move the merged item they belong to', () => {
renderTimeline({}, {}, { mode: 'edit' })
const ups = screen.getAllByLabelText('dayplan.moveUp')
const downs = screen.getAllByLabelText('dayplan.moveDown')
// one stack per reorderable row: place, transport, transit, note, place
expect(ups).toHaveLength(5)
expect(ups[0]).toBeDisabled()
expect(downs[4]).toBeDisabled()
fireEvent.click(downs[0])
fireEvent.click(ups[4])
expect(mocks.tl.moveRow).toHaveBeenNthCalledWith(1, M_PLACE, 'down')
expect(mocks.tl.moveRow).toHaveBeenNthCalledWith(2, M_PLACE2, 'up')
})
})
describe('per-segment travel mode', () => {
it('FE-MOB-PLTL-017: a connector with an origin assignment opens the mode menu', () => {
renderTimeline()
fireEvent.click(screen.getByRole('button'))
expect(screen.getByText('Driving')).toBeInTheDocument()
expect(screen.getByText('Walking')).toBeInTheDocument()
expect(screen.getByText('EV fastest')).toBeInTheDocument()
expect(screen.getByText('dayplan.transportMode.useDefault')).toBeInTheDocument()
})
it('FE-MOB-PLTL-018: picking a mode sets it on the origin assignment', () => {
renderTimeline()
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByText('Walking'))
expect(mocks.tl.setLegMode).toHaveBeenCalledWith(11, 'walking')
})
it('FE-MOB-PLTL-019: the default entry clears the override', () => {
renderTimeline()
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByText('dayplan.transportMode.useDefault'))
expect(mocks.tl.setLegMode).toHaveBeenCalledWith(11, null)
})
it('FE-MOB-PLTL-020: read-only members get no tappable connectors', () => {
const { container } = renderTimeline({}, { can: vi.fn(() => false) })
expect(container.querySelector('button')).toBeNull()
})
})
describe('plugin day schedule', () => {
it('FE-MOB-PLTL-021: slots contributions under their anchors and at the day edges', () => {
mocks.schedule = {
byAssignment: { 2: { 11: [{ pluginId: 'ev', id: 'a1', dayId: 2, assignmentId: 11, minutes: 35, label: 'Charging stop', tone: 'success' }] } },
byReservation: {
2: {
21: [{ pluginId: 'ev', id: 'r1', dayId: 2, reservationId: 21, minutes: 45, label: 'Security', tone: 'warn' }],
22: [{ pluginId: 'ev', id: 'r2', dayId: 2, reservationId: 22, label: 'Ticket check', tone: 'default' }],
},
},
byPosition: {
2: {
start: [{ pluginId: 'ev', id: 's1', dayId: 2, position: 'start', minutes: 10, label: 'Morning prep', tone: 'default' }],
end: [{ pluginId: 'ev', id: 'e1', dayId: 2, position: 'end', minutes: 20, label: 'Evening wrap', tone: 'danger' }],
},
},
minutesByDay: { 2: 110 },
}
renderTimeline()
expect(screen.getByText('Charging stop')).toBeInTheDocument()
expect(screen.getByText('Security')).toBeInTheDocument()
expect(screen.getByText('Ticket check')).toBeInTheDocument()
expect(screen.getByText('Morning prep')).toBeInTheDocument()
expect(screen.getByText('Evening wrap')).toBeInTheDocument()
})
it('FE-MOB-PLTL-022: contributes nothing while no day is selected', () => {
mocks.schedule = {
byAssignment: { 2: { 11: [{ pluginId: 'ev', id: 'a1', dayId: 2, assignmentId: 11, minutes: 35, label: 'Charging stop', tone: 'success' }] } },
byReservation: {},
byPosition: { 2: { start: [{ pluginId: 'ev', id: 's1', dayId: 2, position: 'start', label: 'Morning prep', tone: 'default' }], end: [] } },
minutesByDay: { 2: 35 },
}
const rows: PlanRow[] = [{ key: 'pl-11', kind: 'place', item: M_PLACE, assignment: MUSEUM, linkedRes: null }]
renderTimeline({ day: undefined, rows })
expect(screen.queryByText('Charging stop')).not.toBeInTheDocument()
expect(screen.queryByText('Morning prep')).not.toBeInTheDocument()
})
})
describe('empty day', () => {
it('FE-MOB-PLTL-023: shows the mascot empty state in go mode', () => {
const { container } = renderTimeline({ rows: [], merged: [], upNext: null })
expect(screen.getByText('dayplan.emptyDay')).toBeInTheDocument()
expect(container.querySelector('.trek--guide')).toBeTruthy()
})
it('FE-MOB-PLTL-024: replaces it with the action grid in edit mode', () => {
renderTimeline({ rows: [], merged: [], upNext: null }, {}, { mode: 'edit' })
expect(screen.queryByText('dayplan.emptyDay')).not.toBeInTheDocument()
expect(screen.getByText('mobileTrip.addPlaceShort')).toBeInTheDocument()
})
})
describe('edit mode', () => {
it('FE-MOB-PLTL-025: reserves the header space and hides the up-next card', () => {
const { container } = renderTimeline({}, {}, { mode: 'edit' })
expect(screen.queryByText('mobileTrip.upNext')).not.toBeInTheDocument()
expect(card(container).style.top).toContain('140px')
})
it('FE-MOB-PLTL-026: splits the day title into city pills', () => {
renderTimeline({}, {}, { mode: 'edit' })
expect(screen.getByText('Tokyo')).toBeInTheDocument()
expect(screen.getByText('Kyoto')).toBeInTheDocument()
})
it('FE-MOB-PLTL-027: falls back to the day number when the day has no title', () => {
renderTimeline({ day: { ...DAY, title: null } }, {}, { mode: 'edit' })
expect(screen.getByText('planner.dayN:2')).toBeInTheDocument()
})
it('FE-MOB-PLTL-028: the pencil renames the day and Enter commits the change', () => {
renderTimeline({}, {}, { mode: 'edit' })
fireEvent.click(screen.getByLabelText('mobileTrip.renameDay'))
const input = screen.getByPlaceholderText('mobileTrip.dayTitlePlaceholder') as HTMLInputElement
expect(input.value).toBe('Tokyo → Kyoto')
fireEvent.change(input, { target: { value: 'Osaka' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(mocks.tl.renameDay).toHaveBeenCalledWith('Osaka')
// committing closes the field again — the pills come back from the store
expect(screen.queryByPlaceholderText('mobileTrip.dayTitlePlaceholder')).not.toBeInTheDocument()
expect(screen.getByText('Tokyo')).toBeInTheDocument()
})
it('FE-MOB-PLTL-029: Escape leaves the title untouched', () => {
renderTimeline({}, {}, { mode: 'edit' })
fireEvent.click(screen.getByLabelText('mobileTrip.renameDay'))
const input = screen.getByPlaceholderText('mobileTrip.dayTitlePlaceholder')
fireEvent.change(input, { target: { value: 'Osaka' } })
fireEvent.keyDown(input, { key: 'Escape' })
expect(mocks.tl.renameDay).not.toHaveBeenCalled()
expect(screen.getByText('Tokyo')).toBeInTheDocument()
})
it('FE-MOB-PLTL-030: blurring an unchanged draft does not rename', () => {
renderTimeline({}, {}, { mode: 'edit' })
fireEvent.click(screen.getByLabelText('mobileTrip.renameDay'))
fireEvent.blur(screen.getByPlaceholderText('mobileTrip.dayTitlePlaceholder'))
expect(mocks.tl.renameDay).not.toHaveBeenCalled()
})
it('FE-MOB-PLTL-031: the calendar button opens the day reorder sheet', () => {
const { shell } = renderTimeline({}, {}, { mode: 'edit' })
fireEvent.click(screen.getByLabelText('dayplan.reorderDays'))
expect(shell.openSheet).toHaveBeenCalledWith('days')
})
it('FE-MOB-PLTL-032: undo is disabled until there is something to undo', () => {
const { unmount } = renderTimeline({}, {}, { mode: 'edit' })
expect(screen.getByText('undo.button').closest('button')).toBeDisabled()
unmount()
const { planner } = renderTimeline(
{}, { canUndo: true, lastActionLabel: 'undo.reorder' }, { mode: 'edit' },
)
const button = screen.getByText('undo.button').closest('button') as HTMLButtonElement
expect(button).toHaveAttribute('title', 'undo.tooltip:undo.reorder')
fireEvent.click(button)
expect(planner.handleUndo).toHaveBeenCalledTimes(1)
})
it('FE-MOB-PLTL-033: every action tile fires its planner action', () => {
const { shell } = renderTimeline({}, {}, { mode: 'edit' })
fireEvent.click(screen.getByText('mobileTrip.addPlaceShort'))
fireEvent.click(screen.getByText('mobileTrip.addNoteShort'))
fireEvent.click(screen.getByText('mobileTrip.addBookingShort'))
fireEvent.click(screen.getByText('mobileTrip.addTransportShort'))
fireEvent.click(screen.getByText('dayplan.optimize'))
fireEvent.click(screen.getByText('mobileTrip.googleMaps'))
expect(mocks.tl.addPlace).toHaveBeenCalledTimes(1)
expect(shell.openSheet).toHaveBeenCalledWith('note', { dayId: 2 })
expect(mocks.tl.addBooking).toHaveBeenCalledTimes(1)
expect(mocks.tl.addTransport).toHaveBeenCalledTimes(1)
expect(mocks.tl.optimize).toHaveBeenCalledTimes(1)
expect(mocks.tl.exportGoogleMaps).toHaveBeenCalledTimes(1)
})
it('FE-MOB-PLTL-034: the note tile is inert while no day is selected', () => {
const { shell } = renderTimeline({ day: undefined, rows: [], merged: [] }, {}, { mode: 'edit' })
fireEvent.click(screen.getByText('mobileTrip.addNoteShort'))
expect(shell.openSheet).not.toHaveBeenCalled()
})
it('FE-MOB-PLTL-038: renaming starts from an empty draft while no day is loaded', () => {
renderTimeline({ day: undefined, rows: [], merged: [] }, {}, { mode: 'edit' })
fireEvent.click(screen.getByLabelText('mobileTrip.renameDay'))
const input = screen.getByPlaceholderText('mobileTrip.dayTitlePlaceholder') as HTMLInputElement
expect(input.value).toBe('')
fireEvent.change(input, { target: { value: 'Nara' } })
fireEvent.blur(input)
expect(mocks.tl.renameDay).toHaveBeenCalledWith('Nara')
})
it('FE-MOB-PLTL-035: read-only members never enter edit mode', () => {
renderTimeline({}, { can: vi.fn(() => false) }, { mode: 'edit' })
expect(screen.queryByText('mobileTrip.addPlaceShort')).not.toBeInTheDocument()
expect(screen.getByText('mobileTrip.upNext')).toBeInTheDocument()
})
it('FE-MOB-PLTL-036: place rows expose edit and remove in edit mode', () => {
renderTimeline({}, {}, { mode: 'edit' })
const edits = screen.getAllByLabelText('common.edit')
fireEvent.click(edits[0])
fireEvent.click(screen.getAllByLabelText('planner.removeFromDay')[0])
expect(mocks.tl.editAssignment).toHaveBeenCalledWith(MUSEUM)
expect(mocks.tl.removeAssignment).toHaveBeenCalledWith(MUSEUM)
})
it('FE-MOB-PLTL-037: the transit row opens the journey view from its edit circle', () => {
renderTimeline({}, {}, { mode: 'edit' })
const transitCard = screen.getByText('Shibuya → Asakusa').closest('.mt-1\\.5') as HTMLElement
fireEvent.click(within(transitCard).getByLabelText('common.edit'))
expect(mocks.tl.openTransitJourney).toHaveBeenCalledWith(TRANSIT_RES)
})
})
})
@@ -0,0 +1,481 @@
import { describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '../../../helpers/render'
import {
ConnRow, HotelConnRow, NoteRow, PlaceRow, PlanScheduleRow, ReorderStack, TransitRow, TransportRow,
} from '../../../../src/mobile/screens/trip/plan/MPlanTimelineRows'
import type { TransitMeta, TransportEntry } from '../../../../src/mobile/screens/trip/plan/planTimelineModel'
import type { PluginDayScheduleItem } from '../../../../src/api/client'
import type { Assignment, DayNote, Place, Reservation, RouteSegment, TranslationFn } from '../../../../src/types'
// FE-MOB-PLROW-001 to FE-MOB-PLROW-038
// Same echo strategy as tests/helpers/mobileTrip: assertions stay on keys, not copy.
const t: TranslationFn = (key, params) =>
params ? `${key}:${Object.values(params).join(',')}` : key
const chrome = (editing = false) => ({ editing, t, language: 'en', timeFormat: '24h' })
const REORDER = <span data-testid="reorder">reorder</span>
function assignment(over: Record<string, unknown> = {}): Assignment {
return {
id: 11,
day_id: 2,
place_id: 101,
order_index: 0,
place: {
id: 101,
name: 'Museum',
place_time: '09:30',
address: 'Museum Rd 1',
lat: 35.71,
lng: 139.79,
category: { icon: 'Landmark', color: '#a855f7' },
},
...over,
} as unknown as Assignment
}
const SEG: RouteSegment = {
mid: [35.7, 139.7], from: [35.71, 139.79], to: [35.65, 139.7],
distance: 5400, duration: 900,
walkingText: '1 h 5 min', drivingText: '14 min', distanceText: '5.4 km',
}
describe('ReorderStack', () => {
it('FE-MOB-PLROW-001: renders both arrows with their labels and fires them', () => {
const onUp = vi.fn()
const onDown = vi.fn()
render(<ReorderStack onUp={onUp} onDown={onDown} canUp canDown t={t} />)
fireEvent.click(screen.getByLabelText('dayplan.moveUp'))
fireEvent.click(screen.getByLabelText('dayplan.moveDown'))
expect(onUp).toHaveBeenCalledTimes(1)
expect(onDown).toHaveBeenCalledTimes(1)
})
it('FE-MOB-PLROW-002: disables the arrow that would fall off the list', () => {
render(<ReorderStack onUp={vi.fn()} onDown={vi.fn()} canUp={false} canDown={false} t={t} />)
expect(screen.getByLabelText('dayplan.moveUp')).toBeDisabled()
expect(screen.getByLabelText('dayplan.moveDown')).toBeDisabled()
})
it('FE-MOB-PLROW-003: stops the click from opening the row it sits in', () => {
const rowClick = vi.fn()
const onUp = vi.fn()
render(
<div onClick={rowClick}>
<ReorderStack onUp={onUp} onDown={vi.fn()} canUp canDown t={t} />
</div>,
)
fireEvent.click(screen.getByLabelText('dayplan.moveUp'))
expect(onUp).toHaveBeenCalled()
expect(rowClick).not.toHaveBeenCalled()
})
})
describe('PlaceRow', () => {
const props = {
assignment: assignment(),
fullPlace: undefined,
linkedRes: null,
chrome: chrome(),
reorder: REORDER,
onOpen: vi.fn(),
onEdit: vi.fn(),
onRemove: vi.fn(),
}
it('FE-MOB-PLROW-004: shows name, formatted time and the address subtitle', () => {
render(<PlaceRow {...props} onOpen={vi.fn()} />)
expect(screen.getByText('Museum')).toBeInTheDocument()
expect(screen.getByText('09:30')).toBeInTheDocument()
expect(screen.getByText('Museum Rd 1')).toBeInTheDocument()
})
it('FE-MOB-PLROW-005: falls back to the description when there is no address', () => {
const a = assignment({
place: { id: 101, name: 'Museum', address: null, description: 'Ukiyo-e wing' },
})
render(<PlaceRow {...props} assignment={a} />)
expect(screen.getByText('Ukiyo-e wing')).toBeInTheDocument()
})
it('FE-MOB-PLROW-006: drops the meta line entirely when there is neither time nor subtitle', () => {
const a = assignment({ place: { id: 101, name: 'Museum' } })
const { container } = render(<PlaceRow {...props} assignment={a} />)
expect(container.textContent).toBe('Museum')
})
it('FE-MOB-PLROW-007: a linked booking replaces the subtitle and adds the booking badge', () => {
const linkedRes = { id: 31, status: 'confirmed', confirmation_number: 'X9K' } as unknown as Reservation
render(<PlaceRow {...props} linkedRes={linkedRes} />)
expect(screen.getByText('dayplan.confirmed · #X9K')).toBeInTheDocument()
expect(screen.getByText('mobileTrip.resBadge')).toBeInTheDocument()
})
it('FE-MOB-PLROW-008: a pending booking without a number shows just the status', () => {
const linkedRes = { id: 31, status: 'pending', confirmation_number: null } as unknown as Reservation
render(<PlaceRow {...props} linkedRes={linkedRes} />)
expect(screen.getByText('dayplan.pendingRes')).toBeInTheDocument()
})
it('FE-MOB-PLROW-009: renders the pool photo of the place in go mode', () => {
const fullPlace = {
id: 101, name: 'Museum', image_url: '/uploads/places/museum.jpg',
google_place_id: null, osm_id: null, lat: 35.71, lng: 139.79,
} as unknown as Place
render(<PlaceRow {...props} fullPlace={fullPlace} />)
expect(screen.getByAltText('Museum')).toHaveAttribute('src', '/uploads/places/museum.jpg')
})
it('FE-MOB-PLROW-010: falls back to the assignment projection when the pool entry is missing', () => {
const a = assignment({
place: { id: 101, name: 'Museum', image_url: '/uploads/places/thumb.jpg', category: null },
})
render(<PlaceRow {...props} assignment={a} fullPlace={undefined} />)
expect(screen.getByAltText('Museum')).toHaveAttribute('src', '/uploads/places/thumb.jpg')
})
it('FE-MOB-PLROW-011: tapping the row opens the place', () => {
const onOpen = vi.fn()
render(<PlaceRow {...props} onOpen={onOpen} />)
fireEvent.click(screen.getByText('Museum'))
expect(onOpen).toHaveBeenCalledTimes(1)
})
it('FE-MOB-PLROW-012: edit mode swaps the avatar for edit/remove circles plus the reorder slot', () => {
const onEdit = vi.fn()
const onRemove = vi.fn()
const onOpen = vi.fn()
render(
<PlaceRow {...props} chrome={chrome(true)} onEdit={onEdit} onRemove={onRemove} onOpen={onOpen} />,
)
expect(screen.getByTestId('reorder')).toBeInTheDocument()
fireEvent.click(screen.getByLabelText('common.edit'))
fireEvent.click(screen.getByLabelText('planner.removeFromDay'))
expect(onEdit).toHaveBeenCalledTimes(1)
expect(onRemove).toHaveBeenCalledTimes(1)
// the circles stop propagation so the row does not also open
expect(onOpen).not.toHaveBeenCalled()
})
})
describe('TransportRow', () => {
const FLIGHT = {
id: 21, type: 'flight', title: 'LH 714 to Tokyo', day_id: 2,
reservation_time: '2026-05-02T08:15', reservation_end_time: '2026-05-02T18:40',
metadata: { airline: 'Lufthansa', flight_number: 'LH714', departure_airport: 'MUC', arrival_airport: 'HND' },
} as unknown as TransportEntry
const base = { dayId: 2, chrome: chrome(), reorder: REORDER, onOpen: vi.fn() }
it('FE-MOB-PLROW-013: shows a single-day booking as a start–end range with the flight subtitle', () => {
const { container } = render(<TransportRow {...base} res={FLIGHT} />)
expect(screen.getByText('LH 714 to Tokyo')).toBeInTheDocument()
expect(container.textContent).toContain('08:15 – 18:40')
expect(screen.getByText('Lufthansa · LH714 · MUC → HND')).toBeInTheDocument()
})
it('FE-MOB-PLROW-014: a multi-day span shows only the day-relevant time', () => {
const car = {
id: 22, type: 'car', title: 'Rental car', day_id: 1, end_day_id: 3,
reservation_time: '2026-05-01T09:00', reservation_end_time: '2026-05-03T17:00',
location: 'Hertz Shinjuku',
} as unknown as TransportEntry
const { container, unmount } = render(<TransportRow {...base} dayId={1} res={car} />)
expect(container.textContent).toContain('09:00')
expect(container.textContent).not.toContain('–')
expect(screen.getByText('Hertz Shinjuku')).toBeInTheDocument()
unmount()
const dropOff = render(<TransportRow {...base} dayId={3} res={car} />)
expect(dropOff.container.textContent).toContain('17:00')
})
it('FE-MOB-PLROW-015: a per-leg row is subtitled with its own from → to', () => {
const leg = { ...FLIGHT, __leg: { index: 1, total: 2, from: 'FRA', to: 'HND' } } as TransportEntry
render(<TransportRow {...base} res={leg} />)
expect(screen.getByText('FRA → HND')).toBeInTheDocument()
})
it('FE-MOB-PLROW-016: a per-leg row without endpoints has no subtitle', () => {
const leg = { ...FLIGHT, __leg: { index: 0, total: 2, from: null, to: null } } as TransportEntry
const { container } = render(<TransportRow {...base} res={leg} />)
expect(container.textContent).not.toContain('Lufthansa')
})
it('FE-MOB-PLROW-017: a train subtitle carries number, platform and seat', () => {
const train = {
id: 23, type: 'train', title: 'ICE to Hamburg', day_id: 2,
metadata: JSON.stringify({ train_number: 'ICE 599', platform: '7', seat: '21A' }),
} as unknown as TransportEntry
render(<TransportRow {...base} res={train} />)
expect(screen.getByText('ICE 599 · Gl. 7 · Sitz 21A')).toBeInTheDocument()
})
it('FE-MOB-PLROW-018: an untimed booking of an unknown type still renders its title', () => {
const other = { id: 24, type: 'yoga_retreat', title: 'Sunrise yoga', day_id: 2 } as unknown as TransportEntry
const { container } = render(<TransportRow {...base} res={other} />)
expect(screen.getByText('Sunrise yoga')).toBeInTheDocument()
expect(container.textContent).toBe('Sunrise yoga')
})
it('FE-MOB-PLROW-019: tapping the row opens it', () => {
const onOpen = vi.fn()
render(<TransportRow {...base} res={FLIGHT} onOpen={onOpen} />)
fireEvent.click(screen.getByText('LH 714 to Tokyo'))
expect(onOpen).toHaveBeenCalledTimes(1)
})
it('FE-MOB-PLROW-020: edit mode shows the reorder slot instead of the avatar', () => {
render(<TransportRow {...base} res={FLIGHT} chrome={chrome(true)} />)
expect(screen.getByTestId('reorder')).toBeInTheDocument()
})
})
describe('TransitRow', () => {
const TRANSIT_RES = {
id: 25, type: 'transit', title: 'Shibuya → Asakusa', day_id: 2,
reservation_time: '2026-05-02T10:05', reservation_end_time: '2026-05-02T10:47',
} as unknown as TransportEntry
const TRANSIT: TransitMeta = {
transfers: 1,
duration: 2520,
legs: [
{ mode: 'WALK', duration: 240, to: { name: 'Shibuya Sta.' } },
{
mode: 'SUBWAY', line: 'G', line_color: '#FF9500', line_text_color: '#ffffff',
duration: 1500, stops: 9,
from: { name: 'Shibuya Sta.', time: '10:05', track: '3' },
to: { name: 'Asakusa Sta.', time: '10:30' },
},
{ mode: 'WALK', duration: 30, to: { name: 'Hotel' } },
],
}
const base = {
res: TRANSIT_RES, transit: TRANSIT, dayId: 2, chrome: chrome(), reorder: REORDER,
onToggle: vi.fn(), onOpenJourney: vi.fn(),
}
it('FE-MOB-PLROW-021: collapsed row shows the from → to headline and the time range', () => {
const { container } = render(<TransitRow {...base} open={false} />)
expect(screen.getByText('Shibuya → Asakusa')).toBeInTheDocument()
expect(container.textContent).toContain('10:05 – 10:47')
})
it('FE-MOB-PLROW-022: the strip drops sub-minute walks and keeps the line badge', () => {
render(<TransitRow {...base} open={false} />)
// one badge only — the collapsed strip; the 30s walk is filtered out
expect(screen.getAllByText('G')).toHaveLength(1)
expect(screen.queryByText('transit.min:1 · transit.walkTo:Hotel')).not.toBeInTheDocument()
})
it('FE-MOB-PLROW-023: expanding renders every leg with duration, stops and platform', () => {
render(<TransitRow {...base} open />)
expect(screen.getAllByText('G')).toHaveLength(2)
expect(screen.getByText('transit.min:4 · transit.walkTo:Shibuya Sta.')).toBeInTheDocument()
expect(screen.getByText('Shibuya Sta. → Asakusa Sta.')).toBeInTheDocument()
expect(screen.getByText('transit.min:25 · transit.stops:9 · transit.platform:3')).toBeInTheDocument()
expect(screen.getByText('transit.min:1 · transit.walkTo:Hotel')).toBeInTheDocument()
})
it('FE-MOB-PLROW-024: tapping the header toggles the row', () => {
const onToggle = vi.fn()
render(<TransitRow {...base} open={false} onToggle={onToggle} />)
fireEvent.click(screen.getByText('Shibuya → Asakusa'))
expect(onToggle).toHaveBeenCalledTimes(1)
})
it('FE-MOB-PLROW-025: an untimed single-leg journey falls back to the raw title and the mode badge', () => {
const res = { id: 26, type: 'transit', title: 'Airport shuttle', day_id: 2 } as unknown as TransportEntry
const transit: TransitMeta = { legs: [{ mode: 'BUS', line: null, duration: 0, from: {}, to: {} }] }
const { container } = render(<TransitRow {...base} res={res} transit={transit} open />)
expect(screen.getByText('Airport shuttle')).toBeInTheDocument()
expect(screen.getAllByText('BUS')).toHaveLength(2)
expect(container.textContent).not.toContain('10:05')
})
it('FE-MOB-PLROW-038: survives uncoloured lines, missing durations and unnamed stops', () => {
const res = {
id: 27, type: 'transit', title: 'Depot → Bridge', day_id: 2, reservation_time: '2026-05-02T06:00',
} as unknown as TransportEntry
const transit: TransitMeta = {
legs: [
{ mode: 'TRAM', line: '17', from: { name: 'Depot' }, to: { name: 'Bridge' } },
{ mode: 'WALK', duration: 600 },
],
}
const { container } = render(<TransitRow {...base} res={res} transit={transit} open />)
expect(container.textContent).toContain('06:00')
// no end time — the chip stays a single value
expect(container.textContent).not.toContain('–')
// once as the row headline, once as the expanded leg
expect(screen.getAllByText('Depot → Bridge')).toHaveLength(2)
expect(screen.getByText('transit.min:10 · transit.walkTo:')).toBeInTheDocument()
})
it('FE-MOB-PLROW-026: edit mode opens the journey view and keeps the reorder slot', () => {
const onOpenJourney = vi.fn()
render(<TransitRow {...base} open={false} chrome={chrome(true)} onOpenJourney={onOpenJourney} />)
expect(screen.getByTestId('reorder')).toBeInTheDocument()
fireEvent.click(screen.getByLabelText('common.edit'))
expect(onOpenJourney).toHaveBeenCalledTimes(1)
})
})
describe('ConnRow', () => {
it('FE-MOB-PLROW-027: defaults to the driving duration and the distance', () => {
const { container } = render(<ConnRow seg={SEG} />)
expect(screen.getByText('14 min')).toBeInTheDocument()
expect(screen.getByText('· 5.4 km')).toBeInTheDocument()
expect(container.querySelector('button')).toBeNull()
})
it('FE-MOB-PLROW-028: a walking leg shows the walking duration', () => {
render(<ConnRow seg={{ ...SEG, mode: 'walking' }} />)
expect(screen.getByText('1 h 5 min')).toBeInTheDocument()
})
it('FE-MOB-PLROW-029: a plugin leg uses its own duration text and note', () => {
render(<ConnRow seg={{ ...SEG, mode: 'plugin:ev/fastest', durationText: '22 min', noteText: '25 min charge' }} />)
expect(screen.getByText('22 min')).toBeInTheDocument()
expect(screen.getByText('25 min charge')).toBeInTheDocument()
})
it('FE-MOB-PLROW-030: becomes a button when the leg mode can be changed', () => {
const onTap = vi.fn()
render(<ConnRow seg={SEG} onTap={onTap} />)
fireEvent.click(screen.getByRole('button'))
expect(onTap).toHaveBeenCalledTimes(1)
})
})
describe('PlanScheduleRow', () => {
const item = (over: Partial<PluginDayScheduleItem> = {}): PluginDayScheduleItem => ({
pluginId: 'ev', id: 'charge-1', dayId: 2, minutes: 95, label: 'Charging stop', tone: 'success',
...over,
})
it('FE-MOB-PLROW-031: renders the label with formatted minutes and the tone colour', () => {
const { container } = render(<PlanScheduleRow item={item()} />)
expect(screen.getByText('Charging stop')).toBeInTheDocument()
expect(container.textContent).toContain('1 h 35 min')
expect(container.querySelector('svg')).toHaveStyle({ color: '#10b981' })
})
it('FE-MOB-PLROW-032: an unknown tone falls back to the default colour and minutes stay optional', () => {
const { container } = render(
<PlanScheduleRow item={item({ minutes: undefined, tone: 'chartreuse' as unknown as PluginDayScheduleItem['tone'] })} />,
)
expect(container.textContent).toBe('Charging stop')
expect(container.querySelector('svg')).toHaveStyle({ color: '#4F46E5' })
})
})
describe('HotelConnRow', () => {
it('FE-MOB-PLROW-033: puts the hotel above the leg at the top of the day and below it at the bottom', () => {
const { container, unmount } = render(<HotelConnRow seg={SEG} name="Hotel Sacher" placement="top" />)
const top = container.firstElementChild as HTMLElement
expect(top.children[0].textContent).toBe('Hotel Sacher')
expect(top.children[1].textContent).toContain('1 h 5 min')
expect(top.children[1].textContent).toContain('· 5.4 km ·')
expect(top.children[1].textContent).toContain('14 min')
unmount()
const bottom = render(<HotelConnRow seg={SEG} name="Hotel Sacher" placement="bottom" />)
.container.firstElementChild as HTMLElement
expect(bottom.children[0].textContent).toContain('1 h 5 min')
expect(bottom.children[1].textContent).toBe('Hotel Sacher')
})
})
describe('NoteRow', () => {
const note = (over: Record<string, unknown> = {}): DayNote => ({
id: 41, day_id: 2, text: 'Buy museum tickets\nCash only', time: '09:30 at the kiosk',
icon: 'Ticket', sort_order: 0,
...over,
} as unknown as DayNote)
const base = { chrome: chrome(), reorder: REORDER, onEdit: vi.fn() }
it('FE-MOB-PLROW-034: splits the leading time off and joins the rest into the subtitle', () => {
render(<NoteRow {...base} note={note()} />)
expect(screen.getByText('09:30')).toBeInTheDocument()
expect(screen.getByText('Buy museum tickets')).toBeInTheDocument()
expect(screen.getByText('Cash only · at the kiosk')).toBeInTheDocument()
})
it('FE-MOB-PLROW-035: a free-text time stays in the subtitle and shows no time chip', () => {
render(<NoteRow {...base} note={note({ text: 'Pack the day bag', time: 'before leaving' })} />)
expect(screen.getByText('before leaving')).toBeInTheDocument()
expect(screen.queryByText('09:30')).not.toBeInTheDocument()
})
it('FE-MOB-PLROW-036: a bare one-line note renders title only', () => {
const { container } = render(<NoteRow {...base} note={note({ text: 'Pack the day bag', time: null })} />)
expect(container.textContent).toBe('Pack the day bag')
})
it('FE-MOB-PLROW-037: is inert in go mode and editable in edit mode', () => {
const onEdit = vi.fn()
const { unmount } = render(<NoteRow {...base} note={note()} onEdit={onEdit} />)
fireEvent.click(screen.getByText('Buy museum tickets'))
expect(onEdit).not.toHaveBeenCalled()
unmount()
render(<NoteRow {...base} note={note()} chrome={chrome(true)} onEdit={onEdit} />)
fireEvent.click(screen.getByText('Buy museum tickets'))
expect(onEdit).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('reorder')).toBeInTheDocument()
})
})
@@ -0,0 +1,714 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import MReservationSheet from '../../../../src/mobile/screens/trip/sheets/MReservationSheet'
import { useAddonStore } from '../../../../src/store/addonStore'
import { useTripStore } from '../../../../src/store/tripStore'
import type { Accommodation, Day, Place, Reservation, TripMember } from '../../../../src/types'
import { buildPlanner } from '../../../helpers/mobileTrip'
import { resetAllStores } from '../../../helpers/store'
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-RESSH-001 to FE-MOB-RESSH-051
// Date/time/select pickers have their own suites — here they only have to be
// addressable, so they render as plain controls.
vi.mock('../../../../src/components/shared/CustomSelect', () => ({
default: ({ value, onChange, options = [], placeholder }: {
value: string | number
onChange: (v: string | number) => void
options?: { value: string | number; label: string }[]
placeholder?: string
}) => (
<select
data-testid="cselect"
data-value={String(value ?? '')}
aria-label={placeholder}
value={options.some(o => String(o.value) === String(value)) ? String(value) : ''}
onChange={e => {
const hit = options.find(o => String(o.value) === e.target.value)
onChange(hit ? hit.value : e.target.value)
}}
>
<option value="" />
{options.map(o => <option key={String(o.value)} value={String(o.value)}>{o.label}</option>)}
</select>
),
}))
vi.mock('../../../../src/components/shared/CustomTimePicker', () => ({
default: ({ value, onChange, placeholder = '00:00' }: {
value: string
onChange: (v: string) => void
placeholder?: string
}) => (
<input data-testid="ctime" placeholder={placeholder} value={value} onChange={e => onChange(e.target.value)} />
),
}))
vi.mock('../../../../src/components/shared/CustomDateTimePicker', () => ({
CustomDatePicker: ({ value, onChange, min, max }: {
value: string
onChange: (v: string) => void
min?: string
max?: string
}) => (
<input
data-testid="cdate"
data-min={min ?? ''}
data-max={max ?? ''}
value={value}
onChange={e => onChange(e.target.value)}
/>
),
}))
const DAYS = [
{ id: 11, trip_id: 5, day_number: 1, date: '2026-05-01', title: null },
{ id: 12, trip_id: 5, day_number: 2, date: '2026-05-02', title: null },
{ id: 13, trip_id: 5, day_number: 3, date: '2026-05-03', title: 'Fly home' },
] as unknown as Day[]
const PLACES = [
{ id: 101, name: 'Hotel Sacher', address: 'Philharmonikerstrasse 4' },
{ id: 102, name: 'Cafe Central', address: null },
] as unknown as Place[]
const ACCOMMODATIONS = [
{ id: 77, trip_id: 5, place_id: 101, start_day_id: 12, end_day_id: 13 },
] as unknown as Accommodation[]
const MEMBERS = [
{ id: 7, username: 'Ana', avatar_url: '/uploads/ana.png' },
{ id: 8, username: 'bob', is_guest: true },
] as unknown as TripMember[]
const DINNER = {
id: 55, trip_id: 5, title: 'Dinner', type: 'restaurant', status: 'confirmed',
reservation_time: '2026-05-02T19:30:00', reservation_end_time: '2026-05-02T21:00',
location: 'Old Town', confirmation_number: 'C1', notes: 'window seat', url: 'https://example.test',
place_id: 102, metadata: '{"check_in_time":"15:00"}',
travelers: [{ user_id: 7 }],
} as unknown as Reservation
const HOTEL = {
id: 56, trip_id: 5, title: 'Hotel Sacher', type: 'hotel', status: 'pending',
accommodation_id: 77, location: 'fallback address',
metadata: { check_in_time: '15:00', check_out_time: '11:00' },
} as unknown as Reservation
let setTravelers: ReturnType<typeof vi.fn>
function makePlanner(overrides: Record<string, unknown> = {}) {
return buildPlanner({
tripId: 5,
days: DAYS,
places: PLACES,
tripAccommodations: ACCOMMODATIONS,
tripMembers: [],
selectedDayId: 12,
showReservationModal: true,
canUploadFiles: true,
handleSaveReservation: vi.fn(async () => ({ id: 99 })),
...overrides,
} as never)
}
function setup(planner = makePlanner()) {
const onOpenExpense = vi.fn()
const view = render(<MReservationSheet planner={planner} onOpenExpense={onOpenExpense} />)
return { ...view, planner, onOpenExpense }
}
const times = () => screen.getAllByTestId('ctime') as HTMLInputElement[]
const dates = () => screen.getAllByTestId('cdate') as HTMLInputElement[]
const selects = () => screen.getAllByTestId('cselect') as HTMLSelectElement[]
const titleField = () => screen.getByPlaceholderText('reservations.titlePlaceholder')
const submitBtn = () => screen.getByRole('button', { name: /^common\.(add|update|saving)$/ })
function type(el: HTMLElement, value: string) {
fireEvent.change(el, { target: { value } })
}
function pick(select: HTMLSelectElement, value: string | number) {
fireEvent.change(select, { target: { value: String(value) } })
}
/** The payload the sheet handed to planner.handleSaveReservation. */
function savedPayload(planner: ReturnType<typeof makePlanner>) {
const fn = planner.handleSaveReservation as unknown as ReturnType<typeof vi.fn>
return fn.mock.calls[0][0] as Record<string, unknown>
}
describe('MReservationSheet', () => {
beforeEach(() => {
resetAllStores()
setTravelers = vi.fn(async () => undefined)
useTripStore.setState({ setReservationTravelers: setTravelers as never })
useAddonStore.setState({
addons: [{ id: 'budget', name: 'Budget', type: 'trip', icon: 'wallet', enabled: true }],
loaded: true,
})
})
afterEach(() => {
vi.restoreAllMocks()
})
// ── Open / close ───────────────────────────────────────────────────────────
it('FE-MOB-RESSH-001: renders nothing while the planner has the editor closed', () => {
setup(makePlanner({ showReservationModal: false }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-RESSH-002: opens the empty create form with a disabled submit', () => {
setup()
expect(screen.getByRole('dialog', { name: 'reservations.newTitle' })).toBeInTheDocument()
expect(titleField()).toHaveValue('')
expect(screen.getByRole('button', { name: 'reservations.type.other' })).toHaveAttribute('aria-pressed', 'true')
expect(submitBtn()).toHaveTextContent('common.add')
expect(submitBtn()).toBeDisabled()
})
it('FE-MOB-RESSH-003: cancel clears every editor flag of the planner', () => {
const { planner } = setup()
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
expect(planner.setShowReservationModal).toHaveBeenCalledWith(false)
expect(planner.setEditingReservation).toHaveBeenCalledWith(null)
expect(planner.setBookingForAssignmentId).toHaveBeenCalledWith(null)
})
it('FE-MOB-RESSH-004: the header X closes the same way', () => {
const { planner } = setup()
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
expect(planner.setShowReservationModal).toHaveBeenCalledWith(false)
})
it('FE-MOB-RESSH-005: during an import review closing advances the queue instead', () => {
const { planner } = setup(makePlanner({ importReviewActive: true }))
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
expect(planner.advanceImportReview).toHaveBeenCalledTimes(1)
expect(planner.setShowReservationModal).not.toHaveBeenCalled()
})
// ── Form basics ────────────────────────────────────────────────────────────
it('FE-MOB-RESSH-006: a title unlocks the submit and the default booking is saved as-is', async () => {
const { planner } = setup()
type(titleField(), 'Museum ticket')
expect(submitBtn()).toBeEnabled()
fireEvent.click(submitBtn())
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
expect(savedPayload(planner)).toEqual({
title: 'Museum ticket', type: 'other', status: 'pending',
reservation_time: null, reservation_end_time: null,
location: '', confirmation_number: '', notes: '', url: '',
assignment_id: null, accommodation_id: null, place_id: null,
metadata: null, endpoints: [], needs_review: false,
})
})
it('FE-MOB-RESSH-007: the type chips switch the form between event and hotel layout', () => {
setup()
expect(screen.getByLabelText('reservations.meta.pickPlace')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.hotel' }))
expect(screen.getByRole('button', { name: 'reservations.type.hotel' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByLabelText('reservations.meta.pickHotel')).toBeInTheDocument()
expect(screen.queryByLabelText('reservations.meta.pickPlace')).not.toBeInTheDocument()
expect(screen.queryAllByTestId('cdate')).toHaveLength(0)
})
it('FE-MOB-RESSH-008: status, code, link and notes all reach the payload', async () => {
const { planner } = setup()
type(titleField(), 'Opera')
fireEvent.click(screen.getByRole('button', { name: 'reservations.confirmed' }))
type(screen.getByPlaceholderText('reservations.confirmationPlaceholder'), 'OP-7')
type(screen.getByPlaceholderText('reservations.urlPlaceholder'), 'https://opera.test')
type(screen.getByPlaceholderText('reservations.notesPlaceholder'), 'balcony')
type(screen.getByPlaceholderText('reservations.locationPlaceholder'), 'Ringstrasse 2')
fireEvent.click(submitBtn())
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
expect(savedPayload(planner)).toMatchObject({
status: 'confirmed', confirmation_number: 'OP-7',
url: 'https://opera.test', notes: 'balcony', location: 'Ringstrasse 2',
})
})
it('FE-MOB-RESSH-009: the date pickers are pinned to the trip span', () => {
setup()
for (const el of dates()) {
expect(el).toHaveAttribute('data-min', '2026-05-01')
expect(el).toHaveAttribute('data-max', '2026-05-03')
}
})
it('FE-MOB-RESSH-010: linking a place fills the still-empty title and address', () => {
setup()
pick(screen.getByLabelText('reservations.meta.pickPlace') as HTMLSelectElement, 101)
expect(titleField()).toHaveValue('Hotel Sacher')
expect(screen.getByPlaceholderText('reservations.locationPlaceholder')).toHaveValue('Philharmonikerstrasse 4')
})
it('FE-MOB-RESSH-011: linking a place never overwrites what the user already typed', () => {
setup()
type(titleField(), 'My dinner')
type(screen.getByPlaceholderText('reservations.locationPlaceholder'), 'My address')
pick(screen.getByLabelText('reservations.meta.pickPlace') as HTMLSelectElement, 101)
expect(titleField()).toHaveValue('My dinner')
expect(screen.getByPlaceholderText('reservations.locationPlaceholder')).toHaveValue('My address')
})
it('FE-MOB-RESSH-012: unlinking the place leaves the typed fields alone', async () => {
const { planner } = setup()
type(titleField(), 'Walk')
const placeSelect = screen.getByLabelText('reservations.meta.pickPlace') as HTMLSelectElement
pick(placeSelect, 102)
pick(placeSelect, '')
fireEvent.click(submitBtn())
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
expect(savedPayload(planner)).toMatchObject({ place_id: null, title: 'Walk' })
})
// ── Times ──────────────────────────────────────────────────────────────────
it('FE-MOB-RESSH-013: date plus start and end time are combined into one timestamp pair', async () => {
const { planner } = setup()
type(titleField(), 'Dinner')
type(dates()[0], '2026-05-02')
type(times()[0], '19:30')
type(times()[1], '21:00')
fireEvent.click(submitBtn())
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
expect(savedPayload(planner)).toMatchObject({
reservation_time: '2026-05-02T19:30',
reservation_end_time: '2026-05-02T21:00',
})
})
it('FE-MOB-RESSH-014: a start time without a date falls back to the selected day', async () => {
const { planner } = setup()
type(titleField(), 'Dinner')
type(times()[0], '09:00')
fireEvent.click(submitBtn())
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
expect(savedPayload(planner)).toMatchObject({ reservation_time: '2026-05-02T09:00' })
})
it('FE-MOB-RESSH-015: an end date without an end time stays a plain date', async () => {
const { planner } = setup()
type(titleField(), 'Festival')
type(dates()[0], '2026-05-01')
type(dates()[1], '2026-05-03')
fireEvent.click(submitBtn())
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
expect(savedPayload(planner)).toMatchObject({
reservation_time: '2026-05-01',
reservation_end_time: '2026-05-03',
})
})
it('FE-MOB-RESSH-016: clearing the start date drops the timestamp again', async () => {
const { planner } = setup()
type(titleField(), 'Festival')
type(dates()[0], '2026-05-01')
type(dates()[0], '')
fireEvent.click(submitBtn())
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
expect(savedPayload(planner)).toMatchObject({ reservation_time: null })
})
it('FE-MOB-RESSH-017: an end before the start blocks the submit and warns inline', () => {
setup()
type(titleField(), 'Dinner')
type(dates()[0], '2026-05-02')
type(times()[0], '19:30')
type(dates()[1], '2026-05-01')
expect(screen.getByText('reservations.validation.endBeforeStart')).toBeInTheDocument()
expect(submitBtn()).toBeDisabled()
})
it('FE-MOB-RESSH-018: submitting an inverted range through the costs shortcut only toasts', async () => {
const { planner, onOpenExpense } = setup()
type(titleField(), 'Dinner')
type(dates()[0], '2026-05-02')
type(times()[0], '19:30')
type(dates()[1], '2026-05-01')
fireEvent.click(screen.getByRole('button', { name: 'reservations.createExpense' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('reservations.validation.endBeforeStart'))
expect(planner.handleSaveReservation).not.toHaveBeenCalled()
expect(onOpenExpense).not.toHaveBeenCalled()
})
// ── Editing an existing booking ────────────────────────────────────────────
it('FE-MOB-RESSH-019: editing seeds every field and splits the end timestamp', () => {
setup(makePlanner({ editingReservation: DINNER }))
expect(screen.getByRole('dialog', { name: 'reservations.editTitle' })).toBeInTheDocument()
expect(submitBtn()).toHaveTextContent('common.update')
expect(titleField()).toHaveValue('Dinner')
expect(screen.getByRole('button', { name: 'reservations.type.restaurant' })).toHaveAttribute('aria-pressed', 'true')
expect(dates()[0]).toHaveValue('2026-05-02')
expect(times()[0]).toHaveValue('19:30')
expect(dates()[1]).toHaveValue('2026-05-02')
expect(times()[1]).toHaveValue('21:00')
expect(screen.getByPlaceholderText('reservations.locationPlaceholder')).toHaveValue('Old Town')
expect(screen.getByPlaceholderText('reservations.confirmationPlaceholder')).toHaveValue('C1')
expect(screen.getByPlaceholderText('reservations.notesPlaceholder')).toHaveValue('window seat')
expect(screen.getByPlaceholderText('reservations.urlPlaceholder')).toHaveValue('https://example.test')
expect(screen.getByLabelText('reservations.meta.pickPlace')).toHaveAttribute('data-value', '102')
})
it('FE-MOB-RESSH-020: a date-only end is read back into the end-date field', () => {
setup(makePlanner({ editingReservation: { ...DINNER, reservation_end_time: '2026-05-03' } }))
expect(dates()[1]).toHaveValue('2026-05-03')
expect(times()[1]).toHaveValue('')
})
it('FE-MOB-RESSH-021: a booking with no times at all seeds empty fields', () => {
setup(makePlanner({
editingReservation: {
id: 57, title: 'Museum', type: undefined, status: undefined,
reservation_time: null, reservation_end_time: null, metadata: null,
} as unknown as Reservation,
}))
expect(titleField()).toHaveValue('Museum')
expect(screen.getByRole('button', { name: 'reservations.type.other' })).toHaveAttribute('aria-pressed', 'true')
expect(dates()[0]).toHaveValue('')
expect(times()[1]).toHaveValue('')
})
it('FE-MOB-RESSH-022: a hotel booking seeds its stay from the linked accommodation', () => {
setup(makePlanner({ editingReservation: HOTEL }))
expect(screen.getByLabelText('reservations.meta.pickHotel')).toHaveAttribute('data-value', '101')
const [from, to] = selects().slice(1)
expect(from).toHaveAttribute('data-value', '12')
expect(to).toHaveAttribute('data-value', '13')
expect(screen.getByPlaceholderText('15:00')).toHaveValue('15:00')
expect(screen.getByPlaceholderText('11:00')).toHaveValue('11:00')
expect(screen.getByPlaceholderText('reservations.locationPlaceholder')).toHaveValue('Philharmonikerstrasse 4')
})
it('FE-MOB-RESSH-023: a hotel without an accommodation falls back to the booking location', () => {
setup(makePlanner({ editingReservation: { ...HOTEL, accommodation_id: null } }))
expect(screen.getByPlaceholderText('reservations.locationPlaceholder')).toHaveValue('fallback address')
expect(screen.getByLabelText('reservations.meta.pickHotel')).toHaveAttribute('data-value', '')
})
// ── Hotels ─────────────────────────────────────────────────────────────────
it('FE-MOB-RESSH-024: a hotel with a linked place and a day range creates the accommodation', async () => {
const { planner } = setup()
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.hotel' }))
type(titleField(), 'Sacher')
pick(screen.getByLabelText('reservations.meta.pickHotel') as HTMLSelectElement, 101)
pick(selects()[1], 12)
pick(selects()[2], 13)
type(screen.getByPlaceholderText('15:00'), '15:00')
type(screen.getByPlaceholderText('11:00'), '11:00')
fireEvent.click(submitBtn())
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
expect(savedPayload(planner)).toMatchObject({
type: 'hotel',
reservation_time: null,
reservation_end_time: null,
location: 'Philharmonikerstrasse 4',
accommodation_id: null,
place_id: null,
metadata: { check_in_time: '15:00', check_out_time: '11:00' },
create_accommodation: {
place_id: 101, venue: null, address: 'Philharmonikerstrasse 4',
start_day_id: 12, end_day_id: 13, check_in: '15:00', check_out: '11:00', confirmation: null,
},
})
})
it('FE-MOB-RESSH-025: a hotel without a linked place proposes a venue from title and address', async () => {
const { planner } = setup()
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.hotel' }))
type(titleField(), 'Ibis')
type(screen.getByPlaceholderText('reservations.locationPlaceholder'), 'Somewhere 5')
pick(selects()[1], 12)
fireEvent.click(submitBtn())
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
expect(savedPayload(planner)).toMatchObject({
create_accommodation: {
place_id: null,
venue: { name: 'Ibis', address: 'Somewhere 5' },
start_day_id: 12, end_day_id: 12,
},
})
})
it('FE-MOB-RESSH-026: a hotel without any day range creates no accommodation', async () => {
const { planner } = setup()
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.hotel' }))
type(titleField(), 'Ibis')
fireEvent.click(submitBtn())
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
expect(savedPayload(planner)).not.toHaveProperty('create_accommodation')
})
it('FE-MOB-RESSH-027: picking a hotel place adopts its name and address', () => {
setup()
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.hotel' }))
pick(screen.getByLabelText('reservations.meta.pickHotel') as HTMLSelectElement, 101)
expect(titleField()).toHaveValue('Hotel Sacher')
expect(screen.getByPlaceholderText('reservations.locationPlaceholder')).toHaveValue('Philharmonikerstrasse 4')
})
it('FE-MOB-RESSH-028: an earlier end day pulls the start day back with it', () => {
setup()
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.hotel' }))
pick(selects()[1], 13)
expect(selects()[2]).toHaveAttribute('data-value', '13')
pick(selects()[2], 11)
expect(selects()[1]).toHaveAttribute('data-value', '11')
})
// ── Prefill from the booking import ────────────────────────────────────────
it('FE-MOB-RESSH-029: an import prefill seeds the hotel form, its day range and the source file', () => {
setup(makePlanner({
reservationPrefill: {
title: 'Hotel Ibis', type: 'hotel', status: 'confirmed',
reservation_time: '2026-05-02T14:00:00', reservation_end_time: '2026-05-03T10:00',
location: 'Vienna', confirmation_number: 'PF1', notes: 'late arrival', url: 'https://ibis.test',
metadata: { check_in_time: '14:00', check_out_time: '10:00' },
_accommodation: { check_in: '2026-05-02', check_out: '2026-05-03' },
_venue: { address: 'Mariahilfer 1' },
_sourceFiles: [new File(['x'], 'voucher.pdf', { type: 'application/pdf' })],
},
}))
expect(titleField()).toHaveValue('Hotel Ibis')
expect(screen.getByPlaceholderText('reservations.confirmationPlaceholder')).toHaveValue('PF1')
expect(screen.getByPlaceholderText('reservations.urlPlaceholder')).toHaveValue('https://ibis.test')
expect(screen.getByPlaceholderText('15:00')).toHaveValue('14:00')
expect(selects()[1]).toHaveAttribute('data-value', '12')
expect(selects()[2]).toHaveAttribute('data-value', '13')
expect(screen.getByPlaceholderText('reservations.locationPlaceholder')).toHaveValue('Mariahilfer 1')
expect(screen.getByText('voucher.pdf')).toBeInTheDocument()
})
it('FE-MOB-RESSH-030: a prefill with a date-only end fills the end-date field', () => {
setup(makePlanner({
reservationPrefill: { title: 'Tour', type: 'tour', reservation_end_time: '2026-05-03', metadata: null },
}))
expect(dates()[1]).toHaveValue('2026-05-03')
expect(times()[1]).toHaveValue('')
})
// ── Travelers ──────────────────────────────────────────────────────────────
it('FE-MOB-RESSH-031: without trip members the traveler list shows its empty note', () => {
setup()
expect(screen.getByText('reservations.travelers.none')).toBeInTheDocument()
})
it('FE-MOB-RESSH-032: members render with avatar, initial and the guest badge', () => {
setup(makePlanner({ tripMembers: MEMBERS }))
const ana = screen.getByText('Ana').closest('button') as HTMLButtonElement
expect(ana.querySelector('img')).toHaveAttribute('src', '/uploads/ana.png')
const bob = screen.getByText('bob').closest('button') as HTMLButtonElement
expect(bob.querySelector('img')).toBeNull()
expect(bob).toHaveTextContent('B')
expect(bob).toHaveTextContent('Guest')
})
it('FE-MOB-RESSH-033: assigning travelers persists after the booking was created', async () => {
setup(makePlanner({ tripMembers: MEMBERS }))
type(titleField(), 'Dinner')
fireEvent.click(screen.getByText('Ana'))
fireEvent.click(screen.getByText('bob'))
fireEvent.click(screen.getByText('Ana'))
fireEvent.click(submitBtn())
await waitFor(() => expect(setTravelers).toHaveBeenCalledWith(5, 99, [8]))
})
it('FE-MOB-RESSH-034: an unchanged traveler set is not written again', async () => {
const { planner } = setup(makePlanner({ tripMembers: MEMBERS, editingReservation: DINNER }))
fireEvent.click(submitBtn())
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
expect(setTravelers).not.toHaveBeenCalled()
})
it('FE-MOB-RESSH-035: removing a traveler falls back to the edited booking id', async () => {
const planner = makePlanner({
tripMembers: MEMBERS,
editingReservation: DINNER,
handleSaveReservation: vi.fn(async () => undefined),
})
setup(planner)
fireEvent.click(screen.getByText('Ana'))
fireEvent.click(submitBtn())
await waitFor(() => expect(setTravelers).toHaveBeenCalledWith(5, 55, []))
})
// ── Files, costs, errors ───────────────────────────────────────────────────
it('FE-MOB-RESSH-036: attached files are uploaded against the new booking', async () => {
const { planner } = setup()
type(titleField(), 'Flight docs')
const input = document.querySelector('input[type="file"]') as HTMLInputElement
fireEvent.change(input, { target: { files: [new File(['a'], 'a.pdf'), new File(['b'], 'b.pdf')] } })
expect(screen.getByText('a.pdf')).toBeInTheDocument()
fireEvent.click(submitBtn())
await waitFor(() => expect(planner.tripActions.addFile).toHaveBeenCalledTimes(2))
const [tripId, fd] = vi.mocked(planner.tripActions.addFile).mock.calls[0] as [number, FormData]
expect(tripId).toBe(5)
expect(fd.get('reservation_id')).toBe('99')
expect(fd.get('description')).toBe('Flight docs')
expect((fd.get('file') as File).name).toBe('a.pdf')
})
it('FE-MOB-RESSH-037: files are not re-uploaded when an existing booking is edited', async () => {
const { planner } = setup(makePlanner({ editingReservation: DINNER }))
const input = document.querySelector('input[type="file"]') as HTMLInputElement
fireEvent.change(input, { target: { files: [new File(['a'], 'a.pdf')] } })
fireEvent.click(submitBtn())
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
expect(planner.tripActions.addFile).not.toHaveBeenCalled()
})
it('FE-MOB-RESSH-038: without upload permission the attachment block is gone', () => {
setup(makePlanner({ canUploadFiles: false }))
expect(screen.queryByRole('button', { name: 'files.attach' })).not.toBeInTheDocument()
})
it('FE-MOB-RESSH-039: the costs shortcut saves first and then opens the expense form', async () => {
const { planner, onOpenExpense } = setup()
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.restaurant' }))
type(titleField(), 'Dinner')
fireEvent.click(screen.getByRole('button', { name: 'reservations.createExpense' }))
await waitFor(() => expect(onOpenExpense).toHaveBeenCalled())
expect(planner.handleSaveReservation).toHaveBeenCalledTimes(1)
expect(onOpenExpense).toHaveBeenCalledWith({
prefill: { reservationId: 99, name: 'Dinner', category: 'food' },
})
})
it('FE-MOB-RESSH-040: a plain submit never opens the expense form', async () => {
const { planner, onOpenExpense } = setup()
type(titleField(), 'Dinner')
fireEvent.click(submitBtn())
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
expect(onOpenExpense).not.toHaveBeenCalled()
})
it('FE-MOB-RESSH-041: without the budget addon there is no costs section', () => {
useAddonStore.setState({ addons: [], loaded: true })
setup()
expect(screen.queryByRole('button', { name: 'reservations.createExpense' })).not.toBeInTheDocument()
})
it('FE-MOB-RESSH-042: a failing save surfaces the error message and unlocks the form', async () => {
const planner = makePlanner({ handleSaveReservation: vi.fn(async () => { throw new Error('server said no') }) })
setup(planner)
type(titleField(), 'Dinner')
fireEvent.click(submitBtn())
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('server said no'))
expect(submitBtn()).toBeEnabled()
})
it('FE-MOB-RESSH-043: a non-Error rejection falls back to the generic message', async () => {
const planner = makePlanner({ handleSaveReservation: vi.fn(async () => { throw 'nope' }) })
setup(planner)
type(titleField(), 'Dinner')
fireEvent.click(submitBtn())
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.unknownError'))
})
it('FE-MOB-RESSH-044: a booking opened for a transport segment keeps that assignment', async () => {
const { planner } = setup(makePlanner({ bookingForAssignmentId: 42 }))
type(titleField(), 'Seat reservation')
fireEvent.click(submitBtn())
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
expect(savedPayload(planner)).toMatchObject({ assignment_id: 42 })
})
it('FE-MOB-RESSH-045: a saved import review advances to the next parsed booking', async () => {
const { planner } = setup(makePlanner({ importReviewActive: true }))
type(titleField(), 'Imported hotel')
fireEvent.click(submitBtn())
await waitFor(() => expect(planner.advanceImportReview).toHaveBeenCalledTimes(1))
})
it('FE-MOB-RESSH-046: a pending attachment can be dropped again before saving', async () => {
const { planner } = setup()
type(titleField(), 'Flight docs')
const input = document.querySelector('input[type="file"]') as HTMLInputElement
fireEvent.change(input, { target: { files: [new File(['a'], 'a.pdf'), new File(['b'], 'b.pdf')] } })
fireEvent.click(screen.getAllByRole('button', { name: 'common.delete' })[0])
expect(screen.queryByText('a.pdf')).not.toBeInTheDocument()
fireEvent.click(submitBtn())
await waitFor(() => expect(planner.tripActions.addFile).toHaveBeenCalledTimes(1))
const [, fd] = vi.mocked(planner.tripActions.addFile).mock.calls[0] as [number, FormData]
expect((fd.get('file') as File).name).toBe('b.pdf')
})
it('FE-MOB-RESSH-047: picking the date after the time keeps the time', async () => {
const { planner } = setup()
type(titleField(), 'Dinner')
type(times()[0], '19:30')
type(dates()[0], '2026-05-01')
fireEvent.click(submitBtn())
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
expect(savedPayload(planner)).toMatchObject({ reservation_time: '2026-05-01T19:30' })
})
it('FE-MOB-RESSH-048: clearing the end date drops the end timestamp', async () => {
const { planner } = setup()
type(titleField(), 'Festival')
type(dates()[1], '2026-05-03')
type(dates()[1], '')
fireEvent.click(submitBtn())
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
expect(savedPayload(planner)).toMatchObject({ reservation_end_time: null })
})
it('FE-MOB-RESSH-049: a hotel with only an end day and no address stays a name-only venue', async () => {
const { planner } = setup()
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.hotel' }))
type(titleField(), 'Ibis')
pick(selects()[2], 13)
fireEvent.click(submitBtn())
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
expect(savedPayload(planner)).toMatchObject({
location: '',
create_accommodation: {
place_id: null,
venue: { name: 'Ibis', address: null },
address: null,
start_day_id: 13, end_day_id: 13,
},
})
})
it('FE-MOB-RESSH-050: a later start day inside the range leaves the end day alone', () => {
setup()
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.hotel' }))
pick(selects()[2], 13)
pick(selects()[1], 12)
expect(selects()[1]).toHaveAttribute('data-value', '12')
expect(selects()[2]).toHaveAttribute('data-value', '13')
})
it('FE-MOB-RESSH-051: a hotel place without an address keeps the typed one', () => {
setup()
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.hotel' }))
type(screen.getByPlaceholderText('reservations.locationPlaceholder'), 'Manual 3')
pick(screen.getByLabelText('reservations.meta.pickHotel') as HTMLSelectElement, 102)
expect(titleField()).toHaveValue('Cafe Central')
expect(screen.getByPlaceholderText('reservations.locationPlaceholder')).toHaveValue('Manual 3')
})
})
@@ -0,0 +1,363 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MTaskSheet from '../../../../src/mobile/screens/trip/tabs/MTaskSheet'
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import type { TodoItem, TripMember } from '../../../../src/types'
import { buildPlanner, buildToast, buildTripActions } from '../../../helpers/mobileTrip'
import { resetAllStores } from '../../../helpers/store'
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-TASK-001 to FE-MOB-TASK-020
const MEMBERS = [
{ id: 7, username: 'maurice', avatar: null, avatar_url: null },
{ id: 8, username: 'lea', avatar: 'lea.png', avatar_url: null },
] as unknown as TripMember[]
function todo(overrides: Partial<TodoItem> = {}): TodoItem {
return {
id: 42,
trip_id: 1,
name: 'Book flights',
category: 'Admin',
checked: 0,
sort_order: 0,
due_date: '2026-08-01',
description: 'Direct if possible',
assigned_user_id: 8,
priority: 2,
...overrides,
}
}
interface SetupOptions {
items?: TodoItem[]
itemId?: number | null
categories?: string[]
defaultCategory?: string | null
open?: boolean
}
function setup({ items = [], itemId = null, categories = ['Admin', 'Gear'], defaultCategory = null, open = true }: SetupOptions = {}) {
const actions = buildTripActions()
const toast = buildToast()
const onClose = vi.fn()
const planner = buildPlanner({
todoItems: items,
tripActions: actions as unknown as TripPlanner['tripActions'],
toast: toast as unknown as TripPlanner['toast'],
})
const view = render(
<MTaskSheet
planner={planner}
open={open}
itemId={itemId}
categories={categories}
members={MEMBERS}
defaultCategory={defaultCategory}
onClose={onClose}
/>,
)
return { ...view, actions, toast, onClose, planner }
}
describe('MTaskSheet', () => {
beforeEach(() => {
resetAllStores()
})
it('FE-MOB-TASK-001: stays unmounted while closed', () => {
setup({ open: false })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-TASK-002: opens in create mode with empty fields and no delete action', () => {
setup()
expect(screen.getAllByText('todo.newItem').length).toBeGreaterThan(0)
expect(screen.getByPlaceholderText('todo.namePlaceholder')).toHaveValue('')
expect(screen.getByPlaceholderText('todo.descriptionPlaceholder')).toHaveValue('')
expect(screen.getByRole('button', { name: 'todo.detail.create' })).toBeDisabled()
expect(screen.queryByRole('button', { name: 'todo.detail.delete' })).not.toBeInTheDocument()
})
it('FE-MOB-TASK-003: enables the submit only once a non-blank name is typed', () => {
setup()
const submit = screen.getByRole('button', { name: 'todo.detail.create' })
fireEvent.change(screen.getByPlaceholderText('todo.namePlaceholder'), { target: { value: ' ' } })
expect(submit).toBeDisabled()
fireEvent.change(screen.getByPlaceholderText('todo.namePlaceholder'), { target: { value: 'Visa' } })
expect(submit).toBeEnabled()
})
it('FE-MOB-TASK-004: creates a task with the trimmed name and undefined optionals', async () => {
const { actions, onClose } = setup()
fireEvent.change(screen.getByPlaceholderText('todo.namePlaceholder'), { target: { value: ' Visa ' } })
fireEvent.click(screen.getByRole('button', { name: 'todo.detail.create' }))
await waitFor(() => expect(actions.addTodoItem).toHaveBeenCalledWith(1, {
name: 'Visa',
description: undefined,
due_date: undefined,
category: undefined,
assigned_user_id: undefined,
priority: 0,
}))
expect(onClose).toHaveBeenCalled()
})
it('FE-MOB-TASK-005: seeds the category from defaultCategory and sends it on create', async () => {
const { actions } = setup({ defaultCategory: 'Gear' })
fireEvent.change(screen.getByPlaceholderText('todo.namePlaceholder'), { target: { value: 'Boots' } })
fireEvent.click(screen.getByRole('button', { name: 'todo.detail.create' }))
await waitFor(() => expect(actions.addTodoItem).toHaveBeenCalledWith(1, expect.objectContaining({ category: 'Gear' })))
})
it('FE-MOB-TASK-006: sends description, priority and assignee picked in the form', async () => {
const { actions } = setup()
fireEvent.change(screen.getByPlaceholderText('todo.namePlaceholder'), { target: { value: 'Visa' } })
fireEvent.change(screen.getByPlaceholderText('todo.descriptionPlaceholder'), { target: { value: 'Embassy' } })
fireEvent.click(screen.getByRole('button', { name: /^P1$/ }))
fireEvent.click(screen.getByRole('button', { name: /lea/ }))
fireEvent.click(screen.getByRole('button', { name: 'todo.detail.create' }))
await waitFor(() => expect(actions.addTodoItem).toHaveBeenCalledWith(1, expect.objectContaining({
description: 'Embassy',
priority: 1,
assigned_user_id: 8,
})))
})
it('FE-MOB-TASK-007: only submits once while a save is in flight', async () => {
const { actions } = setup()
let release = () => {}
actions.addTodoItem.mockImplementation(() => new Promise<void>(resolve => { release = () => resolve() }))
fireEvent.change(screen.getByPlaceholderText('todo.namePlaceholder'), { target: { value: 'Visa' } })
const submit = screen.getByRole('button', { name: 'todo.detail.create' })
fireEvent.click(submit)
fireEvent.click(submit)
expect(actions.addTodoItem).toHaveBeenCalledTimes(1)
release()
await waitFor(() => expect(submit).toBeEnabled())
})
it('FE-MOB-TASK-008: toasts and keeps the sheet open when creating fails', async () => {
const { actions, toast, onClose } = setup()
actions.addTodoItem.mockRejectedValue(new Error('boom'))
fireEvent.change(screen.getByPlaceholderText('todo.namePlaceholder'), { target: { value: 'Visa' } })
fireEvent.click(screen.getByRole('button', { name: 'todo.detail.create' }))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('common.error'))
expect(onClose).not.toHaveBeenCalled()
})
it('FE-MOB-TASK-009: prefills every field in edit mode', () => {
setup({ items: [todo()], itemId: 42 })
expect(screen.getAllByText('todo.detail.title').length).toBeGreaterThan(0)
expect(screen.getByPlaceholderText('todo.namePlaceholder')).toHaveValue('Book flights')
expect(screen.getByPlaceholderText('todo.descriptionPlaceholder')).toHaveValue('Direct if possible')
expect(screen.getByRole('button', { name: 'todo.detail.save' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'todo.detail.delete' })).toBeInTheDocument()
})
it('FE-MOB-TASK-010: updates a task and nulls out the cleared optionals', async () => {
const { actions, onClose } = setup({ items: [todo({ description: null, due_date: null, category: null, assigned_user_id: null, priority: 0 })], itemId: 42 })
fireEvent.change(screen.getByPlaceholderText('todo.namePlaceholder'), { target: { value: 'Book trains' } })
fireEvent.click(screen.getByRole('button', { name: 'todo.detail.save' }))
await waitFor(() => expect(actions.updateTodoItem).toHaveBeenCalledWith(1, 42, {
name: 'Book trains',
description: null,
due_date: null,
category: null,
assigned_user_id: null,
priority: 0,
}))
expect(onClose).toHaveBeenCalled()
})
it('FE-MOB-TASK-011: keeps the stored due date and category when only the name changes', async () => {
const { actions } = setup({ items: [todo()], itemId: 42 })
fireEvent.change(screen.getByPlaceholderText('todo.namePlaceholder'), { target: { value: 'Book trains' } })
fireEvent.click(screen.getByRole('button', { name: 'todo.detail.save' }))
await waitFor(() => expect(actions.updateTodoItem).toHaveBeenCalledWith(1, 42, expect.objectContaining({
due_date: '2026-08-01',
category: 'Admin',
assigned_user_id: 8,
priority: 2,
})))
})
it('FE-MOB-TASK-012: toasts when updating fails', async () => {
const { actions, toast, onClose } = setup({ items: [todo()], itemId: 42 })
actions.updateTodoItem.mockRejectedValue(new Error('boom'))
fireEvent.click(screen.getByRole('button', { name: 'todo.detail.save' }))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('common.error'))
expect(onClose).not.toHaveBeenCalled()
})
it('FE-MOB-TASK-013: deletes the task and closes', async () => {
const { actions, onClose } = setup({ items: [todo()], itemId: 42 })
fireEvent.click(screen.getByRole('button', { name: 'todo.detail.delete' }))
await waitFor(() => expect(actions.deleteTodoItem).toHaveBeenCalledWith(1, 42))
expect(onClose).toHaveBeenCalled()
})
it('FE-MOB-TASK-014: toasts when deleting fails', async () => {
const { actions, toast, onClose } = setup({ items: [todo()], itemId: 42 })
actions.deleteTodoItem.mockRejectedValue(new Error('boom'))
fireEvent.click(screen.getByRole('button', { name: 'todo.detail.delete' }))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('common.error'))
expect(onClose).not.toHaveBeenCalled()
})
it('FE-MOB-TASK-015: holds on to the last known row when it vanishes from the store', () => {
const { rerender, planner } = setup({ items: [todo()], itemId: 42 })
expect(screen.getByPlaceholderText('todo.namePlaceholder')).toHaveValue('Book flights')
const emptied = { ...planner, todoItems: [] } as unknown as TripPlanner
rerender(
<MTaskSheet
planner={emptied}
open
itemId={42}
categories={['Admin', 'Gear']}
members={MEMBERS}
defaultCategory={null}
onClose={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: 'todo.detail.save' })).toBeInTheDocument()
expect(screen.getByPlaceholderText('todo.namePlaceholder')).toHaveValue('Book flights')
})
it('FE-MOB-TASK-016: switches the category between the offered pills', async () => {
const { actions } = setup({ items: [todo()], itemId: 42 })
fireEvent.click(screen.getByRole('button', { name: 'Gear' }))
fireEvent.click(screen.getByRole('button', { name: 'todo.detail.save' }))
await waitFor(() => expect(actions.updateTodoItem).toHaveBeenCalledWith(1, 42, expect.objectContaining({ category: 'Gear' })))
})
it('FE-MOB-TASK-017: clears the category via the "no category" pill', async () => {
const { actions } = setup({ items: [todo()], itemId: 42 })
fireEvent.click(screen.getByRole('button', { name: 'todo.noCategory' }))
fireEvent.click(screen.getByRole('button', { name: 'todo.detail.save' }))
await waitFor(() => expect(actions.updateTodoItem).toHaveBeenCalledWith(1, 42, expect.objectContaining({ category: null })))
})
it('FE-MOB-TASK-018: keeps a stored category that is not in the offered list', async () => {
const { actions } = setup({ items: [todo({ category: 'Legacy' })], itemId: 42, categories: ['Admin'] })
fireEvent.click(screen.getByRole('button', { name: 'Legacy' }))
fireEvent.click(screen.getByRole('button', { name: 'todo.detail.save' }))
await waitFor(() => expect(actions.updateTodoItem).toHaveBeenCalledWith(1, 42, expect.objectContaining({ category: 'Legacy' })))
})
it('FE-MOB-TASK-019: adds a new category with the confirm button', async () => {
const { actions } = setup()
fireEvent.change(screen.getByPlaceholderText('todo.namePlaceholder'), { target: { value: 'Visa' } })
fireEvent.click(screen.getByRole('button', { name: /todo.addCategory/ }))
expect(screen.getByRole('button', { name: 'common.add' })).toBeDisabled()
fireEvent.change(screen.getByPlaceholderText('todo.newCategory'), { target: { value: ' Docs ' } })
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
expect(screen.getByRole('button', { name: 'Docs' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'todo.detail.create' }))
await waitFor(() => expect(actions.addTodoItem).toHaveBeenCalledWith(1, expect.objectContaining({ category: 'Docs' })))
})
it('FE-MOB-TASK-020: commits a new category on Enter and abandons it on Escape', () => {
setup()
fireEvent.click(screen.getByRole('button', { name: /todo.addCategory/ }))
fireEvent.change(screen.getByPlaceholderText('todo.newCategory'), { target: { value: 'Docs' } })
fireEvent.keyDown(screen.getByPlaceholderText('todo.newCategory'), { key: 'Enter' })
expect(screen.getByRole('button', { name: 'Docs' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /todo.addCategory/ }))
fireEvent.change(screen.getByPlaceholderText('todo.newCategory'), { target: { value: 'Ignored' } })
fireEvent.keyDown(screen.getByPlaceholderText('todo.newCategory'), { key: 'Escape' })
expect(screen.queryByPlaceholderText('todo.newCategory')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Ignored' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Docs' })).toBeInTheDocument()
})
it('FE-MOB-TASK-021: keeps the current category when the draft is blank', () => {
setup({ items: [todo()], itemId: 42 })
fireEvent.click(screen.getByRole('button', { name: /todo.addCategory/ }))
fireEvent.change(screen.getByPlaceholderText('todo.newCategory'), { target: { value: ' ' } })
fireEvent.keyDown(screen.getByPlaceholderText('todo.newCategory'), { key: 'Enter' })
expect(screen.queryByPlaceholderText('todo.newCategory')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Admin' })).toBeInTheDocument()
})
it('FE-MOB-TASK-022: unassigns a task again', async () => {
const { actions } = setup({ items: [todo()], itemId: 42 })
expect(document.querySelector('img[src="/uploads/avatars/lea.png"]')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /todo.unassigned/ }))
fireEvent.click(screen.getByRole('button', { name: 'todo.detail.save' }))
await waitFor(() => expect(actions.updateTodoItem).toHaveBeenCalledWith(1, 42, expect.objectContaining({ assigned_user_id: null })))
})
it('FE-MOB-TASK-023: falls back to the initial for a member without an avatar', () => {
setup()
const chip = screen.getByRole('button', { name: /maurice/ })
expect(chip).toHaveTextContent('M')
expect(chip.querySelector('img')).toBeNull()
})
it('FE-MOB-TASK-024: resets the form when the same sheet is reused for another task', () => {
const items = [todo(), todo({ id: 43, name: 'Renew passport', description: null, category: null, assigned_user_id: null, priority: 0 })]
const { rerender, planner } = setup({ items, itemId: 42 })
expect(screen.getByPlaceholderText('todo.namePlaceholder')).toHaveValue('Book flights')
rerender(
<MTaskSheet
planner={planner}
open
itemId={43}
categories={['Admin', 'Gear']}
members={MEMBERS}
defaultCategory={null}
onClose={vi.fn()}
/>,
)
expect(screen.getByPlaceholderText('todo.namePlaceholder')).toHaveValue('Renew passport')
expect(screen.getByPlaceholderText('todo.descriptionPlaceholder')).toHaveValue('')
})
})
@@ -0,0 +1,296 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MTodoListTab from '../../../../src/mobile/screens/trip/tabs/MTodoListTab'
import type { MTaskSheetProps } from '../../../../src/mobile/screens/trip/tabs/MTaskSheet'
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { useAuthStore } from '../../../../src/store/authStore'
import type { TodoItem, TripMember } from '../../../../src/types'
import { buildUser } from '../../../helpers/factories'
import { buildPlanner, buildTripActions } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { fireEvent, render, screen } from '../../../helpers/render'
// FE-MOB-TODO-001 to FE-MOB-TODO-016
// The sheet has its own suite; here it only has to report the props the tab feeds it.
vi.mock('../../../../src/mobile/screens/trip/tabs/MTaskSheet', () => ({
default: (props: MTaskSheetProps) => (
<div
data-testid="task-sheet"
data-open={String(props.open)}
data-item-id={String(props.itemId)}
data-default-category={String(props.defaultCategory)}
data-categories={props.categories.join('|')}
data-members={props.members.map(m => m.username).join('|')}
/>
),
}))
const ME = 7
const PAST = '2020-01-05'
const FUTURE = '2999-12-31'
const MEMBERS = [
{ id: ME, username: 'maurice', avatar: null, avatar_url: null },
{ id: 8, username: 'lea', avatar: null, avatar_url: '/uploads/avatars/lea.png' },
] as unknown as TripMember[]
function todo(overrides: Partial<TodoItem> = {}): TodoItem {
return {
id: 1,
trip_id: 1,
name: 'Book flights',
category: null,
checked: 0,
sort_order: 0,
due_date: null,
description: null,
assigned_user_id: null,
priority: 0,
...overrides,
}
}
function setup(items: TodoItem[], plannerOverrides: Partial<TripPlanner> = {}) {
const actions = buildTripActions()
const planner = buildPlanner({
todoItems: items,
tripMembers: MEMBERS,
tripActions: actions as unknown as TripPlanner['tripActions'],
...plannerOverrides,
})
const view = render(<MTodoListTab planner={planner} />)
return { ...view, actions, planner }
}
function sheet() {
return screen.getByTestId('task-sheet')
}
/** Row order, read off the check buttons — they are the only ones carrying the task name as aria-label. */
function rowOrder(names: string[]) {
return screen
.getAllByRole('button')
.map(b => b.getAttribute('aria-label'))
.filter((label): label is string => !!label && names.includes(label))
}
describe('MTodoListTab', () => {
beforeEach(() => {
resetAllStores()
seedStore(useAuthStore, { user: buildUser({ id: ME, username: 'maurice' }) })
})
it('FE-MOB-TODO-001: renders the empty state and no filter row without items', () => {
setup([])
expect(screen.getByText('todo.empty')).toBeInTheDocument()
expect(screen.getByText('0/0')).toBeInTheDocument()
expect(screen.getByText('0% · todo.completed')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /todo.filter.all/ })).not.toBeInTheDocument()
})
it('FE-MOB-TODO-002: reports done/total and the completion percentage', () => {
setup([todo({ id: 1 }), todo({ id: 2, checked: 1 }), todo({ id: 3, checked: 1 }), todo({ id: 4 })])
expect(screen.getByText('2/4')).toBeInTheDocument()
expect(screen.getByText('50% · todo.completed')).toBeInTheDocument()
})
it('FE-MOB-TODO-003: hides "new task" without packing_edit', () => {
setup([todo()], { can: vi.fn(() => false) as unknown as TripPlanner['can'] })
expect(screen.queryByRole('button', { name: /todo.newItem/ })).not.toBeInTheDocument()
expect(screen.getByText('Book flights')).toBeInTheDocument()
})
it('FE-MOB-TODO-004: shows the four built-in filters with their counts', () => {
setup([
todo({ id: 1, name: 'Open' }),
todo({ id: 2, name: 'Mine', assigned_user_id: ME }),
todo({ id: 3, name: 'Late', due_date: PAST }),
todo({ id: 4, name: 'Done', checked: 1 }),
])
expect(screen.getByRole('button', { name: 'todo.filter.all3' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'todo.filter.my1' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'todo.filter.overdue1' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'todo.filter.done1' })).toBeInTheDocument()
})
it('FE-MOB-TODO-005: defaults to the open items and hides done ones', () => {
setup([todo({ id: 1, name: 'Open' }), todo({ id: 2, name: 'Packed', checked: 1 })])
expect(screen.getByText('Open')).toBeInTheDocument()
expect(screen.queryByText('Packed')).not.toBeInTheDocument()
})
it('FE-MOB-TODO-006: switches to the my / overdue / done buckets', () => {
setup([
todo({ id: 1, name: 'Open' }),
todo({ id: 2, name: 'Mine', assigned_user_id: ME }),
todo({ id: 3, name: 'Late', due_date: PAST }),
todo({ id: 4, name: 'Packed', checked: 1 }),
])
fireEvent.click(screen.getByRole('button', { name: /todo.filter.my/ }))
expect(screen.getByText('Mine')).toBeInTheDocument()
expect(screen.queryByText('Open')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /todo.filter.overdue/ }))
expect(screen.getByText('Late')).toBeInTheDocument()
expect(screen.queryByText('Mine')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /todo.filter.done/ }))
expect(screen.getByText('Packed')).toBeInTheDocument()
expect(screen.queryByText('Late')).not.toBeInTheDocument()
})
it('FE-MOB-TODO-006b: leaves the "my" bucket empty when nobody is signed in', () => {
seedStore(useAuthStore, { user: null })
setup([todo({ id: 1, name: 'Mine', assigned_user_id: ME })])
expect(screen.getByRole('button', { name: 'todo.filter.my0' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /todo.filter.my/ }))
expect(screen.getByText('todo.emptyFiltered')).toBeInTheDocument()
})
it('FE-MOB-TODO-007: shows the filtered-empty hint when a bucket is empty', () => {
setup([todo({ id: 1, name: 'Open' })])
fireEvent.click(screen.getByRole('button', { name: /todo.filter.done/ }))
expect(screen.getByText('todo.emptyFiltered')).toBeInTheDocument()
expect(screen.queryByText('todo.empty')).not.toBeInTheDocument()
})
it('FE-MOB-TODO-008: toggles the priority sort and reorders the rows', () => {
setup([
todo({ id: 1, name: 'Low', priority: 3 }),
todo({ id: 2, name: 'High', priority: 1 }),
])
expect(rowOrder(['Low', 'High'])).toEqual(['Low', 'High'])
const sortButton = screen.getByRole('button', { name: /todo.priority/ })
expect(sortButton).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(sortButton)
expect(sortButton).toHaveAttribute('aria-pressed', 'true')
expect(rowOrder(['Low', 'High'])).toEqual(['High', 'Low'])
})
it('FE-MOB-TODO-009: floats overdue rows above the other open ones', () => {
setup([
todo({ id: 1, name: 'Open' }),
todo({ id: 2, name: 'Packed', checked: 1 }),
todo({ id: 3, name: 'Late', due_date: PAST }),
])
expect(rowOrder(['Open', 'Packed', 'Late'])).toEqual(['Late', 'Open'])
})
it('FE-MOB-TODO-009b: sinks done rows to the end of a category bucket', () => {
setup([
todo({ id: 1, name: 'Packed', category: 'Gear', checked: 1 }),
todo({ id: 2, name: 'Boots', category: 'Gear' }),
])
fireEvent.click(screen.getByRole('button', { name: 'Gear1' }))
expect(rowOrder(['Packed', 'Boots'])).toEqual(['Boots', 'Packed'])
})
it('FE-MOB-TODO-010: renders a category rail with open counts and filters by it', () => {
setup([
todo({ id: 1, name: 'Visa', category: 'Admin' }),
todo({ id: 2, name: 'Insurance', category: 'Admin', checked: 1 }),
todo({ id: 3, name: 'Boots', category: 'Gear' }),
])
expect(screen.getByRole('button', { name: 'Admin1' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Gear1' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Admin1' }))
// the category bucket keeps done items visible
expect(screen.getByText('Visa')).toBeInTheDocument()
expect(screen.getByText('Insurance')).toBeInTheDocument()
expect(screen.queryByText('Boots')).not.toBeInTheDocument()
})
it('FE-MOB-TODO-011: toggles a task through the store action', () => {
const { actions } = setup([todo({ id: 12, name: 'Book flights' })])
fireEvent.click(screen.getByRole('button', { name: 'Book flights', pressed: false }))
expect(actions.toggleTodoItem).toHaveBeenCalledWith(1, 12, true)
})
it('FE-MOB-TODO-012: unchecks an already done task', () => {
const { actions } = setup([todo({ id: 12, name: 'Book flights', checked: 1 })])
fireEvent.click(screen.getByRole('button', { name: /todo.filter.done/ }))
fireEvent.click(screen.getByRole('button', { name: 'Book flights', pressed: true }))
expect(actions.toggleTodoItem).toHaveBeenCalledWith(1, 12, false)
})
it('FE-MOB-TODO-013: renders description, priority, due date and assignee on a card', () => {
setup([todo({
id: 3,
name: 'Book flights',
description: 'Direct if possible',
priority: 1,
due_date: PAST,
assigned_user_id: 8,
})])
expect(screen.getByText('Direct if possible')).toBeInTheDocument()
expect(screen.getByText('P1')).toBeInTheDocument()
expect(screen.getByText(/Jan/)).toBeInTheDocument()
expect(screen.getByText('lea')).toBeInTheDocument()
expect(document.querySelector('img[src="/uploads/avatars/lea.png"]')).toBeInTheDocument()
})
it('FE-MOB-TODO-014: falls back to the username initial when a member has no avatar', () => {
setup([todo({ id: 3, due_date: FUTURE, assigned_user_id: ME })])
expect(screen.getByText('maurice')).toBeInTheDocument()
expect(screen.getByText('M')).toBeInTheDocument()
expect(document.querySelector('img')).toBeNull()
})
it('FE-MOB-TODO-015: opens the sheet in create mode and closes it again', () => {
setup([todo({ id: 3, category: 'Admin' })])
expect(sheet()).toHaveAttribute('data-open', 'false')
expect(sheet()).toHaveAttribute('data-categories', 'Admin')
expect(sheet()).toHaveAttribute('data-members', 'maurice|lea')
fireEvent.click(screen.getByRole('button', { name: /todo.newItem/ }))
expect(sheet()).toHaveAttribute('data-open', 'true')
expect(sheet()).toHaveAttribute('data-item-id', 'null')
expect(sheet()).toHaveAttribute('data-default-category', 'null')
})
it('FE-MOB-TODO-016: seeds the new task with the active category filter', () => {
setup([todo({ id: 3, name: 'Visa', category: 'Admin' })])
fireEvent.click(screen.getByRole('button', { name: 'Admin1' }))
fireEvent.click(screen.getByRole('button', { name: /todo.newItem/ }))
expect(sheet()).toHaveAttribute('data-default-category', 'Admin')
})
it('FE-MOB-TODO-017: opens the sheet in edit mode for the tapped row', () => {
setup([todo({ id: 42, name: 'Book flights' })])
fireEvent.click(screen.getByText('Book flights'))
expect(sheet()).toHaveAttribute('data-open', 'true')
expect(sheet()).toHaveAttribute('data-item-id', '42')
})
})
@@ -0,0 +1,877 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MTransportFormSheet from '../../../../src/mobile/screens/trip/sheets/MTransportFormSheet'
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { useAddonStore } from '../../../../src/store/addonStore'
import { useTripStore } from '../../../../src/store/tripStore'
import type { Day, Reservation, TripMember } from '../../../../src/types'
import { buildPlanner } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { act, fireEvent, render, screen } from '../../../helpers/render'
// FE-MOB-TRFRM-001 to FE-MOB-TRFRM-045
//
// The sheet's own pickers (airport/location search, day select, time picker) and
// the embedded transit panel are replaced by minimal controlled stand-ins so the
// form's own state machine and payload assembly are what the tests exercise.
const { AIRPORTS, LOCATIONS } = vi.hoisted(() => ({
AIRPORTS: {
FRA: { iata: 'FRA', icao: null, name: 'Frankfurt Airport', city: 'Frankfurt', country: 'DE', lat: 50.03, lng: 8.57, tz: 'Europe/Berlin' },
IST: { iata: 'IST', icao: null, name: 'Istanbul Airport', city: 'Istanbul', country: 'TR', lat: 41.27, lng: 28.75, tz: 'Europe/Istanbul' },
HND: { iata: 'HND', icao: null, name: 'Haneda Airport', city: 'Tokyo', country: 'JP', lat: 35.55, lng: 139.78, tz: 'Asia/Tokyo' },
} as Record<string, { iata: string; icao: null; name: string; city: string; country: string; lat: number; lng: number; tz: string }>,
LOCATIONS: {
osaka: { name: 'Osaka Station', lat: 34.7, lng: 135.5, address: null },
kyoto: { name: 'Kyoto Station', lat: 34.98, lng: 135.75, address: null },
nara: { name: 'Nara Station', lat: 34.68, lng: 135.82, address: null },
} as Record<string, { name: string; lat: number; lng: number; address: null }>,
}))
vi.mock('../../../../src/components/shared/CustomSelect', () => ({
default: ({ value, onChange, options = [] }: {
value: string | number
onChange: (v: string | number) => void
options?: { value: string | number; label: string }[]
}) => (
<select
aria-label="day-select"
value={String(value)}
onChange={e => {
const opt = options.find(o => String(o.value) === e.target.value)
onChange(opt ? opt.value : e.target.value)
}}
>
{options.map(o => <option key={String(o.value)} value={String(o.value)}>{o.label}</option>)}
</select>
),
}))
vi.mock('../../../../src/components/shared/CustomTimePicker', () => ({
default: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
<input aria-label="time-input" value={value} onChange={e => onChange(e.target.value)} />
),
}))
vi.mock('../../../../src/components/Planner/AirportSelect', () => ({
default: ({ value, onChange }: { value: { iata: string } | null; onChange: (a: unknown) => void }) => (
<input
aria-label="airport-select"
value={value?.iata ?? ''}
onChange={e => onChange(AIRPORTS[e.target.value] ?? null)}
/>
),
}))
vi.mock('../../../../src/components/Planner/LocationSelect', () => ({
default: ({ value, onChange }: { value: { name: string } | null; onChange: (l: unknown) => void }) => (
<input
aria-label="location-select"
value={value?.name ?? ''}
onChange={e => onChange(LOCATIONS[e.target.value] ?? null)}
/>
),
}))
vi.mock('../../../../src/components/Planner/TransitSearchPanel', () => ({
default: ({ day, places, onAdd, initialFrom }: {
day: { id: number }
places: { name: string }[]
onAdd: (p: Record<string, unknown>) => void
initialFrom: { name: string } | null
}) => (
<div>
<span data-testid="transit-day">{day.id}</span>
<span data-testid="transit-places">{places.map(p => p.name).join(',')}</span>
<span data-testid="transit-from">{initialFrom?.name ?? ''}</span>
<button type="button" onClick={() => onAdd({ title: 'U4 to Prater', type: 'transit' })}>add-itinerary</button>
</div>
),
}))
vi.mock('../../../../src/mobile/screens/trip/sheets/PlFileAttach', () => ({
default: ({ files, onAdd, onRemove }: { files: File[]; onAdd: (f: File[]) => void; onRemove: (i: number) => void }) => (
<div>
<button type="button" onClick={() => onAdd([new File(['x'], 'extra.pdf')])}>attach-file</button>
{files.map((f, i) => (
<button key={`${f.name}-${i}`} type="button" onClick={() => onRemove(i)}>{`drop-${f.name}`}</button>
))}
</div>
),
}))
const DAYS = [
{ id: 11, trip_id: 1, day_number: 1, date: '2026-05-01', title: null },
{ id: 12, trip_id: 1, day_number: 2, date: '2026-05-02', title: 'Kyoto' },
{ id: 13, trip_id: 1, day_number: 3, date: '2026-05-03', title: null },
] as unknown as Day[]
const MEMBERS = [
{ id: 4, username: 'maurice', avatar_url: null },
{ id: 5, username: 'julien', avatar_url: '/uploads/avatars/j.png', is_guest: true },
] as unknown as TripMember[]
type SavePayload = Record<string, unknown> & { title: string }
function makeSave(result: unknown = { id: 99 } as unknown) {
return vi.fn(async (_payload: SavePayload) => result)
}
function makePlanner(overrides: Record<string, unknown> = {}): TripPlanner {
return buildPlanner({
days: DAYS,
showTransportModal: true,
trip: { id: 1, title: 'Japan 2026', start_date: '2026-05-01', end_date: '2026-05-03', currency: 'EUR' },
...overrides,
} as unknown as Parameters<typeof buildPlanner>[0])
}
function renderSheet(planner: TripPlanner, onOpenExpense = vi.fn()) {
const view = render(<MTransportFormSheet planner={planner} onOpenExpense={onOpenExpense} />)
return { ...view, planner, onOpenExpense }
}
const daySelects = () => screen.getAllByLabelText('day-select') as HTMLSelectElement[]
const timeInputs = () => screen.getAllByLabelText('time-input') as HTMLInputElement[]
const airportInputs = () => screen.getAllByLabelText('airport-select') as HTMLInputElement[]
const locationInputs = () => screen.getAllByLabelText('location-select') as HTMLInputElement[]
const setValue = (el: HTMLElement, value: string) => fireEvent.change(el, { target: { value } })
const typeTitle = (value: string) =>
setValue(screen.getByPlaceholderText('reservations.titlePlaceholder'), value)
async function submit(label = 'common.add') {
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: label }))
})
}
describe('MTransportFormSheet', () => {
beforeEach(() => {
resetAllStores()
seedStore(useAddonStore, { addons: [{ id: 'budget', name: 'Budget', type: 'addon', icon: '', enabled: true }] })
})
it('FE-MOB-TRFRM-001: renders nothing while the planner has the editor closed', () => {
renderSheet(makePlanner({ showTransportModal: false }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-TRFRM-002: opens in create mode with a disabled save and the manual/automated switch', () => {
renderSheet(makePlanner())
expect(screen.getByRole('dialog', { name: 'transport.modalTitle.create' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.add' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'transport.modeManual' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'transport.modeAutomated' })).toBeInTheDocument()
// Nothing to delete on a fresh booking.
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
})
it('FE-MOB-TRFRM-003: hides the automated switch when the trip has no dates', () => {
renderSheet(makePlanner({ trip: { id: 1, title: 'Someday', start_date: null, end_date: null } }))
expect(screen.queryByRole('button', { name: 'transport.modeAutomated' })).not.toBeInTheDocument()
})
it('FE-MOB-TRFRM-004: offers every transport type and marks the active one', () => {
renderSheet(makePlanner())
for (const type of ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other']) {
expect(screen.getByRole('button', { name: `reservations.type.${type}` })).toBeInTheDocument()
}
expect(screen.getByRole('button', { name: 'reservations.type.flight' })).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.bus' }))
expect(screen.getByRole('button', { name: 'reservations.type.bus' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'reservations.type.flight' })).toHaveAttribute('aria-pressed', 'false')
})
it('FE-MOB-TRFRM-005: a car booking relabels the date rows as pick-up and return', () => {
renderSheet(makePlanner())
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.bus' }))
expect(screen.getByText('reservations.date')).toBeInTheDocument()
expect(screen.getByText('reservations.startTime')).toBeInTheDocument()
expect(screen.getByText('reservations.endDate')).toBeInTheDocument()
expect(screen.getByText('reservations.endTime')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.car' }))
expect(screen.getByText('reservations.pickupDate')).toBeInTheDocument()
expect(screen.getByText('reservations.pickupTime')).toBeInTheDocument()
expect(screen.getByText('reservations.returnDate')).toBeInTheDocument()
expect(screen.getByText('reservations.returnTime')).toBeInTheDocument()
})
it('FE-MOB-TRFRM-006: saves a simple two-endpoint booking with the day-anchored times', async () => {
const handleSaveTransport = makeSave()
renderSheet(makePlanner({ handleSaveTransport }))
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.bus' }))
typeTitle('Bus to Kyoto')
setValue(locationInputs()[0], 'osaka')
setValue(locationInputs()[1], 'kyoto')
setValue(daySelects()[0], '11')
setValue(timeInputs()[0], '08:15')
setValue(daySelects()[1], '12')
setValue(timeInputs()[1], '11:00')
setValue(screen.getByPlaceholderText('reservations.confirmationPlaceholder'), 'BUS-4711')
setValue(screen.getByPlaceholderText('reservations.notesPlaceholder'), 'Seat 4 reserved')
fireEvent.click(screen.getByRole('button', { name: 'reservations.confirmed' }))
await submit()
expect(handleSaveTransport).toHaveBeenCalledTimes(1)
expect(handleSaveTransport.mock.calls[0][0]).toEqual({
title: 'Bus to Kyoto',
type: 'bus',
status: 'confirmed',
day_id: 11,
end_day_id: 12,
reservation_time: '2026-05-01T08:15',
reservation_end_time: '2026-05-02T11:00',
location: null,
confirmation_number: 'BUS-4711',
notes: 'Seat 4 reserved',
metadata: null,
endpoints: [
{ role: 'from', sequence: 0, name: 'Osaka Station', code: null, lat: 34.7, lng: 135.5, timezone: null, local_date: '2026-05-01', local_time: '08:15' },
{ role: 'to', sequence: 1, name: 'Kyoto Station', code: null, lat: 34.98, lng: 135.75, timezone: null, local_date: '2026-05-02', local_time: '11:00' },
],
needs_review: false,
})
})
it('FE-MOB-TRFRM-007: falls back to the start day for the end date and keeps a bare time', async () => {
const handleSaveTransport = makeSave()
renderSheet(makePlanner({ handleSaveTransport, days: [] }))
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.taxi' }))
typeTitle('Airport transfer')
setValue(timeInputs()[0], '05:40')
await submit()
const payload = handleSaveTransport.mock.calls[0][0]
expect(payload.day_id).toBeNull()
expect(payload.end_day_id).toBeNull()
// No day means no date prefix — the bare clock time is stored.
expect(payload.reservation_time).toBe('05:40')
expect(payload.reservation_end_time).toBeNull()
expect(payload.endpoints).toEqual([])
})
it('FE-MOB-TRFRM-008: saves a single-leg flight with the flat airline metadata', async () => {
const handleSaveTransport = makeSave()
renderSheet(makePlanner({ handleSaveTransport }))
typeTitle('LH 716')
setValue(airportInputs()[0], 'FRA')
setValue(airportInputs()[1], 'HND')
setValue(daySelects()[0], '11')
setValue(timeInputs()[0], '10:20')
setValue(daySelects()[1], '12')
setValue(timeInputs()[1], '06:45')
setValue(screen.getByPlaceholderText('Lufthansa'), 'Lufthansa')
setValue(screen.getByPlaceholderText('LH 123'), 'LH 716')
setValue(screen.getByPlaceholderText('12A'), '31A')
await submit()
expect(handleSaveTransport.mock.calls[0][0]).toEqual({
title: 'LH 716',
type: 'flight',
status: 'pending',
day_id: 11,
end_day_id: 12,
reservation_time: '2026-05-01T10:20',
reservation_end_time: '2026-05-02T06:45',
location: null,
confirmation_number: null,
notes: null,
metadata: {
airline: 'Lufthansa',
flight_number: 'LH 716',
seat: '31A',
departure_airport: 'FRA',
departure_timezone: 'Europe/Berlin',
arrival_airport: 'HND',
arrival_timezone: 'Asia/Tokyo',
},
endpoints: [
{ role: 'from', sequence: 0, name: 'Frankfurt (FRA)', code: 'FRA', lat: 50.03, lng: 8.57, timezone: 'Europe/Berlin', local_date: '2026-05-01', local_time: '10:20' },
{ role: 'to', sequence: 1, name: 'Tokyo (HND)', code: 'HND', lat: 35.55, lng: 139.78, timezone: 'Asia/Tokyo', local_date: '2026-05-02', local_time: '06:45' },
],
needs_review: false,
})
})
it('FE-MOB-TRFRM-009: adds a layover and writes one metadata leg per segment', async () => {
const handleSaveTransport = makeSave()
renderSheet(makePlanner({ handleSaveTransport }))
typeTitle('FRA-IST-HND')
fireEvent.click(screen.getByRole('button', { name: 'reservations.layover.addStop' }))
expect(screen.getAllByLabelText('airport-select')).toHaveLength(3)
expect(screen.getByText('reservations.layover.stop')).toBeInTheDocument()
setValue(airportInputs()[0], 'FRA')
setValue(airportInputs()[1], 'IST')
setValue(airportInputs()[2], 'HND')
// selects: dep(0) | arr(1) dep(1) | arr(2)
setValue(daySelects()[0], '11')
setValue(timeInputs()[0], '10:20')
setValue(daySelects()[1], '11')
setValue(timeInputs()[1], '14:05')
setValue(daySelects()[2], '11')
setValue(timeInputs()[2], '16:30')
setValue(daySelects()[3], '12')
setValue(timeInputs()[3], '09:00')
const airlines = screen.getAllByPlaceholderText('Lufthansa')
setValue(airlines[0], 'Lufthansa')
setValue(airlines[1], 'Turkish Airlines')
const flightNumbers = screen.getAllByPlaceholderText('LH 123')
setValue(flightNumbers[0], 'LH 1300')
setValue(flightNumbers[1], 'TK 46')
setValue(screen.getAllByPlaceholderText('12A')[0], '2A')
await submit()
const payload = handleSaveTransport.mock.calls[0][0]
expect(payload.metadata).toEqual({
airline: 'Lufthansa',
flight_number: 'LH 1300',
seat: '2A',
departure_airport: 'FRA',
departure_timezone: 'Europe/Berlin',
arrival_airport: 'HND',
arrival_timezone: 'Asia/Tokyo',
legs: [
{ from: 'FRA', to: 'IST', airline: 'Lufthansa', flight_number: 'LH 1300', seat: '2A', dep_day_id: 11, dep_time: '10:20', arr_day_id: 11, arr_time: '14:05' },
{ from: 'IST', to: 'HND', airline: 'Turkish Airlines', flight_number: 'TK 46', dep_day_id: 11, dep_time: '16:30', arr_day_id: 12, arr_time: '09:00' },
],
})
expect(payload.endpoints).toEqual([
expect.objectContaining({ role: 'from', sequence: 0, code: 'FRA', local_time: '10:20' }),
expect.objectContaining({ role: 'stop', sequence: 1, code: 'IST', local_time: '16:30' }),
expect.objectContaining({ role: 'to', sequence: 2, code: 'HND', local_time: '09:00' }),
])
expect(payload.day_id).toBe(11)
expect(payload.end_day_id).toBe(12)
})
it('FE-MOB-TRFRM-010: removing a layover falls back to the flat single-leg shape', async () => {
const handleSaveTransport = makeSave()
renderSheet(makePlanner({ handleSaveTransport }))
typeTitle('FRA-HND')
fireEvent.click(screen.getByRole('button', { name: 'reservations.layover.addStop' }))
expect(airportInputs()).toHaveLength(3)
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
expect(airportInputs()).toHaveLength(2)
setValue(airportInputs()[0], 'FRA')
setValue(airportInputs()[1], 'HND')
await submit()
expect(handleSaveTransport.mock.calls[0][0].metadata).not.toHaveProperty('legs')
})
it('FE-MOB-TRFRM-011: a flight without any picked airport saves no metadata and no endpoints', async () => {
const handleSaveTransport = makeSave()
renderSheet(makePlanner({ handleSaveTransport }))
typeTitle('Placeholder flight')
await submit()
const payload = handleSaveTransport.mock.calls[0][0]
expect(payload.metadata).toBeNull()
expect(payload.endpoints).toEqual([])
expect(payload.day_id).toBeNull()
})
it('FE-MOB-TRFRM-012: saves a single-leg train with train number, platform and seat', async () => {
const handleSaveTransport = makeSave()
renderSheet(makePlanner({ handleSaveTransport }))
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.train' }))
typeTitle('ICE 599')
setValue(locationInputs()[0], 'osaka')
setValue(locationInputs()[1], 'kyoto')
setValue(daySelects()[0], '12')
setValue(timeInputs()[0], '07:45')
setValue(daySelects()[1], '12')
setValue(timeInputs()[1], '09:12')
setValue(screen.getByPlaceholderText('ICE 123'), 'ICE 599')
setValue(screen.getByPlaceholderText('12'), '7')
setValue(screen.getByPlaceholderText('42A'), '12C')
await submit()
const payload = handleSaveTransport.mock.calls[0][0]
expect(payload.metadata).toEqual({ train_number: 'ICE 599', platform: '7', seat: '12C' })
expect(payload.day_id).toBe(12)
expect(payload.end_day_id).toBe(12)
expect(payload.reservation_time).toBe('2026-05-02T07:45')
expect(payload.reservation_end_time).toBe('2026-05-02T09:12')
expect(payload.endpoints).toEqual([
{ role: 'from', sequence: 0, name: 'Osaka Station', code: null, lat: 34.7, lng: 135.5, timezone: null, local_date: '2026-05-02', local_time: '07:45' },
{ role: 'to', sequence: 1, name: 'Kyoto Station', code: null, lat: 34.98, lng: 135.75, timezone: null, local_date: '2026-05-02', local_time: '09:12' },
])
})
it('FE-MOB-TRFRM-013: a multi-stop train writes per-leg train numbers and stop endpoints', async () => {
const handleSaveTransport = makeSave()
renderSheet(makePlanner({ handleSaveTransport }))
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.train' }))
typeTitle('Osaka via Nara')
fireEvent.click(screen.getByRole('button', { name: 'reservations.layover.addStop' }))
setValue(locationInputs()[0], 'osaka')
setValue(locationInputs()[1], 'nara')
setValue(locationInputs()[2], 'kyoto')
setValue(daySelects()[0], '12')
setValue(timeInputs()[0], '08:00')
setValue(daySelects()[1], '12')
setValue(timeInputs()[1], '08:40')
setValue(daySelects()[2], '12')
setValue(timeInputs()[2], '09:00')
setValue(daySelects()[3], '12')
setValue(timeInputs()[3], '09:35')
const trainNumbers = screen.getAllByPlaceholderText('ICE 123')
setValue(trainNumbers[0], 'JR 12')
setValue(trainNumbers[1], 'JR 88')
setValue(screen.getAllByPlaceholderText('12')[0], '3')
await submit()
const payload = handleSaveTransport.mock.calls[0][0]
expect(payload.metadata).toEqual({
train_number: 'JR 12',
platform: '3',
legs: [
{ from: 'Osaka Station', to: 'Nara Station', train_number: 'JR 12', platform: '3', dep_day_id: 12, dep_time: '08:00', arr_day_id: 12, arr_time: '08:40' },
{ from: 'Nara Station', to: 'Kyoto Station', train_number: 'JR 88', dep_day_id: 12, dep_time: '09:00', arr_day_id: 12, arr_time: '09:35' },
],
})
expect(payload.endpoints).toEqual([
expect.objectContaining({ role: 'from', sequence: 0, name: 'Osaka Station' }),
expect.objectContaining({ role: 'stop', sequence: 1, name: 'Nara Station' }),
expect.objectContaining({ role: 'to', sequence: 2, name: 'Kyoto Station' }),
])
})
it('FE-MOB-TRFRM-043: removing a train stop restores the two-station route', () => {
renderSheet(makePlanner())
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.train' }))
fireEvent.click(screen.getAllByRole('button', { name: 'reservations.layover.addStop' })[0])
expect(locationInputs()).toHaveLength(3)
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
expect(locationInputs()).toHaveLength(2)
})
it('FE-MOB-TRFRM-044: seeds an edited train route from its ordered station endpoints', () => {
const editingTransport = {
id: 51, trip_id: 1, type: 'train', title: 'Osaka via Nara', status: 'pending',
day_id: 12, end_day_id: 13, metadata: { train_number: 'JR 12', platform: '3', seat: '5B' },
endpoints: [
{ id: 3, reservation_id: 51, role: 'to', sequence: 2, name: 'Kyoto Station', code: null, lat: 34.98, lng: 135.75, timezone: null, local_date: '2026-05-03', local_time: '09:35' },
{ id: 1, reservation_id: 51, role: 'from', sequence: 0, name: 'Osaka Station', code: null, lat: 34.7, lng: 135.5, timezone: null, local_date: '2026-05-02', local_time: '08:00' },
{ id: 2, reservation_id: 51, role: 'stop', sequence: 1, name: 'Nara Station', code: null, lat: 34.68, lng: 135.82, timezone: null, local_date: '2026-05-02', local_time: '08:40' },
],
} as unknown as Reservation
renderSheet(makePlanner({ editingTransport }))
expect(locationInputs().map(i => i.value)).toEqual(['Osaka Station', 'Nara Station', 'Kyoto Station'])
// The stop's own local_time seeds both its arrival and its departure.
expect(timeInputs().map(i => i.value)).toEqual(['08:00', '08:40', '08:40', '09:35'])
expect(daySelects().map(s => s.value)).toEqual(['12', '', '', '13'])
expect(screen.getAllByPlaceholderText('ICE 123')[0]).toHaveValue('JR 12')
expect(screen.getAllByPlaceholderText('42A')[0]).toHaveValue('5B')
})
it('FE-MOB-TRFRM-045: picking a day in the automated tab reveals the transit search', () => {
renderSheet(makePlanner({ transportModalAutomated: true }))
expect(screen.getByText('transit.pickDay')).toBeInTheDocument()
setValue(daySelects()[0], '13')
expect(screen.queryByText('transit.pickDay')).not.toBeInTheDocument()
expect(screen.getByTestId('transit-day')).toHaveTextContent('13')
})
it('FE-MOB-TRFRM-014: reports a failing save through the toast', async () => {
const planner = makePlanner({ handleSaveTransport: vi.fn(async () => { throw new Error('server said no') }) })
renderSheet(planner)
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.bus' }))
typeTitle('Broken bus')
await submit()
expect(planner.toast.error).toHaveBeenCalledWith('server said no')
expect(screen.getByRole('button', { name: 'common.add' })).toBeEnabled()
})
it('FE-MOB-TRFRM-015: falls back to the generic error text for a non-Error rejection', async () => {
const planner = makePlanner({ handleSaveTransport: vi.fn(async () => { throw 'nope' }) })
renderSheet(planner)
typeTitle('Broken flight')
await submit()
expect(planner.toast.error).toHaveBeenCalledWith('common.unknownError')
})
it('FE-MOB-TRFRM-016: disables and relabels the save button while the request is in flight', async () => {
let release: (v: { id: number }) => void = () => {}
const handleSaveTransport = vi.fn(() => new Promise<{ id: number }>(resolve => { release = resolve }))
renderSheet(makePlanner({ handleSaveTransport }))
typeTitle('Slow flight')
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
const saving = await screen.findByRole('button', { name: 'common.saving' })
expect(saving).toBeDisabled()
await act(async () => { release({ id: 99 }) })
expect(screen.getByRole('button', { name: 'common.add' })).toBeInTheDocument()
})
it('FE-MOB-TRFRM-017: closing resets every editor flag on the planner', () => {
const planner = makePlanner()
renderSheet(planner)
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
expect(planner.setShowTransportModal).toHaveBeenCalledWith(false)
expect(planner.setEditingTransport).toHaveBeenCalledWith(null)
expect(planner.setTransportModalDayId).toHaveBeenCalledWith(null)
expect(planner.setTransportModalAutomated).toHaveBeenCalledWith(false)
expect(planner.setTransitPrefill).toHaveBeenCalledWith(null)
})
it('FE-MOB-TRFRM-018: during an import review the close button advances instead of resetting', () => {
const planner = makePlanner({ importReviewActive: true })
renderSheet(planner)
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
expect(planner.advanceImportReview).toHaveBeenCalledTimes(1)
expect(planner.setShowTransportModal).not.toHaveBeenCalled()
})
it('FE-MOB-TRFRM-019: a successful save during an import review advances to the next item', async () => {
const planner = makePlanner({ importReviewActive: true, handleSaveTransport: makeSave() })
renderSheet(planner)
typeTitle('Imported flight')
await submit()
expect(planner.advanceImportReview).toHaveBeenCalledTimes(1)
})
it('FE-MOB-TRFRM-020: a save that returns nothing does not advance the import review', async () => {
const planner = makePlanner({ importReviewActive: true, handleSaveTransport: makeSave(null) })
renderSheet(planner)
typeTitle('Imported flight')
await submit()
expect(planner.advanceImportReview).not.toHaveBeenCalled()
})
it('FE-MOB-TRFRM-021: seeds the form from the edited flight including the day selects', () => {
const editingTransport = {
id: 42, trip_id: 1, type: 'flight', title: 'LH 716', status: 'confirmed',
day_id: 11, end_day_id: 12,
reservation_time: '2026-05-01T10:20', reservation_end_time: '2026-05-02T06:45',
confirmation_number: 'ABC123', notes: 'Exit row',
metadata: JSON.stringify({ airline: 'Lufthansa', flight_number: 'LH 716', seat: '31A' }),
endpoints: [
{ id: 1, reservation_id: 42, role: 'to', sequence: 1, name: 'Tokyo (HND)', code: 'HND', lat: 35.55, lng: 139.78, timezone: 'Asia/Tokyo', local_date: '2026-05-02', local_time: '06:45' },
{ id: 2, reservation_id: 42, role: 'from', sequence: 0, name: 'Frankfurt (FRA)', code: 'FRA', lat: 50.03, lng: 8.57, timezone: 'Europe/Berlin', local_date: '2026-05-01', local_time: '10:20' },
],
} as unknown as Reservation
renderSheet(makePlanner({ editingTransport }))
expect(screen.getByRole('dialog', { name: 'transport.modalTitle.edit' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.update' })).toBeInTheDocument()
expect(screen.getByPlaceholderText('reservations.titlePlaceholder')).toHaveValue('LH 716')
expect(screen.getByPlaceholderText('reservations.confirmationPlaceholder')).toHaveValue('ABC123')
expect(screen.getByPlaceholderText('reservations.notesPlaceholder')).toHaveValue('Exit row')
expect(screen.getByRole('button', { name: 'reservations.confirmed' }).className).toContain('bg-m-act')
// Endpoints are read in sequence order, not array order.
expect(airportInputs().map(i => i.value)).toEqual(['FRA', 'HND'])
expect(daySelects().map(s => s.value)).toEqual(['11', '12'])
expect(timeInputs().map(i => i.value)).toEqual(['10:20', '06:45'])
expect(screen.getByPlaceholderText('Lufthansa')).toHaveValue('Lufthansa')
expect(screen.getByPlaceholderText('12A')).toHaveValue('31A')
// No mode switch while editing.
expect(screen.queryByRole('button', { name: 'transport.modeAutomated' })).not.toBeInTheDocument()
})
it('FE-MOB-TRFRM-022: seeds a multi-leg flight from metadata.legs and keeps the day positions on re-save', async () => {
const handleSaveTransport = makeSave()
const editingTransport = {
id: 43, trip_id: 1, type: 'flight', title: 'FRA-IST-HND', status: 'pending',
day_id: 11, end_day_id: 12,
metadata: {
legs: [
{ from: 'FRA', to: 'IST', airline: 'Lufthansa', flight_number: 'LH 1300', seat: '2A', dep_day_id: 11, dep_time: '10:20', arr_day_id: 11, arr_time: '14:05', day_positions: { '11': 3 } },
{ from: 'IST', to: 'HND', airline: 'Turkish Airlines', flight_number: 'TK 46', dep_day_id: 11, dep_time: '16:30', arr_day_id: 12, arr_time: '09:00' },
],
},
endpoints: [
{ id: 1, reservation_id: 43, role: 'from', sequence: 0, name: 'Frankfurt (FRA)', code: 'FRA', lat: 50.03, lng: 8.57, timezone: 'Europe/Berlin', local_date: null, local_time: null },
{ id: 2, reservation_id: 43, role: 'stop', sequence: 1, name: 'Istanbul (IST)', code: 'IST', lat: 41.27, lng: 28.75, timezone: 'Europe/Istanbul', local_date: null, local_time: null },
{ id: 3, reservation_id: 43, role: 'to', sequence: 2, name: 'Tokyo (HND)', code: 'HND', lat: 35.55, lng: 139.78, timezone: 'Asia/Tokyo', local_date: null, local_time: null },
],
} as unknown as Reservation
renderSheet(makePlanner({ editingTransport, handleSaveTransport }))
expect(airportInputs().map(i => i.value)).toEqual(['FRA', 'IST', 'HND'])
expect(timeInputs().map(i => i.value)).toEqual(['10:20', '14:05', '16:30', '09:00'])
await submit('common.update')
const legs = (handleSaveTransport.mock.calls[0][0].metadata as { legs: Record<string, unknown>[] }).legs
expect(legs[0]).toMatchObject({ from: 'FRA', to: 'IST', day_positions: { '11': 3 } })
expect(legs[1]).not.toHaveProperty('day_positions')
})
it('FE-MOB-TRFRM-023: an edited flight without endpoints falls back to the reservation times', () => {
const editingTransport = {
id: 44, trip_id: 1, type: 'flight', title: 'Legacy flight', status: 'pending',
day_id: 11, end_day_id: 13,
reservation_time: '2026-05-01T06:00', reservation_end_time: '2026-05-03T21:30',
metadata: { airline: 'Air Legacy', flight_number: 'AL 1', seat: '9F' },
endpoints: [],
} as unknown as Reservation
renderSheet(makePlanner({ editingTransport }))
expect(airportInputs().map(i => i.value)).toEqual(['', ''])
expect(daySelects().map(s => s.value)).toEqual(['11', '13'])
expect(timeInputs().map(i => i.value)).toEqual(['06:00', '21:30'])
expect(screen.getByPlaceholderText('Lufthansa')).toHaveValue('Air Legacy')
})
it('FE-MOB-TRFRM-024: an edited train without endpoints falls back to the flat metadata', () => {
const editingTransport = {
id: 45, trip_id: 1, type: 'train', title: 'Legacy train', status: 'pending',
day_id: 12, reservation_time: '2026-05-02T07:45', reservation_end_time: '2026-05-02T09:12',
metadata: { train_number: 'ICE 599', platform: '7', seat: '12C' },
endpoints: [],
} as unknown as Reservation
renderSheet(makePlanner({ editingTransport }))
expect(screen.getByPlaceholderText('ICE 123')).toHaveValue('ICE 599')
expect(screen.getByPlaceholderText('12')).toHaveValue('7')
expect(screen.getByPlaceholderText('42A')).toHaveValue('12C')
expect(timeInputs().map(i => i.value)).toEqual(['07:45', '09:12'])
})
it('FE-MOB-TRFRM-025: an unknown booking type is edited as a flight', () => {
const editingTransport = {
id: 46, trip_id: 1, type: 'hotel', title: 'Wrong type', status: 'pending', endpoints: [], metadata: null,
} as unknown as Reservation
renderSheet(makePlanner({ editingTransport }))
expect(screen.getByRole('button', { name: 'reservations.type.flight' })).toHaveAttribute('aria-pressed', 'true')
})
it('FE-MOB-TRFRM-026: keeps a transit itinerary and its stops while the endpoints stay put', async () => {
const handleSaveTransport = makeSave()
const editingTransport = {
id: 47, trip_id: 1, type: 'transit', title: 'U4 to Prater', status: 'pending', day_id: 12,
reservation_time: '2026-05-02T09:05', reservation_end_time: '2026-05-02T09:30',
metadata: { transit: { legs: [{ mode: 'SUBWAY', line: 'U4' }] }, airtrail_ids: [7, 8] },
endpoints: [
{ id: 1, reservation_id: 47, role: 'from', sequence: 0, name: 'Osaka Station', code: null, lat: 34.7, lng: 135.5, timezone: null, local_date: '2026-05-02', local_time: '09:05' },
{ id: 2, reservation_id: 47, role: 'stop', sequence: 1, name: 'Change', code: null, lat: 34.8, lng: 135.6, timezone: null, local_date: '2026-05-02', local_time: '09:15' },
{ id: 3, reservation_id: 47, role: 'to', sequence: 2, name: 'Kyoto Station', code: null, lat: 34.98, lng: 135.75, timezone: null, local_date: '2026-05-02', local_time: '09:30' },
],
} as unknown as Reservation
renderSheet(makePlanner({ editingTransport, handleSaveTransport }))
expect(locationInputs().map(i => i.value)).toEqual(['Osaka Station', 'Kyoto Station'])
await submit('common.update')
const payload = handleSaveTransport.mock.calls[0][0]
expect(payload.metadata).toEqual({
transit: { legs: [{ mode: 'SUBWAY', line: 'U4' }] },
airtrail_ids: [7, 8],
})
expect(payload.endpoints).toEqual([
expect.objectContaining({ role: 'from', sequence: 0, name: 'Osaka Station' }),
expect.objectContaining({ role: 'stop', sequence: 1, name: 'Change', local_time: '09:15' }),
expect.objectContaining({ role: 'to', sequence: 2, name: 'Kyoto Station' }),
])
})
it('FE-MOB-TRFRM-027: changing an endpoint drops the stale transit itinerary', async () => {
const handleSaveTransport = makeSave()
const editingTransport = {
id: 48, trip_id: 1, type: 'transit', title: 'U4 to Prater', status: 'pending', day_id: 12,
metadata: { transit: { legs: [{ mode: 'SUBWAY', line: 'U4' }] } },
endpoints: [
{ id: 1, reservation_id: 48, role: 'from', sequence: 0, name: 'Osaka Station', code: null, lat: 34.7, lng: 135.5, timezone: null, local_date: null, local_time: null },
{ id: 2, reservation_id: 48, role: 'stop', sequence: 1, name: 'Change', code: null, lat: 34.8, lng: 135.6, timezone: null, local_date: null, local_time: null },
{ id: 3, reservation_id: 48, role: 'to', sequence: 2, name: 'Kyoto Station', code: null, lat: 34.98, lng: 135.75, timezone: null, local_date: null, local_time: null },
],
} as unknown as Reservation
renderSheet(makePlanner({ editingTransport, handleSaveTransport }))
setValue(locationInputs()[1], 'nara')
await submit('common.update')
const payload = handleSaveTransport.mock.calls[0][0]
expect(payload.metadata).toBeNull()
expect(payload.endpoints).toEqual([
expect.objectContaining({ role: 'from', name: 'Osaka Station' }),
expect.objectContaining({ role: 'to', sequence: 1, name: 'Nara Station' }),
])
})
it('FE-MOB-TRFRM-028: deleting asks for a second tap before it removes the booking', async () => {
const editingTransport = {
id: 49, trip_id: 1, type: 'bus', title: 'Bus', status: 'pending', endpoints: [], metadata: null,
} as unknown as Reservation
const planner = makePlanner({ editingTransport })
renderSheet(planner)
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
expect(planner.toast.warning).toHaveBeenCalledWith('mobileTrip.tapAgainToDelete')
expect(planner.handleDeleteReservation).not.toHaveBeenCalled()
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'common.delete' })) })
expect(planner.handleDeleteReservation).toHaveBeenCalledWith(49)
expect(planner.setShowTransportModal).toHaveBeenCalledWith(false)
})
it('FE-MOB-TRFRM-029: imports a parsed booking with its day, source file and parsed price', async () => {
const handleSaveTransport = makeSave()
const addFile = vi.fn(async (_tripId: number, _form: FormData) => undefined)
const sourceFile = new File(['pdf'], 'voucher.pdf')
const transportPrefill = {
type: 'car', title: 'Rental car', status: 'pending',
reservation_time: '2026-05-02T09:00', reservation_end_time: '2026-05-03T18:00',
metadata: { price: 120 }, endpoints: [], _sourceFiles: [sourceFile],
}
const planner = makePlanner({
transportPrefill, handleSaveTransport, tripActions: { addFile },
})
renderSheet(planner)
expect(screen.getByPlaceholderText('reservations.titlePlaceholder')).toHaveValue('Rental car')
expect(screen.getByRole('button', { name: 'reservations.type.car' })).toHaveAttribute('aria-pressed', 'true')
expect(daySelects().map(s => s.value)).toEqual(['12', '13'])
expect(timeInputs().map(i => i.value)).toEqual(['09:00', '18:00'])
expect(screen.getByRole('button', { name: 'drop-voucher.pdf' })).toBeInTheDocument()
await submit()
const payload = handleSaveTransport.mock.calls[0][0]
expect(payload.create_budget_entry).toEqual({ total_price: 120, category: 'transport' })
expect(addFile).toHaveBeenCalledTimes(1)
expect(addFile.mock.calls[0][0]).toBe(1)
expect((addFile.mock.calls[0][1] as FormData).get('description')).toBe('Rental car')
})
it('FE-MOB-TRFRM-030: a prefill without a price and with the budget addon off skips the cost entry', async () => {
seedStore(useAddonStore, { addons: [] })
const handleSaveTransport = makeSave()
const planner = makePlanner({
transportPrefill: { type: 'bus', title: 'Shuttle', metadata: { price: 0 }, endpoints: [] },
handleSaveTransport,
})
renderSheet(planner)
expect(screen.queryByRole('button', { name: 'reservations.createExpense' })).not.toBeInTheDocument()
await submit()
expect(handleSaveTransport.mock.calls[0][0]).not.toHaveProperty('create_budget_entry')
})
it('FE-MOB-TRFRM-031: attaching and dropping files only uploads what is left', async () => {
const handleSaveTransport = makeSave()
const addFile = vi.fn(async (_tripId: number, _form: FormData) => undefined)
renderSheet(makePlanner({ handleSaveTransport, tripActions: { addFile } }))
typeTitle('Flight with docs')
fireEvent.click(screen.getByRole('button', { name: 'attach-file' }))
fireEvent.click(screen.getByRole('button', { name: 'attach-file' }))
expect(screen.getAllByRole('button', { name: 'drop-extra.pdf' })).toHaveLength(2)
fireEvent.click(screen.getAllByRole('button', { name: 'drop-extra.pdf' })[0])
await submit()
expect(addFile).toHaveBeenCalledTimes(1)
})
it('FE-MOB-TRFRM-032: hides the file row without upload permission', () => {
renderSheet(makePlanner({ canUploadFiles: false }))
expect(screen.queryByRole('button', { name: 'attach-file' })).not.toBeInTheDocument()
})
it('FE-MOB-TRFRM-033: assigns travelers and persists them once the booking has an id', async () => {
const setReservationTravelers = vi.fn(async (_tripId: number, _resId: number, _ids: number[]) => undefined)
seedStore(useTripStore, { setReservationTravelers })
const handleSaveTransport = makeSave()
renderSheet(makePlanner({ handleSaveTransport, tripMembers: MEMBERS }))
typeTitle('Shared flight')
fireEvent.click(screen.getByRole('button', { name: /maurice/ }))
fireEvent.click(screen.getByRole('button', { name: /julien/ }))
fireEvent.click(screen.getByRole('button', { name: /maurice/ }))
await submit()
expect(setReservationTravelers).toHaveBeenCalledWith(1, 99, [5])
})
it('FE-MOB-TRFRM-034: leaves the traveler list alone when nothing changed', async () => {
const setReservationTravelers = vi.fn(async (_tripId: number, _resId: number, _ids: number[]) => undefined)
seedStore(useTripStore, { setReservationTravelers })
const editingTransport = {
id: 50, trip_id: 1, type: 'bus', title: 'Bus', status: 'pending', endpoints: [], metadata: null,
travelers: [{ user_id: 4, username: 'maurice' }],
} as unknown as Reservation
renderSheet(makePlanner({ editingTransport, tripMembers: MEMBERS, handleSaveTransport: makeSave(null) }))
expect(screen.getByRole('button', { name: /maurice/ }).className).not.toContain('opacity-60')
await submit('common.update')
expect(setReservationTravelers).not.toHaveBeenCalled()
})
it('FE-MOB-TRFRM-035: shows the empty hint when the trip has no members', () => {
renderSheet(makePlanner({ tripMembers: [] }))
expect(screen.getByText('reservations.travelers.none')).toBeInTheDocument()
})
it('FE-MOB-TRFRM-036: creating an expense saves first and hands the new id to the cost sheet', async () => {
const handleSaveTransport = makeSave()
const onOpenExpense = vi.fn()
renderSheet(makePlanner({ handleSaveTransport }), onOpenExpense)
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.ferry' }))
typeTitle('Ferry ticket')
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'reservations.createExpense' }))
})
expect(handleSaveTransport).toHaveBeenCalledTimes(1)
expect(onOpenExpense).toHaveBeenCalledWith({
prefill: { reservationId: 99, name: 'Ferry ticket', category: 'transport' },
})
})
it('FE-MOB-TRFRM-037: a plain save does not open the cost sheet', async () => {
const onOpenExpense = vi.fn()
renderSheet(makePlanner({ handleSaveTransport: makeSave() }), onOpenExpense)
typeTitle('No costs please')
await submit()
expect(onOpenExpense).not.toHaveBeenCalled()
})
it('FE-MOB-TRFRM-038: the automated tab asks for a day before it searches', () => {
renderSheet(makePlanner({ transportModalAutomated: true }))
expect(screen.getByRole('dialog', { name: 'transit.title' })).toBeInTheDocument()
expect(screen.getByText('transit.pickDay')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.add' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.cancel' })).toBeInTheDocument()
})
it('FE-MOB-TRFRM-039: the transit panel gets the chosen day, its ordered places and the prefill', () => {
const planner = makePlanner({
transportModalAutomated: true,
transportModalDayId: 12,
transitPrefill: { from: { name: 'Hotel', lat: 34.7, lng: 135.5 }, to: null },
places: [{ id: 1, name: 'Fushimi Inari' }, { id: 2, name: 'Nishiki Market' }],
assignments: { '12': [{ id: 9, place_id: 2, order_index: 1 }, { id: 8, place_id: 1, order_index: 0 }] },
})
renderSheet(planner)
expect(screen.getByTestId('transit-day')).toHaveTextContent('12')
expect(screen.getByTestId('transit-places')).toHaveTextContent('Fushimi Inari,Nishiki Market')
expect(screen.getByTestId('transit-from')).toHaveTextContent('Hotel')
})
it('FE-MOB-TRFRM-040: the transit panel saves its itinerary through the shared save path', async () => {
const handleSaveTransport = makeSave()
renderSheet(makePlanner({ transportModalAutomated: true, transportModalDayId: 12, handleSaveTransport }))
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'add-itinerary' })) })
expect(handleSaveTransport).toHaveBeenCalledWith({ title: 'U4 to Prater', type: 'transit' })
})
it('FE-MOB-TRFRM-041: switching between manual and automated swaps the form', () => {
renderSheet(makePlanner())
expect(screen.getByText('reservations.bookingType')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'transport.modeAutomated' }))
expect(screen.queryByText('reservations.bookingType')).not.toBeInTheDocument()
expect(screen.getByText('transit.searchHint')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'transport.modeManual' }))
expect(screen.getByText('reservations.bookingType')).toBeInTheDocument()
})
it('FE-MOB-TRFRM-042: a new booking opened from a day preselects that day', () => {
renderSheet(makePlanner({ transportModalDayId: 13 }))
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.bus' }))
expect(daySelects().map(s => s.value)).toEqual(['13', '13'])
})
})
@@ -0,0 +1,276 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import MTransportSheet from '../../../../src/mobile/screens/trip/sheets/MTransportSheet'
import { openFile } from '../../../../src/utils/fileDownload'
import { useSettingsStore } from '../../../../src/store/settingsStore'
import type { Reservation, TripFile } from '../../../../src/types'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { fireEvent, render, screen } from '../../../helpers/render'
// FE-MOB-TRSH-001 to FE-MOB-TRSH-020
vi.mock('../../../../src/utils/fileDownload', () => ({ openFile: vi.fn() }))
const FLIGHT = {
id: 7,
trip_id: 1,
type: 'flight',
title: 'FRA → HND',
status: 'pending',
day_id: 3,
reservation_time: '2026-05-01T10:20',
reservation_end_time: '2026-05-02T06:45',
confirmation_number: 'QW7788',
notes: 'Window seat requested',
metadata: JSON.stringify({ airline: 'Lufthansa', flight_number: 'LH 716', seat: '31A' }),
endpoints: [
{ id: 1, reservation_id: 7, role: 'from', sequence: 0, name: 'Frankfurt (FRA)', code: 'FRA', lat: 50.03, lng: 8.57, timezone: 'Europe/Berlin', local_date: '2026-05-01', local_time: '10:20' },
{ id: 2, reservation_id: 7, role: 'to', sequence: 1, name: 'Tokyo (HND)', code: 'HND', lat: 35.55, lng: 139.78, timezone: 'Asia/Tokyo', local_date: '2026-05-02', local_time: '06:45' },
],
} as unknown as Reservation
function makePlanner(overrides: Record<string, unknown> = {}) {
return buildPlanner({
reservations: [FLIGHT],
files: [],
// The real planner exposes the visible connection ids as an array.
visibleConnections: [],
...overrides,
} as unknown as Parameters<typeof buildPlanner>[0])
}
function makeShell(overrides: Record<string, unknown> = {}) {
return buildShell({ sheet: { id: 'transport', payload: { reservationId: 7 } }, ...overrides } as unknown as Parameters<typeof buildShell>[0])
}
describe('MTransportSheet', () => {
beforeEach(() => {
resetAllStores()
})
afterEach(() => {
vi.clearAllMocks()
})
it('FE-MOB-TRSH-001: renders nothing when the payload points at an unknown reservation', () => {
render(<MTransportSheet planner={makePlanner({ reservations: [] })} shell={makeShell()} />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-TRSH-002: stays closed while another sheet id is active', () => {
render(<MTransportSheet planner={makePlanner()} shell={makeShell({ sheet: { id: 'mehr', payload: { reservationId: 7 } } })} />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-TRSH-003: shows title, airline meta and the endpoint route in the subline', () => {
render(<MTransportSheet planner={makePlanner()} shell={makeShell()} />)
expect(screen.getByRole('dialog', { name: 'FRA → HND' })).toBeInTheDocument()
expect(screen.getByText('Lufthansa · LH 716 · Frankfurt (FRA) → Tokyo (HND)')).toBeInTheDocument()
})
it('FE-MOB-TRSH-004: falls back to the formatted date when the booking has no endpoints', () => {
const res = { ...FLIGHT, endpoints: [], metadata: null } as unknown as Reservation
render(<MTransportSheet planner={makePlanner({ reservations: [res] })} shell={makeShell()} />)
expect(screen.getByText('Fri, May 1')).toBeInTheDocument()
})
it('FE-MOB-TRSH-005: shows departure and arrival stat boxes labelled with the endpoint names', () => {
render(<MTransportSheet planner={makePlanner()} shell={makeShell()} />)
expect(screen.getByText('10:20')).toBeInTheDocument()
expect(screen.getByText('06:45')).toBeInTheDocument()
expect(screen.getByText('Frankfurt (FRA)')).toBeInTheDocument()
expect(screen.getByText('Tokyo (HND)')).toBeInTheDocument()
expect(screen.getByText('31A')).toBeInTheDocument()
expect(screen.getByText('Seat')).toBeInTheDocument()
})
it('FE-MOB-TRSH-006: renders the ISO reservation time in the 12h format and the generic time label', () => {
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, time_format: '12h' } })
const res = {
...FLIGHT,
endpoints: [],
reservation_time: '2026-05-01T15:30:00Z',
reservation_end_time: null,
metadata: null,
} as unknown as Reservation
render(<MTransportSheet planner={makePlanner({ reservations: [res] })} shell={makeShell()} />)
expect(screen.getByText(/PM$/)).toBeInTheDocument()
expect(screen.getByText('Time')).toBeInTheDocument()
})
it('FE-MOB-TRSH-007: shows the train number and platform of a train booking', () => {
const res = {
...FLIGHT, type: 'train', title: 'ICE to Berlin',
metadata: { train_number: 'ICE 599', platform: '7', seat: '12C' },
} as unknown as Reservation
render(<MTransportSheet planner={makePlanner({ reservations: [res] })} shell={makeShell()} />)
expect(screen.getByText(/ICE 599/)).toBeInTheDocument()
expect(screen.getByText('7')).toBeInTheDocument()
expect(screen.getByText('Platform')).toBeInTheDocument()
})
it('FE-MOB-TRSH-008: survives unparsable metadata without meta rows', () => {
const res = { ...FLIGHT, metadata: '{not json', endpoints: [] } as unknown as Reservation
render(<MTransportSheet planner={makePlanner({ reservations: [res] })} shell={makeShell()} />)
expect(screen.getByRole('dialog', { name: 'FRA → HND' })).toBeInTheDocument()
expect(screen.queryByText('Seat')).not.toBeInTheDocument()
})
it('FE-MOB-TRSH-009: renders the transit itinerary with line, walk, duration and stop counts', () => {
const res = {
...FLIGHT, type: 'transit', title: 'To the museum', endpoints: [], metadata: {
transit: {
legs: [
{ mode: 'WALK', duration: 240, to: { name: 'Karlsplatz' } },
{
mode: 'SUBWAY', line: 'U4', line_color: '#00a94f', line_text_color: '#fff', duration: 600, stops: 4,
from: { name: 'Karlsplatz', time: '09:10' }, to: { name: 'Schwedenplatz', time: '09:20' },
},
{ mode: 'BUS', line: null, duration: 0, from: { name: 'Schwedenplatz', time: null }, to: { name: 'Prater' } },
],
},
},
} as unknown as Reservation
render(<MTransportSheet planner={makePlanner({ reservations: [res] })} shell={makeShell()} />)
expect(screen.getByText('Walk to Karlsplatz')).toBeInTheDocument()
expect(screen.getByText('4 min')).toBeInTheDocument()
expect(screen.getByText('U4')).toBeInTheDocument()
expect(screen.getByText('09:10 – 09:20 · 10 min · 4 stops')).toBeInTheDocument()
// A leg without a line falls back to its mode and renders no meta line.
expect(screen.getByText('BUS')).toBeInTheDocument()
expect(screen.getByText('Prater')).toBeInTheDocument()
})
it('FE-MOB-TRSH-010: shows the pending status and the booking code unblurred by default', () => {
render(<MTransportSheet planner={makePlanner()} shell={makeShell()} />)
expect(screen.getByText('Pending')).toBeInTheDocument()
const code = screen.getByText('#QW7788')
expect(code.className).not.toContain('blur')
})
it('FE-MOB-TRSH-011: blurs the booking code until it is tapped', () => {
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, blur_booking_codes: true } })
const res = { ...FLIGHT, status: 'confirmed' } as unknown as Reservation
render(<MTransportSheet planner={makePlanner({ reservations: [res] })} shell={makeShell()} />)
expect(screen.getByText('Confirmed')).toBeInTheDocument()
const code = screen.getByText('#QW7788')
expect(code.className).toContain('blur-[4px]')
fireEvent.click(code)
expect(screen.getByText('#QW7788').className).not.toContain('blur-[4px]')
})
it('FE-MOB-TRSH-012: re-blurs the code once the sheet is closed again', () => {
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, blur_booking_codes: true } })
const planner = makePlanner()
const { rerender } = render(<MTransportSheet planner={planner} shell={makeShell()} />)
fireEvent.click(screen.getByText('#QW7788'))
expect(screen.getByText('#QW7788').className).not.toContain('blur-[4px]')
rerender(<MTransportSheet planner={planner} shell={makeShell({ sheet: null })} />)
rerender(<MTransportSheet planner={planner} shell={makeShell()} />)
expect(screen.getByText('#QW7788').className).toContain('blur-[4px]')
})
it('FE-MOB-TRSH-013: renders the notes block only when the booking has notes', () => {
render(<MTransportSheet planner={makePlanner()} shell={makeShell()} />)
expect(screen.getByText('Window seat requested')).toBeInTheDocument()
const res = { ...FLIGHT, notes: null } as unknown as Reservation
render(<MTransportSheet planner={makePlanner({ reservations: [res] })} shell={makeShell()} />)
expect(screen.getAllByText('Notes')).toHaveLength(1)
})
it('FE-MOB-TRSH-014: drawing the route closes the sheet and jumps to the plan map', () => {
const planner = makePlanner()
const shell = makeShell({ trTab: 'transports', view: 'plan' })
render(<MTransportSheet planner={planner} shell={shell} />)
const btn = screen.getByRole('button', { name: 'On map' })
expect(btn).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(btn)
expect(planner.toggleConnection).toHaveBeenCalledWith(7)
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
expect(shell.setTrTab).toHaveBeenCalledWith('plan')
expect(shell.toggleView).toHaveBeenCalledTimes(1)
})
it('FE-MOB-TRSH-015: hides an already drawn route without navigating away', () => {
const planner = makePlanner({ visibleConnections: [7] })
const shell = makeShell({ trTab: 'plan', view: 'map' })
render(<MTransportSheet planner={planner} shell={shell} />)
const btn = screen.getByRole('button', { name: 'On map' })
expect(btn).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(btn)
expect(planner.toggleConnection).toHaveBeenCalledWith(7)
expect(shell.closeSheet).not.toHaveBeenCalled()
expect(shell.setTrTab).not.toHaveBeenCalled()
expect(shell.toggleView).not.toHaveBeenCalled()
})
it('FE-MOB-TRSH-016: lists the attached files and opens one on tap', () => {
const files = [
{ id: 1, reservation_id: 7, original_name: 'boardingpass.pdf', url: '/api/trips/1/files/1/download' },
{ id: 2, reservation_id: null, linked_reservation_ids: [7], original_name: 'invoice.pdf', url: '/api/trips/1/files/2/download' },
{ id: 3, reservation_id: 7, deleted_at: '2026-04-01', original_name: 'trashed.pdf', url: '/x' },
{ id: 4, reservation_id: 99, original_name: 'other.pdf', url: '/y' },
] as unknown as TripFile[]
render(<MTransportSheet planner={makePlanner({ files })} shell={makeShell()} />)
expect(screen.getByText('boardingpass.pdf')).toBeInTheDocument()
expect(screen.getByText('invoice.pdf')).toBeInTheDocument()
expect(screen.queryByText('trashed.pdf')).not.toBeInTheDocument()
expect(screen.queryByText('other.pdf')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('boardingpass.pdf'))
expect(openFile).toHaveBeenCalledWith('/api/trips/1/files/1/download', 'boardingpass.pdf')
})
it('FE-MOB-TRSH-017: opens the transport editor for the booking', () => {
const planner = makePlanner()
const shell = makeShell()
render(<MTransportSheet planner={planner} shell={shell} />)
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
expect(planner.setEditingTransport).toHaveBeenCalledWith(expect.objectContaining({ id: 7 }))
expect(planner.setTransportModalDayId).toHaveBeenCalledWith(3)
expect(planner.setShowTransportModal).toHaveBeenCalledWith(true)
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
})
it('FE-MOB-TRSH-018: passes a null day id to the editor when the booking has none', () => {
const res = { ...FLIGHT, day_id: null } as unknown as Reservation
const planner = makePlanner({ reservations: [res] })
render(<MTransportSheet planner={planner} shell={makeShell()} />)
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
expect(planner.setTransportModalDayId).toHaveBeenCalledWith(null)
})
it('FE-MOB-TRSH-019: deletes the booking and closes the sheet', () => {
const planner = makePlanner()
const shell = makeShell()
render(<MTransportSheet planner={planner} shell={shell} />)
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
expect(planner.handleDeleteReservation).toHaveBeenCalledWith(7)
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
})
it('FE-MOB-TRSH-020: hides edit and delete without the day_edit permission but keeps the map toggle', () => {
const planner = makePlanner({ can: vi.fn(() => false) })
render(<MTransportSheet planner={planner} shell={makeShell()} />)
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'On map' })).toBeInTheDocument()
})
it('FE-MOB-TRSH-021: keeps the last booking on screen while the sheet animates out', () => {
const planner = makePlanner()
const shell = makeShell()
const { rerender } = render(<MTransportSheet planner={planner} shell={shell} />)
expect(screen.getByText('FRA → HND')).toBeInTheDocument()
rerender(<MTransportSheet planner={makePlanner({ reservations: [] })} shell={shell} />)
expect(screen.getByText('FRA → HND')).toBeInTheDocument()
})
it('FE-MOB-TRSH-022: closes from the header button', () => {
const shell = makeShell()
render(<MTransportSheet planner={makePlanner()} shell={shell} />)
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,323 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MTransportsTab from '../../../../src/mobile/screens/trip/tabs/MTransportsTab'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { openFile } from '../../../../src/utils/fileDownload'
import type { Day, Reservation, TripFile } from '../../../../src/types'
import { buildSettings } from '../../../helpers/factories'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
// FE-MOB-TRTAB-001 to FE-MOB-TRTAB-022
vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({
...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()),
openFile: vi.fn(),
}))
const DAYS = [
{ id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null },
{ id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: 'Kyoto' },
] as unknown as Day[]
const MEMBERS = [
{ id: 1, username: 'Me', avatar_url: null },
{ id: 2, username: 'Ada', avatar_url: '/uploads/avatars/ada.png' },
]
/** Endpoints deliberately out of sequence — orderedEndpoints has to sort them. */
const FLIGHT = {
id: 101, trip_id: 7, type: 'flight', status: 'confirmed', title: 'HND to ITM',
day_id: 1, reservation_time: '2026-05-01T08:30', reservation_end_time: '2026-05-01T11:45',
confirmation_number: 'ABC123',
metadata: JSON.stringify({ airline: 'ANA', flight_number: 'NH203', departure_airport: 'HND', arrival_airport: 'ITM' }),
endpoints: [
{ role: 'to', sequence: 1, name: 'Osaka Itami' },
{ role: 'from', sequence: 0, name: 'Tokyo Haneda' },
],
travelers: [{ user_id: 2, username: 'Ada', avatar: null }],
} as unknown as Reservation
/** metadata as an already-parsed object, spans two days, flagged for review. */
const TRAIN = {
id: 102, trip_id: 7, type: 'train', status: 'pending', title: 'Shinkansen Nozomi',
day_id: 1, end_day_id: 2, reservation_time: '2026-05-01T14:00', needs_review: 1,
metadata: { train_number: 'ICE 599', platform: '7', seat: '12A', class: 'Business', price: 49.9, priceCurrency: 'EUR' },
} as unknown as Reservation
const BUS = {
id: 103, trip_id: 7, type: 'bus', status: 'pending', title: 'Airport shuttle',
metadata: JSON.stringify({ price: '25', priceCurrency: 'CHF' }),
} as unknown as Reservation
const TRANSIT = {
id: 104, trip_id: 7, type: 'transit', status: 'confirmed', title: 'Metro to hotel',
day_id: 2, reservation_time: '2026-05-02T09:10',
metadata: JSON.stringify({ departure_airport: 'Shibuya' }),
} as unknown as Reservation
const CAR = {
id: 105, trip_id: 7, type: 'car', status: 'confirmed', title: 'Rental pickup',
reservation_time: '2026-06-15T09:00',
metadata: JSON.stringify({ arrival_airport: 'Nagoya' }),
} as unknown as Reservation
const DINNER = {
id: 106, trip_id: 7, type: 'restaurant', status: 'confirmed', title: 'Sushi Saito',
} as unknown as Reservation
const FILES = [
{ id: 201, trip_id: 7, reservation_id: 101, original_name: 'boarding-pass.pdf', url: '/uploads/f/201' },
{ id: 202, trip_id: 7, linked_reservation_ids: [101], original_name: 'seat-map.pdf', url: '/uploads/f/202' },
{ id: 203, trip_id: 7, reservation_id: 101, original_name: 'stale.pdf', url: '/uploads/f/203', deleted_at: '2026-01-01' },
] as unknown as TripFile[]
const ALL = [FLIGHT, TRAIN, BUS, TRANSIT, CAR, DINNER]
function planner(overrides: Partial<TripPlanner> = {}) {
return buildPlanner({
tripId: 7,
days: DAYS,
reservations: ALL,
files: FILES,
tripMembers: MEMBERS as unknown as TripPlanner['tripMembers'],
settings: buildSettings({ time_format: '24h' }),
...overrides,
})
}
function renderTab(p: TripPlanner = planner(), shell: MTripShellApi = buildShell()) {
const view = render(<MTransportsTab planner={p} shell={shell} />)
return { ...view, planner: p, shell }
}
/** The card wrapper of a transport row — walks up from its title. */
function cardOf(title: string): HTMLElement {
let el = screen.getByText(title).parentElement
while (el && !el.className.includes('rounded-2xl')) el = el.parentElement
if (!el) throw new Error(`no card for ${title}`)
return el
}
describe('MTransportsTab', () => {
beforeEach(() => {
vi.mocked(openFile).mockClear()
})
it('FE-MOB-TRTAB-001: groups the transport reservations and leaves bookings out', () => {
renderTab()
expect(screen.getByRole('button', { name: 'reservations.confirmed2' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'reservations.pending2' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'reservations.type.transit1' })).toBeInTheDocument()
expect(screen.queryByText('Sushi Saito')).not.toBeInTheDocument()
})
it('FE-MOB-TRTAB-002: orders each group chronologically with undated rows last', () => {
renderTab()
const titles = screen.getAllByText(/HND to ITM|Rental pickup|Shinkansen Nozomi|Airport shuttle/)
.map(n => n.textContent)
expect(titles).toEqual(['HND to ITM', 'Rental pickup', 'Shinkansen Nozomi', 'Airport shuttle'])
})
it('FE-MOB-TRTAB-003: collapses a section and keeps the others open', () => {
renderTab()
const header = screen.getByRole('button', { name: 'reservations.confirmed2' })
fireEvent.click(header)
expect(header).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('HND to ITM')).not.toBeInTheDocument()
expect(screen.getByText('Shinkansen Nozomi')).toBeInTheDocument()
fireEvent.click(header)
expect(screen.getByText('HND to ITM')).toBeInTheDocument()
})
it('FE-MOB-TRTAB-004: shows the mascot empty state without transports', () => {
renderTab(planner({ reservations: [DINNER] }))
expect(screen.getByText('mobileTrip.transportsEmpty')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /reservations.confirmed/ })).not.toBeInTheDocument()
})
it('FE-MOB-TRTAB-005: renders day range, times and the confirmation code of a flight', () => {
renderTab()
const card = cardOf('HND to ITM')
expect(within(card).getByText('dayplan.dayN:1')).toBeInTheDocument()
expect(within(card).getByText('08:30 – 11:45')).toBeInTheDocument()
expect(within(card).getByText('ABC123')).toBeInTheDocument()
expect(within(card).getByText('ABC123')).not.toHaveClass('blur-[4px]')
})
it('FE-MOB-TRTAB-006: spans start and end day when they differ', () => {
renderTab()
expect(within(cardOf('Shinkansen Nozomi')).getByText('dayplan.dayN:1 – Kyoto')).toBeInTheDocument()
})
it('FE-MOB-TRTAB-007: falls back to the raw reservation date without a linked day', () => {
renderTab()
expect(within(cardOf('Rental pickup')).getByText('Jun 15')).toBeInTheDocument()
})
it('FE-MOB-TRTAB-008: shows em dashes when neither day nor time is known', () => {
renderTab()
const card = cardOf('Airport shuttle')
expect(within(card).getAllByText('—')).toHaveLength(2)
})
it('FE-MOB-TRTAB-009: renders the ordered endpoints and drops the airport meta cells', () => {
renderTab()
const card = cardOf('HND to ITM')
expect(within(card).getByText('Tokyo Haneda')).toBeInTheDocument()
expect(within(card).getByText('Osaka Itami')).toBeInTheDocument()
expect(within(card).getByText('reservations.meta.airline')).toBeInTheDocument()
expect(within(card).getByText('ANA')).toBeInTheDocument()
expect(within(card).getByText('NH203')).toBeInTheDocument()
expect(within(card).queryByText('reservations.meta.from')).not.toBeInTheDocument()
})
it('FE-MOB-TRTAB-010: reads object metadata and caps the meta row at three cells', () => {
renderTab()
const card = cardOf('Shinkansen Nozomi')
expect(within(card).getByText('ICE 599')).toBeInTheDocument()
expect(within(card).getByText('7')).toBeInTheDocument()
expect(within(card).getByText('12A · Business')).toBeInTheDocument()
expect(within(card).queryByText('reservations.price')).not.toBeInTheDocument()
expect(within(card).getByText('reservations.needsReview')).toBeInTheDocument()
})
it('FE-MOB-TRTAB-011: appends the currency to the price cell', () => {
renderTab()
const card = cardOf('Airport shuttle')
expect(within(card).getByText('reservations.price')).toBeInTheDocument()
expect(within(card).getByText('25 CHF')).toBeInTheDocument()
})
it('FE-MOB-TRTAB-012: uses the airport meta cells when no endpoints are stored', () => {
renderTab()
expect(within(cardOf('Metro to hotel')).getByText('Shibuya')).toBeInTheDocument()
expect(within(cardOf('Rental pickup')).getByText('Nagoya')).toBeInTheDocument()
})
it('FE-MOB-TRTAB-013: lists the linked, non-deleted files and opens one on tap', () => {
renderTab()
const card = cardOf('HND to ITM')
expect(within(card).getByText('boarding-pass.pdf')).toBeInTheDocument()
expect(within(card).getByText('seat-map.pdf')).toBeInTheDocument()
expect(within(card).queryByText('stale.pdf')).not.toBeInTheDocument()
fireEvent.click(within(card).getByText('boarding-pass.pdf'))
expect(openFile).toHaveBeenCalledWith('/uploads/f/201', 'boarding-pass.pdf')
})
it('FE-MOB-TRTAB-014: reveals a blurred confirmation code on tap', () => {
renderTab(planner({ settings: buildSettings({ time_format: '24h', blur_booking_codes: true }) }))
const code = within(cardOf('HND to ITM')).getByText('ABC123')
expect(code).toHaveClass('blur-[4px]')
fireEvent.click(code)
expect(code).not.toHaveClass('blur-[4px]')
})
it('FE-MOB-TRTAB-015: opens the transport detail sheet from the row', () => {
const { shell } = renderTab()
fireEvent.click(screen.getByText('HND to ITM'))
expect(shell.openSheet).toHaveBeenCalledWith('transport', { reservationId: 101 })
})
it('FE-MOB-TRTAB-016: toggles the reservation status from the dot', async () => {
const p = planner()
renderTab(p)
fireEvent.click(within(cardOf('HND to ITM')).getByRole('button', { name: 'reservations.pending' }))
await waitFor(() => expect(p.tripActions.toggleReservationStatus).toHaveBeenCalledWith(7, 101))
})
it('FE-MOB-TRTAB-017: toasts when the status toggle fails', async () => {
const p = planner()
vi.mocked(p.tripActions.toggleReservationStatus).mockRejectedValueOnce(new Error('offline'))
renderTab(p)
fireEvent.click(within(cardOf('HND to ITM')).getByRole('button', { name: 'reservations.pending' }))
await waitFor(() => expect(p.toast.error).toHaveBeenCalledWith('reservations.toast.updateError'))
})
it('FE-MOB-TRTAB-018: opens the transport editor with the row and its day', () => {
const p = planner()
renderTab(p)
fireEvent.click(within(cardOf('Shinkansen Nozomi')).getByRole('button', { name: 'common.edit' }))
expect(p.setEditingTransport).toHaveBeenCalledWith(expect.objectContaining({ id: 102 }))
expect(p.setTransportModalDayId).toHaveBeenCalledWith(1)
expect(p.setShowTransportModal).toHaveBeenCalledWith(true)
})
it('FE-MOB-TRTAB-019: deletes after confirming and toasts on failure', async () => {
const p = planner({ handleDeleteReservation: vi.fn(() => Promise.reject(new Error('boom'))) as unknown as TripPlanner['handleDeleteReservation'] })
renderTab(p)
fireEvent.click(within(cardOf('HND to ITM')).getByRole('button', { name: 'common.delete' }))
const dialog = screen.getByRole('dialog', { name: 'reservations.confirm.deleteTitle' })
expect(within(dialog).getByText('reservations.confirm.deleteBody:HND to ITM')).toBeInTheDocument()
fireEvent.click(within(dialog).getByText('common.delete'))
expect(p.handleDeleteReservation).toHaveBeenCalledWith(101)
await waitFor(() => expect(p.toast.error).toHaveBeenCalledWith('reservations.toast.deleteError'))
})
it('FE-MOB-TRTAB-020: hides every edit affordance without the day_edit permission', () => {
renderTab(planner({ can: vi.fn(() => false) as unknown as TripPlanner['can'] }))
expect(screen.queryByRole('button', { name: 'common.edit' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'reservations.pending' })).not.toBeInTheDocument()
expect(screen.getByText('HND to ITM')).toBeInTheDocument()
})
it('FE-MOB-TRTAB-021: compact mode keeps the header and drops the card body', () => {
renderTab(planner(), buildShell({ transportsCompact: true }))
expect(screen.getByText('HND to ITM')).toBeInTheDocument()
expect(screen.queryByText('reservations.date')).not.toBeInTheDocument()
expect(screen.queryByText('ABC123')).not.toBeInTheDocument()
})
it('FE-MOB-TRTAB-022: filters by assigned traveler and shows the avatars on the card', () => {
renderTab()
expect(within(cardOf('HND to ITM')).getByText('reservations.travelers.label')).toBeInTheDocument()
const ada = screen.getByTitle('Ada')
fireEvent.click(ada)
expect(screen.getByText('HND to ITM')).toBeInTheDocument()
expect(screen.queryByText('Shinkansen Nozomi')).not.toBeInTheDocument()
fireEvent.click(ada)
expect(screen.getByText('Shinkansen Nozomi')).toBeInTheDocument()
})
it('FE-MOB-TRTAB-023: hides the traveler filter for a solo trip', () => {
renderTab(planner({ tripMembers: [MEMBERS[0]] as unknown as TripPlanner['tripMembers'] }))
expect(screen.queryByTitle('Ada')).not.toBeInTheDocument()
expect(screen.getByText('HND to ITM')).toBeInTheDocument()
})
it('FE-MOB-TRTAB-024: cancelling the confirm keeps the transport', () => {
const p = planner()
renderTab(p)
fireEvent.click(within(cardOf('HND to ITM')).getByRole('button', { name: 'common.delete' }))
fireEvent.click(within(screen.getByRole('dialog')).getByText('common.cancel'))
expect(p.handleDeleteReservation).not.toHaveBeenCalled()
expect(screen.getByText('HND to ITM')).toBeInTheDocument()
})
it('FE-MOB-TRTAB-025: opens the editor without a day for an unassigned transport', () => {
const p = planner()
renderTab(p)
fireEvent.click(within(cardOf('Airport shuttle')).getByRole('button', { name: 'common.edit' }))
expect(p.setTransportModalDayId).toHaveBeenCalledWith(null)
})
it('FE-MOB-TRTAB-026: falls back to the generic icon for an unknown transport type', () => {
const HELI = {
id: 107, trip_id: 7, type: 'helicopter', status: 'confirmed', title: 'Heli transfer',
metadata: JSON.stringify({ seat: '3B', price: '99' }),
} as unknown as Reservation
renderTab(planner({
reservations: [HELI],
files: undefined as unknown as TripPlanner['files'],
settings: { ...buildSettings(), time_format: '' },
TRANSPORT_TYPES: new Set(['helicopter']) as unknown as TripPlanner['TRANSPORT_TYPES'],
}))
const card = cardOf('Heli transfer')
expect(within(card).getByText('reservations.type.helicopter')).toBeInTheDocument()
// seat without a class and a price without a currency stay bare
expect(within(card).getByText('3B')).toBeInTheDocument()
expect(within(card).getByText('99')).toBeInTheDocument()
// nobody is assigned, so the traveler filter row stays away
expect(screen.queryByTitle('Ada')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,137 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, render, screen } from '../../../helpers/render'
import MTripLoadingSplash from '../../../../src/mobile/screens/trip/MTripLoadingSplash'
// FE-MOB-SPLASH-001 to FE-MOB-SPLASH-010
const STEP_MS = 1400
const origMatchMedia = window.matchMedia
function stubReducedMotion(matches: boolean) {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})) as unknown as typeof window.matchMedia
}
const tick = (ms: number) => act(() => { vi.advanceTimersByTime(ms) })
const dots = (container: HTMLElement) =>
Array.from(container.querySelectorAll('span[class*="h-[6px]"]'))
const activeDotIndex = (container: HTMLElement) =>
dots(container).findIndex(d => d.className.includes('w-5'))
describe('MTripLoadingSplash', () => {
beforeEach(() => {
vi.useFakeTimers()
stubReducedMotion(false)
})
afterEach(() => {
vi.useRealTimers()
window.matchMedia = origMatchMedia
})
it('FE-MOB-SPLASH-001: opens on the packing beat with the trip title', () => {
const { container } = render(<MTripLoadingSplash title="Japan 2026" />)
expect(screen.getByText('Japan 2026')).toBeInTheDocument()
expect(screen.getByText('Packing your bags...')).toBeInTheDocument()
expect(container.querySelector('.trek--packing')).toBeTruthy()
})
it('FE-MOB-SPLASH-002: falls back to the TREK wordmark when the trip has no title', () => {
render(<MTripLoadingSplash title="" />)
expect(screen.getByText('TREK')).toBeInTheDocument()
})
it('FE-MOB-SPLASH-003: walks the four beats in order, one every 1400ms', () => {
const { container } = render(<MTripLoadingSplash title="Japan 2026" />)
tick(STEP_MS)
expect(screen.getByText('Hitting the road...')).toBeInTheDocument()
expect(container.querySelector('.trek--transport')).toBeTruthy()
tick(STEP_MS)
expect(screen.getByText('Loading place photos...')).toBeInTheDocument()
expect(container.querySelector('.trek--dashboard')).toBeTruthy()
tick(STEP_MS)
expect(screen.getByText('Almost there...')).toBeInTheDocument()
expect(container.querySelector('.trek--collections')).toBeTruthy()
})
it('FE-MOB-SPLASH-004: wraps back to the first beat after the last one', () => {
render(<MTripLoadingSplash title="Japan 2026" />)
tick(STEP_MS * 4)
expect(screen.getByText('Packing your bags...')).toBeInTheDocument()
})
it('FE-MOB-SPLASH-005: does not advance before the beat is due', () => {
render(<MTripLoadingSplash title="Japan 2026" />)
tick(STEP_MS - 1)
expect(screen.getByText('Packing your bags...')).toBeInTheDocument()
})
it('FE-MOB-SPLASH-006: the active dot tracks the current beat', () => {
const { container } = render(<MTripLoadingSplash title="Japan 2026" />)
expect(dots(container)).toHaveLength(4)
expect(activeDotIndex(container)).toBe(0)
tick(STEP_MS * 2)
expect(activeDotIndex(container)).toBe(2)
})
it('FE-MOB-SPLASH-007: animates the mascot stage between beats', () => {
const { container } = render(<MTripLoadingSplash title="Japan 2026" />)
const stage = container.querySelector('.trek--packing')?.parentElement
expect(stage).toHaveStyle({ animation: 'm-trek-beat 460ms cubic-bezier(.34,1.56,.64,1) both' })
})
it('FE-MOB-SPLASH-008: reduced motion parks on the photo beat and never advances', () => {
stubReducedMotion(true)
const { container } = render(<MTripLoadingSplash title="Japan 2026" />)
expect(screen.getByText('Loading place photos...')).toBeInTheDocument()
expect(activeDotIndex(container)).toBe(2)
tick(STEP_MS * 3)
expect(screen.getByText('Loading place photos...')).toBeInTheDocument()
expect(activeDotIndex(container)).toBe(2)
})
it('FE-MOB-SPLASH-009: reduced motion drops the beat animation', () => {
stubReducedMotion(true)
const { container } = render(<MTripLoadingSplash title="Japan 2026" />)
const stage = container.querySelector('.trek--dashboard')?.parentElement
expect(stage?.getAttribute('style')).toBeNull()
})
it('FE-MOB-SPLASH-010: clears its beat interval on unmount', () => {
const before = vi.getTimerCount()
const { unmount } = render(<MTripLoadingSplash title="Japan 2026" />)
expect(vi.getTimerCount()).toBe(before + 1)
unmount()
expect(vi.getTimerCount()).toBe(before)
})
})
@@ -0,0 +1,115 @@
import { describe, expect, it, vi } from 'vitest'
import { Home } from 'lucide-react'
import {
ActionCircle,
Eyebrow,
INNER_CLS,
StatBox,
TileHeader,
displayTime,
} from '../../../../src/mobile/screens/trip/sheets/MTripSheetUi'
import { fireEvent, render, screen } from '../../../helpers/render'
// FE-MOB-SHUI-001 to FE-MOB-SHUI-014
describe('MTripSheetUi', () => {
it('FE-MOB-SHUI-001: INNER_CLS carries both inner-surface tokens', () => {
expect(INNER_CLS).toContain('var(--m-inbr)')
expect(INNER_CLS).toContain('var(--m-inner)')
})
it('FE-MOB-SHUI-002: Eyebrow renders its children with the faint label styling', () => {
render(<Eyebrow>Stops</Eyebrow>)
const el = screen.getByText('Stops')
expect(el).toHaveClass('font-geist', 'text-m-faint')
})
it('FE-MOB-SHUI-003: Eyebrow appends the caller class', () => {
render(<Eyebrow className="uppercase">Stops</Eyebrow>)
expect(screen.getByText('Stops')).toHaveClass('uppercase')
})
it('FE-MOB-SHUI-004: TileHeader shows icon, title and subline', () => {
render(
<TileHeader
icon={<Home data-testid="tile-icon" />}
title="Hotel Sacher"
sub="Philharmonikerstrasse 4"
onClose={vi.fn()}
closeLabel="Close"
/>,
)
expect(screen.getByTestId('tile-icon')).toBeInTheDocument()
expect(screen.getByText('Hotel Sacher')).toBeInTheDocument()
expect(screen.getByText('Philharmonikerstrasse 4')).toBeInTheDocument()
})
it('FE-MOB-SHUI-005: TileHeader omits the subline when no sub is given', () => {
render(<TileHeader icon={<Home />} title="Hotel" onClose={vi.fn()} closeLabel="Close" />)
expect(document.querySelector('.text-m-muted')).toBeNull()
})
it('FE-MOB-SHUI-006: TileHeader close button reports the label and fires onClose', () => {
const onClose = vi.fn()
render(<TileHeader icon={<Home />} title="Hotel" onClose={onClose} closeLabel="Close" />)
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('FE-MOB-SHUI-007: StatBox renders value over label', () => {
render(<StatBox value="15:00" label="Check-in" />)
expect(screen.getByText('15:00')).toBeInTheDocument()
expect(screen.getByText('Check-in')).toBeInTheDocument()
expect(screen.getByText('15:00')).not.toHaveClass('blur-[4px]')
})
it('FE-MOB-SHUI-008: StatBox blurs the value when asked', () => {
render(<StatBox value="ABC123" label="Code" blurred />)
expect(screen.getByText('ABC123')).toHaveClass('blur-[4px]', 'select-none')
})
it('FE-MOB-SHUI-009: StatBox forwards clicks', () => {
const onClick = vi.fn()
render(<StatBox value="ABC123" label="Code" onClick={onClick} />)
fireEvent.click(screen.getByText('Code'))
expect(onClick).toHaveBeenCalledTimes(1)
})
it('FE-MOB-SHUI-010: ActionCircle uses the accent surface when primary', () => {
const onClick = vi.fn()
render(<ActionCircle label="Route" primary onClick={onClick}><Home /></ActionCircle>)
const btn = screen.getByRole('button', { name: 'Route' })
expect(btn).toHaveClass('bg-m-act', 'text-m-actfg')
expect(btn).toHaveAttribute('title', 'Route')
fireEvent.click(btn)
expect(onClick).toHaveBeenCalledTimes(1)
})
it('FE-MOB-SHUI-011: ActionCircle switches to the danger tint and keeps the caller class', () => {
render(<ActionCircle label="Delete" danger className="ml-2"><Home /></ActionCircle>)
const btn = screen.getByRole('button', { name: 'Delete' })
expect(btn.className).toContain('var(--m-st-danger)')
expect(btn).toHaveClass('ml-2')
})
it('FE-MOB-SHUI-012: ActionCircle defaults to the neutral glass surface', () => {
render(<ActionCircle label="Share"><Home /></ActionCircle>)
const btn = screen.getByRole('button', { name: 'Share' })
expect(btn).toHaveClass('text-m-ink')
expect(btn).not.toHaveClass('bg-m-act')
})
it('FE-MOB-SHUI-013: displayTime formats a plain HH:MM through formatTime', () => {
expect(displayTime('15:00', 'en-US', '24h')).toBe('15:00')
expect(displayTime('15:00', 'en-US', '12h')).toBe('3:00 PM')
expect(displayTime(null, 'en-US', '24h')).toBe('')
expect(displayTime(undefined, 'en-US', '24h')).toBe('')
})
it('FE-MOB-SHUI-014: displayTime reads an ISO timestamp and falls back when it is unparsable', () => {
expect(displayTime('2026-05-02T19:30:00Z', 'en-US', '24h')).toMatch(/^\d{2}:\d{2}$/)
expect(displayTime('2026-05-02T19:30:00Z', 'en-US', '12h')).toMatch(/(AM|PM)$/)
// Not a date, still contains "T" — formatTime takes over and returns "00:00".
expect(displayTime('TBD', 'en-US', '24h')).toBe('00:00')
})
})
@@ -0,0 +1,435 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ReactNode } from 'react'
import type { BookingExpenseRequest } from '../../../../src/components/Planner/BookingCostsSection.types'
import type { ExpensePrefill } from '../../../../src/components/Budget/CostsPanel'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import type { BudgetItem, Reservation, Trip } from '../../../../src/types'
import { useAuthStore } from '../../../../src/store/authStore'
import { useSettingsStore } from '../../../../src/store/settingsStore'
import { useTripStore } from '../../../../src/store/tripStore'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-SHOST-001 to FE-MOB-SHOST-027
//
// Every child sheet is stubbed: this file is about the host — which sheet is
// mounted for which shell.sheet id, and how the host's own callbacks wire the
// planner, the trip store and the booking-linked expense editor together.
interface StubProps { shell: MTripShellApi; planner: TripPlanner }
/** Sheets that route on shell.sheet themselves — the stub reports what it saw. */
function selfRouted(testid: string) {
return ({ planner, shell }: StubProps) => (
<div data-testid={testid} data-sheet={shell.sheet?.id ?? 'none'} data-trip={planner.tripId} />
)
}
vi.mock('../../../../src/mobile/screens/trip/sheets/MPlaceSheet', () => ({ default: selfRouted('stub-place') }))
vi.mock('../../../../src/mobile/screens/trip/sheets/MDaySheet', () => ({ default: selfRouted('stub-day') }))
vi.mock('../../../../src/mobile/screens/trip/sheets/MDaysSheet', () => ({ default: selfRouted('stub-days') }))
vi.mock('../../../../src/mobile/screens/trip/sheets/MAccommodationSheet', () => ({ default: selfRouted('stub-accommodation') }))
vi.mock('../../../../src/mobile/screens/trip/sheets/MTransportSheet', () => ({ default: selfRouted('stub-transport') }))
vi.mock('../../../../src/mobile/screens/trip/sheets/MBrowseActionsSheet', () => ({ default: selfRouted('stub-bract') }))
vi.mock('../../../../src/mobile/screens/trip/sheets/MMehrSheet', () => ({ default: selfRouted('stub-mehr') }))
vi.mock('../../../../src/mobile/screens/trip/sheets/MExportSheet', () => ({ default: selfRouted('stub-export') }))
vi.mock('../../../../src/mobile/screens/trip/sheets/MNoteSheet', () => ({
default: ({ open, payload, onClose }: { open: boolean; payload?: { dayId?: number }; onClose: () => void }) => (
<div data-testid="stub-note" data-open={String(open)} data-day={payload?.dayId ?? 'none'}>
<button type="button" onClick={onClose}>close note</button>
</div>
),
}))
vi.mock('../../../../src/mobile/screens/trip/sheets/MImportSheet', () => ({
default: ({ open, onClose }: { open: boolean; onClose: () => void }) => (
<div data-testid="stub-import" data-open={String(open)}>
<button type="button" onClick={onClose}>close import</button>
</div>
),
}))
vi.mock('../../../../src/mobile/screens/trip/sheets/MPlaceEditSheet', () => ({
default: ({ planner }: StubProps) => <div data-testid="stub-placeedit" data-trip={planner.tripId} />,
}))
vi.mock('../../../../src/mobile/screens/trip/sheets/MReservationSheet', () => ({
default: ({ onOpenExpense }: { onOpenExpense: (req: BookingExpenseRequest) => void }) => (
<div data-testid="stub-reservation">
<button type="button" onClick={() => onOpenExpense({ editItem: { id: 99, name: 'Ticket' } as BudgetItem })}>
expense edit
</button>
<button type="button" onClick={() => onOpenExpense({})}>expense noop</button>
</div>
),
}))
vi.mock('../../../../src/mobile/screens/trip/sheets/MTransportFormSheet', () => ({
default: ({ onOpenExpense }: { onOpenExpense: (req: BookingExpenseRequest) => void }) => (
<div data-testid="stub-transportform">
<button type="button" onClick={() => onOpenExpense({ prefill: { name: 'Shinkansen', amount: 120 } })}>
expense prefill
</button>
</div>
),
}))
vi.mock('../../../../src/mobile/screens/trip/sheets/MCostSheet', () => ({
default: ({ tripId, base, me, editing, prefill, onClose, onSaved }: {
tripId: number; base: string; me: number
editing: BudgetItem | null; prefill?: ExpensePrefill
onClose: () => void; onSaved: () => void
}) => (
<div
data-testid="stub-cost"
data-trip={tripId}
data-base={base}
data-me={me}
data-editing={editing?.id ?? 'none'}
data-prefill={prefill?.name ?? 'none'}
>
<button type="button" onClick={onClose}>close cost</button>
<button type="button" onClick={onSaved}>save cost</button>
</div>
),
}))
vi.mock('../../../../src/mobile/screens/settings/MConfirmSheet', () => ({
default: ({ open, title, message, onClose, onConfirm }: {
open: boolean; title: string; message: ReactNode; onClose: () => void; onConfirm?: () => void
}) =>
open ? (
<div data-testid="stub-confirm" data-title={title}>
<span>{message}</span>
<button type="button" onClick={onConfirm}>confirm delete</button>
<button type="button" onClick={onClose}>cancel delete</button>
</div>
) : null,
}))
vi.mock('../../../../src/components/Planner/BookingImportModal', () => ({
default: ({ isOpen, onClose, tripId }: { isOpen: boolean; onClose: () => void; tripId: number }) => (
<div data-testid="stub-bookingimport" data-open={String(isOpen)} data-trip={tripId}>
<button type="button" onClick={onClose}>close booking import</button>
</div>
),
}))
vi.mock('../../../../src/components/Planner/AirTrailImportModal', () => ({
default: ({ isOpen, onClose, pushUndo }: { isOpen: boolean; onClose: () => void; pushUndo: unknown }) => (
<div data-testid="stub-airtrail" data-open={String(isOpen)} data-hasundo={String(typeof pushUndo === 'function')}>
<button type="button" onClick={onClose}>close airtrail</button>
</div>
),
}))
vi.mock('../../../../src/components/Trips/TripFormModal', () => ({
default: ({ isOpen, onClose, onSave, trip, onCoverUpdate }: {
isOpen: boolean; onClose: () => void
onSave: (data: Record<string, unknown>) => Promise<void>
trip?: Trip | null
onCoverUpdate: (id: number, coverUrl: string) => void
}) => (
<div data-testid="stub-tripform" data-open={String(isOpen)} data-title={trip?.title ?? 'none'}>
<button type="button" onClick={() => void onSave({ title: 'Japan 2027' })}>save trip</button>
<button type="button" onClick={() => onCoverUpdate(1, '/uploads/covers/new.jpg')}>update cover</button>
<button type="button" onClick={onClose}>close trip form</button>
</div>
),
}))
vi.mock('../../../../src/components/Trips/TripMembersModal', () => ({
default: ({ isOpen, onClose, tripTitle, onMembersChanged }: {
isOpen: boolean; onClose: () => void; tripTitle?: string; onMembersChanged: () => void
}) => (
<div data-testid="stub-members" data-open={String(isOpen)} data-title={tripTitle ?? 'none'}>
<button type="button" onClick={onMembersChanged}>members changed</button>
<button type="button" onClick={onClose}>close members</button>
</div>
),
}))
vi.mock('../../../../src/components/Planner/TransitJourneyModal', () => ({
default: ({ reservation, canEdit, onClose, onSave, onDelete, onChangeRoute }: {
reservation: Reservation
canEdit: boolean
onClose: () => void
onSave: (fields: Record<string, unknown>) => Promise<void>
onDelete: () => Promise<void>
onChangeRoute: () => void
}) => (
<div data-testid="stub-transit" data-title={reservation.title} data-canedit={String(canEdit)}>
<button type="button" onClick={() => void onSave({ title: 'Renamed' })}>save transit</button>
<button type="button" onClick={() => void onDelete()}>delete transit</button>
<button type="button" onClick={onChangeRoute}>change route</button>
<button type="button" onClick={onClose}>close transit</button>
</div>
),
}))
import MTripSheets from '../../../../src/mobile/screens/trip/sheets/MTripSheets'
const JOURNEY = {
id: 55, trip_id: 1, day_id: 4, type: 'transit', title: 'Tokyo → Kyoto',
endpoints: [
{ role: 'from', name: 'Tokyo Sta.', lat: 35.68, lng: 139.76 },
{ role: 'to', name: 'Kyoto Sta.', lat: 34.98, lng: 135.75 },
],
} as unknown as Reservation
function renderHost(plannerOverrides: Partial<TripPlanner> = {}, shellOverrides: Partial<MTripShellApi> = {}) {
const planner = buildPlanner(plannerOverrides)
const shell = buildShell(shellOverrides)
render(<MTripSheets planner={planner} shell={shell} />)
return { planner, shell }
}
describe('MTripSheets', () => {
beforeEach(() => {
resetAllStores()
seedStore(useAuthStore, { user: { id: 7, username: 'maurice', email: 'm@example.com' } })
})
it('FE-MOB-SHOST-001: mounts every sheet of the host with the same planner and shell', () => {
renderHost({}, { sheet: null })
for (const id of ['stub-place', 'stub-day', 'stub-days', 'stub-accommodation', 'stub-transport',
'stub-bract', 'stub-mehr', 'stub-export', 'stub-note', 'stub-import', 'stub-placeedit',
'stub-reservation', 'stub-transportform', 'stub-bookingimport', 'stub-airtrail',
'stub-tripform', 'stub-members']) {
expect(screen.getByTestId(id)).toBeInTheDocument()
}
expect(screen.getByTestId('stub-day')).toHaveAttribute('data-sheet', 'none')
expect(screen.getByTestId('stub-placeedit')).toHaveAttribute('data-trip', '1')
})
it.each([
['day', 'stub-day'],
['days', 'stub-days'],
['accommodation', 'stub-accommodation'],
['transport', 'stub-transport'],
['bract', 'stub-bract'],
['mehr', 'stub-mehr'],
['export', 'stub-export'],
])('FE-MOB-SHOST-002: forwards the "%s" sheet id to %s', (id, testid) => {
renderHost({}, { sheet: { id } })
expect(screen.getByTestId(testid)).toHaveAttribute('data-sheet', id)
})
it.each([
['note', 'stub-note'],
['import', 'stub-import'],
['tripedit', 'stub-tripform'],
['members', 'stub-members'],
])('FE-MOB-SHOST-003: opens only %s for its own id', (id, testid) => {
renderHost({}, { sheet: { id } })
const hostRouted = ['stub-note', 'stub-import', 'stub-tripform', 'stub-members']
for (const other of hostRouted) {
expect(screen.getByTestId(other)).toHaveAttribute('data-open', String(other === testid))
}
})
it('FE-MOB-SHOST-004: hands the note payload to the note sheet and only for that id', () => {
renderHost({}, { sheet: { id: 'note', payload: { dayId: 4 } } })
expect(screen.getByTestId('stub-note')).toHaveAttribute('data-day', '4')
})
it('FE-MOB-SHOST-005: withholds the payload while a different sheet is open', () => {
renderHost({}, { sheet: { id: 'day', payload: { dayId: 4 } } })
expect(screen.getByTestId('stub-note')).toHaveAttribute('data-day', 'none')
})
it('FE-MOB-SHOST-006: the note and import sheets close through the shell', () => {
const { shell } = renderHost({}, { sheet: { id: 'note' } })
fireEvent.click(screen.getByText('close note'))
fireEvent.click(screen.getByText('close import'))
expect(shell.closeSheet).toHaveBeenCalledTimes(2)
})
it('FE-MOB-SHOST-007: saving the trip form updates the trip and toasts', async () => {
const { planner } = renderHost({}, { sheet: { id: 'tripedit' } })
fireEvent.click(screen.getByText('save trip'))
await waitFor(() => expect(planner.tripActions.updateTrip).toHaveBeenCalledWith(1, { title: 'Japan 2027' }))
expect(planner.toast.success).toHaveBeenCalledWith('trip.toast.tripUpdated')
})
it('FE-MOB-SHOST-008: a new cover is patched straight into the trip store', () => {
seedStore(useTripStore, { trip: { id: 1, title: 'Japan 2026', cover_image: '/uploads/covers/old.jpg' } })
renderHost({}, { sheet: { id: 'tripedit' } })
fireEvent.click(screen.getByText('update cover'))
expect(useTripStore.getState().trip?.cover_image).toBe('/uploads/covers/new.jpg')
})
it('FE-MOB-SHOST-009: a cover update without a loaded trip leaves the store alone', () => {
seedStore(useTripStore, { trip: null })
renderHost({}, { sheet: { id: 'tripedit' } })
fireEvent.click(screen.getByText('update cover'))
expect(useTripStore.getState().trip).toBeNull()
})
it('FE-MOB-SHOST-010: the trip form and members modal close through the shell', () => {
const { shell } = renderHost({}, { sheet: { id: 'members' } })
fireEvent.click(screen.getByText('close trip form'))
fireEvent.click(screen.getByText('close members'))
expect(shell.closeSheet).toHaveBeenCalledTimes(2)
})
it('FE-MOB-SHOST-011: the members modal gets the trip title and reports changes to the planner', () => {
const { planner } = renderHost({}, { sheet: { id: 'members' } })
expect(screen.getByTestId('stub-members')).toHaveAttribute('data-title', 'Japan 2026')
fireEvent.click(screen.getByText('members changed'))
expect(planner.refreshMembers).toHaveBeenCalledTimes(1)
})
it('FE-MOB-SHOST-012: the booking and AirTrail importers follow their planner flags', () => {
const { planner } = renderHost({ showBookingImport: true, showAirTrailImport: true })
expect(screen.getByTestId('stub-bookingimport')).toHaveAttribute('data-open', 'true')
expect(screen.getByTestId('stub-bookingimport')).toHaveAttribute('data-trip', '1')
expect(screen.getByTestId('stub-airtrail')).toHaveAttribute('data-hasundo', 'true')
fireEvent.click(screen.getByText('close booking import'))
fireEvent.click(screen.getByText('close airtrail'))
expect(planner.setShowBookingImport).toHaveBeenCalledWith(false)
expect(planner.setShowAirTrailImport).toHaveBeenCalledWith(false)
})
it('FE-MOB-SHOST-013: no transit journey modal without a selected journey', () => {
renderHost()
expect(screen.queryByTestId('stub-transit')).not.toBeInTheDocument()
})
it('FE-MOB-SHOST-014: prefers the stored reservation over the journey snapshot', () => {
const stored = { ...JOURNEY, title: 'Tokyo → Kyoto (saved)' } as unknown as Reservation
renderHost({ transitJourney: JOURNEY, reservations: [stored] })
expect(screen.getByTestId('stub-transit')).toHaveAttribute('data-title', 'Tokyo → Kyoto (saved)')
expect(screen.getByTestId('stub-transit')).toHaveAttribute('data-canedit', 'true')
})
it('FE-MOB-SHOST-015: falls back to the journey itself when it is not in the list', () => {
const planner = buildPlanner({ transitJourney: JOURNEY, reservations: [], can: vi.fn(() => false) as TripPlanner['can'] })
render(<MTripSheets planner={planner} shell={buildShell()} />)
expect(screen.getByTestId('stub-transit')).toHaveAttribute('data-title', 'Tokyo → Kyoto')
expect(screen.getByTestId('stub-transit')).toHaveAttribute('data-canedit', 'false')
expect(planner.can).toHaveBeenCalledWith('day_edit', planner.trip)
})
it('FE-MOB-SHOST-016: saving the journey updates the reservation and clears the selection', async () => {
const { planner } = renderHost({ transitJourney: JOURNEY })
fireEvent.click(screen.getByText('save transit'))
await waitFor(() =>
expect(planner.tripActions.updateReservation).toHaveBeenCalledWith(1, 55, { title: 'Renamed' }))
expect(planner.setTransitJourney).toHaveBeenLastCalledWith(null)
})
it('FE-MOB-SHOST-017: deleting the journey goes through the planner and clears the selection', async () => {
const { planner } = renderHost({ transitJourney: JOURNEY })
fireEvent.click(screen.getByText('delete transit'))
await waitFor(() => expect(planner.handleDeleteReservation).toHaveBeenCalledWith(55))
expect(planner.setTransitJourney).toHaveBeenLastCalledWith(null)
fireEvent.click(screen.getByText('close transit'))
expect(planner.setTransitJourney).toHaveBeenCalledWith(null)
})
it('FE-MOB-SHOST-018: changing the route reopens the transport search seeded with both endpoints', () => {
const { planner } = renderHost({ transitJourney: JOURNEY })
fireEvent.click(screen.getByText('change route'))
expect(planner.setTransitPrefill).toHaveBeenCalledWith({
from: { name: 'Tokyo Sta.', lat: 35.68, lng: 139.76 },
to: { name: 'Kyoto Sta.', lat: 34.98, lng: 135.75 },
})
expect(planner.setEditingTransport).toHaveBeenCalledWith(JOURNEY)
expect(planner.setTransportModalDayId).toHaveBeenCalledWith(4)
expect(planner.setTransportModalAutomated).toHaveBeenCalledWith(true)
expect(planner.setTransitJourney).toHaveBeenCalledWith(null)
expect(planner.setShowTransportModal).toHaveBeenCalledWith(true)
})
it('FE-MOB-SHOST-019: a journey without endpoints seeds empty prefills and no day', () => {
const bare = { id: 56, trip_id: 1, type: 'transit', title: 'Unknown leg' } as unknown as Reservation
const { planner } = renderHost({ transitJourney: bare })
fireEvent.click(screen.getByText('change route'))
expect(planner.setTransitPrefill).toHaveBeenCalledWith({ from: null, to: null })
expect(planner.setTransportModalDayId).toHaveBeenCalledWith(null)
})
it('FE-MOB-SHOST-020: a booking opens the expense editor for its linked item and closes again', () => {
renderHost()
expect(screen.queryByTestId('stub-cost')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('expense noop'))
expect(screen.queryByTestId('stub-cost')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('expense edit'))
const cost = screen.getByTestId('stub-cost')
expect(cost).toHaveAttribute('data-editing', '99')
expect(cost).toHaveAttribute('data-prefill', 'none')
expect(cost).toHaveAttribute('data-me', '7')
expect(cost).toHaveAttribute('data-trip', '1')
fireEvent.click(screen.getByText('close cost'))
expect(screen.queryByTestId('stub-cost')).not.toBeInTheDocument()
})
it('FE-MOB-SHOST-021: a transport prefill opens a new expense and reloads the budget on save', () => {
const loadBudgetItems = vi.fn()
seedStore(useTripStore, { loadBudgetItems })
renderHost()
fireEvent.click(screen.getByText('expense prefill'))
const cost = screen.getByTestId('stub-cost')
expect(cost).toHaveAttribute('data-editing', 'none')
expect(cost).toHaveAttribute('data-prefill', 'Shinkansen')
fireEvent.click(screen.getByText('save cost'))
expect(loadBudgetItems).toHaveBeenCalledWith(1)
expect(screen.queryByTestId('stub-cost')).not.toBeInTheDocument()
})
it('FE-MOB-SHOST-022: the expense base currency prefers the display setting, then the trip', () => {
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'jpy' } })
renderHost()
fireEvent.click(screen.getByText('expense edit'))
expect(screen.getByTestId('stub-cost')).toHaveAttribute('data-base', 'JPY')
})
it('FE-MOB-SHOST-023: falls back to the trip currency and finally to EUR', () => {
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: '' } })
const { planner } = renderHost()
fireEvent.click(screen.getByText('expense edit'))
expect(screen.getByTestId('stub-cost')).toHaveAttribute('data-base', 'EUR')
const noCurrency = { ...planner.trip, currency: '' } as unknown as Trip
render(<MTripSheets planner={buildPlanner({ trip: noCurrency })} shell={buildShell()} />)
fireEvent.click(screen.getAllByText('expense edit')[1])
expect(screen.getAllByTestId('stub-cost')[1]).toHaveAttribute('data-base', 'EUR')
})
it('FE-MOB-SHOST-024: an anonymous session pays as user -1', () => {
seedStore(useAuthStore, { user: null })
renderHost()
fireEvent.click(screen.getByText('expense edit'))
expect(screen.getByTestId('stub-cost')).toHaveAttribute('data-me', '-1')
})
it('FE-MOB-SHOST-025: the delete-place confirm runs the planner confirmation and disarms', () => {
const { planner } = renderHost({ deletePlaceId: 101 })
expect(screen.getByTestId('stub-confirm')).toHaveAttribute('data-title', 'common.delete')
expect(screen.getByText('trip.confirm.deletePlace')).toBeInTheDocument()
fireEvent.click(screen.getByText('confirm delete'))
expect(planner.confirmDeletePlace).toHaveBeenCalledTimes(1)
expect(planner.setDeletePlaceId).toHaveBeenCalledWith(null)
})
it('FE-MOB-SHOST-026: cancelling the confirm only disarms the flag', () => {
const { planner } = renderHost({ deletePlaceId: 101 })
fireEvent.click(screen.getByText('cancel delete'))
expect(planner.confirmDeletePlace).not.toHaveBeenCalled()
expect(planner.setDeletePlaceId).toHaveBeenCalledWith(null)
})
it('FE-MOB-SHOST-027: the place edit sheet owns the confirm while its own form is open', () => {
renderHost({ deletePlaceId: 101, showPlaceForm: true })
expect(screen.queryByTestId('stub-confirm')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,421 @@
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 still gets a chip.
expect(screen.getByRole('button', { name: 'planner.dayN:0' })).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)
})
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)
})
})
@@ -0,0 +1,95 @@
import { describe, expect, it, vi } from 'vitest'
import MTripTabPanel from '../../../../src/mobile/screens/trip/tabs/MTripTabPanel'
import type { MTabScreenProps } from '../../../../src/mobile/screens/trip/tabs/tabModel'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { render, screen } from '../../../helpers/render'
// FE-MOB-TABPANEL-001 to FE-MOB-TABPANEL-010
/** Each tab is stubbed so the routing itself is what gets asserted. */
function stub(name: string) {
return function Stub({ planner, shell }: MTabScreenProps) {
return <div data-testid={name} data-trip={String(planner.tripId)} data-tab={shell.trTab} />
}
}
vi.mock('../../../../src/mobile/screens/trip/tabs/MTransportsTab', () => ({ default: stub('transports') }))
vi.mock('../../../../src/mobile/screens/trip/tabs/MBookingsTab', () => ({ default: stub('bookings') }))
vi.mock('../../../../src/mobile/screens/trip/tabs/MCostsTab', () => ({ default: stub('costs') }))
vi.mock('../../../../src/mobile/screens/trip/tabs/MFilesTab', () => ({ default: stub('files') }))
vi.mock('../../../../src/mobile/screens/trip/tabs/MCollabTab', () => ({ default: stub('collab') }))
vi.mock('../../../../src/mobile/screens/trip/tabs/MListsTab', () => ({ default: stub('lists') }))
vi.mock('../../../../src/components/Plugins/PluginFrame', () => ({
default: ({ pluginId, tripId, fill }: { pluginId: string; tripId: string | null; fill?: boolean }) => (
<div data-testid="plugin-frame" data-plugin={pluginId} data-trip={String(tripId)} data-fill={String(!!fill)} />
),
}))
function renderTab(tab: string, plannerOverrides = {}) {
const planner = buildPlanner(plannerOverrides)
const shell = buildShell({ trTab: tab })
return { ...render(<MTripTabPanel planner={planner} shell={shell} tab={tab} />), planner, shell }
}
describe('MTripTabPanel', () => {
it('FE-MOB-TABPANEL-001: routes transports and hands both props down', () => {
renderTab('transports')
const panel = screen.getByTestId('transports')
expect(panel).toHaveAttribute('data-trip', '1')
expect(panel).toHaveAttribute('data-tab', 'transports')
expect(screen.queryByTestId('bookings')).not.toBeInTheDocument()
})
it('FE-MOB-TABPANEL-002: routes buchungen to the bookings panel', () => {
renderTab('buchungen')
expect(screen.getByTestId('bookings')).toBeInTheDocument()
})
it('FE-MOB-TABPANEL-003: routes finanzplan to the costs panel', () => {
renderTab('finanzplan')
expect(screen.getByTestId('costs')).toBeInTheDocument()
})
it('FE-MOB-TABPANEL-004: routes dateien to the files panel', () => {
renderTab('dateien')
expect(screen.getByTestId('files')).toBeInTheDocument()
})
it('FE-MOB-TABPANEL-005: routes collab to the collab panel', () => {
renderTab('collab')
expect(screen.getByTestId('collab')).toBeInTheDocument()
})
it('FE-MOB-TABPANEL-006: routes listen to the lists panel', () => {
renderTab('listen')
expect(screen.getByTestId('lists')).toBeInTheDocument()
})
it('FE-MOB-TABPANEL-007: mounts the sandboxed frame for a plugin tab', () => {
renderTab('plugin:trip-todos')
const frame = screen.getByTestId('plugin-frame')
expect(frame).toHaveAttribute('data-plugin', 'trip-todos')
expect(frame).toHaveAttribute('data-trip', '1')
expect(frame).toHaveAttribute('data-fill', 'true')
// The frame keeps the dock clearance the scroll body uses.
expect((frame.parentElement as HTMLElement).style.paddingBottom).toContain('safe-area-inset-bottom')
})
it('FE-MOB-TABPANEL-008: passes a null tripId to the plugin frame before the trip is known', () => {
renderTab('plugin:koffi', { tripId: null })
expect(screen.getByTestId('plugin-frame')).toHaveAttribute('data-trip', 'null')
})
it('FE-MOB-TABPANEL-009: keeps a plugin id that itself contains a colon intact', () => {
renderTab('plugin:acme:maps')
expect(screen.getByTestId('plugin-frame')).toHaveAttribute('data-plugin', 'acme:maps')
})
it('FE-MOB-TABPANEL-010: an unbuilt tab falls back to the empty scroll body', () => {
const { container } = renderTab('plan')
expect(container.querySelector('[data-testid]')).toBeNull()
const body = container.querySelector('.overflow-y-auto') as HTMLElement
expect(body).not.toBeNull()
expect(body).toBeEmptyDOMElement()
})
})
@@ -0,0 +1,147 @@
import { describe, expect, it, vi } from 'vitest'
import PlCategoryPicker from '../../../../src/mobile/screens/trip/sheets/PlCategoryPicker'
import type { Category } from '../../../../src/types'
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { buildPlanner, buildTripActions } from '../../../helpers/mobileTrip'
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-PLCAT-001 to FE-MOB-PLCAT-011
// planner.t echoes the key, so every visible label is asserted as its key.
const CATEGORIES = [
{ id: 3, name: 'Food', color: '#ef4444', icon: 'Coffee', user_id: 1 },
{ id: 4, name: 'Museums', color: '#22c55e', icon: 'Landmark', user_id: 1 },
] as unknown as Category[]
function setup(plannerOverrides: Partial<TripPlanner> = {}, value = '') {
const onChange = vi.fn()
const planner = buildPlanner({ categories: CATEGORIES, ...plannerOverrides })
const view = render(<PlCategoryPicker planner={planner} value={value} onChange={onChange} />)
return { ...view, planner, onChange }
}
describe('PlCategoryPicker', () => {
it('FE-MOB-PLCAT-001: renders the no-category pill plus one pill per trip category', () => {
setup()
expect(screen.getByRole('button', { name: /places\.noCategory/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Food/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Museums/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /mobileTrip\.newCategory/ })).toBeInTheDocument()
})
it('FE-MOB-PLCAT-002: marks the no-category pill active for the empty value', () => {
setup()
expect(screen.getByRole('button', { name: /places\.noCategory/ }).className).toContain('bg-m-act')
expect(screen.getByRole('button', { name: /Food/ }).className).not.toContain('bg-m-act')
})
it('FE-MOB-PLCAT-003: marks the pill of the selected id active', () => {
setup({}, '4')
expect(screen.getByRole('button', { name: /Museums/ }).className).toContain('bg-m-act')
expect(screen.getByRole('button', { name: /places\.noCategory/ }).className).not.toContain('bg-m-act')
})
it('FE-MOB-PLCAT-004: reports the picked category id as a string', () => {
const { onChange } = setup({}, '')
fireEvent.click(screen.getByRole('button', { name: /Food/ }))
expect(onChange).toHaveBeenCalledWith('3')
})
it('FE-MOB-PLCAT-005: reports the empty value when the category is cleared', () => {
const { onChange } = setup({}, '3')
fireEvent.click(screen.getByRole('button', { name: /places\.noCategory/ }))
expect(onChange).toHaveBeenCalledWith('')
})
it('FE-MOB-PLCAT-006: creates a category inline and selects it right away', async () => {
const tripActions = buildTripActions()
tripActions.addCategory.mockResolvedValue({ id: 9, name: 'Bars' } as unknown as Category)
const { onChange } = setup({ tripActions } as unknown as Partial<TripPlanner>)
fireEvent.click(screen.getByRole('button', { name: /mobileTrip\.newCategory/ }))
const input = screen.getByPlaceholderText('places.categoryNamePlaceholder')
fireEvent.change(input, { target: { value: ' Bars ' } })
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
await waitFor(() => expect(onChange).toHaveBeenCalledWith('9'))
expect(tripActions.addCategory).toHaveBeenCalledWith({ name: 'Bars', color: '#6366f1', icon: 'MapPin' })
// The inline row collapses back to the dashed pill.
await waitFor(() => expect(screen.queryByPlaceholderText('places.categoryNamePlaceholder')).not.toBeInTheDocument())
expect(screen.getByRole('button', { name: /mobileTrip\.newCategory/ })).toBeInTheDocument()
})
it('FE-MOB-PLCAT-007: Enter in the name field creates the category', async () => {
const tripActions = buildTripActions()
tripActions.addCategory.mockResolvedValue({ id: 12, name: 'Beaches' } as unknown as Category)
const { onChange } = setup({ tripActions } as unknown as Partial<TripPlanner>)
fireEvent.click(screen.getByRole('button', { name: /mobileTrip\.newCategory/ }))
const input = screen.getByPlaceholderText('places.categoryNamePlaceholder')
fireEvent.change(input, { target: { value: 'Beaches' } })
fireEvent.keyDown(input, { key: 'Enter' })
await waitFor(() => expect(onChange).toHaveBeenCalledWith('12'))
})
it('FE-MOB-PLCAT-008: ignores Enter and keeps the confirm disabled while the name is blank', () => {
const tripActions = buildTripActions()
setup({ tripActions } as unknown as Partial<TripPlanner>)
fireEvent.click(screen.getByRole('button', { name: /mobileTrip\.newCategory/ }))
const input = screen.getByPlaceholderText('places.categoryNamePlaceholder')
expect(screen.getByRole('button', { name: 'common.add' })).toBeDisabled()
fireEvent.change(input, { target: { value: ' ' } })
fireEvent.keyDown(input, { key: 'a' })
fireEvent.keyDown(input, { key: 'Enter' })
expect(tripActions.addCategory).not.toHaveBeenCalled()
})
it('FE-MOB-PLCAT-009: does not fire a second create while the first is in flight', async () => {
const tripActions = buildTripActions()
let release: (cat: Category) => void = () => {}
tripActions.addCategory.mockReturnValue(new Promise<Category>(resolve => { release = resolve }))
const { onChange } = setup({ tripActions } as unknown as Partial<TripPlanner>)
fireEvent.click(screen.getByRole('button', { name: /mobileTrip\.newCategory/ }))
const input = screen.getByPlaceholderText('places.categoryNamePlaceholder')
fireEvent.change(input, { target: { value: 'Bars' } })
fireEvent.keyDown(input, { key: 'Enter' })
fireEvent.keyDown(input, { key: 'Enter' })
expect(tripActions.addCategory).toHaveBeenCalledTimes(1)
expect(screen.getByRole('button', { name: 'common.add' })).toBeDisabled()
release({ id: 21 } as unknown as Category)
await waitFor(() => expect(onChange).toHaveBeenCalledWith('21'))
})
it('FE-MOB-PLCAT-010: toasts and keeps the draft when the create fails', async () => {
const tripActions = buildTripActions()
tripActions.addCategory.mockRejectedValue(new Error('409'))
const { planner, onChange } = setup({ tripActions } as unknown as Partial<TripPlanner>)
fireEvent.click(screen.getByRole('button', { name: /mobileTrip\.newCategory/ }))
fireEvent.change(screen.getByPlaceholderText('places.categoryNamePlaceholder'), { target: { value: 'Bars' } })
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('places.categoryCreateError'))
expect(onChange).not.toHaveBeenCalled()
expect(screen.getByPlaceholderText('places.categoryNamePlaceholder')).toHaveValue('Bars')
})
it('FE-MOB-PLCAT-011: cancelling drops the draft and restores the dashed pill', () => {
setup()
fireEvent.click(screen.getByRole('button', { name: /mobileTrip\.newCategory/ }))
fireEvent.change(screen.getByPlaceholderText('places.categoryNamePlaceholder'), { target: { value: 'Bars' } })
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
expect(screen.queryByPlaceholderText('places.categoryNamePlaceholder')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /mobileTrip\.newCategory/ }))
expect(screen.getByPlaceholderText('places.categoryNamePlaceholder')).toHaveValue('')
})
it('FE-MOB-PLCAT-012: tolerates a trip without any categories', () => {
setup({ categories: undefined } as unknown as Partial<TripPlanner>)
expect(screen.getByRole('button', { name: /places\.noCategory/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Food/ })).not.toBeInTheDocument()
})
})
@@ -0,0 +1,92 @@
import type { ComponentProps } from 'react'
import { describe, expect, it, vi } from 'vitest'
import PlFileAttach from '../../../../src/mobile/screens/trip/sheets/PlFileAttach'
import { buildPlanner } from '../../../helpers/mobileTrip'
import { fireEvent, render, screen } from '../../../helpers/render'
// FE-MOB-PLFILE-001 to FE-MOB-PLFILE-010
const planner = buildPlanner()
function file(name: string, type = 'application/pdf') {
return new File(['x'], name, { type })
}
function setup(props: Partial<ComponentProps<typeof PlFileAttach>> = {}) {
const onAdd = vi.fn()
const onRemove = vi.fn()
const view = render(
<PlFileAttach planner={planner} files={[]} onAdd={onAdd} onRemove={onRemove} {...props} />,
)
const input = view.container.querySelector('input[type="file"]') as HTMLInputElement
return { ...view, onAdd, onRemove, input }
}
describe('PlFileAttach', () => {
it('FE-MOB-PLFILE-001: renders the files heading, the paste hint and the attach pill', () => {
setup()
expect(screen.getByText('files.title')).toBeInTheDocument()
expect(screen.getByText('files.pasteHint')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'files.attach' })).toBeInTheDocument()
})
it('FE-MOB-PLFILE-002: hideHint drops the paste subline but keeps the heading', () => {
setup({ hideHint: true })
expect(screen.getByText('files.title')).toBeInTheDocument()
expect(screen.queryByText('files.pasteHint')).not.toBeInTheDocument()
})
it('FE-MOB-PLFILE-003: the picker input is hidden and accepts multiple files', () => {
const { input } = setup()
expect(input).toHaveClass('hidden')
expect(input).toHaveAttribute('multiple')
})
it('FE-MOB-PLFILE-004: the attach pill opens the hidden picker', () => {
const { input } = setup()
const click = vi.spyOn(input, 'click').mockImplementation(() => undefined)
fireEvent.click(screen.getByRole('button', { name: 'files.attach' }))
expect(click).toHaveBeenCalledTimes(1)
click.mockRestore()
})
it('FE-MOB-PLFILE-005: picking files reports them as an array and clears the input', () => {
const { input, onAdd } = setup()
const a = file('ticket.pdf')
const b = file('map.png', 'image/png')
fireEvent.change(input, { target: { files: [a, b] } })
expect(onAdd).toHaveBeenCalledTimes(1)
expect(onAdd.mock.calls[0][0]).toEqual([a, b])
expect(input.value).toBe('')
})
it('FE-MOB-PLFILE-006: an empty pick still reports an empty array', () => {
const { input, onAdd } = setup()
fireEvent.change(input, { target: { files: [] } })
expect(onAdd).toHaveBeenCalledWith([])
})
it('FE-MOB-PLFILE-010: a cancelled pick without a file list reports an empty array', () => {
const { input, onAdd } = setup()
fireEvent.change(input, { target: { files: null } })
expect(onAdd).toHaveBeenCalledWith([])
})
it('FE-MOB-PLFILE-007: renders no attachment list while nothing is pending', () => {
setup()
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
})
it('FE-MOB-PLFILE-008: lists every pending attachment by name', () => {
setup({ files: [file('booking.pdf'), file('voucher.pdf')] })
expect(screen.getByText('booking.pdf')).toBeInTheDocument()
expect(screen.getByText('voucher.pdf')).toBeInTheDocument()
expect(screen.getAllByRole('button', { name: 'common.delete' })).toHaveLength(2)
})
it('FE-MOB-PLFILE-009: the row X removes exactly its own index', () => {
const { onRemove } = setup({ files: [file('a.pdf'), file('b.pdf'), file('c.pdf')] })
fireEvent.click(screen.getAllByRole('button', { name: 'common.delete' })[1])
expect(onRemove).toHaveBeenCalledWith(1)
})
})
@@ -0,0 +1,372 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { HttpResponse, delay, http } from 'msw'
import PlPlaceSearch from '../../../../src/mobile/screens/trip/sheets/PlPlaceSearch'
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { buildPlanner } from '../../../helpers/mobileTrip'
import { server } from '../../../helpers/msw/server'
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-PLSRCH-001 to FE-MOB-PLSRCH-018
// planner.t echoes the key, so labels/toasts are asserted as their keys.
const LOUVRE = {
name: 'Louvre Museum',
address: 'Rue de Rivoli, Paris',
lat: 48.8606,
lng: 2.3376,
google_place_id: 'ChIJ_louvre',
google_ftid: '0x47e:0x1',
osm_id: 'W7444,',
website: 'https://louvre.fr',
phone: '+33 1',
}
const SUGGESTION = { placeId: 'sug-1', mainText: 'Louvre', secondaryText: 'Paris, France' }
/** Bodies of every autocomplete request the component fired. */
let autocompleteBodies: Record<string, unknown>[] = []
let searchBodies: Record<string, unknown>[] = []
function recordAutocomplete(suggestions: unknown[] = [SUGGESTION]) {
return http.post('/api/maps/autocomplete', async ({ request }) => {
autocompleteBodies.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({ suggestions, source: 'osm' })
})
}
function recordSearch(places: unknown[] = [LOUVRE]) {
return http.post('/api/maps/search', async ({ request }) => {
searchBodies.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({ places, source: 'osm' })
})
}
function setup(plannerOverrides: Partial<TripPlanner> = {}, locationBias?: Parameters<typeof PlPlaceSearch>[0]['locationBias']) {
const onPick = vi.fn()
const onResolvingChange = vi.fn()
const planner = buildPlanner(plannerOverrides)
const view = render(
<PlPlaceSearch planner={planner} locationBias={locationBias} onPick={onPick} onResolvingChange={onResolvingChange} />,
)
const input = screen.getByPlaceholderText('places.mapsSearchPlaceholder')
return { ...view, planner, onPick, onResolvingChange, input }
}
describe('PlPlaceSearch', () => {
beforeEach(() => {
autocompleteBodies = []
searchBodies = []
})
it('FE-MOB-PLSRCH-001: stays quiet below two characters', async () => {
server.use(recordAutocomplete())
const { input } = setup()
fireEvent.change(input, { target: { value: 'L' } })
await new Promise(r => setTimeout(r, 450))
expect(autocompleteBodies).toHaveLength(0)
expect(screen.queryByText('Louvre')).not.toBeInTheDocument()
})
it('FE-MOB-PLSRCH-002: debounces the autocomplete and lists both suggestion lines', async () => {
server.use(recordAutocomplete())
const { input } = setup()
fireEvent.change(input, { target: { value: 'Lou' } })
expect(autocompleteBodies).toHaveLength(0)
expect(await screen.findByText('Louvre')).toBeInTheDocument()
expect(screen.getByText('Paris, France')).toBeInTheDocument()
expect(autocompleteBodies[0]).toMatchObject({ input: 'Lou', lang: 'en' })
})
it('FE-MOB-PLSRCH-003: forwards the trip-centre bias', async () => {
server.use(recordAutocomplete())
const bias = { low: { lat: 48.8, lng: 2.3 }, high: { lat: 48.9, lng: 2.4 } }
const { input } = setup({}, bias)
fireEvent.change(input, { target: { value: 'Lou' } })
await waitFor(() => expect(autocompleteBodies).toHaveLength(1))
expect(autocompleteBodies[0].locationBias).toEqual(bias)
})
it('FE-MOB-PLSRCH-004: picking a suggestion applies its resolved details and clears the field', async () => {
server.use(
recordAutocomplete(),
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: LOUVRE })),
)
const { input, onPick } = setup()
fireEvent.change(input, { target: { value: 'Lou' } })
const row = await screen.findByText('Louvre')
// The row swallows pointerdown so the field's blur handler cannot close
// the dropdown before the click lands.
const pointerDown = fireEvent.pointerDown(row)
expect(pointerDown).toBe(false)
fireEvent.click(row)
// Optimistic name first, then the full record.
expect(onPick).toHaveBeenNthCalledWith(1, { name: 'Louvre' })
await waitFor(() => expect(onPick).toHaveBeenCalledTimes(2))
expect(onPick).toHaveBeenLastCalledWith({
name: 'Louvre Museum',
address: 'Rue de Rivoli, Paris',
lat: '48.8606',
lng: '2.3376',
google_place_id: 'ChIJ_louvre',
google_ftid: '0x47e:0x1',
osm_id: 'W7444,',
website: 'https://louvre.fr',
phone: '+33 1',
})
expect(input).toHaveValue('')
})
it('FE-MOB-PLSRCH-005: falls back to the text search when the details hop fails', async () => {
server.use(
recordAutocomplete(),
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ error: 'disabled' }, { status: 500 })),
recordSearch(),
)
const { input, onPick } = setup()
fireEvent.change(input, { target: { value: 'Lou' } })
fireEvent.click(await screen.findByText('Louvre'))
await waitFor(() => expect(onPick).toHaveBeenCalledTimes(2))
expect(searchBodies[0]).toEqual({ query: 'Louvre, Paris, France' })
expect(onPick).toHaveBeenLastCalledWith(expect.objectContaining({ name: 'Louvre Museum', lat: '48.8606' }))
})
it('FE-MOB-PLSRCH-006: also falls back when the details hop answers without coordinates', async () => {
server.use(
recordAutocomplete(),
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: { name: 'Louvre' } })),
recordSearch(),
)
const { input, onPick } = setup()
fireEvent.change(input, { target: { value: 'Lou' } })
fireEvent.click(await screen.findByText('Louvre'))
await waitFor(() => expect(searchBodies).toHaveLength(1))
expect(onPick).toHaveBeenLastCalledWith(expect.objectContaining({ lat: '48.8606' }))
})
it('FE-MOB-PLSRCH-007: restores the typed query and toasts when nothing resolves', async () => {
server.use(
recordAutocomplete(),
http.get('/api/maps/details/:placeId', () => HttpResponse.json({}, { status: 500 })),
recordSearch([]),
)
const { input, planner } = setup()
fireEvent.change(input, { target: { value: 'Lou' } })
fireEvent.click(await screen.findByText('Louvre'))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('places.mapsSearchError'))
expect(input).toHaveValue('Lou')
})
it('FE-MOB-PLSRCH-008: surfaces the server message when the fallback search rejects', async () => {
server.use(
recordAutocomplete(),
http.get('/api/maps/details/:placeId', () => HttpResponse.json({}, { status: 500 })),
http.post('/api/maps/search', () => HttpResponse.json({ error: 'Places API is disabled' }, { status: 502 })),
)
const { input, planner } = setup()
fireEvent.change(input, { target: { value: 'Lou' } })
fireEvent.click(await screen.findByText('Louvre'))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Places API is disabled'))
expect(input).toHaveValue('Lou')
})
it('FE-MOB-PLSRCH-009: a "lat, lng" query becomes coordinates without any lookup', async () => {
server.use(recordAutocomplete(), recordSearch())
const { input, onPick } = setup()
fireEvent.change(input, { target: { value: '48.8566; 2.3522' } })
fireEvent.click(screen.getByRole('button', { name: 'common.search' }))
expect(onPick).toHaveBeenCalledWith({ lat: '48.8566', lng: '2.3522' })
expect(input).toHaveValue('')
await new Promise(r => setTimeout(r, 400))
expect(autocompleteBodies).toHaveLength(0)
expect(searchBodies).toHaveLength(0)
})
it('FE-MOB-PLSRCH-010: resolves a Google Maps URL and confirms it', async () => {
server.use(
recordAutocomplete(),
http.post('/api/maps/resolve-url', () => HttpResponse.json({
lat: 48.86, lng: 2.33, name: 'Louvre', address: 'Paris', google_ftid: '0x1:0x2',
})),
)
const { input, onPick, planner } = setup()
fireEvent.change(input, { target: { value: 'https://maps.app.goo.gl/abc' } })
fireEvent.click(screen.getByRole('button', { name: 'common.search' }))
await waitFor(() => expect(planner.toast.success).toHaveBeenCalledWith('places.urlResolved'))
expect(onPick).toHaveBeenCalledWith({
name: 'Louvre', address: 'Paris', lat: '48.86', lng: '2.33', google_ftid: '0x1:0x2',
})
expect(input).toHaveValue('')
// URLs never hit the autocomplete debounce.
expect(autocompleteBodies).toHaveLength(0)
})
it('FE-MOB-PLSRCH-011: falls through to the text search when the URL carries no coordinates', async () => {
server.use(
http.post('/api/maps/resolve-url', () => HttpResponse.json({ lat: null, lng: null })),
recordSearch(),
)
const { input, planner } = setup()
fireEvent.change(input, { target: { value: 'https://www.google.com/maps/place/Louvre' } })
fireEvent.click(screen.getByRole('button', { name: 'common.search' }))
await waitFor(() => expect(searchBodies).toHaveLength(1))
expect(await screen.findByText('Louvre Museum')).toBeInTheDocument()
expect(planner.toast.success).not.toHaveBeenCalled()
})
it('FE-MOB-PLSRCH-012: lists text results and applies the tapped one', async () => {
server.use(recordSearch([LOUVRE, { name: 'Louvre Lens', address: 'Lens' }]))
const { input, onPick } = setup()
fireEvent.change(input, { target: { value: 'louvre museum' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(await screen.findByText('Louvre Museum')).toBeInTheDocument()
expect(screen.getByText('Louvre Lens')).toBeInTheDocument()
expect(screen.getByText('Rue de Rivoli, Paris')).toBeInTheDocument()
fireEvent.click(screen.getByText('Louvre Lens'))
expect(onPick).toHaveBeenCalledWith(expect.objectContaining({ name: 'Louvre Lens', address: 'Lens', lat: undefined }))
await waitFor(() => expect(screen.queryByText('Louvre Museum')).not.toBeInTheDocument())
expect(input).toHaveValue('')
})
it('FE-MOB-PLSRCH-013: an empty query does nothing', async () => {
server.use(recordSearch())
const { onPick } = setup()
fireEvent.click(screen.getByRole('button', { name: 'common.search' }))
await new Promise(r => setTimeout(r, 50))
expect(searchBodies).toHaveLength(0)
expect(onPick).not.toHaveBeenCalled()
})
it('FE-MOB-PLSRCH-014: reports the resolving state and blocks the button while searching', async () => {
server.use(http.post('/api/maps/search', async () => {
await delay(120)
return HttpResponse.json({ places: [LOUVRE], source: 'osm' })
}))
const { input, onResolvingChange } = setup()
fireEvent.change(input, { target: { value: 'louvre museum' } })
fireEvent.click(screen.getByRole('button', { name: 'common.search' }))
await waitFor(() => expect(screen.getByRole('button', { name: 'common.search' })).toBeDisabled())
expect(onResolvingChange).toHaveBeenCalledWith(true)
await waitFor(() => expect(onResolvingChange).toHaveBeenLastCalledWith(false))
expect(screen.getByRole('button', { name: 'common.search' })).not.toBeDisabled()
})
it('FE-MOB-PLSRCH-015: toasts the fallback message when the search fails without a body', async () => {
server.use(http.post('/api/maps/search', () => new HttpResponse(null, { status: 500 })))
const { input, planner } = setup()
fireEvent.change(input, { target: { value: 'louvre museum' } })
fireEvent.keyDown(input, { key: 'Enter' })
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('places.mapsSearchError'))
})
it('FE-MOB-PLSRCH-016: a failing autocomplete empties the dropdown', async () => {
server.use(recordAutocomplete())
const { input } = setup()
fireEvent.change(input, { target: { value: 'Lou' } })
expect(await screen.findByText('Louvre')).toBeInTheDocument()
server.use(http.post('/api/maps/autocomplete', () => HttpResponse.json({}, { status: 500 })))
fireEvent.change(input, { target: { value: 'Louv' } })
await waitFor(() => expect(screen.queryByText('Louvre')).not.toBeInTheDocument())
})
it('FE-MOB-PLSRCH-017: a superseded autocomplete is aborted and does not clear the newer list', async () => {
server.use(http.post('/api/maps/autocomplete', async ({ request }) => {
const body = await request.json() as { input: string }
autocompleteBodies.push(body)
if (body.input === 'Lou') {
await delay(3000)
return HttpResponse.json({ suggestions: [], source: 'osm' })
}
return HttpResponse.json({ suggestions: [SUGGESTION], source: 'osm' })
}))
const { input } = setup()
fireEvent.change(input, { target: { value: 'Lou' } })
await waitFor(() => expect(autocompleteBodies).toHaveLength(1))
fireEvent.change(input, { target: { value: 'Louvre mus' } })
expect(await screen.findByText('Louvre')).toBeInTheDocument()
await new Promise(r => setTimeout(r, 100))
expect(screen.getByText('Louvre')).toBeInTheDocument()
})
it('FE-MOB-PLSRCH-018b: a suggestion without a second line searches on its main text alone', async () => {
server.use(
recordAutocomplete([{ placeId: 'sug-2', mainText: 'Louvre', secondaryText: '' }]),
http.get('/api/maps/details/:placeId', () => HttpResponse.json({}, { status: 500 })),
recordSearch(),
)
const { input } = setup()
fireEvent.change(input, { target: { value: 'Lou' } })
fireEvent.click(await screen.findByText('Louvre'))
await waitFor(() => expect(searchBodies).toHaveLength(1))
expect(searchBodies[0]).toEqual({ query: 'Louvre' })
})
it('FE-MOB-PLSRCH-018c: a response without a places array leaves the result list empty', async () => {
server.use(http.post('/api/maps/search', () => HttpResponse.json({ source: 'osm' })))
const { input, planner } = setup()
fireEvent.change(input, { target: { value: 'louvre museum' } })
fireEvent.keyDown(input, { key: 'Enter' })
await waitFor(() => expect(screen.getByRole('button', { name: 'common.search' })).not.toBeDisabled())
expect(screen.queryByText('Louvre Museum')).not.toBeInTheDocument()
expect(planner.toast.error).not.toHaveBeenCalled()
})
it('FE-MOB-PLSRCH-018d: a nameless result still renders and can be picked', async () => {
server.use(recordSearch([{ lat: 48.86, lng: 2.33 }]))
const { input, onPick } = setup()
fireEvent.change(input, { target: { value: 'louvre museum' } })
fireEvent.keyDown(input, { key: 'Enter' })
await waitFor(() => expect(searchBodies).toHaveLength(1))
// The only unlabelled button is the result row; its two lines stay empty.
const rows = (await screen.findAllByRole('button')).filter(b => !b.getAttribute('aria-label'))
expect(rows).toHaveLength(1)
expect(rows[0].textContent).toBe('')
fireEvent.click(rows[0])
expect(onPick).toHaveBeenCalledWith(expect.objectContaining({ name: undefined, lat: '48.86', lng: '2.33' }))
})
it('FE-MOB-PLSRCH-018e: keys other than Enter do not trigger a search', async () => {
server.use(recordSearch())
const { input } = setup()
fireEvent.change(input, { target: { value: 'louvre museum' } })
fireEvent.keyDown(input, { key: 'ArrowDown' })
await new Promise(r => setTimeout(r, 50))
expect(searchBodies).toHaveLength(0)
})
it('FE-MOB-PLSRCH-018f: a URL that resolves to bare coordinates picks them without extras', async () => {
server.use(http.post('/api/maps/resolve-url', () => HttpResponse.json({
lat: 48.86, lng: 2.33, name: null, address: null, google_ftid: null,
})))
const { input, onPick, planner } = setup()
fireEvent.change(input, { target: { value: 'https://goo.gl/maps/xyz' } })
fireEvent.click(screen.getByRole('button', { name: 'common.search' }))
await waitFor(() => expect(planner.toast.success).toHaveBeenCalledWith('places.urlResolved'))
expect(onPick).toHaveBeenCalledWith({
name: undefined, address: undefined, lat: '48.86', lng: '2.33', google_ftid: undefined,
})
})
it('FE-MOB-PLSRCH-018: blurring the field dismisses the dropdown', async () => {
server.use(recordAutocomplete())
const { input } = setup()
fireEvent.change(input, { target: { value: 'Lou' } })
expect(await screen.findByText('Louvre')).toBeInTheDocument()
fireEvent.blur(input)
await waitFor(() => expect(screen.queryByText('Louvre')).not.toBeInTheDocument())
})
})
@@ -0,0 +1,127 @@
import { describe, expect, it, vi } from 'vitest'
import PlTimeFields from '../../../../src/mobile/screens/trip/sheets/PlTimeFields'
import type { Assignment } from '../../../../src/types'
import { buildPlanner } from '../../../helpers/mobileTrip'
import { fireEvent, render, screen } from '../../../helpers/render'
// FE-MOB-PLTIME-001 to FE-MOB-PLTIME-012
const planner = buildPlanner()
function assignment(
id: number,
name: string,
place_time: string | null,
end_time: string | null = null,
day_id = 1,
): Assignment {
return { id, day_id, place_id: id, order_index: 0, place: { id, name, place_time, end_time } } as unknown as Assignment
}
const SELF = assignment(1, 'Museum', '10:00', '11:00')
interface Options {
startTime?: string
endTime?: string
dayAssignments?: Assignment[]
hasTimeError?: boolean
assignmentId?: number
}
function setup({
startTime = '10:00',
endTime = '11:00',
dayAssignments = [SELF],
hasTimeError = false,
assignmentId = 1,
}: Options = {}) {
const onChange = vi.fn()
const view = render(
<PlTimeFields
planner={planner}
startTime={startTime}
endTime={endTime}
onChange={onChange}
assignmentId={assignmentId}
dayAssignments={dayAssignments}
hasTimeError={hasTimeError}
/>,
)
const inputs = Array.from(view.container.querySelectorAll('input[type="time"]')) as HTMLInputElement[]
return { ...view, onChange, start: inputs[0], end: inputs[1] }
}
describe('PlTimeFields', () => {
it('FE-MOB-PLTIME-001: renders both labelled time inputs with their current values', () => {
const { start, end } = setup()
expect(screen.getByText('places.startTime')).toBeInTheDocument()
expect(screen.getByText('places.endTime')).toBeInTheDocument()
expect(start).toHaveValue('10:00')
expect(end).toHaveValue('11:00')
})
it('FE-MOB-PLTIME-002: editing the start reports the place_time field', () => {
const { start, onChange } = setup()
fireEvent.change(start, { target: { value: '09:15' } })
expect(onChange).toHaveBeenCalledWith('place_time', '09:15')
})
it('FE-MOB-PLTIME-003: editing the end reports the end_time field', () => {
const { end, onChange } = setup()
fireEvent.change(end, { target: { value: '12:45' } })
expect(onChange).toHaveBeenCalledWith('end_time', '12:45')
})
it('FE-MOB-PLTIME-004: shows the end-before-start warning only when the sheet flags it', () => {
setup()
expect(screen.queryByText('places.endTimeBeforeStart')).not.toBeInTheDocument()
setup({ hasTimeError: true })
expect(screen.getByText('places.endTimeBeforeStart')).toBeInTheDocument()
})
it('FE-MOB-PLTIME-005: stays quiet without a usable start time', () => {
setup({ startTime: '', dayAssignments: [assignment(1, 'Museum', null), assignment(2, 'Park', '10:30')] })
expect(screen.queryByText(/places.timeCollision/)).not.toBeInTheDocument()
})
it('FE-MOB-PLTIME-006: stays quiet when the edited assignment is not in the day', () => {
setup({ assignmentId: 99, dayAssignments: [SELF, assignment(2, 'Park', '10:30')] })
expect(screen.queryByText(/places.timeCollision/)).not.toBeInTheDocument()
})
it('FE-MOB-PLTIME-007: names the overlapping places of the same day', () => {
setup({
dayAssignments: [SELF, assignment(2, 'Park', '10:30', '12:00'), assignment(3, 'Cafe', '10:45')],
})
expect(screen.getByText(/places.timeCollision/)).toHaveTextContent('places.timeCollision Park, Cafe')
})
it('FE-MOB-PLTIME-008: ignores places of other days', () => {
setup({ dayAssignments: [SELF, assignment(2, 'Park', '10:30', '12:00', 2)] })
expect(screen.queryByText(/places.timeCollision/)).not.toBeInTheDocument()
})
it('FE-MOB-PLTIME-009: ignores untimed places', () => {
setup({ dayAssignments: [SELF, assignment(2, 'Park', null)] })
expect(screen.queryByText(/places.timeCollision/)).not.toBeInTheDocument()
})
it('FE-MOB-PLTIME-010: back-to-back slots are not a collision', () => {
setup({ dayAssignments: [SELF, assignment(2, 'Park', '11:00', '12:00')] })
expect(screen.queryByText(/places.timeCollision/)).not.toBeInTheDocument()
})
it('FE-MOB-PLTIME-011: an open-ended slot still collides with a place starting later', () => {
setup({
endTime: '',
dayAssignments: [assignment(1, 'Museum', '10:00'), assignment(2, 'Park', '09:00', '10:30')],
})
expect(screen.getByText(/places.timeCollision/)).toHaveTextContent('Park')
})
it('FE-MOB-PLTIME-012: a half-typed end time is discarded instead of widening the slot', () => {
// With a complete 11:00 end the 10:30 place would overlap — '11:0' must not count.
setup({ endTime: '11:0', dayAssignments: [SELF, assignment(2, 'Park', '10:30', '12:00')] })
expect(screen.queryByText(/places.timeCollision/)).not.toBeInTheDocument()
})
})
@@ -0,0 +1,313 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import {
NOTE_COLORS,
OTHER_BUBBLE_RADIUS,
OWN_BUBBLE_RADIUS,
QUICK_REACTIONS,
buildCategoryColorMap,
formatChatClockTime,
formatChatDateSeparator,
formatPollCountdown,
getCategoryColor,
hasUserVoted,
isEmojiOnlyText,
isPollActive,
isPollExpired,
isSameSender,
noteCategoriesList,
parseUtcDate,
pollMaxVoteCount,
shouldShowChatDateSeparator,
sortNotes,
splitPolls,
totalPollVotes,
type ChatMessage,
type CollabNoteData,
type CollabPollData,
type PollVoter,
} from '../../../../src/mobile/screens/trip/tabs/collabModel';
// FE-MOB-CLBM-001 to FE-MOB-CLBM-023
/** SQLite-style timestamp (UTC, no 'Z') for a given local wall-clock time, so
* assertions on getHours() hold in any test-runner timezone. */
function utcStampForLocal(y: number, monthIndex: number, d: number, h: number, min = 0): string {
return new Date(y, monthIndex, d, h, min, 0, 0).toISOString().slice(0, -1);
}
function msg(overrides: Partial<ChatMessage> = {}): ChatMessage {
return {
id: 1,
trip_id: 1,
user_id: 1,
text: 'hi',
reply_to: null,
username: 'ada',
avatar: null,
avatar_url: null,
created_at: '2026-07-15T10:00:00',
reactions: [],
...overrides,
};
}
function note(overrides: Partial<CollabNoteData> = {}): CollabNoteData {
return {
id: 1,
trip_id: 1,
user_id: 1,
title: 'Note',
content: null,
category: null,
color: null,
website: null,
pinned: 0,
username: 'ada',
avatar: null,
avatar_url: null,
created_at: '2026-07-01T10:00:00.000Z',
attachments: [],
...overrides,
};
}
function voter(user_id: number): PollVoter {
return { id: user_id * 10, user_id, username: `u${user_id}`, avatar: null, avatar_url: null };
}
function poll(overrides: Partial<CollabPollData> = {}): CollabPollData {
return {
id: 1,
trip_id: 1,
user_id: 1,
question: 'Where to?',
options: [],
multiple_choice: false,
is_closed: false,
deadline: null,
username: 'ada',
avatar: null,
avatar_url: null,
created_at: '2026-07-01T10:00:00.000Z',
...overrides,
};
}
const t = (key: string, params?: Record<string, string | number>) =>
params ? `${key}:${Object.entries(params).map(([k, v]) => `${k}=${v}`).join(',')}` : key;
afterEach(() => {
vi.useRealTimers();
});
describe('collabModel — constants', () => {
it('FE-MOB-CLBM-001: exposes the quick reaction set, bubble radii and note palette', () => {
expect(QUICK_REACTIONS).toHaveLength(8);
expect(QUICK_REACTIONS).toContain('❤️');
expect(new Set(QUICK_REACTIONS).size).toBe(QUICK_REACTIONS.length);
expect(OWN_BUBBLE_RADIUS).toBe('16px 16px 4px 16px');
expect(OTHER_BUBBLE_RADIUS).toBe('4px 16px 16px 16px');
expect(NOTE_COLORS).toHaveLength(6);
});
});
describe('collabModel — chat', () => {
it('FE-MOB-CLBM-002: parseUtcDate reads a suffix-less SQLite timestamp as UTC', () => {
expect(parseUtcDate('2026-07-15T10:30:00').getTime()).toBe(Date.UTC(2026, 6, 15, 10, 30, 0));
expect(parseUtcDate('2026-07-15T10:30:00Z').getTime()).toBe(Date.UTC(2026, 6, 15, 10, 30, 0));
});
it('FE-MOB-CLBM-003: isEmojiOnlyText accepts up to three emoji and nothing else', () => {
expect(isEmojiOnlyText('👍')).toBe(true);
expect(isEmojiOnlyText(' ❤️ ')).toBe(true);
expect(isEmojiOnlyText('🔥🎉👏')).toBe(true);
expect(isEmojiOnlyText('👨‍👩‍👧')).toBe(true);
});
it('FE-MOB-CLBM-004: isEmojiOnlyText rejects text and long emoji runs', () => {
expect(isEmojiOnlyText('nice 👍')).toBe(false);
expect(isEmojiOnlyText('🔥🎉👏😮')).toBe(false);
expect(isEmojiOnlyText('')).toBe(false);
});
it('FE-MOB-CLBM-005: formatChatClockTime renders zero-padded 24h time', () => {
expect(formatChatClockTime(utcStampForLocal(2026, 6, 15, 9, 5), false)).toBe('09:05');
expect(formatChatClockTime(utcStampForLocal(2026, 6, 15, 23, 59), false)).toBe('23:59');
});
it('FE-MOB-CLBM-006: formatChatClockTime maps midnight and noon correctly in 12h mode', () => {
expect(formatChatClockTime(utcStampForLocal(2026, 6, 15, 0, 5), true)).toBe('12:05 AM');
expect(formatChatClockTime(utcStampForLocal(2026, 6, 15, 12, 0), true)).toBe('12:00 PM');
expect(formatChatClockTime(utcStampForLocal(2026, 6, 15, 15, 30), true)).toBe('3:30 PM');
expect(formatChatClockTime(utcStampForLocal(2026, 6, 15, 7, 30), true)).toBe('7:30 AM');
});
it('FE-MOB-CLBM-007: formatChatDateSeparator labels today and yesterday', () => {
const now = new Date();
expect(formatChatDateSeparator(now.toISOString(), t, 'en-US')).toBe('collab.chat.today');
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
expect(formatChatDateSeparator(yesterday.toISOString(), t, 'en-US')).toBe('collab.chat.yesterday');
});
it('FE-MOB-CLBM-008: formatChatDateSeparator falls back to a localized date', () => {
expect(formatChatDateSeparator(utcStampForLocal(2020, 2, 5, 12), t, 'en-US')).toBe('Mar 5, 2020');
});
it('FE-MOB-CLBM-009: shouldShowChatDateSeparator triggers on the first message and on day changes', () => {
const a = msg({ created_at: utcStampForLocal(2026, 6, 15, 20) });
const b = msg({ id: 2, created_at: utcStampForLocal(2026, 6, 15, 22) });
const c = msg({ id: 3, created_at: utcStampForLocal(2026, 6, 16, 9) });
expect(shouldShowChatDateSeparator(a, undefined)).toBe(true);
expect(shouldShowChatDateSeparator(b, a)).toBe(false);
expect(shouldShowChatDateSeparator(c, b)).toBe(true);
});
it('FE-MOB-CLBM-010: isSameSender compares user ids loosely and needs both messages', () => {
const a = msg({ user_id: 7 });
const b = msg({ id: 2, user_id: 7 });
const other = msg({ id: 3, user_id: 8 });
expect(isSameSender(a, b)).toBe(true);
expect(isSameSender(a, other)).toBe(false);
expect(isSameSender(a, undefined)).toBe(false);
expect(isSameSender(undefined, b)).toBe(false);
expect(isSameSender(a, { ...b, user_id: '7' } as unknown as ChatMessage)).toBe(true);
});
});
describe('collabModel — notes', () => {
it('FE-MOB-CLBM-011: buildCategoryColorMap keeps the first colour seen per category', () => {
const notes = [
note({ id: 1, category: 'Ideas', color: '#ef4444' }),
note({ id: 2, category: 'Ideas', color: '#10b981' }),
note({ id: 3, category: 'Food', color: null }),
note({ id: 4, category: null, color: '#3b82f6' }),
];
expect(buildCategoryColorMap(notes)).toEqual({ Ideas: '#ef4444' });
});
it('FE-MOB-CLBM-012: getCategoryColor round-robins fresh categories onto the palette', () => {
expect(getCategoryColor(null, {})).toBe(NOTE_COLORS[0]);
expect(getCategoryColor(undefined, {})).toBe(NOTE_COLORS[0]);
expect(getCategoryColor('Ideas', { Ideas: '#123456' })).toBe('#123456');
expect(getCategoryColor('New', { A: 'x', B: 'y' })).toBe(NOTE_COLORS[2]);
// wraps around once every palette slot is taken
const full = Object.fromEntries(NOTE_COLORS.map((c, i) => [`c${i}`, c]));
expect(getCategoryColor('New', full)).toBe(NOTE_COLORS[0]);
});
it('FE-MOB-CLBM-013: noteCategoriesList returns distinct categories in first-appearance order', () => {
const notes = [
note({ id: 1, category: 'Ideas' }),
note({ id: 2, category: null }),
note({ id: 3, category: 'Food' }),
note({ id: 4, category: 'Ideas' }),
];
expect(noteCategoriesList(notes)).toEqual(['Ideas', 'Food']);
expect(noteCategoriesList([])).toEqual([]);
});
it('FE-MOB-CLBM-014: sortNotes puts pinned notes first, then most recently touched', () => {
const old = note({ id: 1, created_at: '2026-07-01T10:00:00.000Z' });
const fresh = note({ id: 2, created_at: '2026-07-05T10:00:00.000Z' });
const edited = note({ id: 3, created_at: '2026-06-01T10:00:00.000Z', updated_at: '2026-07-09T10:00:00.000Z' });
const pinned = note({ id: 4, created_at: '2026-01-01T10:00:00.000Z', pinned: 1 });
expect(sortNotes([old, fresh, edited, pinned], null).map(n => n.id)).toEqual([4, 3, 2, 1]);
// pinned wins no matter which side of the comparison it lands on
expect(sortNotes([pinned, fresh], null).map(n => n.id)).toEqual([4, 2]);
});
it('FE-MOB-CLBM-015: sortNotes filters by the active category and leaves the input untouched', () => {
const input = [
note({ id: 1, category: 'Food', created_at: '2026-07-01T10:00:00.000Z' }),
note({ id: 2, category: 'Ideas', created_at: '2026-07-02T10:00:00.000Z' }),
note({ id: 3, category: 'Food', created_at: '2026-07-03T10:00:00.000Z', pinned: true }),
];
const snapshot = input.map(n => n.id);
expect(sortNotes(input, 'Food').map(n => n.id)).toEqual([3, 1]);
expect(sortNotes(input, 'Nope')).toEqual([]);
expect(input.map(n => n.id)).toEqual(snapshot);
});
});
describe('collabModel — polls', () => {
const optA = { text: 'Rome', label: 'A', voters: [voter(1), voter(2)] };
const optB = { text: 'Oslo', label: 'B', voters: [voter(3)] };
it('FE-MOB-CLBM-016: totalPollVotes and pollMaxVoteCount count across options', () => {
const p = poll({ options: [optA, optB] });
expect(totalPollVotes(p)).toBe(3);
expect(pollMaxVoteCount(p)).toBe(2);
expect(totalPollVotes(poll())).toBe(0);
expect(pollMaxVoteCount(poll())).toBe(0);
});
it('FE-MOB-CLBM-017: vote counters tolerate missing option/voter arrays', () => {
const broken = poll({
options: [{ text: 'Rome', label: 'A' }] as unknown as CollabPollData['options'],
});
expect(totalPollVotes(broken)).toBe(0);
expect(pollMaxVoteCount(broken)).toBe(0);
expect(hasUserVoted(broken, 1)).toBe(false);
const noOptions = { ...poll(), options: undefined } as unknown as CollabPollData;
expect(totalPollVotes(noOptions)).toBe(0);
expect(pollMaxVoteCount(noOptions)).toBe(0);
expect(hasUserVoted(noOptions, 1)).toBe(false);
});
it('FE-MOB-CLBM-018: hasUserVoted matches ids across string/number storage', () => {
const p = poll({ options: [optA, optB] });
expect(hasUserVoted(p, 2)).toBe(true);
expect(hasUserVoted(p, 9)).toBe(false);
expect(hasUserVoted(p, '3' as unknown as number)).toBe(true);
});
it('FE-MOB-CLBM-019: isPollExpired only fires on a deadline in the past', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-15T12:00:00Z'));
expect(isPollExpired(null)).toBe(false);
expect(isPollExpired('2026-07-15T11:59:00Z')).toBe(true);
expect(isPollExpired('2026-07-15T12:00:00Z')).toBe(true);
expect(isPollExpired('2026-07-15T12:01:00Z')).toBe(false);
});
it('FE-MOB-CLBM-020: isPollActive requires an open poll with an unexpired deadline', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-15T12:00:00Z'));
expect(isPollActive(poll())).toBe(true);
expect(isPollActive(poll({ is_closed: true }))).toBe(false);
expect(isPollActive(poll({ deadline: '2026-07-14T12:00:00Z' }))).toBe(false);
expect(isPollActive(poll({ deadline: '2026-07-16T12:00:00Z' }))).toBe(true);
});
it('FE-MOB-CLBM-021: splitPolls separates active from closed/expired', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-15T12:00:00Z'));
const open = poll({ id: 1 });
const closed = poll({ id: 2, is_closed: true });
const expired = poll({ id: 3, deadline: '2026-07-01T12:00:00Z' });
const { active, closed: done } = splitPolls([open, closed, expired]);
expect(active.map(p => p.id)).toEqual([1]);
expect(done.map(p => p.id)).toEqual([2, 3]);
expect(splitPolls([])).toEqual({ active: [], closed: [] });
});
it('FE-MOB-CLBM-022: formatPollCountdown picks the days/hours/minutes tier', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-15T12:00:00Z'));
expect(formatPollCountdown(null, t)).toBeNull();
expect(formatPollCountdown('2026-07-15T11:00:00Z', t)).toBeNull();
expect(formatPollCountdown('2026-07-17T16:00:00Z', t)).toBe('collab.polls.countdownDaysHours:d=2,h=4');
expect(formatPollCountdown('2026-07-15T15:45:00Z', t)).toBe('collab.polls.countdownHoursMinutes:h=3,m=45');
expect(formatPollCountdown('2026-07-15T12:12:00Z', t)).toBe('collab.polls.countdownMinutes:m=12');
});
});
@@ -0,0 +1,392 @@
import { describe, it, expect } from 'vitest';
import {
baseTotal,
buildCostsCsv,
categoryBreakdown,
categoryFilterKeys,
computeTotals,
currencyOf,
dayFilterKeys,
filterBudgetItems,
groupByDay,
isUnfinished,
memberShareOf,
myPaidOf,
myShareOf,
tint,
type CostsCtx,
type CostsFilterState,
type CostsSettlementFlow,
} from '../../../../src/mobile/screens/trip/tabs/costsModel';
import { buildBudgetItem } from '../../../helpers/factories';
import type { BudgetItem } from '../../../../src/types';
// FE-MOB-CMOD-001 to FE-MOB-CMOD-027
const RATES: Record<string, number> = { EUR: 1, USD: 0.5, JPY: 0.01 };
const ctx: CostsCtx = {
me: 1,
// lower case on purpose — currencyOf has to normalise it
tripCurrency: 'eur',
convert: (amount, currency) => amount * (RATES[(currency || 'EUR').toUpperCase()] ?? 1),
};
type Member = NonNullable<BudgetItem['members']>[number];
type Payer = NonNullable<BudgetItem['payers']>[number];
function member(user_id: number, amount?: number | null): Member {
return { user_id, paid: 0, username: `u${user_id}`, ...(amount === undefined ? {} : { amount }) };
}
function payer(user_id: number, amount: number): Payer {
return { user_id, amount, username: `u${user_id}` };
}
function expense(overrides: Partial<BudgetItem> = {}): BudgetItem {
return buildBudgetItem({ category: 'other', ...overrides });
}
function filters(overrides: Partial<CostsFilterState> = {}): CostsFilterState {
return { search: '', segment: 'all', categoryKey: '', dayKey: '', ...overrides };
}
describe('costsModel — money maths', () => {
it('FE-MOB-CMOD-001: uppercases the expense currency and falls back to the trip currency', () => {
expect(currencyOf(expense({ currency: 'usd' }), ctx)).toBe('USD');
expect(currencyOf(expense({ currency: null }), ctx)).toBe('EUR');
expect(currencyOf(expense({ currency: '' }), ctx)).toBe('EUR');
});
it('FE-MOB-CMOD-002: converts the expense total into the base currency', () => {
expect(baseTotal(expense({ total_price: 100, currency: 'USD' }), ctx)).toBe(50);
expect(baseTotal(expense({ total_price: 100, currency: null }), ctx)).toBe(100);
expect(baseTotal(expense({ total_price: 0 }), ctx)).toBe(0);
});
it('FE-MOB-CMOD-003: myPaidOf only sums my own payer rows, converted', () => {
const e = expense({ total_price: 100, currency: 'USD', payers: [payer(1, 60), payer(2, 40)] });
expect(myPaidOf(e, ctx)).toBe(30);
expect(myPaidOf(expense({ payers: [payer(2, 40)] }), ctx)).toBe(0);
expect(myPaidOf(expense({ payers: undefined }), ctx)).toBe(0);
});
it('FE-MOB-CMOD-004: memberShareOf prefers an explicit custom amount', () => {
const e = expense({
total_price: 100,
currency: 'USD',
members: [member(1, 70), member(2, 30)],
});
expect(memberShareOf(e, 1, ctx)).toBe(35);
expect(memberShareOf(e, 2, ctx)).toBe(15);
});
it('FE-MOB-CMOD-005: an explicit zero share is honoured instead of falling back to the equal split', () => {
const e = expense({ id: 3, total_price: 100, members: [member(1, 0), member(2, 100)] });
expect(memberShareOf(e, 1, ctx)).toBe(0);
});
it('FE-MOB-CMOD-006: memberShareOf falls back to the rotating equal split', () => {
// id 3 % 3 members = rotation index 0, so user 1 carries the leftover cent
const e = expense({ id: 3, total_price: 100, members: [member(1), member(2), member(3)] });
expect(memberShareOf(e, 1, ctx)).toBe(33.34);
expect(memberShareOf(e, 2, ctx)).toBe(33.33);
expect(memberShareOf(e, 3, ctx)).toBe(33.33);
});
it('FE-MOB-CMOD-025: a zero-total expense splits into zero shares', () => {
const e = expense({ id: 2, total_price: 0, members: [member(1), member(2)] });
expect(memberShareOf(e, 1, ctx)).toBe(0);
});
it('FE-MOB-CMOD-007: a non-participant has no share', () => {
const e = expense({ total_price: 100, members: [member(2), member(3)] });
expect(memberShareOf(e, 1, ctx)).toBe(0);
expect(memberShareOf(expense({ members: undefined }), 1, ctx)).toBe(0);
});
it('FE-MOB-CMOD-008: myShareOf resolves the share of ctx.me', () => {
const e = expense({ id: 4, total_price: 80, currency: 'USD', members: [member(1), member(2)] });
expect(myShareOf(e, ctx)).toBe(20);
expect(myShareOf(e, ctx)).toBe(memberShareOf(e, 1, ctx));
});
it('FE-MOB-CMOD-009: isUnfinished flags totals nobody has actually paid', () => {
expect(isUnfinished(expense({ total_price: 100, payers: [] }), ctx)).toBe(true);
expect(isUnfinished(expense({ total_price: 100, payers: [payer(1, 0)] }), ctx)).toBe(true);
expect(isUnfinished(expense({ total_price: 100, payers: undefined }), ctx)).toBe(true);
expect(isUnfinished(expense({ total_price: 100, payers: [payer(1, 100)] }), ctx)).toBe(false);
expect(isUnfinished(expense({ total_price: 0, payers: [] }), ctx)).toBe(false);
});
});
describe('costsModel — computeTotals', () => {
it('FE-MOB-CMOD-010: aggregates spend, my paid/share, settlement direction and outstanding', () => {
const paid = expense({
id: 10,
total_price: 100,
payers: [payer(1, 100)],
members: [member(1), member(2)],
});
const unpaid = expense({
id: 11,
total_price: 50,
currency: 'USD',
payers: [],
members: [member(2, 50)],
});
const flows: CostsSettlementFlow[] = [
{ from: { user_id: 1, username: 'me' }, to: { user_id: 2, username: 'ada' }, amount: 20 },
{ from: { user_id: 3, username: 'bo' }, to: { user_id: 1, username: 'me' }, amount: 5 },
{ from: { user_id: 2, username: 'ada' }, to: { user_id: 3, username: 'bo' }, amount: 7 },
];
expect(computeTotals([paid, unpaid], flows, ctx)).toEqual({
totalSpend: 125,
myPaid: 100,
myShare: 50,
owe: 20,
owed: 5,
outstanding: 25,
outstandingCount: 1,
});
});
it('FE-MOB-CMOD-011: returns zeroes for an empty trip', () => {
expect(computeTotals([], [], ctx)).toEqual({
totalSpend: 0,
myPaid: 0,
myShare: 0,
owe: 0,
owed: 0,
outstanding: 0,
outstandingCount: 0,
});
});
});
describe('costsModel — list filters and grouping', () => {
// e1: I fronted it and am net owed. e2: someone else paid. e3: I paid exactly my own share.
const e1 = expense({
id: 20,
name: 'Hotel Berlin',
category: 'accommodation',
expense_date: '2026-07-01',
total_price: 100,
payers: [payer(1, 100)],
members: [member(1), member(2)],
});
const e2 = expense({
id: 21,
name: 'Sushi',
category: 'food',
expense_date: '2026-07-02',
total_price: 60,
payers: [payer(2, 60)],
members: [member(1), member(2)],
});
const e3 = expense({
id: 22,
name: 'Museum ticket',
category: 'activities',
expense_date: null,
total_price: 30,
payers: [payer(1, 30)],
members: [member(1)],
});
const all = [e1, e2, e3];
it('FE-MOB-CMOD-012: "all" keeps every expense and does not mutate the input', () => {
const result = filterBudgetItems(all, filters(), ctx);
expect(result).toEqual(all);
expect(result).not.toBe(all);
});
it('FE-MOB-CMOD-013: "mine" keeps expenses I fronted money on', () => {
expect(filterBudgetItems(all, filters({ segment: 'mine' }), ctx).map(e => e.id)).toEqual([20, 22]);
});
it('FE-MOB-CMOD-014: "owed" keeps expenses where I am net owed', () => {
expect(filterBudgetItems(all, filters({ segment: 'owed' }), ctx).map(e => e.id)).toEqual([20]);
});
it('FE-MOB-CMOD-015: category, day and search narrow the list and combine', () => {
expect(filterBudgetItems(all, filters({ categoryKey: 'food' }), ctx).map(e => e.id)).toEqual([21]);
expect(filterBudgetItems(all, filters({ dayKey: '2026-07-02' }), ctx).map(e => e.id)).toEqual([21]);
// search is trimmed and case-insensitive
expect(filterBudgetItems(all, filters({ search: ' HOTEL ' }), ctx).map(e => e.id)).toEqual([20]);
expect(filterBudgetItems(all, filters({ search: 'nothing' }), ctx)).toEqual([]);
expect(
filterBudgetItems(all, filters({ segment: 'mine', categoryKey: 'accommodation' }), ctx).map(e => e.id),
).toEqual([20]);
});
it('FE-MOB-CMOD-016: groupByDay sorts newest first and sinks the no-date bucket', () => {
const a = expense({ id: 30, expense_date: '2026-07-01' });
const b = expense({ id: 31, expense_date: '2026-07-03' });
const c = expense({ id: 32, expense_date: null });
const d = expense({ id: 33, expense_date: '2026-07-01' });
expect(groupByDay([a, b, c, d])).toEqual([
{ dateKey: '2026-07-03', items: [b] },
{ dateKey: '2026-07-01', items: [a, d] },
{ dateKey: '', items: [c] },
]);
expect(groupByDay([])).toEqual([]);
});
it('FE-MOB-CMOD-026: groupByDay sinks the no-date bucket even when it comes first', () => {
const undated = expense({ id: 34, expense_date: null });
const mid = expense({ id: 35, expense_date: '2026-07-02' });
const late = expense({ id: 36, expense_date: '2026-07-04' });
expect(groupByDay([undated, mid, late]).map(g => g.dateKey)).toEqual(['2026-07-04', '2026-07-02', '']);
});
it('FE-MOB-CMOD-017: categoryFilterKeys lists only used categories, in canonical order', () => {
const items = [
expense({ category: 'food' }),
expense({ category: 'accommodation' }),
// legacy free-text value that catMeta folds into "flights"
expense({ category: 'Flight' }),
expense({ category: 'food' }),
];
expect(categoryFilterKeys(items)).toEqual(['accommodation', 'food', 'flights']);
expect(categoryFilterKeys([])).toEqual([]);
});
it('FE-MOB-CMOD-018: dayFilterKeys returns distinct dates ascending, ignoring undated rows', () => {
const items = [
expense({ expense_date: '2026-07-03' }),
expense({ expense_date: null }),
expense({ expense_date: '2026-07-01' }),
expense({ expense_date: '2026-07-01' }),
];
expect(dayFilterKeys(items)).toEqual(['2026-07-01', '2026-07-03']);
});
});
describe('costsModel — breakdown and presentation', () => {
it('FE-MOB-CMOD-019: categoryBreakdown sorts by amount and scales the bars against the largest', () => {
const items = [
expense({ category: 'accommodation', total_price: 300 }),
expense({ category: 'food', total_price: 100 }),
expense({ category: 'food', total_price: 200, currency: 'USD' }),
expense({ category: 'transport', total_price: 0 }),
];
const bars = categoryBreakdown(items, ctx);
expect(bars.map(b => b.key)).toEqual(['accommodation', 'food']);
expect(bars[0]).toEqual({ key: 'accommodation', amount: 300, widthPct: 100 });
expect(bars[1].amount).toBe(200);
expect(bars[1].widthPct).toBeCloseTo(66.667, 3);
});
it('FE-MOB-CMOD-020: categoryBreakdown drops categories without spend', () => {
expect(categoryBreakdown([], ctx)).toEqual([]);
expect(categoryBreakdown([expense({ category: 'food', total_price: 0 })], ctx)).toEqual([]);
});
it('FE-MOB-CMOD-021: tint turns a hex colour into rgba, with or without the leading hash', () => {
expect(tint('#8B5CF6', 0.3)).toBe('rgba(139, 92, 246, 0.3)');
expect(tint('000000', 1)).toBe('rgba(0, 0, 0, 1)');
});
});
describe('costsModel — CSV export', () => {
const t = (key: string) => key;
it('FE-MOB-CMOD-022: builds a semicolon CSV sorted by date with per-currency decimals', () => {
const items = [
expense({
id: 40,
name: 'Ryokan',
category: 'accommodation',
expense_date: '2026-07-16',
total_price: 1500,
currency: 'JPY',
note: 'paid cash',
}),
expense({
id: 41,
name: 'Coffee',
category: 'food',
expense_date: '2026-07-15',
total_price: 4.5,
currency: null,
note: null,
}),
];
const { filename, content } = buildCostsCsv(items, {
base: 'EUR',
ctx,
locale: 'en-US',
tripTitle: 'Trip: Tokyo/2026!',
t,
});
const rows = content.split('\r\n');
expect(rows[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Note');
expect(rows[1]).toBe('07/15/2026;Coffee;costs.cat.food;4.50;EUR;4.50;');
// JPY is a zero-decimal currency, the base column stays at 2
expect(rows[2]).toBe('07/16/2026;Ryokan;costs.cat.accommodation;1500;JPY;15.00;paid cash');
expect(filename).toBe('costs-Trip Tokyo2026.csv');
});
it('FE-MOB-CMOD-023: escapes separators, quotes and newlines, and drops ticket JSON notes', () => {
const items = [
expense({
id: 42,
name: 'Dinner; drinks',
category: 'food',
expense_date: '',
total_price: 20,
note: 'say "hi"\nlater',
}),
expense({
id: 43,
name: 'Tickets',
category: 'activities',
expense_date: null,
total_price: 10,
note: 'TICKETJSON:{"items":[]}',
}),
];
const { content, filename } = buildCostsCsv(items, {
base: 'EUR',
ctx,
locale: 'en-US',
tripTitle: null,
t,
});
const rows = content.split('\r\n');
expect(rows[1]).toContain('"Dinner; drinks"');
expect(rows[1]).toContain('"say ""hi""\nlater"');
// undated rows keep an empty date cell
expect(rows[1].startsWith(';')).toBe(true);
expect(rows[2].endsWith(';')).toBe(true);
expect(filename).toBe('costs-trip.csv');
});
it('FE-MOB-CMOD-024: falls back to the raw ISO date when the locale is unusable', () => {
const { content } = buildCostsCsv(
[expense({ id: 44, name: 'Bus', category: 'transport', expense_date: '2026-07-16', total_price: 3 })],
{ base: 'EUR', ctx, locale: '!!', t },
);
expect(content.split('\r\n')[1].startsWith('2026-07-16;Bus;')).toBe(true);
});
it('FE-MOB-CMOD-027: an expense without a recorded total exports as zero', () => {
const bare = expense({ id: 45, name: 'Placeholder', category: 'other', expense_date: null });
const { content } = buildCostsCsv([{ ...bare, total_price: undefined } as unknown as BudgetItem], {
base: 'EUR',
ctx,
locale: 'en-US',
t,
});
expect(content.split('\r\n')[1]).toBe(';Placeholder;costs.cat.other;0.00;EUR;0.00;');
});
});
@@ -0,0 +1,181 @@
import { describe, it, expect } from 'vitest';
import { FileText, Image as ImageIcon, Ticket } from 'lucide-react';
import {
FILE_FILTERS,
buildFileLinkLabels,
formatFileDate,
getFileTypeCategory,
getFileTypeMeta,
matchesFileFilter,
sortFilesStarredFirst,
type FileFilterId,
} from '../../../../src/mobile/screens/trip/tabs/filesModel';
import { buildPlace, buildReservation, buildTripFile } from '../../../helpers/factories';
import type { TranslationFn, TripFile } from '../../../../src/types';
// FE-MOB-FILM-001 to FE-MOB-FILM-017
const t: TranslationFn = key => key;
describe('filesModel — type categorisation', () => {
it('FE-MOB-FILM-001: detects wallet passes ahead of everything else', () => {
expect(getFileTypeCategory({ mime_type: 'application/vnd.apple.pkpass', original_name: 'boardingpass' })).toBe('pass');
// extension wins even when the browser sent a generic MIME
expect(getFileTypeCategory({ mime_type: 'application/octet-stream', original_name: 'ticket.pkpass' })).toBe('pass');
});
it('FE-MOB-FILM-002: detects PDFs and media', () => {
expect(getFileTypeCategory({ mime_type: 'application/pdf', original_name: 'invoice.pdf' })).toBe('pdf');
expect(getFileTypeCategory({ mime_type: 'image/jpeg', original_name: 'beach.jpg' })).toBe('image');
// video folds into the image bucket (#823) since both open in the lightbox
expect(getFileTypeCategory({ mime_type: 'video/mp4', original_name: 'clip.mp4' })).toBe('image');
});
it('FE-MOB-FILM-003: detects spreadsheets by extension and by MIME', () => {
expect(getFileTypeCategory({ mime_type: '', original_name: 'budget.xlsx' })).toBe('xls');
expect(getFileTypeCategory({ mime_type: '', original_name: 'list.csv' })).toBe('xls');
expect(getFileTypeCategory({ mime_type: '', original_name: 'sheet.ods' })).toBe('xls');
expect(getFileTypeCategory({ mime_type: 'application/vnd.ms-excel', original_name: 'legacy' })).toBe('xls');
expect(getFileTypeCategory({ mime_type: 'text/csv', original_name: 'export' })).toBe('xls');
expect(getFileTypeCategory({ mime_type: 'application/vnd.oasis.opendocument.spreadsheet', original_name: 'x' })).toBe('xls');
});
it('FE-MOB-FILM-004: falls back to "other" for plain attachments', () => {
expect(getFileTypeCategory({ mime_type: 'application/zip', original_name: 'trip.zip' })).toBe('other');
expect(getFileTypeCategory({ mime_type: null, original_name: null })).toBe('other');
});
it('FE-MOB-FILM-005: getFileTypeMeta pairs the category with its tile icon, colour and caption', () => {
expect(getFileTypeMeta({ mime_type: 'application/pdf', original_name: 'a.pdf' })).toEqual({
category: 'pdf',
icon: FileText,
color: '#D6273B',
label: 'PDF',
});
expect(getFileTypeMeta({ mime_type: 'image/png', original_name: 'a.png' }).icon).toBe(ImageIcon);
expect(getFileTypeMeta({ mime_type: null, original_name: 'a.pkpass' }).icon).toBe(Ticket);
expect(getFileTypeMeta({ mime_type: 'application/zip', original_name: 'a.zip' }).label).toBe('FILE');
});
});
describe('filesModel — filters', () => {
const pdf = buildTripFile({ id: 1, mime_type: 'application/pdf', original_name: 'invoice.pdf' });
const image = buildTripFile({ id: 2, mime_type: 'image/jpeg', original_name: 'beach.jpg' });
const sheet = buildTripFile({ id: 3, mime_type: 'text/csv', original_name: 'budget.csv' });
const pass = buildTripFile({ id: 4, mime_type: 'application/vnd.apple.pkpass', original_name: 'bp.pkpass' });
const zip = buildTripFile({ id: 5, mime_type: 'application/zip', original_name: 'stuff.zip' });
const starred = buildTripFile({ id: 6, mime_type: 'application/pdf', original_name: 's.pdf', starred: 1 });
const collab = buildTripFile({ id: 7, mime_type: 'application/pdf', original_name: 'c.pdf', note_id: 42 });
const all = [pdf, image, sheet, pass, zip, starred, collab];
const idsFor = (filter: FileFilterId) => all.filter(f => matchesFileFilter(f, filter)).map(f => f.id);
it('FE-MOB-FILM-006: the filter grid is defined in display order', () => {
expect(FILE_FILTERS.map(f => f.id)).toEqual(['all', 'pdf', 'image', 'doc', 'starred', 'collab']);
expect(FILE_FILTERS.every(f => f.labelKey.startsWith('files.filter'))).toBe(true);
});
it('FE-MOB-FILM-007: "all" keeps everything, "pdf" and "image" keep their type', () => {
expect(idsFor('all')).toEqual([1, 2, 3, 4, 5, 6, 7]);
expect(idsFor('pdf')).toEqual([1, 6, 7]);
expect(idsFor('image')).toEqual([2]);
});
it('FE-MOB-FILM-008: "doc" is the catch-all for xls, passes and generic files', () => {
expect(idsFor('doc')).toEqual([3, 4, 5]);
});
it('FE-MOB-FILM-009: "starred" and "collab" read their flags', () => {
expect(idsFor('starred')).toEqual([6]);
expect(idsFor('collab')).toEqual([7]);
expect(matchesFileFilter(buildTripFile({ starred: 0 }), 'starred')).toBe(false);
});
it('FE-MOB-FILM-010: the filter tiles fully partition the list', () => {
const covered = new Set([...idsFor('pdf'), ...idsFor('image'), ...idsFor('doc')]);
expect(covered.size).toBe(all.length);
});
it('FE-MOB-FILM-011: an unknown filter id keeps every file', () => {
expect(matchesFileFilter(pdf, 'bogus' as FileFilterId)).toBe(true);
});
});
describe('filesModel — sorting and dates', () => {
it('FE-MOB-FILM-012: sortFilesStarredFirst hoists starred files and keeps the rest stable', () => {
const files = [
buildTripFile({ id: 1 }),
buildTripFile({ id: 2, starred: 1 }),
buildTripFile({ id: 3 }),
buildTripFile({ id: 4, starred: 1 }),
];
const sorted = sortFilesStarredFirst(files);
expect(sorted.map(f => f.id)).toEqual([2, 4, 1, 3]);
expect(files.map(f => f.id)).toEqual([1, 2, 3, 4]);
});
it('FE-MOB-FILM-013: formatFileDate renders a compact day/month label', () => {
expect(formatFileDate('2026-07-16T10:00:00.000Z', 'en-US')).toMatch(/Jul\s16|16\sJul/);
expect(formatFileDate('2026-07-16T10:00:00.000Z', 'de-DE')).toMatch(/16\.\s?Juli/);
});
it('FE-MOB-FILM-014: formatFileDate returns an empty string for missing or broken dates', () => {
expect(formatFileDate(null, 'en-US')).toBe('');
expect(formatFileDate(undefined, 'en-US')).toBe('');
expect(formatFileDate('', 'en-US')).toBe('');
expect(formatFileDate('not-a-date', 'en-US')).toBe('');
});
});
describe('filesModel — link labels', () => {
const places = [buildPlace({ id: 11, name: 'Louvre' }), buildPlace({ id: 12, name: 'Eiffel Tower' })];
const reservations = [
buildReservation({ id: 21, type: 'flight', title: 'LH123' }),
buildReservation({ id: 22, type: 'hotel', title: 'Hotel Ritz' }),
buildReservation({ id: 23, type: 'hotel', title: '' }),
];
const transportTypes = new Set(['flight', 'train']);
function file(overrides: Partial<TripFile>): TripFile {
return buildTripFile({ id: 1, ...overrides });
}
it('FE-MOB-FILM-015: merges the single and multi link columns and skips unknown ids', () => {
const labels = buildFileLinkLabels(
file({ place_id: 11, linked_place_ids: [11, 12, null, 99] }),
places,
reservations,
transportTypes,
t,
);
// 11 appears in both columns but is listed once; 99 does not exist any more
expect(labels).toEqual(['files.sourcePlan · Louvre', 'files.sourcePlan · Eiffel Tower']);
});
it('FE-MOB-FILM-016: splits reservations into transport and booking and appends the collab note', () => {
const labels = buildFileLinkLabels(
file({
reservation_id: 21,
linked_reservation_ids: [22, 23, null, 98],
note_id: 5,
}),
places,
reservations,
transportTypes,
t,
);
expect(labels).toEqual([
'files.sourceTransport · LH123',
'files.sourceBooking · Hotel Ritz',
// an untitled reservation repeats its own source label
'files.sourceBooking · files.sourceBooking',
'files.sourceCollab',
]);
});
it('FE-MOB-FILM-017: an unlinked file has no labels', () => {
expect(buildFileLinkLabels(file({}), places, reservations, transportTypes, t)).toEqual([]);
});
});
@@ -0,0 +1,204 @@
import { describe, it, expect } from 'vitest';
import {
PRIORITY_COLOR,
PRIORITY_LABEL,
PRIORITY_LEVELS,
filterTodoItems,
formatWeight,
groupPackingItems,
isLastCustomItemInCategory,
isPackingPlaceholder,
isTodoOverdue,
packingCategoryOrder,
packingItemWeight,
packingProgress,
packingStatusFiltered,
packingViewItems,
sortTodoRows,
todoCategories,
todoCategoryOpenCount,
todoCounts,
} from '../../../../src/mobile/screens/trip/tabs/listsModel';
import { STATUS_COLOR } from '../../../../src/mobile/screens/trip/tabs/tabModel';
import { PACKING_PLACEHOLDER_NAME } from '../../../../src/components/Packing/packingListPanel.constants';
import { buildPackingItem, buildTodoItem } from '../../../helpers/factories';
// FE-MOB-LSTM-001 to FE-MOB-LSTM-020
const TODAY = '2026-07-15';
describe('listsModel — packing', () => {
it('FE-MOB-LSTM-001: packingViewItems splits the common pool from private items', () => {
const common = buildPackingItem({ id: 1, is_private: 0 });
const noFlag = buildPackingItem({ id: 2 });
const personal = buildPackingItem({ id: 3, is_private: 1 });
expect(packingViewItems([common, noFlag, personal], 'common').map(i => i.id)).toEqual([1, 2]);
expect(packingViewItems([common, noFlag, personal], 'personal').map(i => i.id)).toEqual([3]);
});
it('FE-MOB-LSTM-002: packingCategoryOrder keeps first-appearance order and names the default bucket', () => {
const items = [
buildPackingItem({ id: 1, category: 'Clothes' }),
buildPackingItem({ id: 2, category: null }),
buildPackingItem({ id: 3, category: 'Tech' }),
buildPackingItem({ id: 4, category: 'Clothes' }),
];
expect(packingCategoryOrder(items, 'General')).toEqual(['Clothes', 'General', 'Tech']);
expect(packingCategoryOrder([], 'General')).toEqual([]);
});
it('FE-MOB-LSTM-003: packingStatusFiltered honours all/open/done', () => {
const open = buildPackingItem({ id: 1, checked: 0 });
const done = buildPackingItem({ id: 2, checked: 1 });
expect(packingStatusFiltered([open, done], 'all').map(i => i.id)).toEqual([1, 2]);
expect(packingStatusFiltered([open, done], 'open').map(i => i.id)).toEqual([1]);
expect(packingStatusFiltered([open, done], 'done').map(i => i.id)).toEqual([2]);
});
it('FE-MOB-LSTM-004: groupPackingItems buckets by category in first-encounter order', () => {
const items = [
buildPackingItem({ id: 1, category: 'Clothes', checked: 0 }),
buildPackingItem({ id: 2, category: null, checked: 1 }),
buildPackingItem({ id: 3, category: 'Clothes', checked: 1 }),
buildPackingItem({ id: 4, category: 'Tech', checked: 0 }),
];
expect(groupPackingItems(items, 'all', 'General').map(g => [g.category, g.items.map(i => i.id)])).toEqual([
['Clothes', [1, 3]],
['General', [2]],
['Tech', [4]],
]);
});
it('FE-MOB-LSTM-005: groupPackingItems drops categories emptied by the status filter', () => {
const items = [
buildPackingItem({ id: 1, category: 'Clothes', checked: 1 }),
buildPackingItem({ id: 2, category: 'Tech', checked: 0 }),
];
expect(groupPackingItems(items, 'open', 'General').map(g => g.category)).toEqual(['Tech']);
expect(groupPackingItems(items, 'done', 'General').map(g => g.category)).toEqual(['Clothes']);
expect(groupPackingItems([], 'all', 'General')).toEqual([]);
});
it('FE-MOB-LSTM-006: packingProgress reports checked/total and a rounded percentage', () => {
const items = [
buildPackingItem({ id: 1, checked: 1 }),
buildPackingItem({ id: 2, checked: 0 }),
buildPackingItem({ id: 3, checked: 0 }),
];
expect(packingProgress(items)).toEqual({ checked: 1, total: 3, pct: 33 });
expect(packingProgress([])).toEqual({ checked: 0, total: 0, pct: 0 });
});
it('FE-MOB-LSTM-007: formatWeight switches to kilograms at 1000 g', () => {
expect(formatWeight(232)).toBe('232 g');
expect(formatWeight(232.4)).toBe('232 g');
expect(formatWeight(999)).toBe('999 g');
expect(formatWeight(1000)).toBe('1.0 kg');
expect(formatWeight(1234)).toBe('1.2 kg');
expect(formatWeight(0)).toBe('0 g');
});
it('FE-MOB-LSTM-008: packingItemWeight multiplies unit weight by quantity', () => {
expect(packingItemWeight({ weight_grams: 250, quantity: 3 })).toBe(750);
// a missing quantity counts as one, a missing weight as zero
expect(packingItemWeight({ weight_grams: 250, quantity: undefined })).toBe(250);
expect(packingItemWeight({ weight_grams: null, quantity: 4 })).toBe(0);
});
it('FE-MOB-LSTM-009: isLastCustomItemInCategory guards the category placeholder reset', () => {
const only = buildPackingItem({ id: 1, category: 'Tech', name: 'Charger' });
const sibling = buildPackingItem({ id: 2, category: 'Tech', name: 'Cable' });
const uncategorised = buildPackingItem({ id: 3, category: null, name: 'Socks' });
const placeholder = buildPackingItem({ id: 4, category: 'Tech', name: PACKING_PLACEHOLDER_NAME });
expect(isLastCustomItemInCategory(only, [only])).toBe(true);
expect(isLastCustomItemInCategory(only, [only, sibling])).toBe(false);
expect(isLastCustomItemInCategory(uncategorised, [uncategorised])).toBe(false);
expect(isLastCustomItemInCategory(placeholder, [placeholder])).toBe(false);
});
it('FE-MOB-LSTM-010: isPackingPlaceholder recognises the "..." row', () => {
expect(isPackingPlaceholder({ name: PACKING_PLACEHOLDER_NAME })).toBe(true);
expect(isPackingPlaceholder({ name: 'Charger' })).toBe(false);
});
});
describe('listsModel — to-do', () => {
it('FE-MOB-LSTM-011: isTodoOverdue needs an unchecked item with a past due date', () => {
expect(isTodoOverdue(buildTodoItem({ due_date: '2026-07-14' }), TODAY)).toBe(true);
expect(isTodoOverdue(buildTodoItem({ due_date: TODAY }), TODAY)).toBe(false);
expect(isTodoOverdue(buildTodoItem({ due_date: '2026-07-16' }), TODAY)).toBe(false);
expect(isTodoOverdue(buildTodoItem({ due_date: '2026-07-14', checked: 1 }), TODAY)).toBe(false);
expect(isTodoOverdue(buildTodoItem({ due_date: null }), TODAY)).toBe(false);
});
const open = buildTodoItem({ id: 1, name: 'Book train', category: 'Travel' });
const mine = buildTodoItem({ id: 2, name: 'Pack', assigned_user_id: 7, category: 'Home' });
const overdue = buildTodoItem({ id: 3, name: 'Visa', due_date: '2026-07-01', category: 'Travel' });
const done = buildTodoItem({ id: 4, name: 'Passport', checked: 1, category: 'Travel' });
const items = [open, mine, overdue, done];
it('FE-MOB-LSTM-012: filterTodoItems applies the four smart filters', () => {
expect(filterTodoItems(items, 'all', 7, TODAY).map(i => i.id)).toEqual([1, 2, 3]);
expect(filterTodoItems(items, 'done', 7, TODAY).map(i => i.id)).toEqual([4]);
expect(filterTodoItems(items, 'my', 7, TODAY).map(i => i.id)).toEqual([2]);
expect(filterTodoItems(items, 'overdue', 7, TODAY).map(i => i.id)).toEqual([3]);
});
it('FE-MOB-LSTM-013: an unknown filter is treated as a category name', () => {
expect(filterTodoItems(items, 'Travel', 7, TODAY).map(i => i.id)).toEqual([1, 3, 4]);
expect(filterTodoItems(items, 'Nope', 7, TODAY)).toEqual([]);
});
it('FE-MOB-LSTM-020: without a current user "my" collects the unassigned rows', () => {
// Same comparison the desktop sidebar does (useTodoList.ts:74): a null user id
// matches every unassigned item, unlike todoCounts which zeroes "my" instead.
expect(filterTodoItems(items, 'my', null, TODAY).map(i => i.id)).toEqual([1, 3]);
});
it('FE-MOB-LSTM-014: sortTodoRows floats overdue rows up and sinks done rows', () => {
const sorted = sortTodoRows(items, false, TODAY);
expect(sorted.map(i => i.id)).toEqual([3, 1, 2, 4]);
// the input array stays untouched
expect(items.map(i => i.id)).toEqual([1, 2, 3, 4]);
});
it('FE-MOB-LSTM-015: sortTodoRows only breaks ties by priority while the toggle is on', () => {
const p3 = buildTodoItem({ id: 10, priority: 3 });
const p1 = buildTodoItem({ id: 11, priority: 1 });
const none = buildTodoItem({ id: 12, priority: 0 });
const list = [p3, none, p1];
expect(sortTodoRows(list, false, TODAY).map(i => i.id)).toEqual([10, 12, 11]);
expect(sortTodoRows(list, true, TODAY).map(i => i.id)).toEqual([11, 10, 12]);
});
it('FE-MOB-LSTM-016: todoCategories returns distinct categories alphabetically', () => {
expect(todoCategories(items)).toEqual(['Home', 'Travel']);
expect(todoCategories([buildTodoItem({ category: null })])).toEqual([]);
});
it('FE-MOB-LSTM-017: todoCategoryOpenCount counts only unchecked rows of that category', () => {
expect(todoCategoryOpenCount(items, 'Travel')).toBe(2);
expect(todoCategoryOpenCount(items, 'Home')).toBe(1);
expect(todoCategoryOpenCount(items, 'Nope')).toBe(0);
});
it('FE-MOB-LSTM-018: todoCounts fills the badge row and zeroes "my" without a user', () => {
expect(todoCounts(items, 7, TODAY)).toEqual({ total: 4, open: 3, done: 1, overdue: 1, my: 1 });
expect(todoCounts(items, null, TODAY).my).toBe(0);
expect(todoCounts([], 7, TODAY)).toEqual({ total: 0, open: 0, done: 0, overdue: 0, my: 0 });
});
it('FE-MOB-LSTM-019: priority tokens reuse the shared status palette', () => {
expect(PRIORITY_COLOR[1]).toBe(STATUS_COLOR.danger);
expect(PRIORITY_COLOR[2]).toBe(STATUS_COLOR.pending);
expect(PRIORITY_COLOR[3]).toBe(STATUS_COLOR.info);
expect(PRIORITY_LABEL).toEqual({ 1: 'P1', 2: 'P2', 3: 'P3' });
expect(PRIORITY_LEVELS).toEqual([0, 1, 2, 3]);
});
});
@@ -0,0 +1,413 @@
import { describe, expect, it, vi } from 'vitest'
import { Cloud, CloudLightning, CloudRain, CloudSnow, Sun, Wind } from 'lucide-react'
import {
breaksChronology, buildPlanRows, cityPillsForDay, findUpNext, getTransitMeta, hotelChipsForDay,
hotelLegsForDay, itemHasTime, parseReservationMeta, transportSubtitle, weatherIconFor,
type TransportEntry,
} from '../../../../src/mobile/screens/trip/plan/planTimelineModel'
import { getDisplayTimeForDay, type MergedItem } from '../../../../src/utils/dayMerge'
import { buildAssignment, buildDayNote, buildPlace, buildReservation } from '../../../helpers/factories'
import type {
Accommodation, Assignment, Day, DayNote, Reservation, RouteSegment, TranslationFn,
} from '../../../../src/types'
// FE-MOB-PTLM-001 to FE-MOB-PTLM-042
const DAYS = [
{ id: 1, trip_id: 1, day_number: 1, date: '2026-05-01', title: null },
{ id: 2, trip_id: 1, day_number: 2, date: '2026-05-02', title: 'Old Town' },
{ id: 3, trip_id: 1, day_number: 3, date: '2026-05-03', title: null },
] as unknown as Day[]
const DAY2 = DAYS[1]
function place(id: number, name: string, lat: number | null, lng: number | null, time: string | null = null) {
return buildPlace({ id, name, lat, lng, place_time: time })
}
function assignment(id: number, order: number, p: ReturnType<typeof place>): Assignment {
return buildAssignment({ id, day_id: 2, order_index: order, place_id: p.id, place: p })
}
function seg(from: [number, number], to: [number, number], overrides: Partial<RouteSegment> = {}): RouteSegment {
return {
mid: [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2],
from,
to,
distance: 1200,
duration: 600,
walkingText: '15 min',
drivingText: '3 min',
distanceText: '1.2 km',
...overrides,
}
}
const placeItem = (a: Assignment): MergedItem => ({ type: 'place', sortKey: a.order_index, data: a })
const noteItem = (n: DayNote): MergedItem => ({ type: 'note', sortKey: n.sort_order ?? 0, data: n })
const transportItem = (r: TransportEntry): MergedItem => ({ type: 'transport', sortKey: 0, data: r })
function accommodation(overrides: Partial<Accommodation>): Accommodation {
return {
id: 1,
trip_id: 1,
place_id: null,
start_day_id: 1,
end_day_id: 3,
check_in: null,
check_out: null,
place_name: 'Hotel Sacher',
place_lat: 48.2,
place_lng: 16.35,
...overrides,
} as unknown as Accommodation
}
describe('planTimelineModel — metadata parsing', () => {
it('FE-MOB-PTLM-001: returns an already-parsed metadata object untouched', () => {
const meta = { airline: 'LH', legs: [] }
expect(parseReservationMeta({ ...buildReservation(), metadata: meta } as unknown as Reservation)).toBe(meta)
})
it('FE-MOB-PTLM-002: parses a JSON metadata string', () => {
expect(parseReservationMeta(buildReservation({ metadata: '{"airline":"LH"}' }))).toEqual({ airline: 'LH' })
})
it('FE-MOB-PTLM-003: heals a double-encoded metadata string', () => {
const doubled = JSON.stringify(JSON.stringify({ airline: 'LH', flight_number: 'LH123' }))
expect(parseReservationMeta(buildReservation({ metadata: doubled }))).toEqual({
airline: 'LH', flight_number: 'LH123',
})
})
it('FE-MOB-PTLM-004: falls back to an empty object for broken, empty or scalar metadata', () => {
expect(parseReservationMeta(buildReservation({ metadata: '{not json' }))).toEqual({})
expect(parseReservationMeta(buildReservation({ metadata: '' }))).toEqual({})
expect(parseReservationMeta(buildReservation({ metadata: null }))).toEqual({})
expect(parseReservationMeta(buildReservation({ metadata: '5' }))).toEqual({})
// Double-encoded, with an inner payload that is broken or empty.
expect(parseReservationMeta(buildReservation({ metadata: JSON.stringify('{not json') }))).toEqual({})
expect(parseReservationMeta(buildReservation({ metadata: '""' }))).toEqual({})
})
it('FE-MOB-PTLM-005: reads transit metadata only from transit reservations with legs', () => {
const legs = [{ mode: 'subway', line: 'U2' }]
const transit = buildReservation({
type: 'transit', metadata: JSON.stringify({ transit: { legs, transfers: 1, duration: 900 } }),
})
expect(getTransitMeta(transit)).toEqual({ legs, transfers: 1, duration: 900 })
expect(getTransitMeta(buildReservation({ type: 'flight', metadata: JSON.stringify({ transit: { legs } }) }))).toBeNull()
expect(getTransitMeta(buildReservation({ type: 'transit', metadata: '{}' }))).toBeNull()
expect(getTransitMeta(buildReservation({ type: 'transit', metadata: '{"transit":{"legs":"nope"}}' }))).toBeNull()
})
})
describe('planTimelineModel — transportSubtitle', () => {
it('FE-MOB-PTLM-006: renders a synthetic leg as from → to', () => {
const res = { ...buildReservation({ type: 'flight' }), __leg: { index: 0, total: 2, from: 'FRA', to: 'IST' } } as TransportEntry
expect(transportSubtitle(res)).toBe('FRA → IST')
})
it('FE-MOB-PTLM-007: renders a leg without endpoints as an empty subtitle', () => {
const res = { ...buildReservation({ type: 'flight' }), __leg: { index: 1, total: 2, from: null, to: null } } as TransportEntry
expect(transportSubtitle(res)).toBe('')
})
it('FE-MOB-PTLM-008: joins airline, flight number and airports for a flight', () => {
const res = buildReservation({
type: 'flight',
metadata: JSON.stringify({ airline: 'LH', flight_number: 'LH123', departure_airport: 'FRA', arrival_airport: 'HND' }),
})
expect(transportSubtitle(res)).toBe('LH · LH123 · FRA → HND')
})
it('FE-MOB-PTLM-009: omits the airport pair when neither airport is known', () => {
const res = buildReservation({ type: 'flight', metadata: JSON.stringify({ airline: 'LH', flight_number: 'LH123' }) })
expect(transportSubtitle(res)).toBe('LH · LH123')
})
it('FE-MOB-PTLM-010: labels platform and seat for a train', () => {
const full = buildReservation({
type: 'train', metadata: JSON.stringify({ train_number: 'ICE 599', platform: '7', seat: '32A' }),
})
expect(transportSubtitle(full)).toBe('ICE 599 · Gl. 7 · Sitz 32A')
expect(transportSubtitle(buildReservation({ type: 'train', metadata: '{"train_number":"ICE 599"}' }))).toBe('ICE 599')
})
it('FE-MOB-PTLM-011: falls back to the location for any other type', () => {
expect(transportSubtitle(buildReservation({ type: 'bus', location: 'Central Station' }))).toBe('Central Station')
expect(transportSubtitle(buildReservation({ type: 'bus', location: null }))).toBe('')
})
})
describe('planTimelineModel — buildPlanRows', () => {
const museum = assignment(11, 0, place(101, 'Museum', 48.1, 16.1))
const park = assignment(12, 1, place(102, 'Park', 48.2, 16.2))
it('FE-MOB-PTLM-012: maps places, notes and transports to keyed rows', () => {
const note = buildDayNote({ id: 41, day_id: 2, text: 'Buy tickets' })
const bus = buildReservation({ id: 51, type: 'bus', day_id: 2, title: 'Bus 13A' })
const rows = buildPlanRows({
merged: [placeItem(museum), noteItem(note), transportItem(bus)],
reservations: [bus],
routeSegments: [],
dayId: 2,
})
expect(rows.map(r => [r.kind, r.key])).toEqual([
['place', 'pl-11'],
['note', 'note-41'],
['transport', 'tr-51'],
])
})
it('FE-MOB-PTLM-013: links a place row to the reservation booked on it', () => {
const dinner = buildReservation({ id: 52, type: 'restaurant', day_id: 2, assignment_id: 11 })
const rows = buildPlanRows({
merged: [placeItem(museum), placeItem(park)], reservations: [dinner], routeSegments: [], dayId: 2,
})
const [first, second] = rows.filter(r => r.kind === 'place')
expect(first.kind === 'place' && first.linkedRes).toBe(dinner)
expect(second.kind === 'place' && second.linkedRes).toBeNull()
})
it('FE-MOB-PTLM-014: keys a synthetic leg row by its leg index', () => {
const legRes = { ...buildReservation({ id: 60, type: 'flight', day_id: 2 }), __leg: { index: 1, total: 2, from: 'IST', to: 'NRT' } } as TransportEntry
const rows = buildPlanRows({ merged: [transportItem(legRes)], reservations: [], routeSegments: [], dayId: 2 })
expect(rows[0].key).toBe('tr-60-leg1')
})
it('FE-MOB-PTLM-015: recognises a transit booking as its own row kind', () => {
const transit = buildReservation({
id: 61, type: 'transit', day_id: 2,
metadata: JSON.stringify({ transit: { legs: [{ mode: 'subway', line: 'U2' }], transfers: 0 } }),
})
const rows = buildPlanRows({ merged: [transportItem(transit)], reservations: [], routeSegments: [], dayId: 2 })
expect(rows[0].kind).toBe('transit')
expect(rows[0].kind === 'transit' && rows[0].transit.legs).toHaveLength(1)
})
it('FE-MOB-PTLM-016: hides a car rental on the days between pickup and drop-off', () => {
const car = buildReservation({ id: 62, type: 'car', day_id: 1, end_day_id: 3 })
expect(buildPlanRows({ merged: [transportItem(car)], reservations: [], routeSegments: [], dayId: 2 })).toEqual([])
expect(buildPlanRows({ merged: [transportItem(car)], reservations: [], routeSegments: [], dayId: 1 })).toHaveLength(1)
})
it('FE-MOB-PTLM-017: slots a connector between two located places and tags its origin', () => {
const leg = seg([48.1, 16.1], [48.2, 16.2])
const rows = buildPlanRows({
merged: [placeItem(museum), placeItem(park)], reservations: [], routeSegments: [leg], dayId: 2,
})
expect(rows.map(r => r.kind)).toEqual(['place', 'conn', 'place'])
const conn = rows[1]
expect(conn.kind === 'conn' && conn.seg).toBe(leg)
expect(conn.kind === 'conn' && conn.assignmentId).toBe(11)
expect(conn.key).toBe('conn-pl-11')
})
it('FE-MOB-PTLM-018: connects across an intervening note', () => {
const note = buildDayNote({ id: 41, day_id: 2 })
const rows = buildPlanRows({
merged: [placeItem(museum), noteItem(note), placeItem(park)],
reservations: [],
routeSegments: [seg([48.1, 16.1], [48.2, 16.2])],
dayId: 2,
})
expect(rows.map(r => r.kind)).toEqual(['place', 'conn', 'note', 'place'])
})
it('FE-MOB-PTLM-019: draws no connector when a transport is the hop between the places', () => {
const bus = buildReservation({ id: 51, type: 'bus', day_id: 2 })
const rows = buildPlanRows({
merged: [placeItem(museum), transportItem(bus), placeItem(park)],
reservations: [],
routeSegments: [seg([48.1, 16.1], [48.2, 16.2])],
dayId: 2,
})
expect(rows.map(r => r.kind)).toEqual(['place', 'transport', 'place'])
})
it('FE-MOB-PTLM-020: skips places without coordinates and unmatched segments', () => {
const nowhere = assignment(13, 2, place(103, 'Idea', null, null))
const rows = buildPlanRows({
merged: [placeItem(nowhere), placeItem(museum)],
reservations: [],
routeSegments: [seg([49.9, 17.9], [48.2, 16.2])],
dayId: 2,
})
expect(rows.map(r => r.kind)).toEqual(['place', 'place'])
})
it('FE-MOB-PTLM-021: consumes each segment only once', () => {
const again = assignment(14, 2, place(104, 'Museum again', 48.1, 16.1))
const parkAgain = assignment(15, 3, place(105, 'Park again', 48.2, 16.2))
const rows = buildPlanRows({
merged: [placeItem(museum), placeItem(park), placeItem(again), placeItem(parkAgain)],
reservations: [],
routeSegments: [seg([48.1, 16.1], [48.2, 16.2])],
dayId: 2,
})
// Only the first Museum → Park hop finds the single matching leg.
expect(rows.filter(r => r.kind === 'conn')).toHaveLength(1)
})
})
describe('planTimelineModel — hotel chips and legs', () => {
it('FE-MOB-PTLM-022: orders check-out before check-in before an ongoing stay', () => {
const chips = hotelChipsForDay(DAY2, DAYS, [
accommodation({ id: 1, start_day_id: 1, end_day_id: 3, place_name: 'Long Stay' }),
accommodation({ id: 2, start_day_id: 2, end_day_id: 3, place_name: 'New Hotel', check_in: '15:00' }),
accommodation({ id: 3, start_day_id: 1, end_day_id: 2, place_name: 'Old Hotel', check_out: '11:00' }),
])
expect(chips).toEqual([
{ key: 'out-3', variant: 'checkout', name: 'Old Hotel', time: '11:00' },
{ key: 'in-2', variant: 'checkin', name: 'New Hotel', time: '15:00' },
{ key: 'stay-1', variant: 'stay', name: 'Long Stay', time: null },
])
})
it('FE-MOB-PTLM-023: falls back to the reservation title and drops unnamed stays', () => {
const chips = hotelChipsForDay(DAY2, DAYS, [
accommodation({ id: 4, place_name: null, reservation_title: 'Airbnb Wieden' }),
accommodation({ id: 5, place_name: null, reservation_title: null }),
])
expect(chips).toEqual([{ key: 'stay-4', variant: 'stay', name: 'Airbnb Wieden', time: null }])
})
it('FE-MOB-PTLM-042: leaves the chip time empty when the stay has no check-in or check-out', () => {
const chips = hotelChipsForDay(DAY2, DAYS, [
accommodation({ id: 9, start_day_id: 1, end_day_id: 2, place_name: 'Old Hotel' }),
accommodation({ id: 10, start_day_id: 2, end_day_id: 3, place_name: 'New Hotel' }),
])
expect(chips.map(c => [c.variant, c.time])).toEqual([['checkout', null], ['checkin', null]])
})
it('FE-MOB-PTLM-024: ignores accommodations outside the day range', () => {
expect(hotelChipsForDay(DAY2, DAYS, [accommodation({ id: 6, start_day_id: 1, end_day_id: 1 })])).toEqual([])
})
it('FE-MOB-PTLM-025: picks the hotel bookend legs out of the calculated segments', () => {
const hotel = accommodation({ id: 7, start_day_id: 1, end_day_id: 3, place_lat: 48.0, place_lng: 16.0 })
const out = seg([48.0, 16.0], [48.1, 16.1])
const back = seg([48.2, 16.2], [48.0, 16.0])
const legs = hotelLegsForDay(DAY2, DAYS, [hotel], [out, back, seg([48.1, 16.1], [48.2, 16.2])])
expect(legs.top).toEqual({ seg: out, name: 'Hotel Sacher' })
expect(legs.bottom).toEqual({ seg: back, name: 'Hotel Sacher' })
})
it('FE-MOB-PTLM-026: returns no legs when no segment touches the hotel', () => {
const hotel = accommodation({ id: 8, start_day_id: 1, end_day_id: 3, place_lat: 48.0, place_lng: 16.0 })
expect(hotelLegsForDay(DAY2, DAYS, [hotel], [seg([48.1, 16.1], [48.2, 16.2])])).toEqual({ top: null, bottom: null })
})
it('FE-MOB-PTLM-027: returns no legs without an accommodation on the day', () => {
expect(hotelLegsForDay(DAY2, DAYS, [], [seg([48.1, 16.1], [48.2, 16.2])])).toEqual({ top: null, bottom: null })
})
})
describe('planTimelineModel — cityPillsForDay', () => {
const t = ((key: string, params?: Record<string, string | number>) =>
params ? `${key}:${Object.values(params).join(',')}` : key) as unknown as TranslationFn
it('FE-MOB-PTLM-028: splits a transfer title into one pill per city', () => {
expect(cityPillsForDay({ ...DAY2, title: 'Tokyo → Kyoto ' } as Day, t)).toEqual(['Tokyo', 'Kyoto'])
})
it('FE-MOB-PTLM-029: keeps a plain title as a single pill', () => {
expect(cityPillsForDay(DAY2, t)).toEqual(['Old Town'])
})
it('FE-MOB-PTLM-030: falls back to the day number for blank or arrow-only titles', () => {
expect(cityPillsForDay({ ...DAY2, title: ' ' } as Day, t)).toEqual(['planner.dayN:2'])
expect(cityPillsForDay({ ...DAY2, title: ' → ' } as Day, t)).toEqual(['planner.dayN:2'])
expect(cityPillsForDay(undefined, t)).toEqual(['planner.dayN:0'])
})
})
describe('planTimelineModel — findUpNext', () => {
const early = assignment(11, 0, place(101, 'Breakfast', 48.1, 16.1, '09:00'))
const noon = assignment(12, 1, place(102, 'Museum', 48.2, 16.2, '11:00'))
const late = assignment(13, 2, place(103, 'Dinner', 48.3, 16.3, '19:00'))
it('FE-MOB-PTLM-031: returns null for a day without stops', () => {
expect(findUpNext(DAY2, [], new Date(2026, 4, 2, 10, 0))).toBeNull()
})
it('FE-MOB-PTLM-032: counts down to the next stop still ahead today', () => {
const up = findUpNext(DAY2, [late, early, noon], new Date(2026, 4, 2, 10, 15))
expect(up?.assignment.id).toBe(12)
expect(up?.minutesUntil).toBe(45)
})
it('FE-MOB-PTLM-033: falls back to the first timed stop once the day is over', () => {
const up = findUpNext(DAY2, [early, noon, late], new Date(2026, 4, 2, 23, 30))
expect(up?.assignment.id).toBe(11)
expect(up?.minutesUntil).toBeNull()
})
it('FE-MOB-PTLM-034: never counts down on a day that is not today', () => {
const up = findUpNext(DAY2, [late, noon, early], new Date(2026, 4, 1, 8, 0))
expect(up?.assignment.id).toBe(11)
expect(up?.minutesUntil).toBeNull()
})
it('FE-MOB-PTLM-035: uses the manual order when no stop carries a time', () => {
const a = assignment(21, 1, place(201, 'Park', 48.1, 16.1))
const b = assignment(22, 0, place(202, 'Cafe', 48.2, 16.2))
const up = findUpNext(DAY2, [a, b], new Date(2026, 4, 2, 10, 0))
expect(up?.assignment.id).toBe(22)
expect(up?.minutesUntil).toBeNull()
})
it('FE-MOB-PTLM-036: treats a day without a date as not today', () => {
const up = findUpNext({ ...DAY2, date: null } as unknown as Day, [noon, early], new Date(2026, 4, 2, 10, 0))
expect(up?.assignment.id).toBe(11)
expect(findUpNext(undefined, [noon, early], new Date(2026, 4, 2, 10, 0))?.assignment.id).toBe(11)
})
})
describe('planTimelineModel — chronology helpers', () => {
it('FE-MOB-PTLM-037: detects which merged items carry a time', () => {
expect(itemHasTime(placeItem(assignment(11, 0, place(101, 'Museum', 48.1, 16.1, '09:00'))), 2)).toBe(true)
expect(itemHasTime(placeItem(assignment(12, 1, place(102, 'Park', 48.2, 16.2))), 2)).toBe(false)
expect(itemHasTime(noteItem(buildDayNote({ id: 41, time: '10:30' })), 2)).toBe(true)
expect(itemHasTime(noteItem(buildDayNote({ id: 42, time: null })), 2)).toBe(false)
})
it('FE-MOB-PTLM-038: reads a transport time through the per-day display time', () => {
const bus = buildReservation({ id: 51, type: 'bus', day_id: 2, reservation_time: '2026-05-02T08:00' })
expect(itemHasTime(transportItem(bus), 2)).toBe(true)
const car = buildReservation({ id: 52, type: 'car', day_id: 1, end_day_id: 3, reservation_time: '2026-05-01T08:00' })
expect(itemHasTime(transportItem(car), 2)).toBe(false)
})
it('FE-MOB-PTLM-039: reports an order that puts a later time before an earlier one', () => {
const nine = placeItem(assignment(11, 0, place(101, 'Breakfast', 48.1, 16.1, '09:00')))
const eleven = placeItem(assignment(12, 1, place(102, 'Museum', 48.2, 16.2, '11:00')))
expect(breaksChronology([nine, eleven], 2, getDisplayTimeForDay)).toBe(false)
expect(breaksChronology([eleven, nine], 2, getDisplayTimeForDay)).toBe(true)
})
it('FE-MOB-PTLM-040: lets untimed items sit anywhere between timed ones', () => {
const nine = placeItem(assignment(11, 0, place(101, 'Breakfast', 48.1, 16.1, '09:00')))
const free = placeItem(assignment(12, 1, place(102, 'Park', 48.2, 16.2)))
const eleven = noteItem(buildDayNote({ id: 41, time: '11:00' }))
expect(breaksChronology([nine, free, eleven], 2, getDisplayTimeForDay)).toBe(false)
const bus = transportItem(buildReservation({ id: 51, type: 'bus', day_id: 2, reservation_time: '2026-05-02T07:00' }))
const getDisplayTime = vi.fn(getDisplayTimeForDay)
expect(breaksChronology([nine, bus], 2, getDisplayTime)).toBe(true)
expect(getDisplayTime).toHaveBeenCalledWith(bus.data, 2)
})
})
describe('planTimelineModel — weatherIconFor', () => {
it('FE-MOB-PTLM-041: maps the known conditions and defaults to a cloud', () => {
expect(weatherIconFor('Clear')).toBe(Sun)
expect(weatherIconFor('Rain')).toBe(CloudRain)
expect(weatherIconFor('Thunderstorm')).toBe(CloudLightning)
expect(weatherIconFor('Snow')).toBe(CloudSnow)
expect(weatherIconFor('Haze')).toBe(Wind)
expect(weatherIconFor('Tornado')).toBe(Cloud)
expect(weatherIconFor(undefined)).toBe(Cloud)
})
})
@@ -0,0 +1,151 @@
import { describe, expect, it, vi } from 'vitest'
import type { ReservationTraveler } from '@trek/shared'
import {
CountPill, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow,
} from '../../../../src/mobile/screens/trip/tabs/tabChrome'
import { fireEvent, render, screen } from '../../../helpers/render'
// FE-MOB-TABCHR-001 to FE-MOB-TABCHR-014
function traveler(overrides: Partial<ReservationTraveler>): ReservationTraveler {
return {
user_id: 1, username: 'Ada', avatar: null, avatar_url: null, is_guest: 0,
...overrides,
} as unknown as ReservationTraveler
}
describe('tabChrome', () => {
it('FE-MOB-TABCHR-001: TabScroller puts its children in the scroll body with dock + top clearance', () => {
const { container } = render(<TabScroller><p>panel body</p></TabScroller>)
const body = screen.getByText('panel body').parentElement as HTMLElement
expect(body).toHaveClass('overflow-y-auto')
expect(body.className).toContain('--bottom-nav-h')
expect(body.className).toContain('--m-safe-top')
expect(container.firstElementChild).toHaveClass('h-full')
})
it('FE-MOB-TABCHR-002: CountPill renders its count', () => {
render(<CountPill>7</CountPill>)
expect(screen.getByText('7')).toBeInTheDocument()
})
it('FE-MOB-TABCHR-003: SectionHeader exposes the open state and its count pill', () => {
render(<SectionHeader label="Confirmed" count={3} open onToggle={vi.fn()} />)
const header = screen.getByRole('button', { name: /Confirmed/ })
expect(header).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('3')).toBeInTheDocument()
// Open → chevron points up.
expect(header.querySelector('.lucide-chevron-up')).not.toBeNull()
expect(header.querySelector('.lucide-chevron-down')).toBeNull()
})
it('FE-MOB-TABCHR-004: SectionHeader flips the chevron and drops the pill when collapsed without a count', () => {
render(<SectionHeader label="Pending" open={false} onToggle={vi.fn()} />)
const header = screen.getByRole('button', { name: 'Pending' })
expect(header).toHaveAttribute('aria-expanded', 'false')
expect(header.querySelector('.lucide-chevron-down')).not.toBeNull()
expect(header.textContent).toBe('Pending')
})
it('FE-MOB-TABCHR-005: SectionHeader reports taps', () => {
const onToggle = vi.fn()
render(<SectionHeader label="Pending" open={false} onToggle={onToggle} />)
fireEvent.click(screen.getByRole('button', { name: 'Pending' }))
expect(onToggle).toHaveBeenCalledTimes(1)
})
it('FE-MOB-TABCHR-006: Field pairs its label with the value box and takes the caller width', () => {
const { container } = render(<Field label="Departure" className="flex-1">08:15</Field>)
expect(screen.getByText('Departure')).toBeInTheDocument()
const value = screen.getByText('08:15')
expect(value).not.toHaveClass('tabular-nums')
expect(container.firstElementChild).toHaveClass('flex-1')
})
it('FE-MOB-TABCHR-007: Field aligns numbers when asked to', () => {
render(<Field label="Price" tabular>12,50 €</Field>)
expect(screen.getByText('12,50 €')).toHaveClass('tabular-nums')
})
it('FE-MOB-TABCHR-008: StatusDot paints the passed token', () => {
const { container } = render(<StatusDot color="var(--m-st-confirmed)" />)
expect(container.firstElementChild).toHaveStyle({ background: 'var(--m-st-confirmed)' })
})
it('FE-MOB-TABCHR-009: TravelerAvatars renders nothing when nobody is assigned', () => {
const { container } = render(<TravelerAvatars travelers={[]} label="Travelers" />)
expect(container).toBeEmptyDOMElement()
})
it('FE-MOB-TABCHR-010: TravelerAvatars shows the label and one chip per traveler', () => {
render(
<TravelerAvatars
travelers={[traveler({ user_id: 1, username: 'Ada' }), traveler({ user_id: 2, username: 'Bob' })]}
label="Travelers"
/>,
)
expect(screen.getByText('Travelers')).toBeInTheDocument()
expect(screen.getByText('Ada')).toBeInTheDocument()
expect(screen.getByText('Bob')).toBeInTheDocument()
// No avatar → initial only.
expect(screen.getByText('A')).toBeInTheDocument()
})
it('FE-MOB-TABCHR-011: TravelerAvatars prefers avatar_url and falls back to the raw avatar path', () => {
const { container } = render(
<TravelerAvatars
travelers={[
traveler({ user_id: 1, username: 'Ada', avatar_url: '/uploads/avatars/ada.png' }),
traveler({ user_id: 2, username: 'Bob', avatar: 'bob.jpg' }),
traveler({ user_id: 3, username: 'Cid', avatar: 'https://idp.example/cid.png' }),
]}
label="Travelers"
/>,
)
const srcs = [...container.querySelectorAll('img')].map(i => i.getAttribute('src'))
expect(srcs).toEqual([
'/uploads/avatars/ada.png',
'/uploads/avatars/bob.jpg',
'https://idp.example/cid.png',
])
})
it('FE-MOB-TABCHR-012: TravelerAvatars marks accountless guests', () => {
render(<TravelerAvatars travelers={[traveler({ user_id: 9, username: 'Zoe', is_guest: 1 })]} label="Travelers" />)
expect(screen.getByText('Guest')).toBeInTheDocument()
})
it('FE-MOB-TABCHR-013: TravelerFilterRow toggles a member and dims the inactive ones', () => {
const onToggle = vi.fn()
render(
<TravelerFilterRow
members={[{ id: 1, username: 'Ada' }, { id: 2, username: 'Bob', avatar_url: '/uploads/avatars/bob.png' }]}
active={new Set([1])}
onToggle={onToggle}
label="Filter by traveler"
/>,
)
expect(screen.getByLabelText('Filter by traveler')).toBeInTheDocument()
const ada = screen.getByTitle('Ada')
const bob = screen.getByTitle('Bob')
expect(ada.className).toContain('opacity-100')
expect(bob.className).toContain('opacity-40')
expect(bob.querySelector('img')).toHaveAttribute('src', '/uploads/avatars/bob.png')
fireEvent.click(bob)
expect(onToggle).toHaveBeenCalledWith(2)
})
it('FE-MOB-TABCHR-014: TravelerFilterRow keeps everyone at full opacity while no filter is set', () => {
render(
<TravelerFilterRow
members={[{ id: 1, username: 'Ada' }, { id: 2, username: 'Bob' }]}
active={new Set()}
onToggle={vi.fn()}
label="Filter by traveler"
/>,
)
expect(screen.getByTitle('Ada').className).toContain('opacity-100')
expect(screen.getByTitle('Bob').className).toContain('opacity-100')
})
})
@@ -0,0 +1,157 @@
import { describe, it, expect } from 'vitest';
import {
TRANSPORT_TYPE_COLOR,
groupTransports,
orderedEndpoints,
parseTransportMeta,
} from '../../../../src/mobile/screens/trip/tabs/transportsModel';
import { buildDay, buildReservation } from '../../../helpers/factories';
import type { Day, Reservation } from '../../../../src/types';
// FE-MOB-TRPM-001 to FE-MOB-TRPM-014
function res(overrides: Partial<Reservation> = {}): Reservation {
return buildReservation({ type: 'flight', status: 'confirmed', ...overrides });
}
describe('transportsModel — metadata', () => {
it('FE-MOB-TRPM-001: exposes a chip colour per transport type', () => {
expect(TRANSPORT_TYPE_COLOR.flight).toBe('#3b82f6');
expect(TRANSPORT_TYPE_COLOR.transit).toBe('#7c3aed');
expect(TRANSPORT_TYPE_COLOR.transport_other).toBe(TRANSPORT_TYPE_COLOR.car);
expect(TRANSPORT_TYPE_COLOR.nope).toBeUndefined();
});
it('FE-MOB-TRPM-002: parses a JSON metadata string', () => {
const meta = parseTransportMeta(
res({ metadata: '{"airline":"LH","flight_number":"LH123","seat":"14A"}' }),
);
expect(meta).toEqual({ airline: 'LH', flight_number: 'LH123', seat: '14A' });
});
it('FE-MOB-TRPM-003: passes an already-parsed metadata object through', () => {
const parsed = { transit: { legs: [{ mode: 'subway', line: 'U2' }] } };
expect(parseTransportMeta({ ...res(), metadata: parsed } as unknown as Reservation)).toEqual(parsed);
});
it('FE-MOB-TRPM-004: returns an empty object for missing or broken metadata', () => {
expect(parseTransportMeta(res({ metadata: null }))).toEqual({});
expect(parseTransportMeta(res({ metadata: '' }))).toEqual({});
expect(parseTransportMeta(res({ metadata: '{not json' }))).toEqual({});
});
it('FE-MOB-TRPM-005: orderedEndpoints sorts waypoints by sequence without mutating', () => {
const endpoints = [
{ role: 'to' as const, sequence: 2, name: 'Rome', code: null, lat: 0, lng: 0, timezone: null, local_time: null, local_date: null },
{ role: 'from' as const, sequence: 0, name: 'Berlin', code: null, lat: 0, lng: 0, timezone: null, local_time: null, local_date: null },
{ role: 'stop' as const, sequence: 1, name: 'Munich', code: null, lat: 0, lng: 0, timezone: null, local_time: null, local_date: null },
];
const r = res({ endpoints });
expect(orderedEndpoints(r).map(e => e.name)).toEqual(['Berlin', 'Munich', 'Rome']);
expect(endpoints.map(e => e.name)).toEqual(['Rome', 'Berlin', 'Munich']);
expect(orderedEndpoints(res({ endpoints: undefined }))).toEqual([]);
});
it('FE-MOB-TRPM-006: orderedEndpoints treats a missing sequence as zero', () => {
const rome = { role: 'to' as const, sequence: 1, name: 'Rome', code: null, lat: 0, lng: 0, timezone: null, local_time: null, local_date: null };
const berlin = { role: 'from' as const, name: 'Berlin', code: null, lat: 0, lng: 0, timezone: null, local_time: null, local_date: null };
const pair = (list: unknown[]) =>
orderedEndpoints(res({ endpoints: list as unknown as Reservation['endpoints'] })).map(e => e.name);
expect(pair([rome, berlin])).toEqual(['Berlin', 'Rome']);
expect(pair([berlin, rome])).toEqual(['Berlin', 'Rome']);
});
});
describe('transportsModel — groupTransports', () => {
const days: Day[] = [
buildDay({ id: 100, date: '2026-07-01' }),
buildDay({ id: 101, date: '2026-07-02' }),
buildDay({ id: 102, date: '2026-07-03' }),
];
it('FE-MOB-TRPM-007: sorts by reservation date and time', () => {
const late = res({ id: 1, reservation_time: '2026-07-02T18:00' });
const early = res({ id: 2, reservation_time: '2026-07-02T06:30' });
const first = res({ id: 3, reservation_time: '2026-07-01T23:00' });
expect(groupTransports([late, early, first], days).confirmed.map(r => r.id)).toEqual([3, 2, 1]);
});
it('FE-MOB-TRPM-008: falls back to the linked day date, treating a date-only value as midnight', () => {
const viaDay = res({ id: 1, reservation_time: null, day_id: 100 });
const dateOnly = res({ id: 2, reservation_time: '2026-07-01' });
const withTime = res({ id: 3, reservation_time: '2026-07-01T08:00' });
// both undated-but-day-linked entries key on 00:00, so created_at breaks the tie
const sorted = groupTransports(
[
withTime,
{ ...dateOnly, created_at: '2025-01-02T00:00:00.000Z' },
{ ...viaDay, created_at: '2025-01-01T00:00:00.000Z' },
],
days,
).confirmed;
expect(sorted.map(r => r.id)).toEqual([1, 2, 3]);
});
it('FE-MOB-TRPM-009: a hotel keys on its accommodation start day, not its day_id', () => {
const hotel = res({
id: 1,
type: 'hotel',
reservation_time: null,
day_id: 102,
accommodation_start_day_id: 100,
});
const flight = res({ id: 2, reservation_time: '2026-07-02T09:00' });
expect(groupTransports([flight, hotel], days).confirmed.map(r => r.id)).toEqual([1, 2]);
});
it('FE-MOB-TRPM-010: a hotel without an accommodation start day still uses day_id', () => {
const hotel = res({ id: 1, type: 'hotel', reservation_time: null, day_id: 102 });
const flight = res({ id: 2, reservation_time: '2026-07-02T09:00' });
expect(groupTransports([flight, hotel], days).confirmed.map(r => r.id)).toEqual([2, 1]);
});
it('FE-MOB-TRPM-011: undated entries and entries on unknown days sink to the bottom', () => {
const dated = res({ id: 1, reservation_time: '2026-07-02T09:00' });
const undated = res({ id: 2, reservation_time: null, day_id: null, created_at: '2025-01-01T00:00:00.000Z' });
const unknownDay = res({ id: 3, reservation_time: null, day_id: 999, created_at: '2025-01-02T00:00:00.000Z' });
expect(groupTransports([undated, unknownDay, dated], days).confirmed.map(r => r.id)).toEqual([1, 2, 3]);
// same result whichever side of the comparison the undated entry lands on
expect(groupTransports([undated, dated], days).confirmed.map(r => r.id)).toEqual([1, 2]);
expect(groupTransports([dated, undated], days).confirmed.map(r => r.id)).toEqual([1, 2]);
});
it('FE-MOB-TRPM-012: splits confirmed, pending and automated transit', () => {
const confirmed = res({ id: 1, reservation_time: '2026-07-01T08:00' });
const pending = res({ id: 2, status: 'pending', reservation_time: '2026-07-01T09:00' });
const cancelled = res({ id: 3, status: 'cancelled', reservation_time: '2026-07-01T07:00' });
const transitConfirmed = res({ id: 4, type: 'transit', reservation_time: '2026-07-01T10:00' });
const transitPending = res({ id: 5, type: 'transit', status: 'pending', reservation_time: '2026-07-01T06:00' });
const groups = groupTransports([confirmed, pending, cancelled, transitConfirmed, transitPending], days);
expect(groups.confirmed.map(r => r.id)).toEqual([1]);
// anything not confirmed lands in pending, still chronological
expect(groups.pending.map(r => r.id)).toEqual([3, 2]);
// transit is peeled off regardless of status
expect(groups.transit.map(r => r.id)).toEqual([5, 4]);
});
it('FE-MOB-TRPM-013: an empty list yields three empty groups', () => {
expect(groupTransports([], days)).toEqual({ confirmed: [], pending: [], transit: [] });
expect(groupTransports([res({ id: 1, reservation_time: '2026-07-01T08:00' })], []).confirmed).toHaveLength(1);
});
it('FE-MOB-TRPM-014: entries without a created_at still sort deterministically', () => {
const a = { ...res({ id: 1, reservation_time: '2026-07-02T09:00' }), created_at: undefined };
const b = { ...res({ id: 2, reservation_time: '2026-07-02T09:00' }), created_at: undefined };
expect(groupTransports([a, b], days).confirmed.map(r => r.id)).toEqual([1, 2]);
});
});
@@ -0,0 +1,649 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WeatherResult } from '@trek/shared'
import { assignmentsApi, reservationsApi, weatherApi } from '../../../../src/api/client'
import { useMPlanTimeline } from '../../../../src/mobile/screens/trip/plan/useMPlanTimeline'
import type { TransportEntry } from '../../../../src/mobile/screens/trip/plan/planTimelineModel'
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { usePluginStore, type ActivePlugin } from '../../../../src/store/pluginStore'
import { useTripStore } from '../../../../src/store/tripStore'
import type { Accommodation, Day, RouteSegment } from '../../../../src/types'
import { buildAssignment, buildDayNote, buildPlace, buildReservation } from '../../../helpers/factories'
import { buildPlanner, buildTripActions } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { act, renderHook, waitFor } from '../../../helpers/render'
// FE-MOB-PLTL-001 to FE-MOB-PLTL-045
// The connector calculation is its own hook with real OSRM calls — stubbed here so
// the timeline sees exactly the legs a test wants to match against.
const routeCalc = vi.hoisted(() => ({ segments: [] as unknown[] }))
vi.mock('../../../../src/hooks/useRouteCalculation', () => ({
useRouteCalculation: () => ({ routeSegments: routeCalc.segments }),
}))
const DAYS = [
{ id: 1, trip_id: 1, day_number: 1, date: '2026-05-01', title: null },
{ id: 2, trip_id: 1, day_number: 2, date: '2026-05-02', title: 'Old Town' },
{ id: 3, trip_id: 1, day_number: 3, date: '2026-05-03', title: null },
] as unknown as Day[]
const MUSEUM = buildPlace({ id: 101, name: 'Museum', lat: 48, lng: 16.1 })
const PARK = buildPlace({ id: 102, name: 'Park', lat: 48, lng: 16.3 })
const CAFE = buildPlace({ id: 103, name: 'Cafe', lat: 48, lng: 16.2 })
const A_MUSEUM = buildAssignment({ id: 11, day_id: 2, order_index: 0, place_id: 101, place: MUSEUM })
const A_PARK = buildAssignment({ id: 12, day_id: 2, order_index: 1, place_id: 102, place: PARK })
const A_CAFE = buildAssignment({ id: 13, day_id: 2, order_index: 2, place_id: 103, place: CAFE })
// Timed stops without coordinates — the up-next pick needs times, not a map.
const TIMED_EARLY = buildAssignment({
id: 21, day_id: 2, order_index: 0, place_id: 201,
place: buildPlace({ id: 201, name: 'Breakfast', lat: null, lng: null, place_time: '09:00' }),
})
const TIMED_LATE = buildAssignment({
id: 22, day_id: 2, order_index: 1, place_id: 202,
place: buildPlace({ id: 202, name: 'Museum', lat: null, lng: null, place_time: '11:00' }),
})
const HOTEL = {
id: 71, trip_id: 1, start_day_id: 1, end_day_id: 3,
place_name: 'Hotel Sacher', place_lat: 48, place_lng: 16.05,
} as unknown as Accommodation
const FORECAST = {
temp: 20.4, temp_max: 24, temp_min: 12, main: 'Rain', description: 'light rain', type: 'forecast',
} as WeatherResult
function seg(from: [number, number], to: [number, number]): RouteSegment {
return {
mid: [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2],
from, to, distance: 1200, duration: 600,
walkingText: '15 min', drivingText: '3 min', distanceText: '1.2 km',
}
}
const BASE_SETTINGS = {
time_format: '24h', date_format: 'DD.MM.YYYY', default_currency: 'EUR', distance_unit: 'km',
}
function makePlanner(overrides: Record<string, unknown> = {}): TripPlanner {
return buildPlanner({
tripId: 1,
days: DAYS,
selectedDayId: 2,
assignments: { '2': [A_CAFE, A_MUSEUM, A_PARK] },
places: [MUSEUM, PARK, CAFE],
reservations: [],
tripAccommodations: [],
settings: BASE_SETTINGS,
...overrides,
} as unknown as Partial<TripPlanner>)
}
/** Renders and settles the weather request so no state update lands outside act(). */
async function renderTimeline(planner: TripPlanner) {
const view = renderHook(() => useMPlanTimeline(planner))
await act(async () => { await Promise.resolve(); await Promise.resolve() })
return { ...view, planner }
}
describe('useMPlanTimeline', () => {
beforeEach(() => {
resetAllStores()
usePluginStore.setState({ plugins: [] })
routeCalc.segments = []
vi.spyOn(weatherApi, 'get').mockResolvedValue(FORECAST)
vi.spyOn(assignmentsApi, 'updateTransport').mockResolvedValue({})
vi.spyOn(reservationsApi, 'updatePositions').mockResolvedValue({})
vi.spyOn(window, 'open').mockImplementation(() => null)
})
// The plugin store is reset in beforeEach, not here: a setState while the hook
// is still mounted (cleanup runs after this) would re-render outside act().
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
it('FE-MOB-PLTL-001: resolves the selected day and sorts its stops by order index', async () => {
const { result } = await renderTimeline(makePlanner())
expect(result.current.day?.id).toBe(2)
expect(result.current.rows.map(r => r.key)).toEqual(['pl-11', 'pl-12', 'pl-13'])
expect(result.current.timeFormat).toBe('24h')
expect(result.current.language).toBe('en')
})
it('FE-MOB-PLTL-002: folds day notes from the store into the timeline', async () => {
seedStore(useTripStore, { dayNotes: { '2': [buildDayNote({ id: 41, day_id: 2, sort_order: 0.5 })] } })
const { result } = await renderTimeline(makePlanner({ assignments: { '2': [A_MUSEUM, A_PARK] } }))
expect(result.current.rows.map(r => r.key)).toEqual(['pl-11', 'note-41', 'pl-12'])
})
it('FE-MOB-PLTL-003: slots the calculated legs in as connectors', async () => {
routeCalc.segments = [seg([48, 16.1], [48, 16.3]), seg([48, 16.3], [48, 16.2])]
const { result } = await renderTimeline(makePlanner())
expect(result.current.rows.map(r => r.kind)).toEqual(['place', 'conn', 'place', 'conn', 'place'])
})
it('FE-MOB-PLTL-004: exposes the hotel bookend legs and header chips', async () => {
const out = seg([48, 16.05], [48, 16.1])
const back = seg([48, 16.2], [48, 16.05])
routeCalc.segments = [out, back]
const { result } = await renderTimeline(makePlanner({ tripAccommodations: [HOTEL] }))
expect(result.current.hotelLegs.top?.seg).toBe(out)
expect(result.current.hotelLegs.bottom?.seg).toBe(back)
expect(result.current.hotelChips).toEqual([
{ key: 'stay-71', variant: 'stay', name: 'Hotel Sacher', time: null },
])
})
it('FE-MOB-PLTL-005: derives nothing while no day is selected', async () => {
const { result } = await renderTimeline(makePlanner({ selectedDayId: null }))
expect(result.current.day).toBeUndefined()
expect(result.current.rows).toEqual([])
expect(result.current.merged).toEqual([])
expect(result.current.hotelChips).toEqual([])
expect(result.current.hotelLegs).toEqual({ top: null, bottom: null })
expect(result.current.upNext).toBeNull()
expect(weatherApi.get).not.toHaveBeenCalled()
})
it('FE-MOB-PLTL-006: keeps every day-scoped action inert without a selected day', async () => {
const planner = makePlanner({ selectedDayId: null })
const { result } = await renderTimeline(planner)
act(() => {
result.current.moveRow({ type: 'place', sortKey: 0, data: A_MUSEUM }, 'down')
result.current.removeAssignment(A_MUSEUM)
result.current.renameDay('Nope')
result.current.exportGoogleMaps()
result.current.setLegMode(11, 'walking')
result.current.addTransport()
})
await act(async () => { await result.current.optimize() })
expect(planner.handleRemoveAssignment).not.toHaveBeenCalled()
expect(planner.handleUpdateDayTitle).not.toHaveBeenCalled()
expect(window.open).not.toHaveBeenCalled()
expect(assignmentsApi.updateTransport).not.toHaveBeenCalled()
expect(planner.tripActions.reorderAssignments).not.toHaveBeenCalled()
// The transport sheet still opens — it just has no day to attach to.
expect(planner.setTransportModalDayId).toHaveBeenCalledWith(null)
})
it('FE-MOB-PLTL-007: requests the forecast for the first located stop', async () => {
const { result } = await renderTimeline(makePlanner())
await waitFor(() => expect(result.current.weather).not.toBeNull())
expect(weatherApi.get).toHaveBeenCalledWith(48, 16.1, '2026-05-02')
expect(result.current.weatherTemp).toBe(20)
})
it('FE-MOB-PLTL-008: converts the forecast to Fahrenheit on request', async () => {
const planner = makePlanner({ settings: { ...BASE_SETTINGS, temperature_unit: 'fahrenheit' } })
const { result } = await renderTimeline(planner)
await waitFor(() => expect(result.current.weatherTemp).toBe(69))
})
it('FE-MOB-PLTL-009: drops a forecast the service flagged as an error', async () => {
vi.mocked(weatherApi.get).mockResolvedValue({ ...FORECAST, error: 'no_forecast' })
const { result } = await renderTimeline(makePlanner())
expect(result.current.weather).toBeNull()
expect(result.current.weatherTemp).toBeNull()
})
it('FE-MOB-PLTL-010: survives a rejected forecast request', async () => {
vi.mocked(weatherApi.get).mockRejectedValue(new Error('offline'))
const { result } = await renderTimeline(makePlanner())
expect(result.current.weather).toBeNull()
})
it('FE-MOB-PLTL-011: anchors the forecast to the day hotel when no stop has coordinates', async () => {
const vague = buildAssignment({
id: 14, day_id: 2, order_index: 0, place_id: 104,
place: buildPlace({ id: 104, name: 'Idea', lat: null, lng: null }),
})
await renderTimeline(makePlanner({ assignments: { '2': [vague] }, tripAccommodations: [HOTEL] }))
expect(weatherApi.get).toHaveBeenCalledWith(48, 16.05, '2026-05-02')
})
it('FE-MOB-PLTL-012: skips the forecast without any coordinates at all', async () => {
const vague = buildAssignment({
id: 14, day_id: 2, order_index: 0, place_id: 104,
place: buildPlace({ id: 104, name: 'Idea', lat: null, lng: null }),
})
await renderTimeline(makePlanner({ assignments: { '2': [vague] } }))
expect(weatherApi.get).not.toHaveBeenCalled()
})
it('FE-MOB-PLTL-045: drops a forecast that settles after the timeline is gone', async () => {
let resolveWeather: (w: WeatherResult) => void = () => {}
vi.mocked(weatherApi.get).mockReturnValueOnce(new Promise(res => { resolveWeather = res }))
const first = renderHook(() => useMPlanTimeline(makePlanner()))
first.unmount()
await act(async () => { resolveWeather(FORECAST); await Promise.resolve() })
expect(first.result.current.weather).toBeNull()
let failWeather: (e: Error) => void = () => {}
vi.mocked(weatherApi.get).mockReturnValueOnce(new Promise((_res, rej) => { failWeather = rej }))
const second = renderHook(() => useMPlanTimeline(makePlanner()))
second.unmount()
await act(async () => { failWeather(new Error('offline')); await Promise.resolve() })
expect(second.result.current.weather).toBeNull()
})
it('FE-MOB-PLTL-013: toggles an expanded transit row on and off', async () => {
const { result } = await renderTimeline(makePlanner())
act(() => { result.current.toggleTransit('tr-61') })
expect(result.current.openTransitKeys.has('tr-61')).toBe(true)
act(() => { result.current.toggleTransit('tr-62') })
expect([...result.current.openTransitKeys].sort()).toEqual(['tr-61', 'tr-62'])
act(() => { result.current.toggleTransit('tr-61') })
expect(result.current.openTransitKeys.has('tr-61')).toBe(false)
})
it('FE-MOB-PLTL-014: counts down on today and never on any other day', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 2, 10, 15))
const today = makePlanner({ assignments: { '2': [TIMED_EARLY, TIMED_LATE] } })
const { result } = renderHook(() => useMPlanTimeline(today))
expect(result.current.upNext).toEqual({ assignment: TIMED_LATE, minutesUntil: 45 })
const past = makePlanner({
assignments: { '2': [TIMED_EARLY, TIMED_LATE] },
days: DAYS.map(d => (d.id === 2 ? { ...d, date: '2026-04-30' } : d)),
})
const other = renderHook(() => useMPlanTimeline(past))
expect(other.result.current.upNext).toEqual({ assignment: TIMED_EARLY, minutesUntil: null })
})
it('FE-MOB-PLTL-015: re-evaluates the countdown on the half-minute tick', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 2, 10, 0))
const planner = makePlanner({ assignments: { '2': [TIMED_EARLY, TIMED_LATE] } })
const { result } = renderHook(() => useMPlanTimeline(planner))
expect(result.current.upNext).toEqual({ assignment: TIMED_LATE, minutesUntil: 60 })
vi.setSystemTime(new Date(2026, 4, 2, 11, 30))
act(() => { vi.advanceTimersByTime(30_000) })
expect(result.current.upNext).toEqual({ assignment: TIMED_EARLY, minutesUntil: null })
})
it('FE-MOB-PLTL-016: reorders the day when a stop is moved down', async () => {
const planner = makePlanner()
const { result } = await renderTimeline(planner)
await act(async () => { result.current.moveRow(result.current.merged[0], 'down') })
await waitFor(() => expect(planner.tripActions.reorderAssignments).toHaveBeenCalledWith(1, 2, [12, 11, 13]))
expect(planner.pushUndo).toHaveBeenCalledWith('undo.reorder', expect.any(Function))
expect(planner.updateRouteForDay).toHaveBeenCalledWith(2)
})
it('FE-MOB-PLTL-017: restores the previous order through the undo entry', async () => {
const planner = makePlanner()
const { result } = await renderTimeline(planner)
await act(async () => { result.current.moveRow(result.current.merged[2], 'up') })
await waitFor(() => expect(planner.pushUndo).toHaveBeenCalled())
const undo = vi.mocked(planner.pushUndo).mock.calls[0][1] as () => Promise<void>
await undo()
expect(planner.tripActions.reorderAssignments).toHaveBeenLastCalledWith(1, 2, [11, 12, 13])
})
it('FE-MOB-PLTL-018: ignores a move past either end of the timeline', async () => {
const planner = makePlanner()
const { result } = await renderTimeline(planner)
await act(async () => {
result.current.moveRow(result.current.merged[0], 'up')
result.current.moveRow(result.current.merged[2], 'down')
result.current.moveRow({ type: 'place', sortKey: 9, data: A_MUSEUM }, 'down')
})
expect(planner.tripActions.reorderAssignments).not.toHaveBeenCalled()
})
it('FE-MOB-PLTL-019: refuses to move a timed stop past another timed stop', async () => {
const nine = buildAssignment({
id: 21, day_id: 2, order_index: 0, place_id: 201,
place: buildPlace({ id: 201, name: 'Breakfast', lat: 48, lng: 16.1, place_time: '09:00' }),
})
const eleven = buildAssignment({
id: 22, day_id: 2, order_index: 1, place_id: 202,
place: buildPlace({ id: 202, name: 'Museum', lat: 48, lng: 16.2, place_time: '11:00' }),
})
const planner = makePlanner({ assignments: { '2': [nine, eleven] } })
const { result } = await renderTimeline(planner)
await act(async () => { result.current.moveRow(result.current.merged[0], 'down') })
expect(planner.toast.info).toHaveBeenCalledWith('dayplan.cannotBreakChronology')
expect(planner.tripActions.reorderAssignments).not.toHaveBeenCalled()
})
it('FE-MOB-PLTL-020: lets an untimed stop move between timed ones', async () => {
const nine = buildAssignment({
id: 21, day_id: 2, order_index: 0, place_id: 201,
place: buildPlace({ id: 201, name: 'Breakfast', lat: 48, lng: 16.1, place_time: '09:00' }),
})
const free = buildAssignment({
id: 22, day_id: 2, order_index: 1, place_id: 202,
place: buildPlace({ id: 202, name: 'Park', lat: 48, lng: 16.2 }),
})
const eleven = buildAssignment({
id: 23, day_id: 2, order_index: 2, place_id: 203,
place: buildPlace({ id: 203, name: 'Museum', lat: 48, lng: 16.3, place_time: '11:00' }),
})
const planner = makePlanner({ assignments: { '2': [nine, free, eleven] } })
const { result } = await renderTimeline(planner)
await act(async () => { result.current.moveRow(result.current.merged[1], 'up') })
await waitFor(() => expect(planner.tripActions.reorderAssignments).toHaveBeenCalledWith(1, 2, [22, 21, 23]))
expect(planner.toast.info).not.toHaveBeenCalled()
})
it('FE-MOB-PLTL-021: persists note and transport positions alongside the reorder', async () => {
const bus = buildReservation({
id: 51, type: 'bus', title: 'Bus 13A', day_id: 2, day_positions: { 2: 1.5 },
})
seedStore(useTripStore, {
dayNotes: { '2': [buildDayNote({ id: 41, day_id: 2, sort_order: 0.5 })] },
reservations: [bus],
})
const planner = makePlanner({ assignments: { '2': [A_MUSEUM, A_PARK] }, reservations: [bus] })
const { result } = await renderTimeline(planner)
expect(result.current.merged.map(m => m.type)).toEqual(['place', 'note', 'place', 'transport'])
await act(async () => { result.current.moveRow(result.current.merged[3], 'up') })
await waitFor(() => expect(reservationsApi.updatePositions).toHaveBeenCalledWith(
1, [{ id: 51, day_plan_position: 2 / 3 }], 2,
))
expect(planner.tripActions.reorderAssignments).toHaveBeenCalledWith(1, 2, [11, 12])
expect(planner.tripActions.updateDayNote).toHaveBeenCalledWith(1, 2, 41, { sort_order: 1 / 3 })
// The optimistic store write lands before the round trip so the merge stays stable.
expect(useTripStore.getState().reservations[0]).toMatchObject({
id: 51, day_plan_position: 2 / 3, day_positions: { 2: 2 / 3 },
})
})
it('FE-MOB-PLTL-022: writes per-leg positions of a multi-leg flight back as an object', async () => {
const flight = buildReservation({
id: 60, type: 'flight', title: 'FRA → CTS', day_id: 2,
metadata: JSON.stringify({
// The third leg departs on day 3, so it never reaches this day's timeline.
legs: [{ from: 'FRA', to: 'IST' }, { from: 'IST', to: 'NRT' }, { dep_day_id: 3, from: 'NRT', to: 'CTS' }],
}),
})
const dinner = buildReservation({ id: 70, type: 'restaurant', title: 'Dinner', day_id: 3 })
seedStore(useTripStore, { reservations: [flight, dinner] })
const planner = makePlanner({ assignments: {}, reservations: [flight] })
const { result } = await renderTimeline(planner)
expect(result.current.rows.map(r => r.key)).toEqual(['tr-60-leg0', 'tr-60-leg1'])
await act(async () => { result.current.moveRow(result.current.merged[1], 'up') })
await waitFor(() => expect(planner.tripActions.updateReservation).toHaveBeenCalled())
const [tripId, resId, payload] = vi.mocked(planner.tripActions.updateReservation).mock.calls[0]
expect([tripId, resId]).toEqual([1, 60])
const meta = (payload as unknown as { metadata: { legs: Array<{ day_positions?: Record<string, number> }> } }).metadata
// Positions are base + (idx + 1) / (group + 1) with base -1: the whole group sits before slot 0.
expect(meta.legs[0].day_positions).toEqual({ 2: -1 + 2 / 3 })
expect(meta.legs[1].day_positions).toEqual({ 2: -1 + 1 / 3 })
expect(meta.legs[2].day_positions).toBeUndefined()
// Metadata must land in the store as an object, and only this booking is touched.
expect(typeof useTripStore.getState().reservations[0].metadata).toBe('object')
expect(useTripStore.getState().reservations[1]).toBe(dinner)
// No assignments on the day — nothing to undo, and no reorder call.
expect(planner.pushUndo).not.toHaveBeenCalled()
expect(planner.tripActions.reorderAssignments).not.toHaveBeenCalled()
expect(planner.updateRouteForDay).toHaveBeenCalledWith(2)
// A second move now reads the object metadata back out of the store.
await act(async () => { result.current.moveRow(result.current.merged[1], 'up') })
await waitFor(() => expect(planner.tripActions.updateReservation).toHaveBeenCalledTimes(2))
const second = vi.mocked(planner.tripActions.updateReservation).mock.calls[1][2] as unknown as
{ metadata: { legs: Array<{ day_positions?: Record<string, number> }> } }
expect(second.metadata.legs[0].day_positions).toEqual({ 2: -1 + 2 / 3 })
})
it('FE-MOB-PLTL-023: skips leg positions when the booking or its metadata is unusable', async () => {
const flight = buildReservation({
id: 60, type: 'flight', title: 'FRA → NRT', day_id: 2,
metadata: JSON.stringify({ legs: [{ from: 'FRA', to: 'IST' }, { from: 'IST', to: 'NRT' }] }),
})
const planner = makePlanner({ assignments: {}, reservations: [flight] })
const { result } = await renderTimeline(planner)
// Booking missing from the store entirely.
await act(async () => { result.current.moveRow(result.current.merged[1], 'up') })
expect(planner.tripActions.updateReservation).not.toHaveBeenCalled()
// Present, but with metadata that no longer parses into legs.
seedStore(useTripStore, { reservations: [{ ...flight, metadata: '{not json' }] })
await act(async () => { result.current.moveRow(result.current.merged[1], 'up') })
expect(planner.tripActions.updateReservation).not.toHaveBeenCalled()
expect(planner.updateRouteForDay).toHaveBeenCalledWith(2)
})
it('FE-MOB-PLTL-044: slots a timed transport that carries no saved position', async () => {
const bus = buildReservation({
id: 51, type: 'bus', title: 'Bus 13A', day_id: 2, reservation_time: '2026-05-02T08:00',
})
const dinner = buildReservation({ id: 52, type: 'restaurant', title: 'Dinner', day_id: 3 })
seedStore(useTripStore, { reservations: [bus, dinner] })
const planner = makePlanner({ assignments: { '2': [A_MUSEUM, A_PARK] }, reservations: [bus, dinner] })
const { result } = await renderTimeline(planner)
expect(result.current.merged.map(m => m.type)).toEqual(['place', 'place', 'transport'])
await act(async () => { result.current.moveRow(result.current.merged[2], 'up') })
await waitFor(() => expect(reservationsApi.updatePositions).toHaveBeenCalledWith(
1, [{ id: 51, day_plan_position: 0.5 }], 2,
))
// Only the moved booking gains a per-day position; the other day's booking is untouched.
expect(useTripStore.getState().reservations.map(r => r.day_positions)).toEqual([{ 2: 0.5 }, undefined])
})
it('FE-MOB-PLTL-024: surfaces a failed reorder as an error toast', async () => {
const actions = buildTripActions()
actions.reorderAssignments.mockRejectedValue(new Error('offline'))
const planner = makePlanner({ tripActions: actions })
const { result } = await renderTimeline(planner)
await act(async () => { result.current.moveRow(result.current.merged[0], 'down') })
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('offline'))
expect(planner.updateRouteForDay).not.toHaveBeenCalled()
})
it('FE-MOB-PLTL-025: falls back to the generic message for a non-Error failure', async () => {
const actions = buildTripActions()
actions.reorderAssignments.mockRejectedValue('nope')
const planner = makePlanner({ tripActions: actions })
const { result } = await renderTimeline(planner)
await act(async () => { result.current.moveRow(result.current.merged[0], 'down') })
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('trip.toast.reorderError'))
})
it('FE-MOB-PLTL-026: removes and edits a stop through the planner', async () => {
const planner = makePlanner()
const { result } = await renderTimeline(planner)
act(() => {
result.current.removeAssignment(A_PARK)
result.current.editAssignment(A_PARK)
})
expect(planner.handleRemoveAssignment).toHaveBeenCalledWith(2, 12)
expect(planner.openPlaceEditor).toHaveBeenCalledWith(PARK, 12)
})
it('FE-MOB-PLTL-027: does not open the editor for a stop missing from the pool', async () => {
const planner = makePlanner({ places: [] })
const { result } = await renderTimeline(planner)
act(() => { result.current.editAssignment(A_PARK) })
expect(planner.openPlaceEditor).not.toHaveBeenCalled()
expect(result.current.fullPlaceOf(A_PARK)).toBeUndefined()
})
it('FE-MOB-PLTL-028: resolves the full pool entry behind a timeline stop', async () => {
const { result } = await renderTimeline(makePlanner())
expect(result.current.fullPlaceOf(A_CAFE)).toBe(CAFE)
})
it('FE-MOB-PLTL-029: opens a blank place form from the add bar', async () => {
const planner = makePlanner()
const { result } = await renderTimeline(planner)
act(() => { result.current.addPlace() })
expect(planner.setEditingPlace).toHaveBeenCalledWith(null)
expect(planner.setEditingAssignmentId).toHaveBeenCalledWith(null)
expect(planner.setPrefillCoords).toHaveBeenCalledWith(null)
expect(planner.setShowPlaceForm).toHaveBeenCalledWith(true)
})
it('FE-MOB-PLTL-030: opens blank booking and transport forms from the add bar', async () => {
const planner = makePlanner()
const { result } = await renderTimeline(planner)
act(() => {
result.current.addBooking()
result.current.addTransport()
})
expect(planner.setEditingReservation).toHaveBeenCalledWith(null)
expect(planner.setBookingForAssignmentId).toHaveBeenCalledWith(null)
expect(planner.setShowReservationModal).toHaveBeenCalledWith(true)
expect(planner.setEditingTransport).toHaveBeenCalledWith(null)
expect(planner.setTransitPrefill).toHaveBeenCalledWith(null)
expect(planner.setTransportModalAutomated).toHaveBeenCalledWith(false)
expect(planner.setTransportModalDayId).toHaveBeenCalledWith(2)
expect(planner.setShowTransportModal).toHaveBeenCalledWith(true)
})
it('FE-MOB-PLTL-031: edits a transport row through the transport modal and anything else through the booking modal', async () => {
const bus = buildReservation({ id: 51, type: 'bus', title: 'Bus 13A', day_id: 2 })
const dinner = buildReservation({ id: 52, type: 'restaurant', title: 'Dinner', day_id: 2 })
const planner = makePlanner({ reservations: [bus, dinner] })
const { result } = await renderTimeline(planner)
// The row carries a leg-expanded copy; the full reservation is looked up by id.
act(() => { result.current.editTransport({ ...bus, __leg: { index: 0, total: 2, from: null, to: null } } as TransportEntry) })
expect(planner.setEditingTransport).toHaveBeenCalledWith(bus)
expect(planner.setShowTransportModal).toHaveBeenCalledWith(true)
act(() => { result.current.editTransport(dinner as TransportEntry) })
expect(planner.setEditingReservation).toHaveBeenCalledWith(dinner)
expect(planner.setShowReservationModal).toHaveBeenCalledWith(true)
// A row whose booking is no longer in the pool edits the row itself.
const orphan = buildReservation({ id: 99, type: 'taxi', title: 'Taxi', day_id: 2 }) as TransportEntry
act(() => { result.current.editTransport(orphan) })
expect(planner.setEditingTransport).toHaveBeenLastCalledWith(orphan)
})
it('FE-MOB-PLTL-032: opens the journey view for a transit row, falling back to the passed row', async () => {
const transit = buildReservation({ id: 61, type: 'transit', title: 'U2', day_id: 2 })
const planner = makePlanner({ reservations: [transit] })
const { result } = await renderTimeline(planner)
act(() => { result.current.openTransitJourney({ ...transit, title: 'stale' } as TransportEntry) })
expect(planner.setTransitJourney).toHaveBeenCalledWith(transit)
const orphan = buildReservation({ id: 99, type: 'transit', title: 'Gone', day_id: 2 }) as TransportEntry
act(() => { result.current.openTransitJourney(orphan) })
expect(planner.setTransitJourney).toHaveBeenLastCalledWith(orphan)
})
it('FE-MOB-PLTL-033: optimizes the day order and offers an undo', async () => {
const planner = makePlanner()
const { result } = await renderTimeline(planner)
await act(async () => { await result.current.optimize() })
expect(planner.tripActions.reorderAssignments).toHaveBeenCalledWith(1, 2, [11, 13, 12])
expect(planner.pushUndo).toHaveBeenCalledWith('undo.optimize', expect.any(Function))
expect(planner.updateRouteForDay).toHaveBeenCalledWith(2)
expect(planner.toast.success).toHaveBeenCalledWith('dayplan.toast.routeOptimized')
const undo = vi.mocked(planner.pushUndo).mock.calls[0][1] as () => Promise<void>
await undo()
expect(planner.tripActions.reorderAssignments).toHaveBeenLastCalledWith(1, 2, [11, 12, 13])
})
it('FE-MOB-PLTL-034: reports when the accommodation anchored the optimized route', async () => {
const planner = makePlanner({ tripAccommodations: [HOTEL] })
const { result } = await renderTimeline(planner)
await act(async () => { await result.current.optimize() })
expect(planner.toast.success).toHaveBeenCalledWith('dayplan.toast.routeOptimizedFromHotel')
})
it('FE-MOB-PLTL-035: refuses to optimize fewer than two movable stops', async () => {
const planner = makePlanner({ assignments: { '2': [A_MUSEUM] } })
const { result } = await renderTimeline(planner)
await act(async () => { await result.current.optimize() })
expect(planner.toast.info).toHaveBeenCalledWith('dayplan.toast.needTwoPlaces')
expect(planner.tripActions.reorderAssignments).not.toHaveBeenCalled()
})
it('FE-MOB-PLTL-036: reports a failed optimize as an error toast', async () => {
const actions = buildTripActions()
actions.reorderAssignments.mockRejectedValueOnce(new Error('server down'))
const planner = makePlanner({ tripActions: actions })
const { result } = await renderTimeline(planner)
await act(async () => { await result.current.optimize() })
expect(planner.toast.error).toHaveBeenCalledWith('server down')
expect(planner.toast.success).not.toHaveBeenCalled()
actions.reorderAssignments.mockRejectedValue('nope')
await act(async () => { await result.current.optimize() })
expect(planner.toast.error).toHaveBeenLastCalledWith('trip.toast.reorderError')
})
it('FE-MOB-PLTL-037: exports the day as a Google Maps route', async () => {
const { result } = await renderTimeline(makePlanner())
act(() => { result.current.exportGoogleMaps() })
expect(window.open).toHaveBeenCalledWith(
'https://www.google.com/maps/dir/48,16.1/48,16.3/48,16.2', '_blank', 'noopener,noreferrer',
)
})
it('FE-MOB-PLTL-038: opens nothing when the day has no located stops', async () => {
const vague = buildAssignment({
id: 14, day_id: 2, order_index: 0, place_id: 104,
place: buildPlace({ id: 104, name: 'Idea', lat: null, lng: null }),
})
const { result } = await renderTimeline(makePlanner({ assignments: { '2': [vague] } }))
act(() => { result.current.exportGoogleMaps() })
expect(window.open).not.toHaveBeenCalled()
})
it('FE-MOB-PLTL-039: renames the day with a trimmed title', async () => {
const planner = makePlanner()
const { result } = await renderTimeline(planner)
act(() => { result.current.renameDay(' Old Town ') })
expect(planner.handleUpdateDayTitle).toHaveBeenCalledWith(2, 'Old Town')
})
it('FE-MOB-PLTL-040: offers the built-in travel modes plus every plugin profile', async () => {
usePluginStore.setState({
plugins: [
{ id: 'ev', name: 'EV', type: 'integration', icon: null, routeProfiles: [{ id: 'eco', label: 'Eco' }, { id: 'fast', label: 'Fast' }] },
{ id: 'plain', name: 'Plain', type: 'integration', icon: null },
] as ActivePlugin[],
})
const { result } = await renderTimeline(makePlanner())
expect(result.current.routeModeOptions).toEqual([
{ key: 'driving', label: 'Driving' },
{ key: 'walking', label: 'Walking' },
{ key: 'plugin:ev/eco', label: 'Eco' },
{ key: 'plugin:ev/fast', label: 'Fast' },
])
})
it('FE-MOB-PLTL-041: sets a leg mode optimistically and persists it', async () => {
seedStore(useTripStore, { assignments: { '2': [A_MUSEUM, A_PARK] } })
const { result } = await renderTimeline(makePlanner())
await act(async () => { result.current.setLegMode(11, 'walking') })
expect(useTripStore.getState().assignments['2'].map(a => a.leg_transport_mode)).toEqual(['walking', undefined])
expect(assignmentsApi.updateTransport).toHaveBeenCalledWith(1, 11, 'walking')
})
it('FE-MOB-PLTL-042: reverts to the server state when the leg mode fails to save', async () => {
seedStore(useTripStore, { assignments: { '2': [A_MUSEUM] } })
vi.mocked(assignmentsApi.updateTransport).mockRejectedValue(new Error('conflict'))
const planner = makePlanner()
const { result } = await renderTimeline(planner)
await act(async () => { result.current.setLegMode(11, null) })
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('conflict'))
expect(planner.tripActions.refreshDays).toHaveBeenCalledWith(1)
})
// The store may hold no assignments for the day yet (nothing loaded) — the call still goes out.
it('FE-MOB-PLTL-043: uses the generic message for a non-Error leg-mode failure', async () => {
vi.mocked(assignmentsApi.updateTransport).mockRejectedValue('boom')
const planner = makePlanner()
const { result } = await renderTimeline(planner)
await act(async () => { result.current.setLegMode(11, 'driving') })
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.unknownError'))
})
})