diff --git a/client/src/mobile/screens/trip/places/placesBrowserModel.test.ts b/client/src/mobile/screens/trip/places/placesBrowserModel.test.ts index 6bee97075..f1f18b527 100644 --- a/client/src/mobile/screens/trip/places/placesBrowserModel.test.ts +++ b/client/src/mobile/screens/trip/places/placesBrowserModel.test.ts @@ -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 => ({ 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) + }) +}) diff --git a/client/tests/helpers/mobileTrip.ts b/client/tests/helpers/mobileTrip.ts new file mode 100644 index 000000000..b6000d3f0 --- /dev/null +++ b/client/tests/helpers/mobileTrip.ts @@ -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> { + 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> = {}; + for (const name of names) actions[name] = vi.fn(async () => undefined); + return actions; +} + +export function buildToast(): Record> { + return { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }; +} + +/** + * `t` echoes the key so assertions stay independent of the English copy. + * Pass a real translation fn through overrides when a test needs visible text. + */ +const echoT = (key: string, params?: Record): string => + params ? `${key}:${Object.values(params).join(',')}` : key; + +export function buildPlanner(overrides: Partial = {}): TripPlanner { + const trip = buildTrip({ id: 1, title: 'Japan 2026', currency: 'EUR' }); + + const base = { + tripId: 1, + navigate: vi.fn(), + toast: buildToast(), + t: echoT, + language: 'en', + settings: { time_format: '24h', date_format: 'DD.MM.YYYY', default_currency: 'EUR', distance_unit: 'km' }, + placesPhotosEnabled: false, + + trip, + days: [], + places: [], + assignments: [], + 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(), + 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(), + dayPlaces: [], + + mapTileUrl: 'https://tile.example/{z}/{x}/{y}.png', + fontStyle: {}, + splashDone: true, + }; + + return { ...base, ...overrides } as unknown as TripPlanner; +} + +export function buildShell(overrides: Partial = {}): MTripShellApi { + const base: MTripShellApi = { + view: 'plan', + 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 }; +} diff --git a/client/tests/unit/mobile/trip/ImpFileStep.test.tsx b/client/tests/unit/mobile/trip/ImpFileStep.test.tsx new file mode 100644 index 000000000..89b822802 --- /dev/null +++ b/client/tests/unit/mobile/trip/ImpFileStep.test.tsx @@ -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([''], name, { type: 'application/gpx+xml' }) +} + +function kmlFile(name = 'places.kml') { + return new File([''], name, { type: 'application/vnd.google-earth.kml+xml' }) +} + +function renderStep(plannerOverrides: Partial = {}) { + const planner = buildPlanner(plannerOverrides) + const onBack = vi.fn() + const onDone = vi.fn() + render() + 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 + 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 + 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) + }) +}) diff --git a/client/tests/unit/mobile/trip/ImpListStep.test.tsx b/client/tests/unit/mobile/trip/ImpListStep.test.tsx new file mode 100644 index 000000000..c437c9518 --- /dev/null +++ b/client/tests/unit/mobile/trip/ImpListStep.test.tsx @@ -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 = {}) { + const planner = buildPlanner(plannerOverrides) + const onBack = vi.fn() + const onDone = vi.fn() + render() + 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 + 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 + 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) + }) +}) diff --git a/client/tests/unit/mobile/trip/MAccommodationSheet.test.tsx b/client/tests/unit/mobile/trip/MAccommodationSheet.test.tsx new file mode 100644 index 000000000..eef8b0ac1 --- /dev/null +++ b/client/tests/unit/mobile/trip/MAccommodationSheet.test.tsx @@ -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 }[] + }) => ( + + ), +})) + +vi.mock('../../../../src/components/shared/CustomTimePicker', () => ({ + default: ({ value, onChange, placeholder }: { + value: string + onChange: (v: string) => void + placeholder?: string + }) => ( + 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 = {}) { + return buildPlanner({ + tripId: 5, + days: DAYS, + places: PLACES, + categories: CATEGORIES, + tripAccommodations: [EXISTING], + ...overrides, + } as never) +} + +function makeShell(payload: Record | null = { dayId: 11 }) { + return buildShell({ sheet: payload === null ? null : { id: 'accommodation', payload } } as never) +} + +function setup(planner = makePlanner(), shell = makeShell()) { + const view = render() + 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(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() + }) +}) diff --git a/client/tests/unit/mobile/trip/MBagsSheet.test.tsx b/client/tests/unit/mobile/trip/MBagsSheet.test.tsx new file mode 100644 index 000000000..26a2d977f --- /dev/null +++ b/client/tests/unit/mobile/trip/MBagsSheet.test.tsx @@ -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 { + return { + id: 1, trip_id: 1, name: 'Backpack', color: '#6366f1', sort_order: 0, + ...overrides, + } as PackingBag +} + +function item(overrides: Partial = {}): 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> = {}) { + const props: ComponentProps = { + 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() + 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('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() + }) +}) diff --git a/client/tests/unit/mobile/trip/MBookingsTab.test.tsx b/client/tests/unit/mobile/trip/MBookingsTab.test.tsx new file mode 100644 index 000000000..57e92beff --- /dev/null +++ b/client/tests/unit/mobile/trip/MBookingsTab.test.tsx @@ -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()), + 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 = {}) { + 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() + 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) + }) +}) diff --git a/client/tests/unit/mobile/trip/MBrowseActionsSheet.test.tsx b/client/tests/unit/mobile/trip/MBrowseActionsSheet.test.tsx new file mode 100644 index 000000000..9b0531e82 --- /dev/null +++ b/client/tests/unit/mobile/trip/MBrowseActionsSheet.test.tsx @@ -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 = {}, shellOverrides: Partial = {}) { + const planner = buildPlanner({ places: [PLACE], days: DAYS, ...plannerOverrides }) + const shell = buildShell({ sheet: { id: 'bract', payload: { placeId: 77 } }, ...shellOverrides }) + const view = render() + 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() + 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() + 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) + }) +}) diff --git a/client/tests/unit/mobile/trip/MCollabChat.test.tsx b/client/tests/unit/mobile/trip/MCollabChat.test.tsx new file mode 100644 index 000000000..752c505d1 --- /dev/null +++ b/client/tests/unit/mobile/trip/MCollabChat.test.tsx @@ -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 { + 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[] = [] + +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) => ChatMessage, fails = false) { + server.use( + http.post('/api/trips/:tripId/collab/messages', async ({ request }) => { + const body = (await request.json()) as Record + 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() + 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() + 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) + }) +}) diff --git a/client/tests/unit/mobile/trip/MCollabNotes.test.tsx b/client/tests/unit/mobile/trip/MCollabNotes.test.tsx new file mode 100644 index 000000000..a32e84207 --- /dev/null +++ b/client/tests/unit/mobile/trip/MCollabNotes.test.tsx @@ -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 }) => {children}, +})) +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 { + 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[] = [] +let updatedBodies: { id: string; body: Record }[] = [] +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 + 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 + 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() + 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() + 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) + }) +}) diff --git a/client/tests/unit/mobile/trip/MCollabPolls.test.tsx b/client/tests/unit/mobile/trip/MCollabPolls.test.tsx new file mode 100644 index 000000000..72f4ca097 --- /dev/null +++ b/client/tests/unit/mobile/trip/MCollabPolls.test.tsx @@ -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 { + 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 = {}) { + const toast = buildToast() + vi.spyOn(collabApi, 'getPolls').mockResolvedValue({ polls }) + const planner = buildPlanner({ toast: toast as unknown as TripPlanner['toast'], ...plannerOverrides }) + const view = render() + 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() + + 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() + + 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() + + 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() + 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('')) + }) +}) diff --git a/client/tests/unit/mobile/trip/MCollabTab.test.tsx b/client/tests/unit/mobile/trip/MCollabTab.test.tsx new file mode 100644 index 000000000..ff2f74a3c --- /dev/null +++ b/client/tests/unit/mobile/trip/MCollabTab.test.tsx @@ -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 }) =>
{planner.tripId}
, +})) +vi.mock('../../../../src/mobile/screens/trip/tabs/MCollabNotes', () => ({ + default: ({ planner }: { planner: TripPlanner }) =>
{planner.tripId}
, +})) +vi.mock('../../../../src/mobile/screens/trip/tabs/MCollabPolls', () => ({ + default: ({ planner }: { planner: TripPlanner }) =>
{planner.tripId}
, +})) + +function renderTab(collabTab: MTripCollabTab, features: Partial> = {}) { + const planner = buildPlanner({ + collabFeatures: { chat: true, notes: true, polls: true, ...features } as TripPlanner['collabFeatures'], + }) + return render() +} + +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() + }) +}) diff --git a/client/tests/unit/mobile/trip/MCostSheet.test.tsx b/client/tests/unit/mobile/trip/MCostSheet.test.tsx new file mode 100644 index 000000000..b0b18bbd4 --- /dev/null +++ b/client/tests/unit/mobile/trip/MCostSheet.test.tsx @@ -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 +let updateBudgetItem: ReturnType +let deleteBudgetItem: ReturnType +let addToast: ReturnType + +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( + , + ) + 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, + ) + 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() + }) +}) diff --git a/client/tests/unit/mobile/trip/MCostsTab.test.tsx b/client/tests/unit/mobile/trip/MCostsTab.test.tsx new file mode 100644 index 000000000..ec16045d5 --- /dev/null +++ b/client/tests/unit/mobile/trip/MCostsTab.test.tsx @@ -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 + }) => ( +
+ {editing ? `sheet editing ${editing.id}` : `sheet new ${base}`} + + +
+ ), +})) + +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 = {}) { + 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() + 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() + 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() + 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() + 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() + 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() + await screen.findByText('+£12.50') + expect(settlementBases).toEqual(['GBP']) + expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('£92.50') + }) +}) diff --git a/client/tests/unit/mobile/trip/MDaySheet.test.tsx b/client/tests/unit/mobile/trip/MDaySheet.test.tsx new file mode 100644 index 000000000..465e43ba8 --- /dev/null +++ b/client/tests/unit/mobile/trip/MDaySheet.test.tsx @@ -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 = { '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 = {}) { + 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 = {}) { + 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() + 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() + 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() + }) +}) diff --git a/client/tests/unit/mobile/trip/MDaysSheet.test.tsx b/client/tests/unit/mobile/trip/MDaysSheet.test.tsx new file mode 100644 index 000000000..f2b14375f --- /dev/null +++ b/client/tests/unit/mobile/trip/MDaysSheet.test.tsx @@ -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 = {}, shellOverrides: Partial = {}) { + const planner = buildPlanner({ days: DAYS, ...plannerOverrides }) + const shell = buildShell({ sheet: { id: 'days' }, ...shellOverrides }) + render() + 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) + }) +}) diff --git a/client/tests/unit/mobile/trip/MExportSheet.test.tsx b/client/tests/unit/mobile/trip/MExportSheet.test.tsx new file mode 100644 index 000000000..a0b1dc348 --- /dev/null +++ b/client/tests/unit/mobile/trip/MExportSheet.test.tsx @@ -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 }) => ( +
+ +
+ ), +})) + +const DAYS = [{ id: 2, trip_id: 1, day_number: 1, date: '2026-05-01', title: null }] as unknown as Day[] + +const NOTES: Record = { + '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 = {}, sheetId: string | null = 'export') { + const planner = buildPlanner({ days: DAYS, ...plannerOverrides }) + const shell = buildShell({ sheet: sheetId ? { id: sheetId } : null }) + const view = render() + 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(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) + }) +}) diff --git a/client/tests/unit/mobile/trip/MFileLightbox.test.tsx b/client/tests/unit/mobile/trip/MFileLightbox.test.tsx new file mode 100644 index 000000000..7165e08c6 --- /dev/null +++ b/client/tests/unit/mobile/trip/MFileLightbox.test.tsx @@ -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 }) =>
, +})) + +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( + , + ) + 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() + }) +}) diff --git a/client/tests/unit/mobile/trip/MFileLinkSheet.test.tsx b/client/tests/unit/mobile/trip/MFileLinkSheet.test.tsx new file mode 100644 index 000000000..7fbbc3370 --- /dev/null +++ b/client/tests/unit/mobile/trip/MFileLinkSheet.test.tsx @@ -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 { + 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 = {}) { + const planner = buildPlanner({ + places: PLACES, reservations: RESERVATIONS, ...plannerOverrides, + } as unknown as Partial) + const onClose = vi.fn() + const view = render() + 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) + 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) + const { rerender } = render() + rerender() + expect(screen.getByText('ticket.pdf')).toBeInTheDocument() + }) +}) diff --git a/client/tests/unit/mobile/trip/MFileMenuSheet.test.tsx b/client/tests/unit/mobile/trip/MFileMenuSheet.test.tsx new file mode 100644 index 000000000..714194ff6 --- /dev/null +++ b/client/tests/unit/mobile/trip/MFileMenuSheet.test.tsx @@ -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 { + 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 = {}) { + const planner = buildPlanner(plannerOverrides) + const onClose = vi.fn() + const onOpenLinks = vi.fn() + const view = render( + , + ) + 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) + 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) + 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( + , + ) + rerender() + expect(screen.getByText('ticket.pdf')).toBeInTheDocument() + + rerender( + , + ) + expect(screen.getByText('map.png')).toBeInTheDocument() + expect(screen.getByPlaceholderText('files.notePlaceholder')).toHaveValue('Old town') + }) +}) diff --git a/client/tests/unit/mobile/trip/MFileTrashSheet.test.tsx b/client/tests/unit/mobile/trip/MFileTrashSheet.test.tsx new file mode 100644 index 000000000..6f46143a6 --- /dev/null +++ b/client/tests/unit/mobile/trip/MFileTrashSheet.test.tsx @@ -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 = {}) { + const planner = buildPlanner(plannerOverrides) + const onClose = vi.fn() + const view = render() + 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) + 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() + }) +}) diff --git a/client/tests/unit/mobile/trip/MFilesTab.test.tsx b/client/tests/unit/mobile/trip/MFilesTab.test.tsx new file mode 100644 index 000000000..7b99e3dfb --- /dev/null +++ b/client/tests/unit/mobile/trip/MFilesTab.test.tsx @@ -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 ? ( +
+ + +
+ ) : null, +})) +vi.mock('../../../../src/mobile/screens/trip/tabs/MFileLinkSheet', () => ({ + default: ({ file, onClose }: { file: TripFile | null; onClose: () => void }) => file ? ( +
+ +
+ ) : null, +})) +vi.mock('../../../../src/mobile/screens/trip/tabs/MFileTrashSheet', () => ({ + default: ({ open, onClose }: { open: boolean; onClose: () => void }) => open ? ( +
+ +
+ ) : null, +})) +vi.mock('../../../../src/mobile/screens/trip/tabs/MFileLightbox', () => ({ + default: ({ files, index, onIndexChange, onClose }: { + files: TripFile[] + index: number + onIndexChange: (i: number) => void + onClose: () => void + }) => ( +
+ {files[index]?.original_name} + + +
+ ), +})) + +const PLACES = [{ id: 11, name: 'Fushimi Inari' }] as unknown as Place[] + +function tripFile(overrides: Partial): 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 = {}, shellOverrides: Partial = {}) { + const planner = buildPlanner({ files: FILES, places: PLACES, ...plannerOverrides } as unknown as Partial) + const shell = buildShell(shellOverrides) + const view = render() + const rerenderWith = (nextShell: Partial) => + view.rerender() + 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) + 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) + 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) + 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) + 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) + 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) + 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() + }) +}) diff --git a/client/tests/unit/mobile/trip/MImportSheet.test.tsx b/client/tests/unit/mobile/trip/MImportSheet.test.tsx new file mode 100644 index 000000000..c3017ee2e --- /dev/null +++ b/client/tests/unit/mobile/trip/MImportSheet.test.tsx @@ -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() + 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() + rerender() + 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([''], 'route.gpx')] } }) + fireEvent.click(screen.getByRole('button', { name: 'common.import' })) + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)) + }) +}) diff --git a/client/tests/unit/mobile/trip/MMehrSheet.test.tsx b/client/tests/unit/mobile/trip/MMehrSheet.test.tsx new file mode 100644 index 000000000..c44bc37d5 --- /dev/null +++ b/client/tests/unit/mobile/trip/MMehrSheet.test.tsx @@ -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 = {}, shellOverrides: Partial = {}) { + const planner = buildPlanner({ + TRIP_TABS: TABS as TripPlanner['TRIP_TABS'], + files: FILES, + tripMembers: MEMBERS, + ...plannerOverrides, + }) + const shell = buildShell({ sheet: { id: 'mehr' }, ...shellOverrides }) + render() + 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') + }) +}) diff --git a/client/tests/unit/mobile/trip/MNoteSheet.test.tsx b/client/tests/unit/mobile/trip/MNoteSheet.test.tsx new file mode 100644 index 000000000..a2370b697 --- /dev/null +++ b/client/tests/unit/mobile/trip/MNoteSheet.test.tsx @@ -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 + 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( + , + ) + 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( + , + ) + rerender() + // 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( + , + ) + rerender() + + 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') + }) +}) diff --git a/client/tests/unit/mobile/trip/MPackItemSheet.test.tsx b/client/tests/unit/mobile/trip/MPackItemSheet.test.tsx new file mode 100644 index 000000000..dda837593 --- /dev/null +++ b/client/tests/unit/mobile/trip/MPackItemSheet.test.tsx @@ -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 { + 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> = {}, items: PackingItem[] = [packItem()]) { + const planner = buildPlanner({ tripId: 3, packingItems: items }) + const props: ComponentProps = { + planner, + open: true, + itemId: 1, + bagTrackingEnabled: true, + tripMembers: [ANNA, BEN], + currentUserId: ME, + onClose: vi.fn(), + ...overrides, + } + const view = render() + 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 + 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() + + 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) + }) +}) diff --git a/client/tests/unit/mobile/trip/MPackingImportSheet.test.tsx b/client/tests/unit/mobile/trip/MPackingImportSheet.test.tsx new file mode 100644 index 000000000..4d97b38da --- /dev/null +++ b/client/tests/unit/mobile/trip/MPackingImportSheet.test.tsx @@ -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 { + 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() + 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() + 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() + }) +}) diff --git a/client/tests/unit/mobile/trip/MPackingListTab.test.tsx b/client/tests/unit/mobile/trip/MPackingListTab.test.tsx new file mode 100644 index 000000000..8c6da6a9c --- /dev/null +++ b/client/tests/unit/mobile/trip/MPackingListTab.test.tsx @@ -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 { + 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 + 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() }) + 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 { + return fn as ReturnType +} + +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 }) + 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 }) + 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 }) + 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 }) + + 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 }) + + 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 }) + + 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()) + }) +}) diff --git a/client/tests/unit/mobile/trip/MPlaceEditSheet.test.tsx b/client/tests/unit/mobile/trip/MPlaceEditSheet.test.tsx new file mode 100644 index 000000000..b942b02e7 --- /dev/null +++ b/client/tests/unit/mobile/trip/MPlaceEditSheet.test.tsx @@ -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 = {}) { + const planner = buildPlanner({ showPlaceForm: true, categories: CATEGORIES, ...overrides }) + const view = render() + 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(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[] = [] + server.use(http.post('/api/maps/autocomplete', async ({ request }) => { + bodies.push(await request.json() as Record) + 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() + }) +}) diff --git a/client/tests/unit/mobile/trip/MPlaceSheet.test.tsx b/client/tests/unit/mobile/trip/MPlaceSheet.test.tsx new file mode 100644 index 000000000..b9db692b8 --- /dev/null +++ b/client/tests/unit/mobile/trip/MPlaceSheet.test.tsx @@ -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 = {}) { + 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 = {}) { + 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() + 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() + }) +}) diff --git a/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx b/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx new file mode 100644 index 000000000..5bba47519 --- /dev/null +++ b/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx @@ -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 { + 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 { + return buildPlanner({ + places: PLACES, + categories: CATEGORIES, + assignments: ASSIGNMENTS, + days: DAYS, + ...overrides, + } as Partial) +} + +function renderBrowser(planner: TripPlanner = makePlanner(), shell: MTripShellApi = buildShell()) { + const view = render() + 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)) + 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) + 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)) + 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), 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()} shell={shell} />) + }) + expect(screen.getByText('places.selectionCount:2')).toBeInTheDocument() + }) +}) diff --git a/client/tests/unit/mobile/trip/MPlacesBulkCategorySheet.test.tsx b/client/tests/unit/mobile/trip/MPlacesBulkCategorySheet.test.tsx new file mode 100644 index 000000000..8741fe840 --- /dev/null +++ b/client/tests/unit/mobile/trip/MPlacesBulkCategorySheet.test.tsx @@ -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( + , + ) + 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() + }) +}) diff --git a/client/tests/unit/mobile/trip/MPlacesSaveToCollectionSheet.test.tsx b/client/tests/unit/mobile/trip/MPlacesSaveToCollectionSheet.test.tsx new file mode 100644 index 000000000..d7d8a1af7 --- /dev/null +++ b/client/tests/unit/mobile/trip/MPlacesSaveToCollectionSheet.test.tsx @@ -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 { + 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 + +function renderSheet(props: Partial[0]> = {}) { + const onClose = vi.fn() + const onDone = vi.fn() + const view = render( + , + ) + 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() + }) +}) diff --git a/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx b/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx new file mode 100644 index 000000000..fc49ed462 --- /dev/null +++ b/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx @@ -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, + 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() + 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 = {}): 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(), + 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 = {}, + plannerOver: Partial = {}, + shellOver: Partial = {}, +) { + mocks.tl = buildTl(tlOver) as unknown as Record + const planner = buildPlanner(plannerOver) + const shell = buildShell(shellOver) + return { planner, shell, ...render() } +} + +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) + }) + }) +}) diff --git a/client/tests/unit/mobile/trip/MPlanTimelineRows.test.tsx b/client/tests/unit/mobile/trip/MPlanTimelineRows.test.tsx new file mode 100644 index 000000000..1f13f145d --- /dev/null +++ b/client/tests/unit/mobile/trip/MPlanTimelineRows.test.tsx @@ -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 = reorder + +function assignment(over: Record = {}): 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() + + 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() + + 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( +
+ +
, + ) + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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( + , + ) + + 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() + + 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() + expect(container.textContent).toContain('09:00') + expect(container.textContent).not.toContain('–') + expect(screen.getByText('Hertz Shinjuku')).toBeInTheDocument() + unmount() + + const dropOff = render() + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + // 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + expect(screen.getByText('1 h 5 min')).toBeInTheDocument() + }) + + it('FE-MOB-PLROW-029: a plugin leg uses its own duration text and note', () => { + render() + + 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() + + fireEvent.click(screen.getByRole('button')) + + expect(onTap).toHaveBeenCalledTimes(1) + }) +}) + +describe('PlanScheduleRow', () => { + const item = (over: Partial = {}): 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() + + 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( + , + ) + + 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() + 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() + .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 = {}): 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() + + 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() + + 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() + + 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() + fireEvent.click(screen.getByText('Buy museum tickets')) + expect(onEdit).not.toHaveBeenCalled() + unmount() + + render() + fireEvent.click(screen.getByText('Buy museum tickets')) + + expect(onEdit).toHaveBeenCalledTimes(1) + expect(screen.getByTestId('reorder')).toBeInTheDocument() + }) +}) diff --git a/client/tests/unit/mobile/trip/MReservationSheet.test.tsx b/client/tests/unit/mobile/trip/MReservationSheet.test.tsx new file mode 100644 index 000000000..18a1d808c --- /dev/null +++ b/client/tests/unit/mobile/trip/MReservationSheet.test.tsx @@ -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 + }) => ( + + ), +})) + +vi.mock('../../../../src/components/shared/CustomTimePicker', () => ({ + default: ({ value, onChange, placeholder = '00:00' }: { + value: string + onChange: (v: string) => void + placeholder?: string + }) => ( + 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 + }) => ( + 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 + +function makePlanner(overrides: Record = {}) { + 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() + 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) { + const fn = planner.handleSaveReservation as unknown as ReturnType + return fn.mock.calls[0][0] as Record +} + +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') + }) +}) diff --git a/client/tests/unit/mobile/trip/MTaskSheet.test.tsx b/client/tests/unit/mobile/trip/MTaskSheet.test.tsx new file mode 100644 index 000000000..166890264 --- /dev/null +++ b/client/tests/unit/mobile/trip/MTaskSheet.test.tsx @@ -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 { + 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( + , + ) + 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(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( + , + ) + + 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( + , + ) + + expect(screen.getByPlaceholderText('todo.namePlaceholder')).toHaveValue('Renew passport') + expect(screen.getByPlaceholderText('todo.descriptionPlaceholder')).toHaveValue('') + }) +}) diff --git a/client/tests/unit/mobile/trip/MTodoListTab.test.tsx b/client/tests/unit/mobile/trip/MTodoListTab.test.tsx new file mode 100644 index 000000000..ff2b6ae2b --- /dev/null +++ b/client/tests/unit/mobile/trip/MTodoListTab.test.tsx @@ -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) => ( +
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 { + 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 = {}) { + const actions = buildTripActions() + const planner = buildPlanner({ + todoItems: items, + tripMembers: MEMBERS, + tripActions: actions as unknown as TripPlanner['tripActions'], + ...plannerOverrides, + }) + const view = render() + 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') + }) +}) diff --git a/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx b/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx new file mode 100644 index 000000000..fb59e9d8d --- /dev/null +++ b/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx @@ -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, + 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, +})) + +vi.mock('../../../../src/components/shared/CustomSelect', () => ({ + default: ({ value, onChange, options = [] }: { + value: string | number + onChange: (v: string | number) => void + options?: { value: string | number; label: string }[] + }) => ( + + ), +})) + +vi.mock('../../../../src/components/shared/CustomTimePicker', () => ({ + default: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => ( + onChange(e.target.value)} /> + ), +})) + +vi.mock('../../../../src/components/Planner/AirportSelect', () => ({ + default: ({ value, onChange }: { value: { iata: string } | null; onChange: (a: unknown) => void }) => ( + onChange(AIRPORTS[e.target.value] ?? null)} + /> + ), +})) + +vi.mock('../../../../src/components/Planner/LocationSelect', () => ({ + default: ({ value, onChange }: { value: { name: string } | null; onChange: (l: unknown) => void }) => ( + 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) => void + initialFrom: { name: string } | null + }) => ( +
+ {day.id} + {places.map(p => p.name).join(',')} + {initialFrom?.name ?? ''} + +
+ ), +})) + +vi.mock('../../../../src/mobile/screens/trip/sheets/PlFileAttach', () => ({ + default: ({ files, onAdd, onRemove }: { files: File[]; onAdd: (f: File[]) => void; onRemove: (i: number) => void }) => ( +
+ + {files.map((f, i) => ( + + ))} +
+ ), +})) + +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 & { title: string } + +function makeSave(result: unknown = { id: 99 } as unknown) { + return vi.fn(async (_payload: SavePayload) => result) +} + +function makePlanner(overrides: Record = {}): 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[0]) +} + +function renderSheet(planner: TripPlanner, onOpenExpense = vi.fn()) { + const view = render() + 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[] }).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']) + }) +}) diff --git a/client/tests/unit/mobile/trip/MTransportSheet.test.tsx b/client/tests/unit/mobile/trip/MTransportSheet.test.tsx new file mode 100644 index 000000000..28da4cc5c --- /dev/null +++ b/client/tests/unit/mobile/trip/MTransportSheet.test.tsx @@ -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 = {}) { + return buildPlanner({ + reservations: [FLIGHT], + files: [], + // The real planner exposes the visible connection ids as an array. + visibleConnections: [], + ...overrides, + } as unknown as Parameters[0]) +} + +function makeShell(overrides: Record = {}) { + return buildShell({ sheet: { id: 'transport', payload: { reservationId: 7 } }, ...overrides } as unknown as Parameters[0]) +} + +describe('MTransportSheet', () => { + beforeEach(() => { + resetAllStores() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('FE-MOB-TRSH-001: renders nothing when the payload points at an unknown reservation', () => { + render() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('FE-MOB-TRSH-002: stays closed while another sheet id is active', () => { + render() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('FE-MOB-TRSH-003: shows title, airline meta and the endpoint route in the subline', () => { + render() + 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() + expect(screen.getByText('Fri, May 1')).toBeInTheDocument() + }) + + it('FE-MOB-TRSH-005: shows departure and arrival stat boxes labelled with the endpoint names', () => { + render() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + fireEvent.click(screen.getByText('#QW7788')) + expect(screen.getByText('#QW7788').className).not.toContain('blur-[4px]') + rerender() + rerender() + expect(screen.getByText('#QW7788').className).toContain('blur-[4px]') + }) + + it('FE-MOB-TRSH-013: renders the notes block only when the booking has notes', () => { + render() + expect(screen.getByText('Window seat requested')).toBeInTheDocument() + + const res = { ...FLIGHT, notes: null } as unknown as Reservation + render() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + expect(screen.getByText('FRA → HND')).toBeInTheDocument() + rerender() + expect(screen.getByText('FRA → HND')).toBeInTheDocument() + }) + + it('FE-MOB-TRSH-022: closes from the header button', () => { + const shell = makeShell() + render() + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(shell.closeSheet).toHaveBeenCalledTimes(1) + }) +}) diff --git a/client/tests/unit/mobile/trip/MTransportsTab.test.tsx b/client/tests/unit/mobile/trip/MTransportsTab.test.tsx new file mode 100644 index 000000000..4ea00ab67 --- /dev/null +++ b/client/tests/unit/mobile/trip/MTransportsTab.test.tsx @@ -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()), + 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 = {}) { + 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() + 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() + }) +}) diff --git a/client/tests/unit/mobile/trip/MTripLoadingSplash.test.tsx b/client/tests/unit/mobile/trip/MTripLoadingSplash.test.tsx new file mode 100644 index 000000000..3d27394d6 --- /dev/null +++ b/client/tests/unit/mobile/trip/MTripLoadingSplash.test.tsx @@ -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() + + 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() + + expect(screen.getByText('TREK')).toBeInTheDocument() + }) + + it('FE-MOB-SPLASH-003: walks the four beats in order, one every 1400ms', () => { + const { container } = render() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + expect(vi.getTimerCount()).toBe(before + 1) + + unmount() + + expect(vi.getTimerCount()).toBe(before) + }) +}) diff --git a/client/tests/unit/mobile/trip/MTripSheetUi.test.tsx b/client/tests/unit/mobile/trip/MTripSheetUi.test.tsx new file mode 100644 index 000000000..08a086602 --- /dev/null +++ b/client/tests/unit/mobile/trip/MTripSheetUi.test.tsx @@ -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(Stops) + const el = screen.getByText('Stops') + expect(el).toHaveClass('font-geist', 'text-m-faint') + }) + + it('FE-MOB-SHUI-003: Eyebrow appends the caller class', () => { + render(Stops) + expect(screen.getByText('Stops')).toHaveClass('uppercase') + }) + + it('FE-MOB-SHUI-004: TileHeader shows icon, title and subline', () => { + render( + } + 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(} 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(} 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() + 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() + expect(screen.getByText('ABC123')).toHaveClass('blur-[4px]', 'select-none') + }) + + it('FE-MOB-SHUI-009: StatBox forwards clicks', () => { + const onClick = vi.fn() + render() + 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() + 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() + 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() + 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') + }) +}) diff --git a/client/tests/unit/mobile/trip/MTripSheets.test.tsx b/client/tests/unit/mobile/trip/MTripSheets.test.tsx new file mode 100644 index 000000000..a75b4bfdf --- /dev/null +++ b/client/tests/unit/mobile/trip/MTripSheets.test.tsx @@ -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) => ( +
+ ) +} + +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 }) => ( +
+ +
+ ), +})) + +vi.mock('../../../../src/mobile/screens/trip/sheets/MImportSheet', () => ({ + default: ({ open, onClose }: { open: boolean; onClose: () => void }) => ( +
+ +
+ ), +})) + +vi.mock('../../../../src/mobile/screens/trip/sheets/MPlaceEditSheet', () => ({ + default: ({ planner }: StubProps) =>
, +})) + +vi.mock('../../../../src/mobile/screens/trip/sheets/MReservationSheet', () => ({ + default: ({ onOpenExpense }: { onOpenExpense: (req: BookingExpenseRequest) => void }) => ( +
+ + +
+ ), +})) + +vi.mock('../../../../src/mobile/screens/trip/sheets/MTransportFormSheet', () => ({ + default: ({ onOpenExpense }: { onOpenExpense: (req: BookingExpenseRequest) => void }) => ( +
+ +
+ ), +})) + +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 + }) => ( +
+ + +
+ ), +})) + +vi.mock('../../../../src/mobile/screens/settings/MConfirmSheet', () => ({ + default: ({ open, title, message, onClose, onConfirm }: { + open: boolean; title: string; message: ReactNode; onClose: () => void; onConfirm?: () => void + }) => + open ? ( +
+ {message} + + +
+ ) : null, +})) + +vi.mock('../../../../src/components/Planner/BookingImportModal', () => ({ + default: ({ isOpen, onClose, tripId }: { isOpen: boolean; onClose: () => void; tripId: number }) => ( +
+ +
+ ), +})) + +vi.mock('../../../../src/components/Planner/AirTrailImportModal', () => ({ + default: ({ isOpen, onClose, pushUndo }: { isOpen: boolean; onClose: () => void; pushUndo: unknown }) => ( +
+ +
+ ), +})) + +vi.mock('../../../../src/components/Trips/TripFormModal', () => ({ + default: ({ isOpen, onClose, onSave, trip, onCoverUpdate }: { + isOpen: boolean; onClose: () => void + onSave: (data: Record) => Promise + trip?: Trip | null + onCoverUpdate: (id: number, coverUrl: string) => void + }) => ( +
+ + + +
+ ), +})) + +vi.mock('../../../../src/components/Trips/TripMembersModal', () => ({ + default: ({ isOpen, onClose, tripTitle, onMembersChanged }: { + isOpen: boolean; onClose: () => void; tripTitle?: string; onMembersChanged: () => void + }) => ( +
+ + +
+ ), +})) + +vi.mock('../../../../src/components/Planner/TransitJourneyModal', () => ({ + default: ({ reservation, canEdit, onClose, onSave, onDelete, onChangeRoute }: { + reservation: Reservation + canEdit: boolean + onClose: () => void + onSave: (fields: Record) => Promise + onDelete: () => Promise + onChangeRoute: () => void + }) => ( +
+ + + + +
+ ), +})) + +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 = {}, shellOverrides: Partial = {}) { + const planner = buildPlanner(plannerOverrides) + const shell = buildShell(shellOverrides) + render() + 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() + 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() + 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() + }) +}) diff --git a/client/tests/unit/mobile/trip/MTripShell.test.tsx b/client/tests/unit/mobile/trip/MTripShell.test.tsx new file mode 100644 index 000000000..529a15665 --- /dev/null +++ b/client/tests/unit/mobile/trip/MTripShell.test.tsx @@ -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
+ } +} + +function TabSlot({ shell, tab }: { planner: TripPlanner; shell: MTripShellApi; tab: string }) { + shellApi = shell + return
{tab}
+} + +const TRIP_TABS = [ + { id: 'plan', label: 'Plan' }, + { id: 'transports', label: 'Transport' }, + { id: 'buchungen', label: 'Bookings' }, + { id: 'finanzplan', label: 'Budget' }, + { id: 'listen', label: 'Lists' }, + { id: 'dateien', label: 'Files' }, + { id: 'collab', label: 'Collaboration' }, +] + +const DAYS = [ + { id: 11, trip_id: 1, day_number: 1, date: '2026-05-02', title: null }, + { id: 12, trip_id: 1, day_number: 2, date: '2026-05-03', title: null }, +] as unknown as Day[] + +function todayIso(): string { + const now = new Date() + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` +} + +function renderShell(overrides: Partial = {}) { + mocks.planner = buildPlanner({ + days: DAYS, + selectedDayId: 11, + TRIP_TABS, + ...overrides, + } as Partial) + const view = render( + , + ) + return { ...view, planner: mocks.planner } +} + +const spy = (planner: TripPlanner, name: keyof TripPlanner) => + vi.mocked(planner[name] as unknown as ReturnType) + +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) + expect(screen.getByText('Japan 2026')).toBeInTheDocument() + expect(screen.queryByTestId('sheets')).not.toBeInTheDocument() + }) + + it('FE-MOB-SHELL-002: keeps the splash up until the photo warm-up finished', () => { + renderShell({ splashDone: false } as Partial) + expect(screen.getByText('Japan 2026')).toBeInTheDocument() + expect(screen.queryByTestId('map-area')).not.toBeInTheDocument() + }) + + it('FE-MOB-SHELL-003: renders nothing once loading finished without a trip', () => { + const { container } = renderShell({ trip: null } as Partial) + expect(container).toBeEmptyDOMElement() + }) + + it('FE-MOB-SHELL-003b: the splash falls back to the brand name before the trip arrives', () => { + renderShell({ isLoading: true, trip: null } as Partial) + expect(screen.getByText('TREK')).toBeInTheDocument() + }) + + it('FE-MOB-SHELL-004: seeds the running trip on today rather than on day one', () => { + const days = [ + { id: 11, day_number: 1, date: '2026-05-02' }, + { id: 12, day_number: 2, date: todayIso() }, + ] as unknown as Day[] + const { planner } = renderShell({ days, selectedDayId: null } as Partial) + expect(planner.tripActions.setSelectedDay).toHaveBeenCalledWith(12) + }) + + it('FE-MOB-SHELL-005: falls back to the first day when the trip is not running', () => { + const { planner } = renderShell({ selectedDayId: null } as Partial) + expect(planner.tripActions.setSelectedDay).toHaveBeenCalledWith(11) + }) + + it('FE-MOB-SHELL-006: leaves an existing day selection alone', () => { + 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) + 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) + 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) + 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) + 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) + expect(screen.getByRole('button', { name: 'Plan' })).toHaveAttribute('aria-current', 'page') + expect(screen.getByRole('button', { name: 'buchungen' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Lists' })).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Transport' })) + expect(planner.handleTabChange).toHaveBeenCalledWith('transports') + expect(shellApi.mode).toBe('go') + }) + + it('FE-MOB-SHELL-024: a dock tap changes the tab and drops out of browse mode', () => { + const { planner } = renderShell() + fireEvent.click(screen.getByRole('button', { name: 'trip.mobilePlaces' })) + expect(shellApi.mode).toBe('browse') + fireEvent.click(screen.getByRole('button', { name: 'Lists' })) + expect(planner.handleTabChange).toHaveBeenCalledWith('listen') + expect(shellApi.mode).toBe('go') + }) + + it('FE-MOB-SHELL-025: the Mehr button opens the sheet and closeSheet clears it', () => { + renderShell() + fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.more' })) + expect(shellApi.sheet).toEqual({ id: 'mehr', payload: undefined }) + act(() => { shellApi.closeSheet() }) + expect(shellApi.sheet).toBeNull() + }) + + it('FE-MOB-SHELL-026: a non-plan tab replaces the plan chrome with its panel', () => { + renderShell({ activeTab: 'dateien' } as Partial) + expect(screen.getByTestId('tab-panel')).toHaveTextContent('dateien') + expect(screen.queryByTestId('map-area')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'mobileTrip.travel' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'mobileTrip.mapView' })).not.toBeInTheDocument() + }) + + it('FE-MOB-SHELL-027: the transports header opens a blank transport modal', () => { + const { planner } = renderShell({ activeTab: 'transports' } as Partial) + fireEvent.click(screen.getByRole('button', { name: 'transport.addTransport' })) + expect(planner.setEditingTransport).toHaveBeenCalledWith(null) + expect(planner.setTransitPrefill).toHaveBeenCalledWith(null) + expect(planner.setTransportModalAutomated).toHaveBeenCalledWith(false) + expect(planner.setShowTransportModal).toHaveBeenCalledWith(true) + }) + + it('FE-MOB-SHELL-028: the transports header offers both importers when they are available', () => { + const { planner } = renderShell({ activeTab: 'transports', airTrailAvailable: true } as Partial) + fireEvent.click(screen.getByRole('button', { name: 'reservations.import.title' })) + fireEvent.click(screen.getByRole('button', { name: 'reservations.airtrail.title' })) + expect(planner.setShowBookingImport).toHaveBeenCalledWith(true) + expect(planner.setShowAirTrailImport).toHaveBeenCalledWith(true) + }) + + it('FE-MOB-SHELL-029: the transports header hides the importers that are switched off', () => { + renderShell({ + activeTab: 'transports', bookingImportAvailable: false, airTrailAvailable: false, + } as Partial) + expect(screen.queryByRole('button', { name: 'reservations.import.title' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'reservations.airtrail.title' })).not.toBeInTheDocument() + }) + + it('FE-MOB-SHELL-030: the transports compact toggle flips the shell flag', () => { + renderShell({ activeTab: 'transports' } as Partial) + const toggle = screen.getByRole('button', { name: 'mobileTrip.compactView' }) + expect(toggle).toHaveAttribute('aria-pressed', 'false') + fireEvent.click(toggle) + expect(toggle).toHaveAttribute('aria-pressed', 'true') + expect(shellApi.transportsCompact).toBe(true) + expect(shellApi.bookingsCompact).toBe(false) + }) + + it('FE-MOB-SHELL-031: the bookings header opens a blank reservation and toggles its own density', () => { + const { planner } = renderShell({ activeTab: 'buchungen' } as Partial) + fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.newReservation' })) + expect(planner.setEditingReservation).toHaveBeenCalledWith(null) + expect(planner.setShowReservationModal).toHaveBeenCalledWith(true) + fireEvent.click(screen.getByRole('button', { name: 'reservations.import.title' })) + expect(planner.setShowBookingImport).toHaveBeenCalledWith(true) + fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.compactView' })) + expect(shellApi.bookingsCompact).toBe(true) + expect(shellApi.transportsCompact).toBe(false) + }) + + it('FE-MOB-SHELL-032: the costs header raises the add-expense and CSV signals', () => { + renderShell({ activeTab: 'finanzplan' } as Partial) + expect(shellApi.addExpenseSignal).toBe(0) + fireEvent.click(screen.getByRole('button', { name: 'costs.addExpense' })) + fireEvent.click(screen.getByRole('button', { name: 'budget.exportCsv' })) + expect(shellApi.addExpenseSignal).toBe(1) + expect(shellApi.exportCostsCsvSignal).toBe(1) + }) + + it('FE-MOB-SHELL-033: the files header raises the upload and trash signals', () => { + renderShell({ activeTab: 'dateien' } as Partial) + fireEvent.click(screen.getByRole('button', { name: 'common.upload' })) + fireEvent.click(screen.getByRole('button', { name: 'files.trash' })) + fireEvent.click(screen.getByRole('button', { name: 'common.upload' })) + expect(shellApi.uploadFilesSignal).toBe(2) + expect(shellApi.openFilesTrashSignal).toBe(1) + }) + + it('FE-MOB-SHELL-034: the lists header shows packed/open counts and persists the sub-tab', () => { + const packingItems = [ + { id: 1, checked: true }, { id: 2, checked: false }, { id: 3, checked: true }, + ] as unknown as PackingItem[] + const todoItems = [{ id: 1, checked: false }, { id: 2, checked: false }] as unknown as TodoItem[] + renderShell({ activeTab: 'listen', packingItems, todoItems } as Partial) + expect(screen.getByRole('button', { name: /todo\.subtab\.packing/ })).toHaveTextContent('2/3') + const todoTab = screen.getByRole('button', { name: /todo\.subtab\.todo/ }) + expect(todoTab).toHaveTextContent('mobileTrip.todoOpenCount:2') + + fireEvent.click(todoTab) + expect(shellApi.listsTab).toBe('todo') + expect(sessionStorage.getItem('trip-lists-subtab-1')).toBe('todo') + }) + + it('FE-MOB-SHELL-035: the lists sub-tab is restored from the session', () => { + sessionStorage.setItem('trip-lists-subtab-1', 'todo') + renderShell({ activeTab: 'listen' } as Partial) + expect(shellApi.listsTab).toBe('todo') + }) + + it('FE-MOB-SHELL-036: the collab header switches the sub-tab', () => { + renderShell({ activeTab: 'collab' } as Partial) + expect(shellApi.collabTab).toBe('chat') + fireEvent.click(screen.getByRole('button', { name: 'collab.tabs.polls' })) + expect(shellApi.collabTab).toBe('polls') + fireEvent.click(screen.getByRole('button', { name: 'collab.tabs.notes' })) + expect(shellApi.collabTab).toBe('notes') + }) + + it('FE-MOB-SHELL-037: setTrTab from a slot routes through the planner and resets browse', () => { + const { planner } = renderShell() + fireEvent.click(screen.getByRole('button', { name: 'trip.mobilePlaces' })) + act(() => { shellApi.setTrTab('collab') }) + expect(planner.handleTabChange).toHaveBeenCalledWith('collab') + expect(shellApi.mode).toBe('go') + }) + + it('FE-MOB-SHELL-038: openSheet carries the payload through to the slots', () => { + renderShell() + act(() => { shellApi.openSheet('note', { dayId: 12 }) }) + expect(shellApi.sheet).toEqual({ id: 'note', payload: { dayId: 12 } }) + }) +}) + +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) + }) +}) diff --git a/client/tests/unit/mobile/trip/MTripTabPanel.test.tsx b/client/tests/unit/mobile/trip/MTripTabPanel.test.tsx new file mode 100644 index 000000000..57a429251 --- /dev/null +++ b/client/tests/unit/mobile/trip/MTripTabPanel.test.tsx @@ -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
+ } +} + +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 }) => ( +
+ ), +})) + +function renderTab(tab: string, plannerOverrides = {}) { + const planner = buildPlanner(plannerOverrides) + const shell = buildShell({ trTab: tab }) + return { ...render(), 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() + }) +}) diff --git a/client/tests/unit/mobile/trip/PlCategoryPicker.test.tsx b/client/tests/unit/mobile/trip/PlCategoryPicker.test.tsx new file mode 100644 index 000000000..8ee2c22a6 --- /dev/null +++ b/client/tests/unit/mobile/trip/PlCategoryPicker.test.tsx @@ -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 = {}, value = '') { + const onChange = vi.fn() + const planner = buildPlanner({ categories: CATEGORIES, ...plannerOverrides }) + const view = render() + 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) + + 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) + + 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) + + 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(resolve => { release = resolve })) + const { onChange } = setup({ tripActions } as unknown as Partial) + + 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) + + 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) + expect(screen.getByRole('button', { name: /places\.noCategory/ })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Food/ })).not.toBeInTheDocument() + }) +}) diff --git a/client/tests/unit/mobile/trip/PlFileAttach.test.tsx b/client/tests/unit/mobile/trip/PlFileAttach.test.tsx new file mode 100644 index 000000000..097975ff7 --- /dev/null +++ b/client/tests/unit/mobile/trip/PlFileAttach.test.tsx @@ -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> = {}) { + const onAdd = vi.fn() + const onRemove = vi.fn() + const view = render( + , + ) + 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) + }) +}) diff --git a/client/tests/unit/mobile/trip/PlPlaceSearch.test.tsx b/client/tests/unit/mobile/trip/PlPlaceSearch.test.tsx new file mode 100644 index 000000000..bf755e99d --- /dev/null +++ b/client/tests/unit/mobile/trip/PlPlaceSearch.test.tsx @@ -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[] = [] +let searchBodies: Record[] = [] + +function recordAutocomplete(suggestions: unknown[] = [SUGGESTION]) { + return http.post('/api/maps/autocomplete', async ({ request }) => { + autocompleteBodies.push(await request.json() as Record) + 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) + return HttpResponse.json({ places, source: 'osm' }) + }) +} + +function setup(plannerOverrides: Partial = {}, locationBias?: Parameters[0]['locationBias']) { + const onPick = vi.fn() + const onResolvingChange = vi.fn() + const planner = buildPlanner(plannerOverrides) + const view = render( + , + ) + 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()) + }) +}) diff --git a/client/tests/unit/mobile/trip/PlTimeFields.test.tsx b/client/tests/unit/mobile/trip/PlTimeFields.test.tsx new file mode 100644 index 000000000..5e06f9da0 --- /dev/null +++ b/client/tests/unit/mobile/trip/PlTimeFields.test.tsx @@ -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( + , + ) + 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() + }) +}) diff --git a/client/tests/unit/mobile/trip/collabModel.test.ts b/client/tests/unit/mobile/trip/collabModel.test.ts new file mode 100644 index 000000000..205120cdd --- /dev/null +++ b/client/tests/unit/mobile/trip/collabModel.test.ts @@ -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 { + 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 { + 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 { + 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) => + 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'); + }); +}); diff --git a/client/tests/unit/mobile/trip/costsModel.test.ts b/client/tests/unit/mobile/trip/costsModel.test.ts new file mode 100644 index 000000000..6c38dee69 --- /dev/null +++ b/client/tests/unit/mobile/trip/costsModel.test.ts @@ -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 = { 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[number]; +type Payer = NonNullable[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 { + return buildBudgetItem({ category: 'other', ...overrides }); +} + +function filters(overrides: Partial = {}): 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;'); + }); +}); diff --git a/client/tests/unit/mobile/trip/filesModel.test.ts b/client/tests/unit/mobile/trip/filesModel.test.ts new file mode 100644 index 000000000..ab70aacf1 --- /dev/null +++ b/client/tests/unit/mobile/trip/filesModel.test.ts @@ -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 { + 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([]); + }); +}); diff --git a/client/tests/unit/mobile/trip/listsModel.test.ts b/client/tests/unit/mobile/trip/listsModel.test.ts new file mode 100644 index 000000000..48f60a75e --- /dev/null +++ b/client/tests/unit/mobile/trip/listsModel.test.ts @@ -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]); + }); +}); diff --git a/client/tests/unit/mobile/trip/planTimelineModel.test.ts b/client/tests/unit/mobile/trip/planTimelineModel.test.ts new file mode 100644 index 000000000..2df86b296 --- /dev/null +++ b/client/tests/unit/mobile/trip/planTimelineModel.test.ts @@ -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): 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 { + 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 { + 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) => + 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) + }) +}) diff --git a/client/tests/unit/mobile/trip/tabChrome.test.tsx b/client/tests/unit/mobile/trip/tabChrome.test.tsx new file mode 100644 index 000000000..8ab8f67a6 --- /dev/null +++ b/client/tests/unit/mobile/trip/tabChrome.test.tsx @@ -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 { + 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(

panel body

) + 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(7) + expect(screen.getByText('7')).toBeInTheDocument() + }) + + it('FE-MOB-TABCHR-003: SectionHeader exposes the open state and its count pill', () => { + render() + 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() + 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() + 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(08:15) + 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(12,50 €) + expect(screen.getByText('12,50 €')).toHaveClass('tabular-nums') + }) + + it('FE-MOB-TABCHR-008: StatusDot paints the passed token', () => { + const { container } = render() + expect(container.firstElementChild).toHaveStyle({ background: 'var(--m-st-confirmed)' }) + }) + + it('FE-MOB-TABCHR-009: TravelerAvatars renders nothing when nobody is assigned', () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('FE-MOB-TABCHR-010: TravelerAvatars shows the label and one chip per traveler', () => { + render( + , + ) + 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( + , + ) + 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() + expect(screen.getByText('Guest')).toBeInTheDocument() + }) + + it('FE-MOB-TABCHR-013: TravelerFilterRow toggles a member and dims the inactive ones', () => { + const onToggle = vi.fn() + render( + , + ) + 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( + , + ) + expect(screen.getByTitle('Ada').className).toContain('opacity-100') + expect(screen.getByTitle('Bob').className).toContain('opacity-100') + }) +}) diff --git a/client/tests/unit/mobile/trip/transportsModel.test.ts b/client/tests/unit/mobile/trip/transportsModel.test.ts new file mode 100644 index 000000000..94f4c5127 --- /dev/null +++ b/client/tests/unit/mobile/trip/transportsModel.test.ts @@ -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 { + 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]); + }); +}); diff --git a/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts b/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts new file mode 100644 index 000000000..c7c28470f --- /dev/null +++ b/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts @@ -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 = {}): 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) +} + +/** 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 + 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 }> } }).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 }> } } + 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 + 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')) + }) +})