mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
test(mobile): cover the trip screens
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { filterPool, poolCounts } from './placesBrowserModel'
|
||||
import type { Place } from '../../../../types'
|
||||
import {
|
||||
filterPool, firstPlannedDayNumbers, matchesCategoryFilter, matchesSearch,
|
||||
plannedPlaceIds, poolCounts,
|
||||
} from './placesBrowserModel'
|
||||
import type { AssignmentsMap, Day, Place } from '../../../../types'
|
||||
|
||||
const mkPlace = (id: number, over: Partial<Place> = {}): Place =>
|
||||
({ id, name: `Place ${id}`, lat: 0, lng: 0, address: null, category_id: null, ...over } as Place)
|
||||
@@ -38,3 +41,114 @@ describe('placesBrowserModel — planned pool', () => {
|
||||
expect(c.unplanned).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// FE-MOB-PMODEL-001 to FE-MOB-PMODEL-012
|
||||
|
||||
const mkAssignment = (id: number, placeId: number | null) =>
|
||||
({ id, place: placeId == null ? null : { id: placeId, name: `Place ${placeId}` } })
|
||||
|
||||
const mkDays = (spec: [number, number | null][]): Day[] =>
|
||||
spec.map(([id, dayNumber]) => ({ id, day_number: dayNumber }) as Day)
|
||||
|
||||
describe('placesBrowserModel — assignment lookups', () => {
|
||||
it('FE-MOB-PMODEL-001: plannedPlaceIds collects every assigned place across all days', () => {
|
||||
const assignments = {
|
||||
'1': [mkAssignment(10, 5), mkAssignment(11, 6)],
|
||||
'2': [mkAssignment(12, 5)],
|
||||
} as unknown as AssignmentsMap
|
||||
expect([...plannedPlaceIds(assignments)].sort()).toEqual([5, 6])
|
||||
})
|
||||
|
||||
it('FE-MOB-PMODEL-002: plannedPlaceIds skips assignments whose place did not join', () => {
|
||||
const assignments = { '1': [mkAssignment(10, null), mkAssignment(11, 7)] } as unknown as AssignmentsMap
|
||||
expect([...plannedPlaceIds(assignments)]).toEqual([7])
|
||||
})
|
||||
|
||||
it('FE-MOB-PMODEL-003: plannedPlaceIds is empty without assignments', () => {
|
||||
expect(plannedPlaceIds({} as AssignmentsMap).size).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-MOB-PMODEL-004: firstPlannedDayNumbers keeps the lowest day a place appears on', () => {
|
||||
const assignments = {
|
||||
'30': [mkAssignment(10, 5)],
|
||||
'10': [mkAssignment(11, 5)],
|
||||
'20': [mkAssignment(12, 6)],
|
||||
} as unknown as AssignmentsMap
|
||||
const days = mkDays([[10, 1], [20, 2], [30, 3]])
|
||||
const result = firstPlannedDayNumbers(assignments, days)
|
||||
expect(result.get(5)).toBe(1)
|
||||
expect(result.get(6)).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-PMODEL-005: firstPlannedDayNumbers falls back to the array position without a day_number', () => {
|
||||
const assignments = { '20': [mkAssignment(10, 5)] } as unknown as AssignmentsMap
|
||||
const days = mkDays([[10, null], [20, null]])
|
||||
expect(firstPlannedDayNumbers(assignments, days).get(5)).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-PMODEL-006: firstPlannedDayNumbers ignores assignments of a day the trip no longer has', () => {
|
||||
const assignments = {
|
||||
'99': [mkAssignment(10, 5)],
|
||||
'10': [mkAssignment(11, 6), mkAssignment(12, null)],
|
||||
} as unknown as AssignmentsMap
|
||||
const result = firstPlannedDayNumbers(assignments, mkDays([[10, 1]]))
|
||||
expect(result.has(5)).toBe(false)
|
||||
expect(result.get(6)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('placesBrowserModel — predicates', () => {
|
||||
it('FE-MOB-PMODEL-007: an empty category set matches everything', () => {
|
||||
expect(matchesCategoryFilter(mkPlace(1), new Set())).toBe(true)
|
||||
expect(matchesCategoryFilter(mkPlace(2, { category_id: 4 }), new Set())).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-MOB-PMODEL-008: a category set matches on the stringified id', () => {
|
||||
const filters = new Set(['4'])
|
||||
expect(matchesCategoryFilter(mkPlace(1, { category_id: 4 }), filters)).toBe(true)
|
||||
expect(matchesCategoryFilter(mkPlace(2, { category_id: 5 }), filters)).toBe(false)
|
||||
expect(matchesCategoryFilter(mkPlace(3), filters)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-MOB-PMODEL-009: places without a category need the uncategorized entry', () => {
|
||||
expect(matchesCategoryFilter(mkPlace(1), new Set(['uncategorized']))).toBe(true)
|
||||
expect(matchesCategoryFilter(mkPlace(2, { category_id: 4 }), new Set(['uncategorized']))).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-MOB-PMODEL-010: search is case-insensitive over name and address', () => {
|
||||
const place = mkPlace(1, { name: 'Louvre', address: 'Rue de Rivoli' })
|
||||
expect(matchesSearch(place, '')).toBe(true)
|
||||
expect(matchesSearch(place, 'LOUV')).toBe(true)
|
||||
expect(matchesSearch(place, 'rivoli')).toBe(true)
|
||||
expect(matchesSearch(place, 'eiffel')).toBe(false)
|
||||
// A null address must not blow up the lookup.
|
||||
expect(matchesSearch(mkPlace(2, { name: 'Eiffel' }), 'rivoli')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('placesBrowserModel — tracks pool', () => {
|
||||
const places = [
|
||||
mkPlace(1, { name: 'Ridge', route_geometry: 'abc', category_id: 4 }),
|
||||
mkPlace(2, { name: 'Museum', category_id: 4 }),
|
||||
mkPlace(3, { name: 'River', route_geometry: 'def' }),
|
||||
]
|
||||
const plannedIds = new Set([1])
|
||||
|
||||
it('FE-MOB-PMODEL-011: the tracks filter keeps only places carrying a geometry', () => {
|
||||
const out = filterPool(places, { filter: 'tracks', categoryFilters: new Set(), search: '', plannedIds })
|
||||
expect(out.map(p => p.id)).toEqual([1, 3])
|
||||
})
|
||||
|
||||
it('FE-MOB-PMODEL-012: the tracks filter still honors category and search', () => {
|
||||
const out = filterPool(places, { filter: 'tracks', categoryFilters: new Set(['4']), search: 'rid', plannedIds })
|
||||
expect(out.map(p => p.id)).toEqual([1])
|
||||
})
|
||||
|
||||
it('FE-MOB-PMODEL-013: poolCounts counts tracks on the category+search base set', () => {
|
||||
const c = poolCounts(places, new Set(['4']), '', plannedIds)
|
||||
expect(c.all).toBe(2)
|
||||
expect(c.tracks).toBe(1)
|
||||
expect(c.planned).toBe(1)
|
||||
expect(c.unplanned).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { vi } from 'vitest';
|
||||
import type { TripPlanner, MTripShellApi } from '../../src/mobile/screens/trip/MTripShell';
|
||||
import { buildTrip } from './factories';
|
||||
|
||||
/**
|
||||
* Fixtures for the mobile trip screens. Every tab panel, sheet and overlay
|
||||
* under src/mobile/screens/trip receives the same two props — the planner
|
||||
* context from useTripPlanner() and the shell api from MTripShell — so tests
|
||||
* render them directly with these builders instead of mounting the whole shell.
|
||||
*
|
||||
* Defaults are the empty-but-valid state: a trip exists, every list is empty,
|
||||
* every callback is a spy. Pass overrides for the slice under test.
|
||||
*/
|
||||
|
||||
/** Mirrors useTripStore's action surface as spies. */
|
||||
export function buildTripActions(): Record<string, ReturnType<typeof vi.fn>> {
|
||||
const names = [
|
||||
'addCategory', 'addDayNote', 'addFile', 'addPackingContributor', 'addPackingItem',
|
||||
'addPlace', 'addReservation', 'addTodoItem', 'assignPlaceToDay', 'clonePackingItem',
|
||||
'deleteBudgetItem', 'deleteDayNote', 'deleteFile', 'deletePackingItem', 'deletePlace',
|
||||
'deletePlacesMany', 'deleteReservation', 'deleteTodoItem', 'insertDay', 'loadBudgetItems',
|
||||
'loadFiles', 'loadReservations', 'loadTrip', 'moveAssignment', 'moveDayNote', 'ratePlace',
|
||||
'refreshDays', 'removeAssignment', 'removePackingContributor', 'reorderAssignments',
|
||||
'reorderDays', 'setAssignments', 'setPackingItemSharing', 'setSelectedDay',
|
||||
'toggleBudgetMemberPaid', 'togglePackingItem', 'toggleReservationStatus', 'toggleTodoItem',
|
||||
'updateDayNote', 'updateDayTitle', 'updatePackingItem', 'updatePlace', 'updatePlacesMany',
|
||||
'updateReservation', 'updateTodoItem', 'updateTrip', 'uploadPlaceImage',
|
||||
];
|
||||
const actions: Record<string, ReturnType<typeof vi.fn>> = {};
|
||||
for (const name of names) actions[name] = vi.fn(async () => undefined);
|
||||
return actions;
|
||||
}
|
||||
|
||||
export function buildToast(): Record<string, ReturnType<typeof vi.fn>> {
|
||||
return { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
|
||||
}
|
||||
|
||||
/**
|
||||
* `t` echoes the key so assertions stay independent of the English copy.
|
||||
* Pass a real translation fn through overrides when a test needs visible text.
|
||||
*/
|
||||
const echoT = (key: string, params?: Record<string, string | number>): string =>
|
||||
params ? `${key}:${Object.values(params).join(',')}` : key;
|
||||
|
||||
export function buildPlanner(overrides: Partial<TripPlanner> = {}): TripPlanner {
|
||||
const trip = buildTrip({ id: 1, title: 'Japan 2026', currency: 'EUR' });
|
||||
|
||||
const base = {
|
||||
tripId: 1,
|
||||
navigate: vi.fn(),
|
||||
toast: buildToast(),
|
||||
t: echoT,
|
||||
language: 'en',
|
||||
settings: { time_format: '24h', date_format: 'DD.MM.YYYY', default_currency: 'EUR', distance_unit: 'km' },
|
||||
placesPhotosEnabled: false,
|
||||
|
||||
trip,
|
||||
days: [],
|
||||
places: [],
|
||||
assignments: [],
|
||||
packingItems: [],
|
||||
todoItems: [],
|
||||
categories: [],
|
||||
reservations: [],
|
||||
budgetItems: [],
|
||||
files: [],
|
||||
|
||||
selectedDayId: null,
|
||||
isLoading: false,
|
||||
tripActions: buildTripActions(),
|
||||
can: vi.fn(() => true),
|
||||
canUploadFiles: true,
|
||||
|
||||
pushUndo: vi.fn(),
|
||||
undo: vi.fn(),
|
||||
canUndo: false,
|
||||
lastActionLabel: null,
|
||||
handleUndo: vi.fn(),
|
||||
|
||||
enabledAddons: { packing: true, budget: true, documents: true, collab: true, vacay: true, atlas: true },
|
||||
collabFeatures: { chat: true, notes: true, polls: true },
|
||||
tripAccommodations: [],
|
||||
setTripAccommodations: vi.fn(),
|
||||
allowedFileTypes: ['image/jpeg', 'image/png', 'application/pdf'],
|
||||
tripMembers: [],
|
||||
setTripMembers: vi.fn(),
|
||||
refreshMembers: vi.fn(),
|
||||
loadAccommodations: vi.fn(),
|
||||
|
||||
TRANSPORT_TYPES: new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']),
|
||||
TRIP_TABS: [],
|
||||
activeTab: 'plan',
|
||||
setActiveTab: vi.fn(),
|
||||
handleTabChange: vi.fn(),
|
||||
|
||||
leftWidth: 320,
|
||||
rightWidth: 320,
|
||||
leftCollapsed: false,
|
||||
rightCollapsed: false,
|
||||
setLeftCollapsed: vi.fn(),
|
||||
setRightCollapsed: vi.fn(),
|
||||
startResizeLeft: vi.fn(),
|
||||
startResizeRight: vi.fn(),
|
||||
|
||||
selectedPlaceId: null,
|
||||
selectedAssignmentId: null,
|
||||
setSelectedPlaceId: vi.fn(),
|
||||
selectAssignment: vi.fn(),
|
||||
|
||||
showDayDetail: false,
|
||||
setShowDayDetail: vi.fn(),
|
||||
dayDetailCollapsed: false,
|
||||
setDayDetailCollapsed: vi.fn(),
|
||||
showPlaceForm: false,
|
||||
setShowPlaceForm: vi.fn(),
|
||||
editingPlace: null,
|
||||
setEditingPlace: vi.fn(),
|
||||
prefillCoords: null,
|
||||
setPrefillCoords: vi.fn(),
|
||||
editingAssignmentId: null,
|
||||
setEditingAssignmentId: vi.fn(),
|
||||
showTripForm: false,
|
||||
setShowTripForm: vi.fn(),
|
||||
showMembersModal: false,
|
||||
setShowMembersModal: vi.fn(),
|
||||
showReservationModal: false,
|
||||
setShowReservationModal: vi.fn(),
|
||||
editingReservation: null,
|
||||
setEditingReservation: vi.fn(),
|
||||
showBookingImport: false,
|
||||
setShowBookingImport: vi.fn(),
|
||||
bookingImportAvailable: true,
|
||||
airTrailAvailable: false,
|
||||
showAirTrailImport: false,
|
||||
setShowAirTrailImport: vi.fn(),
|
||||
bookingForAssignmentId: null,
|
||||
setBookingForAssignmentId: vi.fn(),
|
||||
|
||||
showTransportModal: false,
|
||||
setShowTransportModal: vi.fn(),
|
||||
editingTransport: null,
|
||||
setEditingTransport: vi.fn(),
|
||||
transportModalDayId: null,
|
||||
setTransportModalDayId: vi.fn(),
|
||||
transportModalAutomated: false,
|
||||
setTransportModalAutomated: vi.fn(),
|
||||
transitPrefill: null,
|
||||
setTransitPrefill: vi.fn(),
|
||||
transitJourney: null,
|
||||
setTransitJourney: vi.fn(),
|
||||
reservationPrefill: null,
|
||||
transportPrefill: null,
|
||||
importReviewActive: false,
|
||||
startImportReview: vi.fn(),
|
||||
advanceImportReview: vi.fn(),
|
||||
|
||||
routeShown: false,
|
||||
setRouteShown: vi.fn(),
|
||||
routeProfile: 'driving',
|
||||
setRouteProfile: vi.fn(),
|
||||
routeVias: [],
|
||||
fitKey: 0,
|
||||
setFitKey: vi.fn(),
|
||||
|
||||
mobileSidebarOpen: false,
|
||||
setMobileSidebarOpen: vi.fn(),
|
||||
mobilePlanScrollTopRef: { current: 0 },
|
||||
mobilePlacesScrollTopRef: { current: 0 },
|
||||
|
||||
deletePlaceId: null,
|
||||
setDeletePlaceId: vi.fn(),
|
||||
deletePlaceIds: null,
|
||||
setDeletePlaceIds: vi.fn(),
|
||||
|
||||
// resolveVisibleConnectionIds returns an array, and consumers call .includes().
|
||||
visibleConnections: [] as number[],
|
||||
toggleConnection: vi.fn(),
|
||||
allConnectionsShown: false,
|
||||
toggleAllConnections: vi.fn(),
|
||||
mapTransportDetail: null,
|
||||
setMapTransportDetail: vi.fn(),
|
||||
|
||||
isMobile: true,
|
||||
isTouch: true,
|
||||
|
||||
expandedDayIds: new Set<number>(),
|
||||
setExpandedDayIds: vi.fn(),
|
||||
mapPlaces: [],
|
||||
|
||||
route: null,
|
||||
routeSegments: [],
|
||||
routeInfo: null,
|
||||
setRoute: vi.fn(),
|
||||
setRouteInfo: vi.fn(),
|
||||
updateRouteForDay: vi.fn(),
|
||||
|
||||
handleSelectDay: vi.fn(),
|
||||
handlePlaceClick: vi.fn(),
|
||||
handleMarkerClick: vi.fn(),
|
||||
handleMapClick: vi.fn(),
|
||||
handleMapContextMenu: vi.fn(),
|
||||
openAddPlaceFromPoi: vi.fn(),
|
||||
handleSavePlace: vi.fn(),
|
||||
openPlaceEditor: vi.fn(),
|
||||
handleDeletePlace: vi.fn(),
|
||||
confirmDeletePlace: vi.fn(),
|
||||
confirmDeletePlaces: vi.fn(),
|
||||
confirmChangeCategory: vi.fn(),
|
||||
handleAssignToDay: vi.fn(),
|
||||
handleRemoveAssignment: vi.fn(),
|
||||
handleReorder: vi.fn(),
|
||||
handleReorderDays: vi.fn(),
|
||||
handleAddDay: vi.fn(),
|
||||
handleUpdateDayTitle: vi.fn(),
|
||||
handleSaveReservation: vi.fn(),
|
||||
handleSaveTransport: vi.fn(),
|
||||
handleDeleteReservation: vi.fn(),
|
||||
|
||||
selectedPlace: null,
|
||||
dayOrderMap: new Map<number, number>(),
|
||||
dayPlaces: [],
|
||||
|
||||
mapTileUrl: 'https://tile.example/{z}/{x}/{y}.png',
|
||||
fontStyle: {},
|
||||
splashDone: true,
|
||||
};
|
||||
|
||||
return { ...base, ...overrides } as unknown as TripPlanner;
|
||||
}
|
||||
|
||||
export function buildShell(overrides: Partial<MTripShellApi> = {}): MTripShellApi {
|
||||
const base: MTripShellApi = {
|
||||
view: 'plan',
|
||||
mode: 'go',
|
||||
trTab: 'plan',
|
||||
setTrTab: vi.fn(),
|
||||
setTravelMode: vi.fn(),
|
||||
toggleView: vi.fn(),
|
||||
browseFromEdit: false,
|
||||
sheet: null,
|
||||
openSheet: vi.fn(),
|
||||
closeSheet: vi.fn(),
|
||||
listsTab: 'packing',
|
||||
setListsTab: vi.fn(),
|
||||
collabTab: 'chat',
|
||||
setCollabTab: vi.fn(),
|
||||
transportsCompact: false,
|
||||
bookingsCompact: false,
|
||||
addExpenseSignal: 0,
|
||||
exportCostsCsvSignal: 0,
|
||||
uploadFilesSignal: 0,
|
||||
openFilesTrashSignal: 0,
|
||||
};
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { placesApi } from '../../../../src/api/client'
|
||||
import ImpFileStep from '../../../../src/mobile/screens/trip/sheets/ImpFileStep'
|
||||
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { buildPlanner } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-IMPF-001 to FE-MOB-IMPF-021
|
||||
//
|
||||
// planner.t echoes its key, so the assertions check keys (with the appended
|
||||
// parameter values) instead of the English copy.
|
||||
|
||||
function gpxFile(name = 'route.gpx') {
|
||||
return new File(['<gpx/>'], name, { type: 'application/gpx+xml' })
|
||||
}
|
||||
|
||||
function kmlFile(name = 'places.kml') {
|
||||
return new File(['<kml/>'], name, { type: 'application/vnd.google-earth.kml+xml' })
|
||||
}
|
||||
|
||||
function renderStep(plannerOverrides: Partial<TripPlanner> = {}) {
|
||||
const planner = buildPlanner(plannerOverrides)
|
||||
const onBack = vi.fn()
|
||||
const onDone = vi.fn()
|
||||
render(<ImpFileStep planner={planner} onBack={onBack} onDone={onDone} />)
|
||||
return { planner, onBack, onDone }
|
||||
}
|
||||
|
||||
function pick(files: File[]) {
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { files } })
|
||||
}
|
||||
|
||||
const submit = () => screen.getByRole('button', { name: 'common.import' })
|
||||
|
||||
describe('ImpFileStep', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPF-001: renders the hint and keeps the import disabled without a file', () => {
|
||||
renderStep()
|
||||
expect(screen.getByText('places.importFileHint')).toBeInTheDocument()
|
||||
expect(screen.getByText('places.importFileDropHere')).toBeInTheDocument()
|
||||
expect(submit()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPF-002: opens the hidden file input when the drop zone is tapped', () => {
|
||||
renderStep()
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const click = vi.spyOn(input, 'click')
|
||||
fireEvent.click(screen.getByText('places.importFileDropHere'))
|
||||
expect(click).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPF-003: rejects an unsupported extension', () => {
|
||||
renderStep()
|
||||
pick([new File(['x'], 'notes.txt', { type: 'text/plain' })])
|
||||
expect(screen.getByText('places.importFileUnsupported')).toBeInTheDocument()
|
||||
expect(screen.getByText('places.importFileDropHere')).toBeInTheDocument()
|
||||
expect(submit()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPF-004: rejects a file above the 10 MB limit', () => {
|
||||
renderStep()
|
||||
const big = gpxFile('huge.gpx')
|
||||
Object.defineProperty(big, 'size', { value: 11 * 1024 * 1024 })
|
||||
pick([big])
|
||||
expect(screen.getByText('places.importFileTooLarge:10')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPF-005: keeps the valid files of a mixed selection and reports the first problem', () => {
|
||||
renderStep()
|
||||
pick([new File(['x'], 'notes.txt'), gpxFile('a.gpx'), kmlFile('b.kml')])
|
||||
expect(screen.getByText('places.importFileUnsupported')).toBeInTheDocument()
|
||||
expect(screen.getByText('a.gpx, b.kml')).toBeInTheDocument()
|
||||
expect(submit()).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPF-006: a GPX selection offers only the GPX entity toggles', () => {
|
||||
renderStep()
|
||||
pick([gpxFile()])
|
||||
expect(screen.getByText('places.gpxImportTypes')).toBeInTheDocument()
|
||||
expect(screen.queryByText('places.kmlImportTypes')).not.toBeInTheDocument()
|
||||
for (const label of ['places.gpxImportWaypoints', 'places.gpxImportRoutes', 'places.gpxImportTracks']) {
|
||||
expect(screen.getByRole('button', { name: label })).toHaveAttribute('aria-pressed', 'true')
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPF-007: a KMZ selection offers only the KML entity toggles', () => {
|
||||
renderStep()
|
||||
pick([new File(['x'], 'map.kmz')])
|
||||
expect(screen.getByText('places.kmlImportTypes')).toBeInTheDocument()
|
||||
expect(screen.queryByText('places.gpxImportTypes')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPF-008: a mixed selection shows both toggle groups', () => {
|
||||
renderStep()
|
||||
pick([gpxFile(), kmlFile()])
|
||||
expect(screen.getByText('places.gpxImportTypes')).toBeInTheDocument()
|
||||
expect(screen.getByText('places.kmlImportTypes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPF-009: turning every GPX entity off blocks the import', () => {
|
||||
renderStep()
|
||||
pick([gpxFile()])
|
||||
for (const label of ['places.gpxImportWaypoints', 'places.gpxImportRoutes', 'places.gpxImportTracks']) {
|
||||
fireEvent.click(screen.getByRole('button', { name: label }))
|
||||
}
|
||||
expect(screen.getByRole('button', { name: 'places.gpxImportWaypoints' })).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(screen.getByText('places.gpxImportNoneSelected')).toBeInTheDocument()
|
||||
expect(submit()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPF-010: turning every KML entity off blocks the import', () => {
|
||||
renderStep()
|
||||
pick([kmlFile()])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'places.kmlImportPoints' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'places.kmlImportPaths' }))
|
||||
expect(screen.getByText('places.kmlImportNoneSelected')).toBeInTheDocument()
|
||||
expect(submit()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPF-011: imports a GPX with the selected entities, toasts and closes', async () => {
|
||||
const importGpx = vi.spyOn(placesApi, 'importGpx').mockResolvedValue({
|
||||
count: 3, skipped: 0, places: [{ id: 1 }, { id: 2 }, { id: 3 }],
|
||||
})
|
||||
const { planner, onDone } = renderStep()
|
||||
const file = gpxFile()
|
||||
pick([file])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'places.gpxImportRoutes' }))
|
||||
fireEvent.click(submit())
|
||||
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1))
|
||||
expect(importGpx).toHaveBeenCalledWith(1, file, { waypoints: true, routes: false, tracks: true })
|
||||
expect(planner.tripActions.loadTrip).toHaveBeenCalledWith(1)
|
||||
expect(planner.toast.success).toHaveBeenCalledWith('places.gpxImported:3')
|
||||
expect(planner.pushUndo).toHaveBeenCalledWith('undo.importGpx', expect.any(Function))
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPF-012: the GPX undo bulk-deletes the created places and reloads', async () => {
|
||||
vi.spyOn(placesApi, 'importGpx').mockResolvedValue({ count: 2, skipped: 0, places: [{ id: 7 }, { id: 8 }] })
|
||||
const bulkDelete = vi.spyOn(placesApi, 'bulkDelete').mockResolvedValue({ deleted: 2 })
|
||||
const { planner, onDone } = renderStep()
|
||||
pick([gpxFile()])
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled())
|
||||
|
||||
const revert = vi.mocked(planner.pushUndo).mock.calls[0][1] as () => Promise<void>
|
||||
await revert()
|
||||
expect(bulkDelete).toHaveBeenCalledWith(1, [7, 8])
|
||||
expect(planner.tripActions.loadTrip).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPF-013: a failing undo still reloads the trip', async () => {
|
||||
vi.spyOn(placesApi, 'importGpx').mockResolvedValue({ count: 1, skipped: 0, places: [{ id: 7 }] })
|
||||
vi.spyOn(placesApi, 'bulkDelete').mockRejectedValue(new Error('nope'))
|
||||
const { planner, onDone } = renderStep()
|
||||
pick([gpxFile()])
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled())
|
||||
|
||||
const revert = vi.mocked(planner.pushUndo).mock.calls[0][1] as () => Promise<void>
|
||||
await expect(revert()).resolves.toBeUndefined()
|
||||
expect(planner.tripActions.loadTrip).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPF-014: a KML import keeps the sheet open to show its summary', async () => {
|
||||
const importMapFile = vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({
|
||||
count: 4,
|
||||
places: [{ id: 9 }],
|
||||
summary: { totalPlacemarks: 5, createdCount: 4, skippedCount: 1, warnings: ['1 placemark had no coordinates'], errors: [] },
|
||||
})
|
||||
const { planner, onDone } = renderStep()
|
||||
const file = kmlFile()
|
||||
pick([file])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'places.kmlImportPaths' }))
|
||||
fireEvent.click(submit())
|
||||
|
||||
await screen.findByText('places.kmlKmzSummaryValues:5,4,1')
|
||||
expect(importMapFile).toHaveBeenCalledWith(1, file, { points: true, paths: false })
|
||||
expect(screen.getByText('1 placemark had no coordinates')).toBeInTheDocument()
|
||||
expect(planner.toast.success).toHaveBeenCalledWith('places.kmlKmzImported:4')
|
||||
expect(planner.pushUndo).toHaveBeenCalledWith('undo.importKeyholeMarkup', expect.any(Function))
|
||||
expect(onDone).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPF-015: merges the summaries of two KML files', async () => {
|
||||
vi.spyOn(placesApi, 'importMapFile')
|
||||
.mockResolvedValueOnce({
|
||||
count: 2, places: [{ id: 1 }],
|
||||
summary: { totalPlacemarks: 3, createdCount: 2, skippedCount: 1, warnings: ['first'], errors: [] },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
count: 1, places: [{ id: 2 }],
|
||||
summary: { totalPlacemarks: 2, createdCount: 1, skippedCount: 1, warnings: ['second'], errors: [] },
|
||||
})
|
||||
const { planner } = renderStep()
|
||||
pick([kmlFile('a.kml'), kmlFile('b.kml')])
|
||||
fireEvent.click(submit())
|
||||
|
||||
await screen.findByText('places.kmlKmzSummaryValues:5,3,2')
|
||||
expect(screen.getByText('first second')).toBeInTheDocument()
|
||||
expect(planner.toast.success).toHaveBeenCalledWith('places.kmlKmzImported:3')
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPF-016: a mixed import labels the undo generically and uses the GPX toast key', async () => {
|
||||
vi.spyOn(placesApi, 'importGpx').mockResolvedValue({ count: 1, skipped: 0, places: [{ id: 1 }] })
|
||||
vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({ count: 2, places: [{ id: 2 }] })
|
||||
const { planner, onDone } = renderStep()
|
||||
pick([gpxFile(), kmlFile()])
|
||||
fireEvent.click(submit())
|
||||
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled())
|
||||
expect(planner.toast.success).toHaveBeenCalledWith('places.gpxImported:3')
|
||||
expect(planner.pushUndo).toHaveBeenCalledWith('undo.importKeyholeMarkup', expect.any(Function))
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPF-017: warns when everything was skipped and registers no undo', async () => {
|
||||
vi.spyOn(placesApi, 'importGpx').mockResolvedValue({ count: 0, skipped: 4, places: [] })
|
||||
const { planner, onDone } = renderStep()
|
||||
pick([gpxFile()])
|
||||
fireEvent.click(submit())
|
||||
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled())
|
||||
expect(planner.toast.warning).toHaveBeenCalledWith('places.importAllSkipped')
|
||||
expect(planner.toast.success).not.toHaveBeenCalled()
|
||||
expect(planner.pushUndo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPF-018: shows the server error of a single failing file and stays open', async () => {
|
||||
vi.spyOn(placesApi, 'importGpx').mockRejectedValue({ response: { data: { error: 'Malformed GPX' } } })
|
||||
const { planner, onDone } = renderStep()
|
||||
pick([gpxFile()])
|
||||
fireEvent.click(submit())
|
||||
|
||||
await screen.findByText('Malformed GPX')
|
||||
expect(planner.toast.error).toHaveBeenCalledWith('Malformed GPX')
|
||||
expect(onDone).not.toHaveBeenCalled()
|
||||
expect(submit()).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPF-019: prefixes each error with its file name on a multi-file import', async () => {
|
||||
vi.spyOn(placesApi, 'importGpx').mockRejectedValue(new Error('boom'))
|
||||
vi.spyOn(placesApi, 'importMapFile').mockRejectedValue({ response: { data: { error: 'bad kml' } } })
|
||||
const { planner } = renderStep()
|
||||
pick([gpxFile('a.gpx'), kmlFile('b.kml')])
|
||||
fireEvent.click(submit())
|
||||
|
||||
await screen.findByText(/a\.gpx: places\.importFileError/)
|
||||
expect(screen.getByText(/b\.kml: bad kml/)).toBeInTheDocument()
|
||||
expect(planner.toast.error).toHaveBeenCalledWith('a.gpx: places.importFileError')
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPF-020: shows the loading label while the request is in flight', async () => {
|
||||
let release: (value: unknown) => void = () => {}
|
||||
vi.spyOn(placesApi, 'importGpx').mockReturnValue(new Promise(resolve => { release = resolve }))
|
||||
const { onDone } = renderStep()
|
||||
pick([gpxFile()])
|
||||
fireEvent.click(submit())
|
||||
|
||||
await screen.findByRole('button', { name: 'common.loading' })
|
||||
expect(screen.getByRole('button', { name: 'common.loading' })).toBeDisabled()
|
||||
|
||||
release({ count: 1, skipped: 0, places: [] })
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPF-021: the cancel button returns to the import menu', () => {
|
||||
const { onBack } = renderStep()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
|
||||
expect(onBack).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,223 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { placesApi } from '../../../../src/api/client'
|
||||
import ImpListStep from '../../../../src/mobile/screens/trip/sheets/ImpListStep'
|
||||
import { useAuthStore } from '../../../../src/store/authStore'
|
||||
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { buildPlanner } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-IMPL-001 to FE-MOB-IMPL-017
|
||||
//
|
||||
// planner.t echoes its key, so the assertions below check keys (plus the
|
||||
// appended parameter values) rather than the English copy.
|
||||
|
||||
function renderStep(plannerOverrides: Partial<TripPlanner> = {}) {
|
||||
const planner = buildPlanner(plannerOverrides)
|
||||
const onBack = vi.fn()
|
||||
const onDone = vi.fn()
|
||||
render(<ImpListStep planner={planner} onBack={onBack} onDone={onDone} />)
|
||||
return { planner, onBack, onDone }
|
||||
}
|
||||
|
||||
function typeUrl(value: string) {
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value } })
|
||||
return input
|
||||
}
|
||||
|
||||
const submit = () => screen.getByRole('button', { name: 'common.import' })
|
||||
|
||||
describe('ImpListStep', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPL-001: starts on Google with its hint, placeholder and a disabled submit', () => {
|
||||
renderStep()
|
||||
expect(screen.getByText('places.importGoogleList')).toBeInTheDocument()
|
||||
expect(screen.getByText('places.googleListHint')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toHaveAttribute('placeholder', 'https://maps.app.goo.gl/…')
|
||||
expect(submit()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPL-002: switching to Naver swaps hint and placeholder', () => {
|
||||
renderStep()
|
||||
fireEvent.click(screen.getByText('places.importNaverList'))
|
||||
expect(screen.getByText('places.naverListHint')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toHaveAttribute('placeholder', 'https://naver.me/…')
|
||||
fireEvent.click(screen.getByText('places.importGoogleList'))
|
||||
expect(screen.getByText('places.googleListHint')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPL-003: hides the enrichment switch without a Maps key', () => {
|
||||
renderStep()
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPL-004: offers the enrichment switch once a Maps key is configured', () => {
|
||||
seedStore(useAuthStore, { hasMapsKey: true })
|
||||
renderStep()
|
||||
const toggle = screen.getByRole('switch', { name: 'places.enrichOnImport' })
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'false')
|
||||
expect(screen.getByText('places.enrichOnImportHint')).toBeInTheDocument()
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPL-005: keeps the submit disabled for whitespace-only input', () => {
|
||||
renderStep()
|
||||
typeUrl(' ')
|
||||
expect(submit()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPL-006: imports a Google list, reloads the trip and toasts the count', async () => {
|
||||
vi.spyOn(placesApi, 'importGoogleList').mockResolvedValue({
|
||||
count: 4, skipped: 0, listName: 'Tokyo eats', places: [{ id: 71 }, { id: 72 }],
|
||||
})
|
||||
const { planner, onDone } = renderStep()
|
||||
typeUrl(' https://maps.app.goo.gl/abc ')
|
||||
fireEvent.click(submit())
|
||||
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1))
|
||||
expect(placesApi.importGoogleList).toHaveBeenCalledWith(1, 'https://maps.app.goo.gl/abc', false)
|
||||
expect(planner.tripActions.loadTrip).toHaveBeenCalledWith(1)
|
||||
expect(planner.toast.success).toHaveBeenCalledWith('places.googleListImported:4,Tokyo eats')
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPL-007: passes the enrichment flag when the user asked for it', async () => {
|
||||
seedStore(useAuthStore, { hasMapsKey: true })
|
||||
vi.spyOn(placesApi, 'importGoogleList').mockResolvedValue({ count: 1, skipped: 0, listName: 'L', places: [] })
|
||||
const { onDone } = renderStep()
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'places.enrichOnImport' }))
|
||||
typeUrl('https://maps.app.goo.gl/abc')
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled())
|
||||
expect(placesApi.importGoogleList).toHaveBeenCalledWith(1, 'https://maps.app.goo.gl/abc', true)
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPL-008: registers an undo that bulk-deletes the imported places', async () => {
|
||||
vi.spyOn(placesApi, 'importGoogleList').mockResolvedValue({
|
||||
count: 2, skipped: 0, listName: 'L', places: [{ id: 71 }, { id: 72 }],
|
||||
})
|
||||
const bulkDelete = vi.spyOn(placesApi, 'bulkDelete').mockResolvedValue({ deleted: 2 })
|
||||
const { planner, onDone } = renderStep()
|
||||
typeUrl('https://maps.app.goo.gl/abc')
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled())
|
||||
|
||||
expect(planner.pushUndo).toHaveBeenCalledWith('undo.importGoogleList', expect.any(Function))
|
||||
const revert = vi.mocked(planner.pushUndo).mock.calls[0][1] as () => Promise<void>
|
||||
await revert()
|
||||
expect(bulkDelete).toHaveBeenCalledWith(1, [71, 72])
|
||||
expect(planner.tripActions.loadTrip).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPL-009: a failing undo still reloads the trip', async () => {
|
||||
vi.spyOn(placesApi, 'importGoogleList').mockResolvedValue({
|
||||
count: 1, skipped: 0, listName: 'L', places: [{ id: 71 }],
|
||||
})
|
||||
vi.spyOn(placesApi, 'bulkDelete').mockRejectedValue(new Error('gone'))
|
||||
const { planner, onDone } = renderStep()
|
||||
typeUrl('https://maps.app.goo.gl/abc')
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled())
|
||||
|
||||
const revert = vi.mocked(planner.pushUndo).mock.calls[0][1] as () => Promise<void>
|
||||
await expect(revert()).resolves.toBeUndefined()
|
||||
expect(planner.tripActions.loadTrip).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPL-010: skips the undo entry when nothing was created', async () => {
|
||||
vi.spyOn(placesApi, 'importGoogleList').mockResolvedValue({ count: 3, skipped: 0, listName: 'L', places: [] })
|
||||
const { planner, onDone } = renderStep()
|
||||
typeUrl('https://maps.app.goo.gl/abc')
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled())
|
||||
expect(planner.pushUndo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPL-011: warns instead of celebrating when every entry was skipped', async () => {
|
||||
vi.spyOn(placesApi, 'importGoogleList').mockResolvedValue({ count: 0, skipped: 5, listName: 'L', places: [] })
|
||||
const { planner, onDone } = renderStep()
|
||||
typeUrl('https://maps.app.goo.gl/abc')
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled())
|
||||
expect(planner.toast.warning).toHaveBeenCalledWith('places.importAllSkipped')
|
||||
expect(planner.toast.success).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPL-012: imports a Naver list on the Naver endpoint', async () => {
|
||||
vi.spyOn(placesApi, 'importNaverList').mockResolvedValue({
|
||||
count: 2, skipped: 0, listName: 'Seoul', places: [{ id: 81 }],
|
||||
})
|
||||
const { planner, onDone } = renderStep()
|
||||
fireEvent.click(screen.getByText('places.importNaverList'))
|
||||
typeUrl('https://naver.me/xyz')
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled())
|
||||
expect(placesApi.importNaverList).toHaveBeenCalledWith(1, 'https://naver.me/xyz', false)
|
||||
expect(planner.toast.success).toHaveBeenCalledWith('places.naverListImported:2,Seoul')
|
||||
expect(planner.pushUndo).toHaveBeenCalledWith('undo.importNaverList', expect.any(Function))
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPL-013: surfaces the server error message and keeps the sheet open', async () => {
|
||||
vi.spyOn(placesApi, 'importGoogleList').mockRejectedValue({ response: { data: { error: 'List is private' } } })
|
||||
const { planner, onDone } = renderStep()
|
||||
typeUrl('https://maps.app.goo.gl/abc')
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('List is private'))
|
||||
expect(onDone).not.toHaveBeenCalled()
|
||||
expect(submit()).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPL-014: falls back to the provider error key without a server message', async () => {
|
||||
vi.spyOn(placesApi, 'importNaverList').mockRejectedValue(new Error('network'))
|
||||
const { planner } = renderStep()
|
||||
fireEvent.click(screen.getByText('places.importNaverList'))
|
||||
typeUrl('https://naver.me/xyz')
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('places.naverListError'))
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPL-015: Enter in the url field submits and ignores an empty field', async () => {
|
||||
vi.spyOn(placesApi, 'importGoogleList').mockResolvedValue({ count: 1, skipped: 0, listName: 'L', places: [] })
|
||||
const { onDone } = renderStep()
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(placesApi.importGoogleList).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'https://maps.app.goo.gl/abc' } })
|
||||
fireEvent.keyDown(input, { key: 'a' })
|
||||
expect(placesApi.importGoogleList).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled())
|
||||
expect(placesApi.importGoogleList).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPL-016: shows the loading label and refuses a second submit while in flight', async () => {
|
||||
let release: (value: unknown) => void = () => {}
|
||||
vi.spyOn(placesApi, 'importGoogleList').mockReturnValue(new Promise(resolve => { release = resolve }))
|
||||
const { onDone } = renderStep()
|
||||
const input = typeUrl('https://maps.app.goo.gl/abc')
|
||||
fireEvent.click(submit())
|
||||
|
||||
await screen.findByRole('button', { name: 'common.loading' })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(placesApi.importGoogleList).toHaveBeenCalledTimes(1)
|
||||
|
||||
release({ count: 1, skipped: 0, listName: 'L', places: [] })
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPL-017: the cancel button goes back to the import menu', () => {
|
||||
const { onBack } = renderStep()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
|
||||
expect(onBack).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,331 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { accommodationsApi } from '../../../../src/api/client'
|
||||
import MAccommodationSheet from '../../../../src/mobile/screens/trip/sheets/MAccommodationSheet'
|
||||
import type { Accommodation, Category, Day, Place } from '../../../../src/types'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores } from '../../../helpers/store'
|
||||
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-ACCSH-001 to FE-MOB-ACCSH-025
|
||||
|
||||
// The pickers have their own suites; here they only need to be addressable, so
|
||||
// they render as plain controls. data-value keeps the requested value readable
|
||||
// even when it is not one of the offered options.
|
||||
vi.mock('../../../../src/components/shared/CustomSelect', () => ({
|
||||
default: ({ value, onChange, options = [] }: {
|
||||
value: string | number
|
||||
onChange: (v: string | number) => void
|
||||
options?: { value: string | number; label: string }[]
|
||||
}) => (
|
||||
<select
|
||||
data-testid="cselect"
|
||||
data-value={String(value ?? '')}
|
||||
value={options.some(o => String(o.value) === String(value)) ? String(value) : ''}
|
||||
onChange={e => {
|
||||
const hit = options.find(o => String(o.value) === e.target.value)
|
||||
onChange(hit ? hit.value : e.target.value)
|
||||
}}
|
||||
>
|
||||
<option value="" />
|
||||
{options.map(o => <option key={String(o.value)} value={String(o.value)}>{o.label}</option>)}
|
||||
</select>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/components/shared/CustomTimePicker', () => ({
|
||||
default: ({ value, onChange, placeholder }: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
placeholder?: string
|
||||
}) => (
|
||||
<input data-testid="ctime" placeholder={placeholder} value={value} onChange={e => onChange(e.target.value)} />
|
||||
),
|
||||
}))
|
||||
|
||||
const DAYS = [
|
||||
{ id: 11, trip_id: 5, day_number: 1, date: '2026-05-01', title: null },
|
||||
{ id: 12, trip_id: 5, day_number: 2, date: '2026-05-02', title: null },
|
||||
{ id: 13, trip_id: 5, day_number: 3, date: null, title: 'Fly home' },
|
||||
] as unknown as Day[]
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 1, name: 'Hotels', color: '#ff0000' },
|
||||
{ id: 2, name: 'Food', color: '#00ff00' },
|
||||
] as unknown as Category[]
|
||||
|
||||
const PLACES = [
|
||||
{ id: 101, name: 'Hotel Sacher', address: 'Philharmonikerstrasse 4', category_id: 1, image_url: '/uploads/sacher.jpg' },
|
||||
{ id: 102, name: 'Cafe Central', address: null, category_id: 2, image_url: null },
|
||||
] as unknown as Place[]
|
||||
|
||||
const EXISTING = {
|
||||
id: 77, trip_id: 5, place_id: 102, start_day_id: 12, end_day_id: 13,
|
||||
check_in: '15:00', check_in_end: '18:00', check_out: '10:00', confirmation: 'ABC-1',
|
||||
} as unknown as Accommodation
|
||||
|
||||
function makePlanner(overrides: Record<string, unknown> = {}) {
|
||||
return buildPlanner({
|
||||
tripId: 5,
|
||||
days: DAYS,
|
||||
places: PLACES,
|
||||
categories: CATEGORIES,
|
||||
tripAccommodations: [EXISTING],
|
||||
...overrides,
|
||||
} as never)
|
||||
}
|
||||
|
||||
function makeShell(payload: Record<string, unknown> | null = { dayId: 11 }) {
|
||||
return buildShell({ sheet: payload === null ? null : { id: 'accommodation', payload } } as never)
|
||||
}
|
||||
|
||||
function setup(planner = makePlanner(), shell = makeShell()) {
|
||||
const view = render(<MAccommodationSheet planner={planner} shell={shell} />)
|
||||
return { ...view, planner, shell }
|
||||
}
|
||||
|
||||
/** [start, end] of the day-range selects as the component holds them. */
|
||||
function range(): [string, string] {
|
||||
const [start, end] = screen.getAllByTestId('cselect')
|
||||
return [start.getAttribute('data-value') ?? '', end.getAttribute('data-value') ?? '']
|
||||
}
|
||||
|
||||
function pickDay(index: 0 | 1, id: number) {
|
||||
fireEvent.change(screen.getAllByTestId('cselect')[index], { target: { value: String(id) } })
|
||||
}
|
||||
|
||||
/** Both the day-range chip and the category filter are labelled "All". */
|
||||
function allChips() {
|
||||
return screen.getAllByRole('button', { name: 'All' })
|
||||
}
|
||||
|
||||
function placeRow(name: string) {
|
||||
return screen.getByText(name).closest('button') as HTMLButtonElement
|
||||
}
|
||||
|
||||
describe('MAccommodationSheet', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('FE-MOB-ACCSH-001: stays closed while another sheet is on top', () => {
|
||||
setup(makePlanner(), buildShell({ sheet: { id: 'day', payload: { dayId: 11 } } } as never))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-ACCSH-002: stays closed without any sheet', () => {
|
||||
setup(makePlanner(), makeShell(null))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-ACCSH-003: opens in add mode with an empty form and a disabled save', () => {
|
||||
setup()
|
||||
expect(screen.getByRole('dialog', { name: 'Add accommodation' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Apply to days')).toBeInTheDocument()
|
||||
expect(screen.getByText('Select accommodation')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('ABC-12345')).toHaveValue('')
|
||||
expect(screen.getByPlaceholderText('14:00')).toHaveValue('')
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-ACCSH-004: defaults the range from the opening day to the next one', () => {
|
||||
setup()
|
||||
expect(range()).toEqual(['11', '12'])
|
||||
})
|
||||
|
||||
it('FE-MOB-ACCSH-005: a day that is not in the trip keeps the range on that single day', () => {
|
||||
setup(makePlanner(), makeShell({ dayId: 999 }))
|
||||
expect(range()).toEqual(['999', '999'])
|
||||
})
|
||||
|
||||
it('FE-MOB-ACCSH-006: opening the last day keeps it as its own end', () => {
|
||||
setup(makePlanner(), makeShell({ dayId: 13 }))
|
||||
expect(range()).toEqual(['13', '13'])
|
||||
})
|
||||
|
||||
it('FE-MOB-ACCSH-007: an empty payload falls back to no day at all', () => {
|
||||
setup(makePlanner(), makeShell({}))
|
||||
expect(range()).toEqual(['0', '0'])
|
||||
})
|
||||
|
||||
it('FE-MOB-ACCSH-008: edit mode seeds range, times, code and the linked place', () => {
|
||||
setup(makePlanner(), makeShell({ dayId: 12, accId: 77 }))
|
||||
expect(screen.getByRole('dialog', { name: 'Edit accommodation' })).toBeInTheDocument()
|
||||
expect(range()).toEqual(['12', '13'])
|
||||
expect(screen.getByPlaceholderText('14:00')).toHaveValue('15:00')
|
||||
expect(screen.getByPlaceholderText('22:00')).toHaveValue('18:00')
|
||||
expect(screen.getByPlaceholderText('11:00')).toHaveValue('10:00')
|
||||
expect(screen.getByPlaceholderText('ABC-12345')).toHaveValue('ABC-1')
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-ACCSH-009: a stay without any times seeds empty fields', () => {
|
||||
const bare = { id: 78, trip_id: 5, place_id: 101, start_day_id: 11, end_day_id: 11 } as unknown as Accommodation
|
||||
setup(makePlanner({ tripAccommodations: [bare] }), makeShell({ dayId: 11, accId: 78 }))
|
||||
expect(screen.getByPlaceholderText('14:00')).toHaveValue('')
|
||||
expect(screen.getByPlaceholderText('ABC-12345')).toHaveValue('')
|
||||
expect(range()).toEqual(['11', '11'])
|
||||
})
|
||||
|
||||
it('FE-MOB-ACCSH-010: an unknown accommodation id falls back to add mode', () => {
|
||||
setup(makePlanner(), makeShell({ dayId: 11, accId: 4242 }))
|
||||
expect(screen.getByRole('dialog', { name: 'Add accommodation' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-ACCSH-011: cancel and the header X both return to the day sheet', () => {
|
||||
const { shell } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(shell.openSheet).toHaveBeenCalledTimes(2)
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('day', { dayId: 11 })
|
||||
})
|
||||
|
||||
it('FE-MOB-ACCSH-012: the All chip spans the whole trip and reaches the payload', async () => {
|
||||
const create = vi.spyOn(accommodationsApi, 'create').mockResolvedValue({})
|
||||
setup()
|
||||
fireEvent.click(allChips()[0])
|
||||
expect(range()).toEqual(['11', '13'])
|
||||
|
||||
fireEvent.click(placeRow('Hotel Sacher'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(create).toHaveBeenCalled())
|
||||
expect(create.mock.calls[0][1]).toMatchObject({ start_day_id: 11, end_day_id: 13 })
|
||||
})
|
||||
|
||||
it('FE-MOB-ACCSH-013: moving the start past the end drags the end with it', () => {
|
||||
setup()
|
||||
pickDay(0, 13)
|
||||
expect(range()).toEqual(['13', '13'])
|
||||
})
|
||||
|
||||
it('FE-MOB-ACCSH-014: moving the end before the start drags the start with it', () => {
|
||||
setup(makePlanner(), makeShell({ dayId: 12 }))
|
||||
pickDay(1, 11)
|
||||
expect(range()).toEqual(['11', '11'])
|
||||
})
|
||||
|
||||
it('FE-MOB-ACCSH-015: widening the range leaves the other end alone', () => {
|
||||
setup()
|
||||
pickDay(1, 13)
|
||||
pickDay(0, 12)
|
||||
expect(range()).toEqual(['12', '13'])
|
||||
})
|
||||
|
||||
it('FE-MOB-ACCSH-016: the category chips filter the place list and All restores it', () => {
|
||||
setup()
|
||||
expect(screen.getByText('Cafe Central')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hotels' }))
|
||||
expect(screen.getByText('Hotel Sacher')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Cafe Central')).not.toBeInTheDocument()
|
||||
fireEvent.click(allChips()[1])
|
||||
expect(screen.getByText('Cafe Central')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-ACCSH-017: a category without places shows the empty hint', () => {
|
||||
setup(makePlanner({ places: [PLACES[0]] }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Food' }))
|
||||
expect(screen.getByText('Add places to your trip first')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-ACCSH-018: without categories there are no filter chips', () => {
|
||||
setup(makePlanner({ categories: [] }))
|
||||
expect(screen.queryByRole('button', { name: 'Hotels' })).not.toBeInTheDocument()
|
||||
expect(allChips()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-ACCSH-019: renders the place thumbnail when there is one, a pin otherwise', () => {
|
||||
setup()
|
||||
expect(placeRow('Hotel Sacher').querySelector('img')).toHaveAttribute('src', '/uploads/sacher.jpg')
|
||||
expect(screen.getByText('Philharmonikerstrasse 4')).toBeInTheDocument()
|
||||
const cafe = placeRow('Cafe Central')
|
||||
expect(cafe.querySelector('img')).toBeNull()
|
||||
expect(cafe.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-ACCSH-020: creating posts the full body, reloads the stays and returns to the day', async () => {
|
||||
const create = vi.spyOn(accommodationsApi, 'create').mockResolvedValue({})
|
||||
const { planner, shell } = setup()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('14:00'), { target: { value: '15:30' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('22:00'), { target: { value: '20:00' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('11:00'), { target: { value: '09:45' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('ABC-12345'), { target: { value: 'XYZ-9' } })
|
||||
fireEvent.click(placeRow('Cafe Central'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(planner.loadAccommodations).toHaveBeenCalled())
|
||||
expect(create).toHaveBeenCalledWith(5, {
|
||||
place_id: 102,
|
||||
start_day_id: 11,
|
||||
end_day_id: 12,
|
||||
check_in: '15:30',
|
||||
check_in_end: '20:00',
|
||||
check_out: '09:45',
|
||||
confirmation: 'XYZ-9',
|
||||
})
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('day', { dayId: 11 })
|
||||
})
|
||||
|
||||
it('FE-MOB-ACCSH-021: editing updates the existing stay and nulls the cleared fields', async () => {
|
||||
const update = vi.spyOn(accommodationsApi, 'update').mockResolvedValue({})
|
||||
const { planner } = setup(makePlanner(), makeShell({ dayId: 12, accId: 77 }))
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('22:00'), { target: { value: '' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('ABC-12345'), { target: { value: '' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(planner.loadAccommodations).toHaveBeenCalled())
|
||||
expect(update).toHaveBeenCalledWith(5, 77, {
|
||||
place_id: 102,
|
||||
start_day_id: 12,
|
||||
end_day_id: 13,
|
||||
check_in: '15:00',
|
||||
check_in_end: null,
|
||||
check_out: '10:00',
|
||||
confirmation: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-MOB-ACCSH-022: the save button locks while the request is in flight', async () => {
|
||||
const deferred = { settle: () => undefined as void }
|
||||
vi.spyOn(accommodationsApi, 'create').mockReturnValue(
|
||||
new Promise<unknown>(res => { deferred.settle = () => res({}) }),
|
||||
)
|
||||
setup()
|
||||
fireEvent.click(placeRow('Hotel Sacher'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
const saving = await screen.findByRole('button', { name: 'Saving...' })
|
||||
expect(saving).toBeDisabled()
|
||||
await act(async () => { deferred.settle() })
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-ACCSH-024: a trip without days opens on an empty range', () => {
|
||||
setup(makePlanner({ days: [] }), makeShell({}))
|
||||
expect(range()).toEqual(['0', '0'])
|
||||
fireEvent.click(allChips()[0])
|
||||
expect(range()).toEqual(['0', '0'])
|
||||
})
|
||||
|
||||
it('FE-MOB-ACCSH-025: a day with neither date nor title is still numbered in the range', () => {
|
||||
const bareDays = [{ id: 21, trip_id: 5, day_number: 1, date: null, title: null }] as unknown as Day[]
|
||||
setup(makePlanner({ days: bareDays }), makeShell({ dayId: 21 }))
|
||||
expect(screen.getAllByRole('option', { name: 'Day 1' }).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-MOB-ACCSH-023: a failing save toasts, keeps the sheet open and re-enables Save', async () => {
|
||||
vi.spyOn(accommodationsApi, 'create').mockRejectedValue(new Error('boom'))
|
||||
const { planner, shell } = setup()
|
||||
|
||||
fireEvent.click(placeRow('Hotel Sacher'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Error'))
|
||||
expect(planner.loadAccommodations).not.toHaveBeenCalled()
|
||||
expect(shell.openSheet).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,250 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ComponentProps } from 'react'
|
||||
import MBagsSheet from '../../../../src/mobile/screens/trip/tabs/MBagsSheet'
|
||||
import type { PackingBag, PackingItem, TripMember } from '../../../../src/types'
|
||||
import { buildPlanner } from '../../../helpers/mobileTrip'
|
||||
import { fireEvent, render, screen, within } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-BAGS-001 to FE-MOB-BAGS-019
|
||||
|
||||
function bag(overrides: Partial<PackingBag> = {}): PackingBag {
|
||||
return {
|
||||
id: 1, trip_id: 1, name: 'Backpack', color: '#6366f1', sort_order: 0,
|
||||
...overrides,
|
||||
} as PackingBag
|
||||
}
|
||||
|
||||
function item(overrides: Partial<PackingItem> = {}): PackingItem {
|
||||
return {
|
||||
id: 1, trip_id: 1, name: 'Shirt', checked: 0, category: 'Clothing', sort_order: 0,
|
||||
...overrides,
|
||||
} as PackingItem
|
||||
}
|
||||
|
||||
const ANNA = { id: 11, username: 'anna', avatar: 'anna.png', avatar_url: null } as unknown as TripMember
|
||||
const BEN = { id: 12, username: 'ben', avatar: null, avatar_url: 'https://cdn.example/ben.png' } as unknown as TripMember
|
||||
|
||||
const BAGS = [
|
||||
bag({ id: 1, name: 'Backpack', color: '#6366f1', members: [{ user_id: 11, username: 'anna', avatar: '/uploads/avatars/anna.png' }] }),
|
||||
bag({ id: 2, name: 'Suitcase', color: '#ec4899', sort_order: 1, members: [{ user_id: 12, username: 'ben', avatar: null }] }),
|
||||
]
|
||||
|
||||
// Backpack 300 g, Suitcase 2 x 600 g, unassigned 40 g → 1540 g total.
|
||||
const ITEMS = [
|
||||
item({ id: 1, name: 'Shirt', bag_id: 1, weight_grams: 300 }),
|
||||
item({ id: 2, name: 'Boots', bag_id: 2, weight_grams: 600, quantity: 2 }),
|
||||
item({ id: 3, name: 'Charger', bag_id: null, weight_grams: 40 }),
|
||||
]
|
||||
|
||||
function setup(overrides: Partial<ComponentProps<typeof MBagsSheet>> = {}) {
|
||||
const props: ComponentProps<typeof MBagsSheet> = {
|
||||
planner: buildPlanner(),
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
bags: BAGS,
|
||||
items: ITEMS,
|
||||
tripMembers: [ANNA, BEN],
|
||||
canEdit: true,
|
||||
onCreateBag: vi.fn(),
|
||||
onUpdateBag: vi.fn(),
|
||||
onDeleteBag: vi.fn(),
|
||||
onSetBagMembers: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
const view = render(<MBagsSheet {...props} />)
|
||||
return { ...props, view }
|
||||
}
|
||||
|
||||
/** The bag block that owns the given name button. */
|
||||
function rowFor(name: string): HTMLElement {
|
||||
return screen.getByRole('button', { name }).closest('div.mb-4') as HTMLElement
|
||||
}
|
||||
|
||||
function fillBars(): HTMLElement[] {
|
||||
return Array.from(document.querySelectorAll<HTMLElement>('div.h-\\[7px\\] > div'))
|
||||
}
|
||||
|
||||
describe('MBagsSheet', () => {
|
||||
it('FE-MOB-BAGS-001: stays unmounted while closed', () => {
|
||||
setup({ open: false })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-BAGS-002: lists every bag with its weight and item count', () => {
|
||||
setup()
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute('aria-label', 'packing.bags')
|
||||
expect(within(rowFor('Backpack')).getByText('300 g')).toBeInTheDocument()
|
||||
expect(within(rowFor('Backpack')).getByText('1 admin.packingTemplates.items')).toBeInTheDocument()
|
||||
expect(within(rowFor('Suitcase')).getByText('1.2 kg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BAGS-003: shows the unassigned pile and the grand total', () => {
|
||||
setup()
|
||||
expect(screen.getByText('packing.noBag')).toBeInTheDocument()
|
||||
expect(screen.getByText('40 g')).toBeInTheDocument()
|
||||
expect(screen.getByText('packing.totalWeight')).toBeInTheDocument()
|
||||
expect(screen.getByText('1.5 kg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BAGS-004: hides the unassigned pile when every item has a bag', () => {
|
||||
setup({ items: ITEMS.filter(i => i.bag_id != null) })
|
||||
expect(screen.queryByText('packing.noBag')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-BAGS-005: scales the fill bar against the heaviest bag when no limit is set', () => {
|
||||
setup()
|
||||
// 300/1200 = 25 %, 1200/1200 = 100 %
|
||||
expect(fillBars().map(b => b.style.width)).toEqual(['25%', '100%'])
|
||||
})
|
||||
|
||||
it('FE-MOB-BAGS-006: scales against the bag limit and caps at 100 %', () => {
|
||||
setup({ bags: [bag({ id: 1, name: 'Backpack', weight_limit_grams: 200 })] })
|
||||
expect(fillBars()[0].style.width).toBe('100%')
|
||||
})
|
||||
|
||||
it('FE-MOB-BAGS-007: renames a bag on Enter', () => {
|
||||
const { onUpdateBag } = setup()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Backpack' }))
|
||||
const input = screen.getByDisplayValue('Backpack')
|
||||
fireEvent.change(input, { target: { value: 'Daypack' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(onUpdateBag).toHaveBeenCalledWith(1, { name: 'Daypack' })
|
||||
// The row keeps showing the prop value until the parent pushes the update back.
|
||||
expect(screen.getByRole('button', { name: 'Backpack' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BAGS-008: renames on blur too, trimming the input', () => {
|
||||
const { onUpdateBag } = setup()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Suitcase' }))
|
||||
const input = screen.getByDisplayValue('Suitcase')
|
||||
fireEvent.change(input, { target: { value: ' Trunk ' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onUpdateBag).toHaveBeenCalledWith(2, { name: 'Trunk' })
|
||||
})
|
||||
|
||||
it('FE-MOB-BAGS-009: keeps the old name when the edit is blank', () => {
|
||||
const { onUpdateBag } = setup()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Backpack' }))
|
||||
const input = screen.getByDisplayValue('Backpack')
|
||||
fireEvent.change(input, { target: { value: ' ' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(onUpdateBag).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: 'Backpack' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BAGS-010: Escape reverts the rename', () => {
|
||||
const { onUpdateBag } = setup()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Backpack' }))
|
||||
const input = screen.getByDisplayValue('Backpack')
|
||||
fireEvent.change(input, { target: { value: 'Nope' } })
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
|
||||
expect(onUpdateBag).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: 'Backpack' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BAGS-011: deletes a bag', () => {
|
||||
const { onDeleteBag } = setup()
|
||||
|
||||
fireEvent.click(within(rowFor('Suitcase')).getByRole('button', { name: 'common.delete' }))
|
||||
|
||||
expect(onDeleteBag).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-BAGS-012: removes a member by tapping their avatar chip', () => {
|
||||
const { onSetBagMembers } = setup()
|
||||
|
||||
fireEvent.click(within(rowFor('Backpack')).getByTitle('anna'))
|
||||
|
||||
expect(onSetBagMembers).toHaveBeenCalledWith(1, [])
|
||||
})
|
||||
|
||||
it('FE-MOB-BAGS-013: falls back to the initial when a member has no avatar', () => {
|
||||
setup()
|
||||
expect(within(rowFor('Suitcase')).getByTitle('ben')).toHaveTextContent('B')
|
||||
})
|
||||
|
||||
it('FE-MOB-BAGS-014: adds a member from the picker', () => {
|
||||
const { onSetBagMembers } = setup()
|
||||
const row = rowFor('Backpack')
|
||||
|
||||
fireEvent.click(within(row).getByRole('button', { name: 'common.add' }))
|
||||
const imgs = Array.from(row.querySelectorAll('img')).map(i => i.getAttribute('src'))
|
||||
expect(imgs).toContain('/uploads/avatars/anna.png')
|
||||
expect(imgs).toContain('https://cdn.example/ben.png')
|
||||
|
||||
fireEvent.click(within(row).getByRole('button', { name: 'ben' }))
|
||||
|
||||
expect(onSetBagMembers).toHaveBeenCalledWith(1, [11, 12])
|
||||
})
|
||||
|
||||
it('FE-MOB-BAGS-015: shows the empty hint when the trip has no members', () => {
|
||||
setup({ tripMembers: [], bags: [bag({ id: 1, name: 'Backpack' })] })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
|
||||
|
||||
expect(screen.getByText('packing.noMembers')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BAGS-016: creates a bag from the inline field', () => {
|
||||
const { onCreateBag } = setup({ bags: [], items: [] })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.addBag' }))
|
||||
expect(screen.getByRole('button', { name: 'common.add' })).toBeDisabled()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('packing.bagName'), { target: { value: ' Duffel ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
|
||||
|
||||
expect(onCreateBag).toHaveBeenCalledWith('Duffel')
|
||||
expect(screen.getByRole('button', { name: 'packing.addBag' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BAGS-017: Enter submits and Escape aborts the new-bag field', () => {
|
||||
const { onCreateBag } = setup({ bags: [], items: [] })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.addBag' }))
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('packing.bagName'), { key: 'Enter' })
|
||||
expect(onCreateBag).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('packing.bagName'), { target: { value: 'Tote' } })
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('packing.bagName'), { key: 'Enter' })
|
||||
expect(onCreateBag).toHaveBeenCalledWith('Tote')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.addBag' }))
|
||||
fireEvent.change(screen.getByPlaceholderText('packing.bagName'), { target: { value: 'x' } })
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('packing.bagName'), { key: 'Escape' })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'packing.addBag' })).toBeInTheDocument()
|
||||
expect(onCreateBag).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-BAGS-018: read-only mode hides delete, add-bag, picker and rename', () => {
|
||||
const { onUpdateBag, onSetBagMembers } = setup({ canEdit: false })
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'common.delete' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'packing.addBag' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'common.add' })).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Backpack' }))
|
||||
expect(screen.queryByDisplayValue('Backpack')).toBeNull()
|
||||
fireEvent.click(within(rowFor('Backpack')).getByTitle('anna'))
|
||||
|
||||
expect(onUpdateBag).not.toHaveBeenCalled()
|
||||
expect(onSetBagMembers).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-BAGS-019: closes from the header', () => {
|
||||
const { onClose } = setup()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
|
||||
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,286 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MBookingsTab from '../../../../src/mobile/screens/trip/tabs/MBookingsTab'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { openFile } from '../../../../src/utils/fileDownload'
|
||||
import type { Day, Reservation, TripFile } from '../../../../src/types'
|
||||
import { buildSettings } from '../../../helpers/factories'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-BKTAB-001 to FE-MOB-BKTAB-018
|
||||
|
||||
vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()),
|
||||
openFile: vi.fn(),
|
||||
}))
|
||||
|
||||
const DAYS = [
|
||||
{ id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null },
|
||||
{ id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: 'Kyoto' },
|
||||
] as unknown as Day[]
|
||||
|
||||
const MEMBERS = [
|
||||
{ id: 1, username: 'Me', avatar_url: null },
|
||||
{ id: 2, username: 'Ada', avatar_url: null },
|
||||
]
|
||||
|
||||
/** Stay: the day range comes from the accommodation link, not day_id. */
|
||||
const HOTEL = {
|
||||
id: 201, trip_id: 7, type: 'hotel', status: 'confirmed', title: 'Hotel Granvia',
|
||||
accommodation_start_day_id: 1, accommodation_end_day_id: 2,
|
||||
confirmation_number: 'HG-77', location: 'Kyoto Station', notes: 'Ask for a high floor',
|
||||
metadata: JSON.stringify({ check_in_time: '15:00', check_in_end_time: '18:00', check_out_time: '11:00' }),
|
||||
} as unknown as Reservation
|
||||
|
||||
const DINNER = {
|
||||
id: 202, trip_id: 7, type: 'restaurant', status: 'confirmed', title: 'Sushi Saito',
|
||||
day_id: 2, reservation_time: '2026-05-02T19:30', reservation_end_time: '2026-05-02T21:00',
|
||||
travelers: [{ user_id: 2, username: 'Ada', avatar: null }],
|
||||
} as unknown as Reservation
|
||||
|
||||
/** Starts and ends on the same day — the range suffix has to stay off. */
|
||||
const SHOW = {
|
||||
id: 203, trip_id: 7, type: 'event', status: 'pending', title: 'Kabuki show',
|
||||
day_id: 2, end_day_id: 2, needs_review: 1,
|
||||
metadata: JSON.stringify({ departure_airport: 'Gion', platform: 'Gate 3', seat: 'B12', price: 40, priceCurrency: 'JPY' }),
|
||||
} as unknown as Reservation
|
||||
|
||||
/** Endpoints present, so the airport meta cells are suppressed and only the price stays. */
|
||||
const TOUR = {
|
||||
id: 204, trip_id: 7, type: 'tour', status: 'pending', title: 'Bamboo walk',
|
||||
metadata: JSON.stringify({ departure_airport: 'Arashiyama', arrival_airport: 'Saga', price: 30, priceCurrency: 'JPY' }),
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'Arashiyama' },
|
||||
{ role: 'to', sequence: 1, name: 'Saga' },
|
||||
],
|
||||
} as unknown as Reservation
|
||||
|
||||
const FLIGHT = {
|
||||
id: 205, trip_id: 7, type: 'flight', status: 'confirmed', title: 'HND to ITM', day_id: 1,
|
||||
} as unknown as Reservation
|
||||
|
||||
const FILES = [
|
||||
{ id: 301, trip_id: 7, reservation_id: 202, original_name: 'voucher.pdf', url: '/uploads/f/301' },
|
||||
{ id: 302, trip_id: 7, reservation_id: 202, original_name: 'stale.pdf', url: '/uploads/f/302', deleted_at: '2026-01-01' },
|
||||
] as unknown as TripFile[]
|
||||
|
||||
const ALL = [HOTEL, DINNER, SHOW, TOUR, FLIGHT]
|
||||
|
||||
function planner(overrides: Partial<TripPlanner> = {}) {
|
||||
return buildPlanner({
|
||||
tripId: 7,
|
||||
days: DAYS,
|
||||
reservations: ALL,
|
||||
files: FILES,
|
||||
tripMembers: MEMBERS as unknown as TripPlanner['tripMembers'],
|
||||
settings: buildSettings({ time_format: '24h' }),
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
function renderTab(p: TripPlanner = planner(), shell: MTripShellApi = buildShell()) {
|
||||
const view = render(<MBookingsTab planner={p} shell={shell} />)
|
||||
return { ...view, planner: p, shell }
|
||||
}
|
||||
|
||||
function cardOf(title: string): HTMLElement {
|
||||
let el = screen.getByText(title).parentElement
|
||||
while (el && !el.className.includes('rounded-2xl')) el = el.parentElement
|
||||
if (!el) throw new Error(`no card for ${title}`)
|
||||
return el
|
||||
}
|
||||
|
||||
describe('MBookingsTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(openFile).mockClear()
|
||||
})
|
||||
|
||||
it('FE-MOB-BKTAB-001: keeps only the non-transport reservations, split by status', () => {
|
||||
renderTab()
|
||||
expect(screen.getByRole('button', { name: 'reservations.confirmed2' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'reservations.pending2' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('HND to ITM')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BKTAB-002: sorts chronologically and sinks the undated booking', () => {
|
||||
renderTab()
|
||||
const titles = screen.getAllByText(/Hotel Granvia|Sushi Saito|Kabuki show|Bamboo walk/).map(n => n.textContent)
|
||||
expect(titles).toEqual(['Hotel Granvia', 'Sushi Saito', 'Kabuki show', 'Bamboo walk'])
|
||||
})
|
||||
|
||||
it('FE-MOB-BKTAB-003: collapses a section', () => {
|
||||
renderTab()
|
||||
const header = screen.getByRole('button', { name: 'reservations.pending2' })
|
||||
fireEvent.click(header)
|
||||
expect(header).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.queryByText('Kabuki show')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Hotel Granvia')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BKTAB-004: shows the mascot empty state when only transports exist', () => {
|
||||
renderTab(planner({ reservations: [FLIGHT] }))
|
||||
expect(screen.getByText('mobileTrip.bookingsEmpty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BKTAB-005: spans the stay over the accommodation day range', () => {
|
||||
renderTab()
|
||||
const card = cardOf('Hotel Granvia')
|
||||
expect(within(card).getByText('dayplan.dayN:1 – Kyoto')).toBeInTheDocument()
|
||||
expect(within(card).getByText('—')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BKTAB-006: renders check-in window and check-out for a stay', () => {
|
||||
renderTab()
|
||||
const card = cardOf('Hotel Granvia')
|
||||
expect(within(card).getByText('reservations.meta.checkIn')).toBeInTheDocument()
|
||||
expect(within(card).getByText('15:00 – 18:00')).toBeInTheDocument()
|
||||
expect(within(card).getByText('reservations.meta.checkOut')).toBeInTheDocument()
|
||||
expect(within(card).getByText('11:00')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BKTAB-007: renders location, notes and the confirmation code of a stay', () => {
|
||||
renderTab()
|
||||
const card = cardOf('Hotel Granvia')
|
||||
expect(within(card).getByText('Kyoto Station')).toBeInTheDocument()
|
||||
expect(within(card).getByText('Ask for a high floor')).toBeInTheDocument()
|
||||
expect(within(card).getByText('HG-77')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BKTAB-008: renders the booking time range, travelers and files', () => {
|
||||
renderTab()
|
||||
const card = cardOf('Sushi Saito')
|
||||
expect(within(card).getByText('Kyoto')).toBeInTheDocument()
|
||||
expect(within(card).getByText('19:30 – 21:00')).toBeInTheDocument()
|
||||
expect(within(card).getByText('reservations.travelers.label')).toBeInTheDocument()
|
||||
expect(within(card).getByText('Ada')).toBeInTheDocument()
|
||||
expect(within(card).getByText('voucher.pdf')).toBeInTheDocument()
|
||||
expect(within(card).queryByText('stale.pdf')).not.toBeInTheDocument()
|
||||
fireEvent.click(within(card).getByText('voucher.pdf'))
|
||||
expect(openFile).toHaveBeenCalledWith('/uploads/f/301', 'voucher.pdf')
|
||||
})
|
||||
|
||||
it('FE-MOB-BKTAB-009: drops the day-range suffix when start and end day are the same', () => {
|
||||
renderTab()
|
||||
const card = cardOf('Kabuki show')
|
||||
expect(within(card).getByText('Kyoto')).toBeInTheDocument()
|
||||
expect(within(card).getByText('reservations.needsReview')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BKTAB-010: caps the meta row at three cells', () => {
|
||||
renderTab()
|
||||
const card = cardOf('Kabuki show')
|
||||
expect(within(card).getByText('Gion')).toBeInTheDocument()
|
||||
expect(within(card).getByText('Gate 3')).toBeInTheDocument()
|
||||
expect(within(card).getByText('B12')).toBeInTheDocument()
|
||||
expect(within(card).queryByText('reservations.price')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BKTAB-011: suppresses the airport cells when endpoints exist and keeps the price', () => {
|
||||
renderTab()
|
||||
const card = cardOf('Bamboo walk')
|
||||
expect(within(card).queryByText('reservations.meta.from')).not.toBeInTheDocument()
|
||||
expect(within(card).getByText('reservations.price')).toBeInTheDocument()
|
||||
expect(within(card).getByText('30 JPY')).toBeInTheDocument()
|
||||
expect(within(card).getAllByText('—')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-BKTAB-012: opens the reservation editor from the row', () => {
|
||||
const p = planner()
|
||||
renderTab(p)
|
||||
fireEvent.click(screen.getByText('Sushi Saito'))
|
||||
expect(p.can).toHaveBeenCalledWith('reservation_edit', p.trip)
|
||||
expect(p.setEditingReservation).toHaveBeenCalledWith(expect.objectContaining({ id: 202 }))
|
||||
expect(p.setShowReservationModal).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('FE-MOB-BKTAB-013: opens the same editor from the pencil', () => {
|
||||
const p = planner()
|
||||
renderTab(p)
|
||||
fireEvent.click(within(cardOf('Hotel Granvia')).getByRole('button', { name: 'common.edit' }))
|
||||
expect(p.setEditingReservation).toHaveBeenCalledWith(expect.objectContaining({ id: 201 }))
|
||||
})
|
||||
|
||||
it('FE-MOB-BKTAB-014: a read-only member gets no editor and no actions', () => {
|
||||
const p = planner({ can: vi.fn(() => false) as unknown as TripPlanner['can'] })
|
||||
renderTab(p)
|
||||
fireEvent.click(screen.getByText('Sushi Saito'))
|
||||
expect(p.setShowReservationModal).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('button', { name: 'common.edit' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BKTAB-015: deletes after confirming', async () => {
|
||||
const p = planner()
|
||||
renderTab(p)
|
||||
fireEvent.click(within(cardOf('Sushi Saito')).getByRole('button', { name: 'common.delete' }))
|
||||
const dialog = screen.getByRole('dialog', { name: 'reservations.confirm.deleteTitle' })
|
||||
expect(within(dialog).getByText('reservations.confirm.deleteBody:Sushi Saito')).toBeInTheDocument()
|
||||
fireEvent.click(within(dialog).getByText('common.delete'))
|
||||
await waitFor(() => expect(p.handleDeleteReservation).toHaveBeenCalledWith(202))
|
||||
expect(p.toast.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-BKTAB-016: toasts when the delete rejects', async () => {
|
||||
const p = planner({
|
||||
handleDeleteReservation: vi.fn(() => Promise.reject(new Error('boom'))) as unknown as TripPlanner['handleDeleteReservation'],
|
||||
})
|
||||
renderTab(p)
|
||||
fireEvent.click(within(cardOf('Sushi Saito')).getByRole('button', { name: 'common.delete' }))
|
||||
fireEvent.click(within(screen.getByRole('dialog')).getByText('common.delete'))
|
||||
await waitFor(() => expect(p.toast.error).toHaveBeenCalledWith('reservations.toast.deleteError'))
|
||||
})
|
||||
|
||||
it('FE-MOB-BKTAB-017: compact mode drops the card bodies', () => {
|
||||
renderTab(planner(), buildShell({ bookingsCompact: true }))
|
||||
expect(screen.getByText('Hotel Granvia')).toBeInTheDocument()
|
||||
expect(screen.queryByText('HG-77')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Kyoto Station')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BKTAB-018: blurs the confirmation code until it is tapped', () => {
|
||||
renderTab(planner({ settings: buildSettings({ time_format: '24h', blur_booking_codes: true }) }))
|
||||
const code = within(cardOf('Hotel Granvia')).getByText('HG-77')
|
||||
expect(code).toHaveClass('blur-[4px]')
|
||||
fireEvent.click(code)
|
||||
expect(code).not.toHaveClass('blur-[4px]')
|
||||
})
|
||||
|
||||
it('FE-MOB-BKTAB-019: filters the list by assigned traveler', () => {
|
||||
renderTab()
|
||||
const ada = screen.getByTitle('Ada')
|
||||
fireEvent.click(ada)
|
||||
expect(screen.getByText('Sushi Saito')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Hotel Granvia')).not.toBeInTheDocument()
|
||||
fireEvent.click(ada)
|
||||
expect(screen.getByText('Hotel Granvia')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BKTAB-020: cancelling the confirm keeps the booking', () => {
|
||||
const p = planner()
|
||||
renderTab(p)
|
||||
fireEvent.click(within(cardOf('Sushi Saito')).getByRole('button', { name: 'common.delete' }))
|
||||
fireEvent.click(within(screen.getByRole('dialog')).getByText('common.cancel'))
|
||||
expect(p.handleDeleteReservation).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Sushi Saito')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BKTAB-021: falls back for an unknown type and formats a dateless booking', () => {
|
||||
const SPA = {
|
||||
id: 206, trip_id: 7, type: 'spa', status: 'confirmed', title: 'Onsen visit',
|
||||
reservation_time: '2026-06-15T09:00',
|
||||
metadata: JSON.stringify({ arrival_airport: 'Hakone', check_in_time: '09:00' }),
|
||||
} as unknown as Reservation
|
||||
renderTab(planner({
|
||||
reservations: [SPA],
|
||||
files: undefined as unknown as TripPlanner['files'],
|
||||
settings: { ...buildSettings(), time_format: '' },
|
||||
}))
|
||||
const card = cardOf('Onsen visit')
|
||||
expect(within(card).getByText('reservations.type.spa')).toBeInTheDocument()
|
||||
expect(within(card).getByText('Jun 15')).toBeInTheDocument()
|
||||
expect(within(card).getByText('reservations.meta.to')).toBeInTheDocument()
|
||||
expect(within(card).getByText('Hakone')).toBeInTheDocument()
|
||||
// the check-in cell and the time field both read 09:00
|
||||
expect(within(card).getAllByText('09:00')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MBrowseActionsSheet from '../../../../src/mobile/screens/trip/sheets/MBrowseActionsSheet'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { useAddonStore } from '../../../../src/store/addonStore'
|
||||
import { useSaveToCollectionStore } from '../../../../src/store/saveToCollectionStore'
|
||||
import type { Day, Place } from '../../../../src/types'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-BRACT-001 to FE-MOB-BRACT-013
|
||||
// This sheet reads its labels from the real TranslationProvider, so the
|
||||
// assertions use the English copy rather than the planner's echo `t`.
|
||||
|
||||
const PLACE = {
|
||||
id: 77, trip_id: 5, name: 'Teamlab Planets', address: '6-chome Toyosu, Koto City',
|
||||
description: 'Digital art museum', lat: 35.65, lng: 139.79, category_id: 3,
|
||||
} as unknown as Place
|
||||
|
||||
const DAYS = [
|
||||
{ id: 1, trip_id: 5, day_number: 1, date: '2026-04-02', title: null },
|
||||
{ id: 2, trip_id: 5, day_number: 2, date: '2026-04-03', title: 'Tokyo Bay' },
|
||||
] as unknown as Day[]
|
||||
|
||||
function setup(plannerOverrides: Partial<TripPlanner> = {}, shellOverrides: Partial<MTripShellApi> = {}) {
|
||||
const planner = buildPlanner({ places: [PLACE], days: DAYS, ...plannerOverrides })
|
||||
const shell = buildShell({ sheet: { id: 'bract', payload: { placeId: 77 } }, ...shellOverrides })
|
||||
const view = render(<MBrowseActionsSheet planner={planner} shell={shell} />)
|
||||
return { ...view, planner, shell }
|
||||
}
|
||||
|
||||
function enableCollections() {
|
||||
seedStore(useAddonStore, { addons: [{ id: 'collections', enabled: true }], loaded: true })
|
||||
}
|
||||
|
||||
describe('MBrowseActionsSheet', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
useSaveToCollectionStore.setState({ target: null, version: 0 })
|
||||
})
|
||||
|
||||
it('FE-MOB-BRACT-001: renders nothing while another sheet id is active', () => {
|
||||
setup({}, { sheet: { id: 'mehr' } })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BRACT-002: renders nothing when the payload points at an unknown place', () => {
|
||||
setup({ places: [] })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BRACT-003: heads the card with the place name and address', () => {
|
||||
setup()
|
||||
expect(screen.getByRole('dialog', { name: 'Teamlab Planets' })).toBeInTheDocument()
|
||||
expect(screen.getByText('6-chome Toyosu, Koto City')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BRACT-004: falls back to the description when there is no address', () => {
|
||||
setup({ places: [{ ...PLACE, address: null } as unknown as Place] })
|
||||
expect(screen.getByText('Digital art museum')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BRACT-005: omits the subline when the place carries neither', () => {
|
||||
setup({ places: [{ ...PLACE, address: null, description: null } as unknown as Place] })
|
||||
expect(screen.queryByText('Digital art museum')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Teamlab Planets')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BRACT-006: view details closes the sheet and opens the place', () => {
|
||||
const { planner, shell } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: /View details/ }))
|
||||
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
|
||||
expect(planner.handlePlaceClick).toHaveBeenCalledWith(77)
|
||||
})
|
||||
|
||||
it('FE-MOB-BRACT-007: edit hands the place to the planner editor', () => {
|
||||
const { planner, shell } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Edit/ }))
|
||||
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
|
||||
expect(planner.openPlaceEditor).toHaveBeenCalledWith(PLACE)
|
||||
})
|
||||
|
||||
it('FE-MOB-BRACT-008: the collection row stays hidden while the addon is off', () => {
|
||||
setup()
|
||||
expect(screen.queryByRole('button', { name: /Save to Collection/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BRACT-008b: saving to a collection hands the full place target to the picker', () => {
|
||||
enableCollections()
|
||||
const { shell } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: /Save to Collection/ }))
|
||||
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
|
||||
expect(useSaveToCollectionStore.getState().target).toMatchObject({
|
||||
name: 'Teamlab Planets', source_trip_id: 5, source_place_id: 77, lat: 35.65, lng: 139.79, category_id: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-MOB-BRACT-009: the day list is collapsed by default and expands on tap', () => {
|
||||
setup()
|
||||
const toggle = screen.getByRole('button', { name: /Add to a day\?/ })
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.queryByRole('button', { name: /Tokyo Bay/ })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByRole('button', { name: /Day 1/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Tokyo Bay/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BRACT-010: the quick-add payload opens with the day list already expanded and dated', () => {
|
||||
const { planner, shell } = setup({}, { sheet: { id: 'bract', payload: { placeId: 77, dayPicker: true } } })
|
||||
expect(screen.getByRole('button', { name: /Add to a day\?/ })).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByText('Thu, Apr 2')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Tokyo Bay/ }))
|
||||
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
|
||||
expect(planner.handleAssignToDay).toHaveBeenCalledWith(77, 2)
|
||||
})
|
||||
|
||||
it('FE-MOB-BRACT-011: numbers an untitled, undated day by position and falls back to "?" for a zero', () => {
|
||||
const days = [
|
||||
{ id: 9, trip_id: 5, day_number: null, date: null, title: null },
|
||||
{ id: 10, trip_id: 5, day_number: 0, date: null, title: null },
|
||||
] as unknown as Day[]
|
||||
setup({ days }, { sheet: { id: 'bract', payload: { placeId: 77, dayPicker: true } } })
|
||||
expect(screen.getByRole('button', { name: 'Day 1' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Day ?' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BRACT-012: delete routes through the planner confirm flow', () => {
|
||||
const { planner, shell } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: /Delete/ }))
|
||||
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
|
||||
expect(planner.handleDeletePlace).toHaveBeenCalledWith(77)
|
||||
})
|
||||
|
||||
it('FE-MOB-BRACT-013: a read-only member only keeps the view row', () => {
|
||||
enableCollections()
|
||||
setup({ can: vi.fn(() => false) })
|
||||
expect(screen.getByRole('button', { name: /View details/ })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /^Edit/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Delete/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Add to a day\?/ })).not.toBeInTheDocument()
|
||||
// Saving to a personal collection is not a trip edit — it stays.
|
||||
expect(screen.getByRole('button', { name: /Save to Collection/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BRACT-014: hides the day section for a trip without days', () => {
|
||||
setup({ days: [] })
|
||||
expect(screen.queryByRole('button', { name: /Add to a day\?/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BRACT-015: keeps the card content while the sheet plays its exit animation', () => {
|
||||
const planner = buildPlanner({ places: [PLACE], days: DAYS })
|
||||
const shell = buildShell({ sheet: { id: 'bract', payload: { placeId: 77 } } })
|
||||
const { rerender } = render(<MBrowseActionsSheet planner={planner} shell={shell} />)
|
||||
expect(screen.getByText('Teamlab Planets')).toBeInTheDocument()
|
||||
|
||||
// The place vanishes from the pool the moment it is deleted — the held
|
||||
// snapshot keeps the card readable until the sheet has faded out.
|
||||
rerender(<MBrowseActionsSheet planner={buildPlanner({ places: [], days: DAYS })} shell={shell} />)
|
||||
expect(screen.getByText('Teamlab Planets')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-BRACT-016: checks the permissions against the trip in context', () => {
|
||||
const can = vi.fn(() => true)
|
||||
const { planner } = setup({ can })
|
||||
expect(can).toHaveBeenCalledWith('place_edit', planner.trip)
|
||||
expect(can).toHaveBeenCalledWith('day_edit', planner.trip)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,517 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import MCollabChat from '../../../../src/mobile/screens/trip/tabs/MCollabChat'
|
||||
import type { ChatMessage } from '../../../../src/mobile/screens/trip/tabs/collabModel'
|
||||
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { addListener, removeListener } from '../../../../src/api/websocket'
|
||||
import { useAuthStore } from '../../../../src/store/authStore'
|
||||
import { buildPlanner } from '../../../helpers/mobileTrip'
|
||||
import { buildUser } from '../../../helpers/factories'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { server } from '../../../helpers/msw/server'
|
||||
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-CCHAT-001 to FE-MOB-CCHAT-031
|
||||
|
||||
const ME = 7
|
||||
|
||||
function chatMsg(id: number, over: Partial<ChatMessage> = {}): ChatMessage {
|
||||
return {
|
||||
id,
|
||||
trip_id: 1,
|
||||
user_id: 2,
|
||||
text: `Message ${id}`,
|
||||
reply_to: null,
|
||||
username: 'Alice',
|
||||
avatar: null,
|
||||
avatar_url: null,
|
||||
created_at: new Date().toISOString(),
|
||||
reactions: [],
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function daysAgo(n: number): string {
|
||||
return new Date(Date.now() - n * 86_400_000).toISOString()
|
||||
}
|
||||
|
||||
/** Records every GET so tests can assert the `before` cursor of load-more. */
|
||||
let beforeCursors: (string | null)[] = []
|
||||
let sentBodies: Record<string, unknown>[] = []
|
||||
|
||||
interface ServeOptions {
|
||||
older?: ChatMessage[]
|
||||
olderFails?: boolean
|
||||
initialFails?: boolean
|
||||
}
|
||||
|
||||
function serveMessages(initial: ChatMessage[], opts: ServeOptions = {}) {
|
||||
server.use(
|
||||
http.get('/api/trips/:tripId/collab/messages', ({ request }) => {
|
||||
const before = new URL(request.url).searchParams.get('before')
|
||||
beforeCursors.push(before)
|
||||
if (before) {
|
||||
if (opts.olderFails) return HttpResponse.json({ error: 'boom' }, { status: 500 })
|
||||
return HttpResponse.json({ messages: opts.older || [] })
|
||||
}
|
||||
if (opts.initialFails) return HttpResponse.json({ error: 'boom' }, { status: 500 })
|
||||
return HttpResponse.json({ messages: initial })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function serveSend(reply: (body: Record<string, unknown>) => ChatMessage, fails = false) {
|
||||
server.use(
|
||||
http.post('/api/trips/:tripId/collab/messages', async ({ request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>
|
||||
sentBodies.push(body)
|
||||
if (fails) return HttpResponse.json({ error: 'boom' }, { status: 500 })
|
||||
return HttpResponse.json({ message: reply(body) })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function renderChat(planner: TripPlanner = buildPlanner()) {
|
||||
const view = render(<MCollabChat planner={planner} />)
|
||||
await waitFor(() => expect(document.querySelector('.animate-spin')).toBeNull())
|
||||
return { ...view, planner }
|
||||
}
|
||||
|
||||
/** The bubble button of the nth rendered (non-deleted) message. */
|
||||
function bubble(index = 0): HTMLElement {
|
||||
return screen.getAllByRole('button', { name: 'collab.chat.messageOptions' })[index]
|
||||
}
|
||||
|
||||
/** Two taps inside DOUBLE_TAP_MS — the popover gesture that needs no timers. */
|
||||
function doubleTap(el: HTMLElement, x = 120, y = 300) {
|
||||
fireEvent.pointerDown(el, { clientX: x, clientY: y })
|
||||
fireEvent.pointerUp(el, { clientX: x, clientY: y })
|
||||
fireEvent.pointerDown(el, { clientX: x, clientY: y })
|
||||
fireEvent.pointerUp(el, { clientX: x, clientY: y })
|
||||
}
|
||||
|
||||
describe('MCollabChat', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
seedStore(useAuthStore, { user: buildUser({ id: ME, username: 'Mo' }) })
|
||||
beforeCursors = []
|
||||
sentBodies = []
|
||||
vi.mocked(addListener).mockClear()
|
||||
vi.mocked(removeListener).mockClear()
|
||||
// jsdom has neither scrollTo on elements nor real layout metrics.
|
||||
Element.prototype.scrollTo = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-001: shows the spinner until the first page has landed', async () => {
|
||||
serveMessages([chatMsg(1)])
|
||||
render(<MCollabChat planner={buildPlanner()} />)
|
||||
expect(document.querySelector('.animate-spin')).not.toBeNull()
|
||||
expect(await screen.findByText('Message 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-002: renders the mascot empty state when the trip has no messages', async () => {
|
||||
serveMessages([])
|
||||
await renderChat()
|
||||
expect(screen.getByText('collab.chat.empty')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'collab.chat.messageOptions' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-003: falls back to the empty state when the first page fails', async () => {
|
||||
serveMessages([], { initialFails: true })
|
||||
await renderChat()
|
||||
expect(screen.getByText('collab.chat.empty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-004: collapses consecutive messages of one sender into a single group', async () => {
|
||||
serveMessages([
|
||||
chatMsg(1, { text: 'First' }),
|
||||
chatMsg(2, { text: 'Second' }),
|
||||
chatMsg(3, { text: 'Mine', user_id: ME, username: 'Mo' }),
|
||||
])
|
||||
await renderChat()
|
||||
// name + avatar initial collapse onto the first message of the group
|
||||
expect(screen.getAllByText('Alice')).toHaveLength(1)
|
||||
expect(screen.getAllByText('A')).toHaveLength(1)
|
||||
// one trailing timestamp per group (Alice, then me)
|
||||
expect(screen.getAllByText(/^\d{2}:\d{2}$/)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-005: separates days with today / yesterday markers', async () => {
|
||||
serveMessages([
|
||||
chatMsg(1, { created_at: daysAgo(1) }),
|
||||
chatMsg(2, { created_at: new Date().toISOString() }),
|
||||
])
|
||||
await renderChat()
|
||||
expect(screen.getByText('collab.chat.yesterday')).toBeInTheDocument()
|
||||
expect(screen.getByText('collab.chat.today')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-006: uses the sender avatar when there is one, the initial otherwise', async () => {
|
||||
serveMessages([
|
||||
chatMsg(1, { avatar_url: '/uploads/avatars/a.png' }),
|
||||
chatMsg(2, { user_id: 3, username: 'bob' }),
|
||||
])
|
||||
const { container } = await renderChat()
|
||||
expect(container.querySelector('img[src="/uploads/avatars/a.png"]')).not.toBeNull()
|
||||
expect(screen.getByText('B')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-007: renders an emoji-only message large and without a bubble', async () => {
|
||||
serveMessages([chatMsg(1, { text: '🎉' }), chatMsg(2, { text: 'plain' })])
|
||||
await renderChat()
|
||||
expect(screen.getByText('🎉')).toHaveClass('text-[2.5rem]')
|
||||
expect(screen.getByText('plain')).toHaveClass('whitespace-pre-wrap')
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-008: shows the quoted message above a reply bubble', async () => {
|
||||
serveMessages([
|
||||
chatMsg(1, { text: 'Where do we meet?' }),
|
||||
chatMsg(2, { text: 'At the station', reply_to: 1, reply_text: 'Where do we meet?', reply_username: 'Alice' }),
|
||||
])
|
||||
await renderChat()
|
||||
const quote = screen.getAllByText('Where do we meet?')
|
||||
expect(quote).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-009: replaces a deleted message with the tombstone line', async () => {
|
||||
serveMessages([chatMsg(1, { text: 'oops', deleted: 1 })])
|
||||
await renderChat()
|
||||
expect(screen.queryByText('oops')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/Alice collab\.chat\.deletedMessage/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-010: honours the 12h time format setting', async () => {
|
||||
serveMessages([chatMsg(1)])
|
||||
await renderChat(buildPlanner({
|
||||
settings: { time_format: '12h' } as unknown as TripPlanner['settings'],
|
||||
}))
|
||||
expect(screen.getByText(/^\d{1,2}:\d{2} (AM|PM)$/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-011: shows reaction badges and re-sends the emoji when one is tapped', async () => {
|
||||
serveMessages([chatMsg(1, {
|
||||
reactions: [
|
||||
{ emoji: '👍', count: 2, users: [{ user_id: 2, username: 'Alice' }, { user_id: ME, username: 'Mo' }] },
|
||||
{ emoji: '🔥', count: 1, users: [{ user_id: 2, username: 'Alice' }] },
|
||||
],
|
||||
})])
|
||||
let reacted = ''
|
||||
server.use(http.post('/api/trips/:tripId/collab/messages/:id/react', async ({ request }) => {
|
||||
reacted = String(((await request.json()) as { emoji: string }).emoji)
|
||||
return HttpResponse.json({ reactions: [{ emoji: '👍', count: 1, users: [{ user_id: 2, username: 'Alice' }] }] })
|
||||
}))
|
||||
await renderChat()
|
||||
// count is only spelled out above 1
|
||||
expect(screen.getByText('2')).toBeInTheDocument()
|
||||
expect(screen.queryByText('1')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('🔥'))
|
||||
await waitFor(() => expect(reacted).toBe('🔥'))
|
||||
await waitFor(() => expect(screen.queryByText('2')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-012: a read-only member gets the notice instead of the composer', async () => {
|
||||
serveMessages([chatMsg(1, { reactions: [{ emoji: '👍', count: 1, users: [] }] })])
|
||||
let reactCalls = 0
|
||||
server.use(http.post('/api/trips/:tripId/collab/messages/:id/react', () => {
|
||||
reactCalls++
|
||||
return HttpResponse.json({ reactions: [] })
|
||||
}))
|
||||
await renderChat(buildPlanner({ can: (() => false) as TripPlanner['can'] }))
|
||||
|
||||
expect(screen.getByText('collab.chat.readOnly')).toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('collab.chat.placeholder')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'collab.chat.messageOptions' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('👍'))
|
||||
await Promise.resolve()
|
||||
expect(reactCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-013: sends the trimmed draft, appends the answer and clears the composer', async () => {
|
||||
serveMessages([])
|
||||
serveSend(body => chatMsg(42, { user_id: ME, username: 'Mo', text: String(body.text) }))
|
||||
await renderChat()
|
||||
|
||||
const input = screen.getByPlaceholderText('collab.chat.placeholder')
|
||||
const send = screen.getByRole('button', { name: 'collab.chat.send' })
|
||||
expect(send).toBeDisabled()
|
||||
|
||||
fireEvent.change(input, { target: { value: ' Landed safely ' } })
|
||||
expect(send).toBeEnabled()
|
||||
fireEvent.click(send)
|
||||
|
||||
expect(await screen.findByText('Landed safely')).toBeInTheDocument()
|
||||
expect(sentBodies).toEqual([{ text: 'Landed safely' }])
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-014: keeps the draft and toasts when sending fails', async () => {
|
||||
serveMessages([])
|
||||
serveSend(() => chatMsg(1), true)
|
||||
const { planner } = await renderChat()
|
||||
|
||||
const input = screen.getByPlaceholderText('collab.chat.placeholder')
|
||||
fireEvent.change(input, { target: { value: 'no network' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.chat.send' }))
|
||||
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.error'))
|
||||
expect(input).toHaveValue('no network')
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-015: auto-grows the composer and caps it at 100px', async () => {
|
||||
serveMessages([])
|
||||
await renderChat()
|
||||
const input = screen.getByPlaceholderText('collab.chat.placeholder') as HTMLTextAreaElement
|
||||
|
||||
fireEvent.change(input, { target: { value: 'one line' } })
|
||||
expect(input.style.overflowY).toBe('hidden')
|
||||
|
||||
Object.defineProperty(input, 'scrollHeight', { configurable: true, value: 340 })
|
||||
fireEvent.change(input, { target: { value: 'a\nvery\ntall\ndraft' } })
|
||||
expect(input.style.height).toBe('100px')
|
||||
expect(input.style.overflowY).toBe('auto')
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-016: offers load-more on a full page and prepends the older ones', async () => {
|
||||
const page = Array.from({ length: 100 }, (_, i) => chatMsg(i + 1))
|
||||
serveMessages(page, { older: [chatMsg(900, { text: 'Ancient' }), chatMsg(901, { text: 'Old' })] })
|
||||
await renderChat()
|
||||
|
||||
const more = screen.getByRole('button', { name: /collab\.chat\.loadMore/ })
|
||||
fireEvent.click(more)
|
||||
|
||||
expect(await screen.findByText('Ancient')).toBeInTheDocument()
|
||||
expect(screen.getByText('Old')).toBeInTheDocument()
|
||||
expect(beforeCursors).toEqual([null, '1'])
|
||||
// a short page means there is nothing left to fetch
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: /collab\.chat\.loadMore/ })).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-017: hides load-more when the older page comes back empty', async () => {
|
||||
const page = Array.from({ length: 100 }, (_, i) => chatMsg(i + 1))
|
||||
serveMessages(page, { older: [] })
|
||||
await renderChat()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /collab\.chat\.loadMore/ }))
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: /collab\.chat\.loadMore/ })).not.toBeInTheDocument())
|
||||
expect(screen.getByText('Message 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-018: toasts and keeps the button when the older page fails', async () => {
|
||||
const page = Array.from({ length: 100 }, (_, i) => chatMsg(i + 1))
|
||||
serveMessages(page, { olderFails: true })
|
||||
const { planner } = await renderChat()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /collab\.chat\.loadMore/ }))
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.error'))
|
||||
expect(screen.getByRole('button', { name: /collab\.chat\.loadMore/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-019: a double tap opens the actions popover and a reaction from it lands', async () => {
|
||||
serveMessages([chatMsg(1)])
|
||||
server.use(http.post('/api/trips/:tripId/collab/messages/:id/react', () =>
|
||||
HttpResponse.json({ reactions: [{ emoji: '❤️', count: 1, users: [{ user_id: ME, username: 'Mo' }] }] })))
|
||||
await renderChat()
|
||||
|
||||
doubleTap(bubble())
|
||||
expect(screen.getByRole('button', { name: 'collab.chat.reply' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '❤️' }))
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'collab.chat.reply' })).not.toBeInTheDocument())
|
||||
expect(await screen.findByText('❤️')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-020: a long press opens the popover, a drag cancels it', async () => {
|
||||
serveMessages([chatMsg(1)])
|
||||
await renderChat()
|
||||
vi.useFakeTimers()
|
||||
|
||||
fireEvent.pointerDown(bubble(), { clientX: 40, clientY: 90 })
|
||||
fireEvent.pointerMove(bubble(), { clientX: 90, clientY: 90 })
|
||||
act(() => { vi.advanceTimersByTime(600) })
|
||||
expect(screen.queryByRole('button', { name: 'collab.chat.reply' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.pointerDown(bubble(), { clientX: 40, clientY: 90 })
|
||||
fireEvent.pointerMove(bubble(), { clientX: 42, clientY: 92 })
|
||||
act(() => { vi.advanceTimersByTime(600) })
|
||||
expect(screen.getByRole('button', { name: 'collab.chat.reply' })).toBeInTheDocument()
|
||||
|
||||
// the release after a fired long press must not toggle it again
|
||||
fireEvent.pointerUp(bubble(), { clientX: 40, clientY: 90 })
|
||||
expect(screen.getByRole('button', { name: 'collab.chat.reply' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-021: a pointer cancel aborts the pending long press', async () => {
|
||||
serveMessages([chatMsg(1)])
|
||||
await renderChat()
|
||||
vi.useFakeTimers()
|
||||
|
||||
fireEvent.pointerDown(bubble(), { clientX: 40, clientY: 90 })
|
||||
fireEvent.pointerCancel(bubble())
|
||||
act(() => { vi.advanceTimersByTime(600) })
|
||||
expect(screen.queryByRole('button', { name: 'collab.chat.reply' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-022: replying quotes the message and sends reply_to, then clears the banner', async () => {
|
||||
serveMessages([chatMsg(1, { text: 'Bring sunscreen' })])
|
||||
serveSend(body => chatMsg(50, { user_id: ME, username: 'Mo', text: String(body.text) }))
|
||||
await renderChat()
|
||||
|
||||
doubleTap(bubble())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.chat.reply' }))
|
||||
|
||||
// banner shows who is quoted
|
||||
expect(screen.getAllByText('Alice')).toHaveLength(2)
|
||||
fireEvent.change(screen.getByPlaceholderText('collab.chat.placeholder'), { target: { value: 'Got it' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.chat.send' }))
|
||||
|
||||
await waitFor(() => expect(sentBodies).toEqual([{ text: 'Got it', reply_to: 1 }]))
|
||||
await waitFor(() => expect(screen.getAllByText('Alice')).toHaveLength(1))
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-023: the reply banner can be dismissed by hand', async () => {
|
||||
serveMessages([chatMsg(1)])
|
||||
await renderChat()
|
||||
|
||||
doubleTap(bubble())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.chat.reply' }))
|
||||
expect(screen.getByRole('button', { name: 'common.close' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
|
||||
expect(screen.queryByRole('button', { name: 'common.close' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-024: delete is offered on own messages only and tombstones the bubble', async () => {
|
||||
serveMessages([chatMsg(1, { text: 'Theirs' }), chatMsg(2, { text: 'Mine', user_id: ME, username: 'Mo' })])
|
||||
let deleted = 0
|
||||
server.use(http.delete('/api/trips/:tripId/collab/messages/:id', () => {
|
||||
deleted++
|
||||
return HttpResponse.json({ success: true })
|
||||
}))
|
||||
await renderChat()
|
||||
|
||||
doubleTap(bubble(0))
|
||||
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
|
||||
fireEvent.pointerDown(document.body)
|
||||
|
||||
doubleTap(bubble(1))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
|
||||
|
||||
await waitFor(() => expect(deleted).toBe(1))
|
||||
expect(await screen.findByText(/Mo collab\.chat\.deletedMessage/)).toBeInTheDocument()
|
||||
expect(screen.getByText('Theirs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-025: toasts when deleting or reacting fails', async () => {
|
||||
serveMessages([chatMsg(1, { user_id: ME, username: 'Mo', text: 'Mine' })])
|
||||
server.use(
|
||||
http.delete('/api/trips/:tripId/collab/messages/:id', () => HttpResponse.json({ error: 'x' }, { status: 500 })),
|
||||
http.post('/api/trips/:tripId/collab/messages/:id/react', () => HttpResponse.json({ error: 'x' }, { status: 500 })),
|
||||
)
|
||||
const { planner } = await renderChat()
|
||||
|
||||
doubleTap(bubble())
|
||||
fireEvent.click(screen.getByRole('button', { name: '😂' }))
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.error'))
|
||||
|
||||
doubleTap(bubble())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledTimes(2))
|
||||
expect(screen.getByText('Mine')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-026: a pointer down outside the popover closes it', async () => {
|
||||
serveMessages([chatMsg(1)])
|
||||
await renderChat()
|
||||
|
||||
doubleTap(bubble())
|
||||
expect(screen.getByRole('button', { name: 'collab.chat.reply' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.pointerDown(document.body)
|
||||
expect(screen.queryByRole('button', { name: 'collab.chat.reply' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-027: a pointer down inside the popover keeps it open', async () => {
|
||||
serveMessages([chatMsg(1)])
|
||||
await renderChat()
|
||||
|
||||
doubleTap(bubble())
|
||||
fireEvent.pointerDown(screen.getByRole('button', { name: '👏' }))
|
||||
expect(screen.getByRole('button', { name: 'collab.chat.reply' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-028: applies remote create, delete and reaction events once each', async () => {
|
||||
serveMessages([chatMsg(1)])
|
||||
await renderChat()
|
||||
const handler = vi.mocked(addListener).mock.calls[0][0]
|
||||
|
||||
act(() => handler({ type: 'collab:message:created', tripId: 1, message: chatMsg(5, { text: 'From WS' }) }))
|
||||
expect(screen.getByText('From WS')).toBeInTheDocument()
|
||||
|
||||
act(() => handler({ type: 'collab:message:created', tripId: 1, message: chatMsg(5, { text: 'From WS' }) }))
|
||||
expect(screen.getAllByText('From WS')).toHaveLength(1)
|
||||
|
||||
act(() => handler({
|
||||
type: 'collab:message:reacted',
|
||||
tripId: 1,
|
||||
messageId: 5,
|
||||
reactions: [{ emoji: '🎉', count: 3, users: [] }],
|
||||
}))
|
||||
expect(screen.getByText('🎉')).toBeInTheDocument()
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
|
||||
act(() => handler({ type: 'collab:message:deleted', tripId: 1, messageId: 5 }))
|
||||
expect(screen.queryByText('From WS')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/Alice collab\.chat\.deletedMessage/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-029: ignores events addressed to another trip', async () => {
|
||||
serveMessages([chatMsg(1)])
|
||||
await renderChat()
|
||||
const handler = vi.mocked(addListener).mock.calls[0][0]
|
||||
|
||||
act(() => handler({ type: 'collab:message:created', tripId: 99, message: chatMsg(5, { text: 'Other trip' }) }))
|
||||
expect(screen.queryByText('Other trip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-030: auto-scrolls to a remote message only while the view sits at the bottom', async () => {
|
||||
serveMessages([chatMsg(1)])
|
||||
const { container } = await renderChat()
|
||||
const scroller = container.querySelector('.overflow-y-auto') as HTMLElement
|
||||
const handler = vi.mocked(addListener).mock.calls[0][0]
|
||||
const metric = (name: string, value: number) =>
|
||||
Object.defineProperty(scroller, name, { configurable: true, value })
|
||||
|
||||
// let the initial "jump to newest" pass finish before measuring
|
||||
await new Promise(resolve => setTimeout(resolve, 80))
|
||||
metric('scrollHeight', 1000)
|
||||
metric('clientHeight', 300)
|
||||
metric('scrollTop', 0)
|
||||
fireEvent.scroll(scroller)
|
||||
vi.mocked(Element.prototype.scrollTo).mockClear()
|
||||
|
||||
act(() => handler({ type: 'collab:message:created', tripId: 1, message: chatMsg(5, { text: 'Ping' }) }))
|
||||
await new Promise(resolve => setTimeout(resolve, 80))
|
||||
expect(Element.prototype.scrollTo).not.toHaveBeenCalled()
|
||||
|
||||
metric('scrollTop', 700)
|
||||
fireEvent.scroll(scroller)
|
||||
act(() => handler({ type: 'collab:message:created', tripId: 1, message: chatMsg(6, { text: 'Pong' }) }))
|
||||
await waitFor(() => expect(Element.prototype.scrollTo).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-031: detaches its websocket listener on unmount', async () => {
|
||||
serveMessages([chatMsg(1)])
|
||||
const { unmount } = await renderChat()
|
||||
const handler = vi.mocked(addListener).mock.calls[0][0]
|
||||
|
||||
unmount()
|
||||
expect(removeListener).toHaveBeenCalledWith(handler)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,517 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import MCollabNotes from '../../../../src/mobile/screens/trip/tabs/MCollabNotes'
|
||||
import type { CollabNoteData } from '../../../../src/mobile/screens/trip/tabs/collabModel'
|
||||
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { addListener, removeListener } from '../../../../src/api/websocket'
|
||||
import { openFile } from '../../../../src/utils/fileDownload'
|
||||
import { buildPlanner } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores } from '../../../helpers/store'
|
||||
import { server } from '../../../helpers/msw/server'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-CNOTE-001 to FE-MOB-CNOTE-026
|
||||
|
||||
// react-markdown ships as ESM-only chunks that jsdom chokes on; the note cards
|
||||
// only care that the raw text reaches the renderer.
|
||||
vi.mock('react-markdown', () => ({
|
||||
default: ({ children }: { children: string }) => <span data-testid="md">{children}</span>,
|
||||
}))
|
||||
vi.mock('remark-gfm', () => ({ default: () => ({}) }))
|
||||
vi.mock('remark-breaks', () => ({ default: () => ({}) }))
|
||||
vi.mock('../../../../src/utils/fileDownload', () => ({
|
||||
openFile: vi.fn(),
|
||||
downloadFile: vi.fn(),
|
||||
}))
|
||||
|
||||
function note(id: number, over: Partial<CollabNoteData> = {}): CollabNoteData {
|
||||
return {
|
||||
id,
|
||||
trip_id: 1,
|
||||
user_id: 2,
|
||||
title: `Note ${id}`,
|
||||
content: null,
|
||||
category: null,
|
||||
color: null,
|
||||
website: null,
|
||||
pinned: 0,
|
||||
username: 'Alice',
|
||||
avatar: null,
|
||||
avatar_url: null,
|
||||
created_at: '2026-06-01T10:00:00Z',
|
||||
attachments: [],
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
let notePages: CollabNoteData[][] = []
|
||||
let getCalls = 0
|
||||
let createdBodies: Record<string, unknown>[] = []
|
||||
let updatedBodies: { id: string; body: Record<string, unknown> }[] = []
|
||||
let deletedIds: string[] = []
|
||||
let deletedFiles: string[] = []
|
||||
let uploadedTo: string[] = []
|
||||
|
||||
/** Each argument is one GET response; the last one repeats. */
|
||||
function serveNotes(...pages: CollabNoteData[][]) {
|
||||
notePages = pages.length > 0 ? pages : [[]]
|
||||
server.use(
|
||||
http.get('/api/trips/:tripId/collab/notes', () => {
|
||||
getCalls++
|
||||
const page = notePages.length > 1 ? notePages.shift()! : notePages[0]
|
||||
return HttpResponse.json({ notes: page })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function serveWrites(opts: { createFails?: boolean; updateFails?: boolean; deleteFails?: boolean; uploadFails?: boolean } = {}) {
|
||||
server.use(
|
||||
http.post('/api/trips/:tripId/collab/notes', async ({ request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>
|
||||
createdBodies.push(body)
|
||||
if (opts.createFails) return HttpResponse.json({ error: 'x' }, { status: 500 })
|
||||
return HttpResponse.json({ note: note(99, { title: String(body.title), category: (body.category as string) ?? null }) })
|
||||
}),
|
||||
http.put('/api/trips/:tripId/collab/notes/:noteId', async ({ params, request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>
|
||||
updatedBodies.push({ id: String(params.noteId), body })
|
||||
if (opts.updateFails) return HttpResponse.json({ error: 'x' }, { status: 500 })
|
||||
return HttpResponse.json({ note: { id: Number(params.noteId), ...body } })
|
||||
}),
|
||||
http.delete('/api/trips/:tripId/collab/notes/:noteId', ({ params }) => {
|
||||
deletedIds.push(String(params.noteId))
|
||||
if (opts.deleteFails) return HttpResponse.json({ error: 'x' }, { status: 500 })
|
||||
return HttpResponse.json({ success: true })
|
||||
}),
|
||||
http.post('/api/trips/:tripId/collab/notes/:noteId/files', ({ params }) => {
|
||||
uploadedTo.push(String(params.noteId))
|
||||
if (opts.uploadFails) return HttpResponse.json({ error: 'x' }, { status: 500 })
|
||||
return HttpResponse.json({ file: { id: 1, filename: 'f', original_name: 'f', url: '/uploads/f' } })
|
||||
}),
|
||||
http.delete('/api/trips/:tripId/collab/notes/:noteId/files/:fileId', ({ params }) => {
|
||||
deletedFiles.push(`${params.noteId}/${params.fileId}`)
|
||||
return HttpResponse.json({ success: true })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function renderNotes(planner: TripPlanner = buildPlanner()) {
|
||||
const view = render(<MCollabNotes planner={planner} />)
|
||||
await waitFor(() => expect(screen.queryByText('common.loading')).not.toBeInTheDocument())
|
||||
return { ...view, planner }
|
||||
}
|
||||
|
||||
function fileInput(): HTMLInputElement {
|
||||
return document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
}
|
||||
|
||||
describe('MCollabNotes', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
getCalls = 0
|
||||
createdBodies = []
|
||||
updatedBodies = []
|
||||
deletedIds = []
|
||||
deletedFiles = []
|
||||
uploadedTo = []
|
||||
vi.mocked(addListener).mockClear()
|
||||
vi.mocked(removeListener).mockClear()
|
||||
vi.mocked(openFile).mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-001: shows the mascot loading state until the notes arrive', async () => {
|
||||
serveNotes([note(1, { title: 'Visa' })])
|
||||
render(<MCollabNotes planner={buildPlanner()} />)
|
||||
expect(screen.getByText('common.loading')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Visa')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-002: shows the empty state with the create affordance', async () => {
|
||||
serveNotes([])
|
||||
await renderNotes()
|
||||
expect(screen.getByText('collab.notes.empty')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /collab\.notes\.new/ })).toBeInTheDocument()
|
||||
// no categories yet, so the filter row stays out
|
||||
expect(screen.queryByText('collab.notes.all')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-003: keeps an empty list when the request fails', async () => {
|
||||
server.use(http.get('/api/trips/:tripId/collab/notes', () => HttpResponse.json({ error: 'x' }, { status: 500 })))
|
||||
await renderNotes()
|
||||
expect(screen.getByText('collab.notes.empty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-004: sorts pinned first, then by last touch', async () => {
|
||||
serveNotes([
|
||||
note(1, { title: 'Oldest', created_at: '2026-06-01T10:00:00Z' }),
|
||||
note(2, { title: 'Pinned', pinned: 1, created_at: '2026-01-01T10:00:00Z' }),
|
||||
note(3, { title: 'Freshest', created_at: '2026-06-02T10:00:00Z', updated_at: '2026-07-09T10:00:00Z' }),
|
||||
])
|
||||
await renderNotes()
|
||||
const titles = screen.getAllByText(/^(Oldest|Pinned|Freshest)$/).map(el => el.textContent)
|
||||
expect(titles).toEqual(['Pinned', 'Freshest', 'Oldest'])
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-005: renders body, author and attachment count on a card', async () => {
|
||||
serveNotes([note(1, {
|
||||
title: 'Packing tips',
|
||||
content: '**bring** an adapter',
|
||||
category: 'Gear',
|
||||
username: 'alice',
|
||||
attachments: [
|
||||
{ id: 1, filename: 'a', original_name: 'a.pdf', url: '/uploads/a.pdf' },
|
||||
{ id: 2, filename: 'b', original_name: 'b.pdf', url: '/uploads/b.pdf' },
|
||||
],
|
||||
})])
|
||||
await renderNotes()
|
||||
expect(screen.getByTestId('md')).toHaveTextContent('**bring** an adapter')
|
||||
expect(screen.getAllByText('Gear').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
expect(screen.getByText('alice')).toBeInTheDocument()
|
||||
expect(screen.getByText('2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-006: filters by category and clears the filter on a second tap', async () => {
|
||||
serveNotes([
|
||||
note(1, { title: 'Visa', category: 'Docs', color: '#ef4444' }),
|
||||
note(2, { title: 'Tent', category: 'Gear', color: '#10b981' }),
|
||||
])
|
||||
const { container } = await renderNotes()
|
||||
expect(screen.getByText('collab.notes.all')).toBeInTheDocument()
|
||||
|
||||
const gearPill = container.querySelectorAll('button')
|
||||
const pill = Array.from(gearPill).find(b => b.textContent === 'Gear')!
|
||||
fireEvent.click(pill)
|
||||
expect(screen.getByText('Tent')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Visa')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(pill)
|
||||
expect(screen.getByText('Visa')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(pill)
|
||||
fireEvent.click(screen.getByText('collab.notes.all'))
|
||||
expect(screen.getByText('Visa')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-007: pins a note and flips the affordance', async () => {
|
||||
serveNotes([note(1, { title: 'Visa' })])
|
||||
serveWrites()
|
||||
await renderNotes()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.pin' }))
|
||||
await waitFor(() => expect(updatedBodies).toEqual([{ id: '1', body: { pinned: true } }]))
|
||||
expect(await screen.findByRole('button', { name: 'collab.notes.unpin' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-008: deletes only after the confirm sheet is accepted', async () => {
|
||||
serveNotes([note(1, { title: 'Visa' })])
|
||||
serveWrites()
|
||||
await renderNotes()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.delete' }))
|
||||
expect(screen.getByText('collab.notes.confirmDeleteTitle')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
|
||||
expect(deletedIds).toEqual([])
|
||||
expect(screen.getByText('Visa')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.delete' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
|
||||
await waitFor(() => expect(deletedIds).toEqual(['1']))
|
||||
expect(screen.queryByText('Visa')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-009: toasts when a delete is rejected and keeps the card', async () => {
|
||||
serveNotes([note(1, { title: 'Visa' })])
|
||||
serveWrites({ deleteFails: true })
|
||||
const { planner } = await renderNotes()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.delete' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.error'))
|
||||
expect(screen.getByText('Visa')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-010: creates a note with an existing category and prepends it', async () => {
|
||||
serveNotes([note(1, { title: 'Visa', category: 'Docs', color: '#ef4444' })])
|
||||
serveWrites()
|
||||
await renderNotes()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.new/ }))
|
||||
const dialog = screen.getByRole('dialog', { name: 'collab.notes.new' })
|
||||
expect(screen.getByRole('button', { name: 'collab.notes.create' })).toBeDisabled()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), { target: { value: ' Insurance ' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('collab.notes.contentPlaceholder'), { target: { value: ' call the hotline ' } })
|
||||
fireEvent.click(Array.from(dialog.querySelectorAll('button')).find(b => b.textContent === 'Docs')!)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.create' }))
|
||||
|
||||
await waitFor(() => expect(createdBodies).toEqual([
|
||||
{ title: 'Insurance', content: 'call the hotline', category: 'Docs', color: '#ef4444' },
|
||||
]))
|
||||
expect(await screen.findByText('Insurance')).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-011: a brand new category picks the first palette colour', async () => {
|
||||
serveNotes([])
|
||||
serveWrites()
|
||||
await renderNotes()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.new/ }))
|
||||
fireEvent.change(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), { target: { value: 'Ferry' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.newCategory/ }))
|
||||
fireEvent.change(screen.getByPlaceholderText('collab.notes.newCategory'), { target: { value: 'Transport' } })
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('collab.notes.newCategory'), { key: 'Enter' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.create' }))
|
||||
|
||||
await waitFor(() => expect(createdBodies).toEqual([
|
||||
{ title: 'Ferry', category: 'Transport', color: '#6366f1' },
|
||||
]))
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-012: the inline category input can be confirmed with the button and ignores blanks', async () => {
|
||||
serveNotes([])
|
||||
serveWrites()
|
||||
await renderNotes()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.new/ }))
|
||||
fireEvent.change(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), { target: { value: 'Ferry' } })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.newCategory/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
|
||||
expect(screen.queryByPlaceholderText('collab.notes.newCategory')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.newCategory/ }))
|
||||
fireEvent.change(screen.getByPlaceholderText('collab.notes.newCategory'), { target: { value: 'Food' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.create' }))
|
||||
|
||||
await waitFor(() => expect(createdBodies).toEqual([
|
||||
{ title: 'Ferry', category: 'Food', color: '#6366f1' },
|
||||
]))
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-013: a failed create toasts and keeps the sheet open', async () => {
|
||||
serveNotes([])
|
||||
serveWrites({ createFails: true })
|
||||
const { planner } = await renderNotes()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.new/ }))
|
||||
fireEvent.change(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), { target: { value: 'Ferry' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.create' }))
|
||||
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.error'))
|
||||
expect(screen.getByRole('dialog', { name: 'collab.notes.new' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-014: editing prefills the sheet and saves the changed fields', async () => {
|
||||
serveNotes([note(1, { title: 'Visa', content: 'apply early', category: 'Docs', color: '#ef4444' })])
|
||||
serveWrites()
|
||||
await renderNotes()
|
||||
|
||||
fireEvent.click(screen.getByText('Visa'))
|
||||
expect(screen.getByRole('dialog', { name: 'collab.notes.edit' })).toBeInTheDocument()
|
||||
const title = screen.getByPlaceholderText('collab.notes.titlePlaceholder')
|
||||
expect(title).toHaveValue('Visa')
|
||||
expect(screen.getByPlaceholderText('collab.notes.contentPlaceholder')).toHaveValue('apply early')
|
||||
|
||||
fireEvent.change(title, { target: { value: 'Visa (done)' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.save' }))
|
||||
|
||||
await waitFor(() => expect(updatedBodies).toEqual([
|
||||
{ id: '1', body: { title: 'Visa (done)', content: 'apply early', category: 'Docs', color: '#ef4444' } },
|
||||
]))
|
||||
expect(await screen.findByText('Visa (done)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-015: a failed update toasts and leaves the card untouched', async () => {
|
||||
serveNotes([note(1, { title: 'Visa' })])
|
||||
serveWrites({ updateFails: true })
|
||||
const { planner } = await renderNotes()
|
||||
|
||||
fireEvent.click(screen.getByText('Visa'))
|
||||
fireEvent.change(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), { target: { value: 'Nope' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.save' }))
|
||||
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.error'))
|
||||
expect(screen.getByText('Visa')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-016: the cancel button closes the form without writing', async () => {
|
||||
serveNotes([note(1, { title: 'Visa' })])
|
||||
serveWrites()
|
||||
await renderNotes()
|
||||
|
||||
fireEvent.click(screen.getByText('Visa'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.cancel' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(updatedBodies).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-017: queues picked files, drops one again and uploads the rest on save', async () => {
|
||||
serveNotes(
|
||||
[note(1, { title: 'Visa' })],
|
||||
[note(1, { title: 'Visa', attachments: [{ id: 5, filename: 'p', original_name: 'passport.pdf', url: '/uploads/p' }] })],
|
||||
)
|
||||
serveWrites()
|
||||
await renderNotes()
|
||||
|
||||
fireEvent.click(screen.getByText('Visa'))
|
||||
fireEvent.change(fileInput(), {
|
||||
target: { files: [new File(['a'], 'passport.pdf'), new File(['b'], 'a-really-long-attachment-name.pdf')] },
|
||||
})
|
||||
expect(screen.getByText('passport.pdf')).toBeInTheDocument()
|
||||
// long names are shortened for the chip
|
||||
const longChip = screen.getByText('a-really-long-att...')
|
||||
|
||||
fireEvent.click(within(longChip).getByRole('button'))
|
||||
expect(screen.queryByText('a-really-long-att...')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('passport.pdf')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.save' }))
|
||||
await waitFor(() => expect(uploadedTo).toEqual(['1']))
|
||||
// the list is refetched so the new attachment shows up
|
||||
await waitFor(() => expect(getCalls).toBe(2))
|
||||
expect(await screen.findByText('1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-018: a failed upload toasts but still refreshes the list', async () => {
|
||||
serveNotes([note(1, { title: 'Visa' })])
|
||||
serveWrites({ uploadFails: true })
|
||||
const { planner } = await renderNotes()
|
||||
|
||||
fireEvent.click(screen.getByText('Visa'))
|
||||
fireEvent.change(fileInput(), { target: { files: [new File(['a'], 'x.pdf')] } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.save' }))
|
||||
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.error'))
|
||||
await waitFor(() => expect(getCalls).toBe(2))
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-019: removing an existing attachment calls the file endpoint', async () => {
|
||||
serveNotes([note(1, {
|
||||
title: 'Visa',
|
||||
attachments: [{ id: 5, filename: 'p', original_name: 'passport.pdf', url: '/uploads/p' }],
|
||||
})])
|
||||
serveWrites()
|
||||
await renderNotes()
|
||||
|
||||
fireEvent.click(screen.getByText('Visa'))
|
||||
const chip = screen.getByText('passport.pdf')
|
||||
fireEvent.click(within(chip).getByRole('button'))
|
||||
|
||||
await waitFor(() => expect(deletedFiles).toEqual(['1/5']))
|
||||
expect(screen.queryByText('passport.pdf')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-020: a new note uploads its queued files against the created id', async () => {
|
||||
serveNotes([], [note(99, { title: 'Ferry', attachments: [{ id: 8, filename: 't', original_name: 't.pdf', url: '/uploads/t' }] })])
|
||||
serveWrites()
|
||||
await renderNotes()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.new/ }))
|
||||
fireEvent.change(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), { target: { value: 'Ferry' } })
|
||||
fireEvent.change(fileInput(), { target: { files: [new File(['a'], 't.pdf')] } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.create' }))
|
||||
|
||||
await waitFor(() => expect(createdBodies).toHaveLength(1))
|
||||
await waitFor(() => expect(uploadedTo).toEqual(['99']))
|
||||
await waitFor(() => expect(getCalls).toBe(2))
|
||||
expect(await screen.findByText('Ferry')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-021: toasts when detaching an existing file is rejected', async () => {
|
||||
serveNotes([note(1, {
|
||||
title: 'Visa',
|
||||
attachments: [{ id: 5, filename: 'p', original_name: 'passport.pdf', url: '/uploads/p' }],
|
||||
})])
|
||||
serveWrites()
|
||||
server.use(http.delete('/api/trips/:tripId/collab/notes/:noteId/files/:fileId', () =>
|
||||
HttpResponse.json({ error: 'x' }, { status: 500 })))
|
||||
const { planner } = await renderNotes()
|
||||
|
||||
fireEvent.click(screen.getByText('Visa'))
|
||||
fireEvent.click(within(screen.getByText('passport.pdf')).getByRole('button'))
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.error'))
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-022: the attach tile opens the hidden file picker', async () => {
|
||||
serveNotes([note(1, { title: 'Visa' })])
|
||||
const click = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {})
|
||||
await renderNotes()
|
||||
|
||||
fireEvent.click(screen.getByText('Visa'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.attachFiles' }))
|
||||
expect(click).toHaveBeenCalledTimes(1)
|
||||
click.mockRestore()
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-023: hides the attachment section without the upload permission', async () => {
|
||||
serveNotes([note(1, { title: 'Visa' })])
|
||||
const can = ((perm: string) => perm !== 'file_upload') as unknown as TripPlanner['can']
|
||||
await renderNotes(buildPlanner({ can }))
|
||||
|
||||
fireEvent.click(screen.getByText('Visa'))
|
||||
expect(screen.queryByText('collab.notes.attachFiles')).not.toBeInTheDocument()
|
||||
expect(fileInput()).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-024: a read-only member gets the viewer sheet and can open attachments', async () => {
|
||||
serveNotes([note(1, {
|
||||
title: 'Visa',
|
||||
content: 'apply early',
|
||||
category: 'Docs',
|
||||
attachments: [{ id: 5, filename: 'p', original_name: 'passport.pdf', url: '/uploads/p' }],
|
||||
})])
|
||||
await renderNotes(buildPlanner({ can: (() => false) as TripPlanner['can'] }))
|
||||
|
||||
expect(screen.queryByRole('button', { name: /collab\.notes\.new/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'collab.notes.pin' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'collab.notes.delete' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Visa'))
|
||||
const viewer = screen.getByRole('dialog', { name: 'Visa' })
|
||||
expect(viewer).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /passport\.pdf/ }))
|
||||
expect(openFile).toHaveBeenCalledWith('/uploads/p', 'passport.pdf')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-025: applies remote create, update and delete events for this trip only', async () => {
|
||||
serveNotes([note(1, { title: 'Visa' })])
|
||||
await renderNotes()
|
||||
const handler = vi.mocked(addListener).mock.calls[0][0]
|
||||
|
||||
act(() => handler({ type: 'collab:note:created', tripId: 1, note: note(2, { title: 'Remote' }) }))
|
||||
expect(screen.getByText('Remote')).toBeInTheDocument()
|
||||
|
||||
act(() => handler({ type: 'collab:note:created', tripId: 1, note: note(2, { title: 'Remote' }) }))
|
||||
expect(screen.getAllByText('Remote')).toHaveLength(1)
|
||||
|
||||
act(() => handler({ type: 'collab:note:updated', tripId: 1, note: note(2, { title: 'Renamed' }) }))
|
||||
expect(screen.getByText('Renamed')).toBeInTheDocument()
|
||||
|
||||
act(() => handler({ type: 'collab:note:created', tripId: 42, note: note(3, { title: 'Elsewhere' }) }))
|
||||
expect(screen.queryByText('Elsewhere')).not.toBeInTheDocument()
|
||||
|
||||
act(() => handler({ type: 'collab:note:deleted', tripId: 1, noteId: 2 }))
|
||||
expect(screen.queryByText('Renamed')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Visa')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-026: detaches its websocket listener on unmount', async () => {
|
||||
serveNotes([note(1)])
|
||||
const { unmount } = await renderNotes()
|
||||
const handler = vi.mocked(addListener).mock.calls[0][0]
|
||||
|
||||
unmount()
|
||||
expect(removeListener).toHaveBeenCalledWith(handler)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,415 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { collabApi } from '../../../../src/api/client'
|
||||
import { addListener, removeListener } from '../../../../src/api/websocket'
|
||||
import MCollabPolls from '../../../../src/mobile/screens/trip/tabs/MCollabPolls'
|
||||
import type { CollabPollData, PollVoter } from '../../../../src/mobile/screens/trip/tabs/collabModel'
|
||||
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { useAuthStore } from '../../../../src/store/authStore'
|
||||
import { buildUser } from '../../../helpers/factories'
|
||||
import { buildPlanner, buildToast } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-POLLS-001 to FE-MOB-POLLS-024
|
||||
|
||||
const ME = 7
|
||||
|
||||
function voter(id: number, username: string, avatarUrl: string | null = null): PollVoter {
|
||||
return { id, user_id: id, username, avatar: null, avatar_url: avatarUrl }
|
||||
}
|
||||
|
||||
function poll(overrides: Partial<CollabPollData> = {}): CollabPollData {
|
||||
return {
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
user_id: ME,
|
||||
question: 'Where to eat?',
|
||||
options: [
|
||||
{ text: 'Ramen', label: 'Ramen', voters: [] },
|
||||
{ text: 'Sushi', label: 'Sushi', voters: [] },
|
||||
],
|
||||
multiple_choice: false,
|
||||
is_closed: false,
|
||||
deadline: null,
|
||||
username: 'maurice',
|
||||
avatar: null,
|
||||
avatar_url: null,
|
||||
created_at: '2026-07-01T10:00:00',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function setup(polls: CollabPollData[], plannerOverrides: Partial<TripPlanner> = {}) {
|
||||
const toast = buildToast()
|
||||
vi.spyOn(collabApi, 'getPolls').mockResolvedValue({ polls })
|
||||
const planner = buildPlanner({ toast: toast as unknown as TripPlanner['toast'], ...plannerOverrides })
|
||||
const view = render(<MCollabPolls planner={planner} />)
|
||||
return { ...view, planner, toast }
|
||||
}
|
||||
|
||||
/** The listener MCollabPolls registered with the (globally mocked) websocket module. */
|
||||
function wsHandler() {
|
||||
const calls = vi.mocked(addListener).mock.calls
|
||||
return calls[calls.length - 1][0]
|
||||
}
|
||||
|
||||
function deadlineInput(): HTMLInputElement {
|
||||
return document.querySelector('input[type="datetime-local"]') as HTMLInputElement
|
||||
}
|
||||
|
||||
describe('MCollabPolls', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
seedStore(useAuthStore, { user: buildUser({ id: ME, username: 'maurice' }) })
|
||||
vi.mocked(addListener).mockClear()
|
||||
vi.mocked(removeListener).mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-001: shows the loading state until the request settles', async () => {
|
||||
vi.spyOn(collabApi, 'getPolls').mockReturnValue(new Promise(() => {}))
|
||||
render(<MCollabPolls planner={buildPlanner()} />)
|
||||
|
||||
expect(screen.getByText('common.loading')).toBeInTheDocument()
|
||||
expect(screen.queryByText('collab.polls.empty')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-002: renders the empty state and the create button for editors', async () => {
|
||||
setup([])
|
||||
|
||||
expect(await screen.findByText('collab.polls.empty')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'collab.polls.new' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-003: keeps the empty state when loading fails', async () => {
|
||||
vi.spyOn(collabApi, 'getPolls').mockRejectedValue(new Error('offline'))
|
||||
render(<MCollabPolls planner={buildPlanner()} />)
|
||||
|
||||
expect(await screen.findByText('collab.polls.empty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-004: treats a missing polls array as an empty list', async () => {
|
||||
vi.spyOn(collabApi, 'getPolls').mockResolvedValue({})
|
||||
render(<MCollabPolls planner={buildPlanner()} />)
|
||||
|
||||
expect(await screen.findByText('collab.polls.empty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-005: hides every editor control without collab_edit', async () => {
|
||||
setup([poll()], { can: vi.fn(() => false) as unknown as TripPlanner['can'] })
|
||||
|
||||
expect(await screen.findByText('Where to eat?')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'collab.polls.new' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'collab.polls.close' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'collab.polls.delete' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Ramen/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-006: renders question, singular vote label and the multi-choice badge', async () => {
|
||||
setup([poll({ multiple_choice: true, options: [
|
||||
{ text: 'Ramen', label: 'Ramen', voters: [voter(9, 'lea')] },
|
||||
{ text: 'Sushi', label: 'Sushi', voters: [] },
|
||||
] })])
|
||||
|
||||
expect(await screen.findByText('Where to eat?')).toBeInTheDocument()
|
||||
expect(screen.getByText('collab.polls.vote:1')).toBeInTheDocument()
|
||||
expect(screen.getByText('collab.polls.multiChoice')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-007: shows the deadline countdown on an active poll', async () => {
|
||||
// half a minute of slack so the floor() breakdown can't tip to 25h while the test runs
|
||||
const deadline = new Date(Date.now() + 26 * 60 * 60 * 1000 + 30_000).toISOString()
|
||||
setup([poll({ deadline })])
|
||||
|
||||
expect(await screen.findByText('collab.polls.countdownDaysHours:1,2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-008: re-renders the countdown on the 30s ticker', async () => {
|
||||
vi.useFakeTimers()
|
||||
const deadline = new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString()
|
||||
vi.spyOn(collabApi, 'getPolls').mockResolvedValue({ polls: [poll({ deadline })] })
|
||||
render(<MCollabPolls planner={buildPlanner()} />)
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); await Promise.resolve() })
|
||||
|
||||
expect(screen.getByText('collab.polls.countdownHoursMinutes:2,0')).toBeInTheDocument()
|
||||
|
||||
act(() => { vi.advanceTimersByTime(30 * 60 * 1000) })
|
||||
|
||||
expect(screen.getByText('collab.polls.countdownHoursMinutes:1,30')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-009: marks an expired poll closed and shows results with voter avatars', async () => {
|
||||
setup([poll({
|
||||
deadline: new Date(Date.now() - 60_000).toISOString(),
|
||||
options: [
|
||||
{ text: 'Ramen', label: 'Ramen', voters: [voter(9, 'lea'), voter(10, 'tom', '/uploads/avatars/tom.png')] },
|
||||
{ text: 'Sushi', label: 'Sushi', voters: [voter(11, 'ada')] },
|
||||
],
|
||||
})])
|
||||
|
||||
expect(await screen.findByText('collab.polls.closed')).toBeInTheDocument()
|
||||
expect(screen.getByText('collab.polls.votes:3')).toBeInTheDocument()
|
||||
// 2 of 3 votes on the winning option, 1 of 3 on the other
|
||||
expect(screen.getByText('67%')).toBeInTheDocument()
|
||||
expect(screen.getByText('33%')).toBeInTheDocument()
|
||||
expect(screen.getByText('L')).toBeInTheDocument()
|
||||
expect(document.querySelector('img[src="/uploads/avatars/tom.png"]')).toBeInTheDocument()
|
||||
// a closed poll can neither be voted on nor closed again
|
||||
expect(screen.getByRole('button', { name: /Ramen/ })).toBeDisabled()
|
||||
expect(screen.queryByRole('button', { name: 'collab.polls.close' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-010: shows 0% for every option while nobody has voted', async () => {
|
||||
setup([poll({ is_closed: true })])
|
||||
|
||||
expect(await screen.findByText('collab.polls.votes:0')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('0%')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-011: collapses the closed section behind its header', async () => {
|
||||
setup([poll({ id: 1, question: 'Active one' }), poll({ id: 2, question: 'Closed one', is_closed: true })])
|
||||
|
||||
expect(await screen.findByText('Closed one')).toBeInTheDocument()
|
||||
const header = screen.getByRole('button', { name: /collab.polls.closedSection/ })
|
||||
expect(header).toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
fireEvent.click(header)
|
||||
|
||||
expect(screen.queryByText('Closed one')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Active one')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /collab.polls.closedSection/ })).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-011b: deletes a poll out of the closed section', async () => {
|
||||
const deletePoll = vi.spyOn(collabApi, 'deletePoll').mockResolvedValue({})
|
||||
setup([poll({ id: 1, question: 'Active one' }), poll({ id: 2, question: 'Closed one', is_closed: true })])
|
||||
|
||||
await screen.findByText('Closed one')
|
||||
// the active poll's row has the lock button in front, so the closed one owns the last trash button
|
||||
const trash = screen.getAllByRole('button', { name: 'collab.polls.delete' })
|
||||
fireEvent.click(trash[trash.length - 1])
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.delete' }))
|
||||
|
||||
await waitFor(() => expect(deletePoll).toHaveBeenCalledWith(1, 2))
|
||||
await waitFor(() => expect(screen.queryByText('Closed one')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('Active one')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-012: renders closed polls without a section header when none are active', async () => {
|
||||
setup([poll({ id: 2, question: 'Closed one', is_closed: true })])
|
||||
|
||||
expect(await screen.findByText('Closed one')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /collab.polls.closedSection/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-013: votes on an option and swaps in the server result', async () => {
|
||||
const voted = poll({ options: [
|
||||
{ text: 'Ramen', label: 'Ramen', voters: [voter(ME, 'maurice')] },
|
||||
{ text: 'Sushi', label: 'Sushi', voters: [] },
|
||||
] })
|
||||
const votePoll = vi.spyOn(collabApi, 'votePoll').mockResolvedValue({ poll: voted })
|
||||
setup([poll()])
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Ramen/ }))
|
||||
|
||||
await waitFor(() => expect(votePoll).toHaveBeenCalledWith(1, 1, 0))
|
||||
expect(await screen.findByText('collab.polls.vote:1')).toBeInTheDocument()
|
||||
expect(screen.getByText('100%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-014: toasts when the vote request fails', async () => {
|
||||
vi.spyOn(collabApi, 'votePoll').mockRejectedValue(new Error('boom'))
|
||||
const { toast } = setup([poll()])
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Sushi/ }))
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('common.error'))
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-015: closes a poll from the lock button', async () => {
|
||||
const closePoll = vi.spyOn(collabApi, 'closePoll').mockResolvedValue({ poll: poll({ is_closed: true }) })
|
||||
setup([poll()])
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'collab.polls.close' }))
|
||||
|
||||
await waitFor(() => expect(closePoll).toHaveBeenCalledWith(1, 1))
|
||||
expect(await screen.findByText('collab.polls.closed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-016: toasts when closing fails', async () => {
|
||||
vi.spyOn(collabApi, 'closePoll').mockRejectedValue(new Error('boom'))
|
||||
const { toast } = setup([poll()])
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'collab.polls.close' }))
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('common.error'))
|
||||
expect(screen.queryByText('collab.polls.closed')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-017: deletes a poll after the confirm sheet is accepted', async () => {
|
||||
const deletePoll = vi.spyOn(collabApi, 'deletePoll').mockResolvedValue({})
|
||||
setup([poll()])
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'collab.polls.delete' }))
|
||||
expect(await screen.findByText('collab.polls.confirmDeleteTitle')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
|
||||
|
||||
await waitFor(() => expect(deletePoll).toHaveBeenCalledWith(1, 1))
|
||||
await waitFor(() => expect(screen.queryByText('Where to eat?')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-018: keeps the poll when the confirm sheet is cancelled', async () => {
|
||||
const deletePoll = vi.spyOn(collabApi, 'deletePoll').mockResolvedValue({})
|
||||
setup([poll()])
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'collab.polls.delete' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.cancel' }))
|
||||
|
||||
expect(deletePoll).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Where to eat?')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-019: toasts when deleting fails', async () => {
|
||||
vi.spyOn(collabApi, 'deletePoll').mockRejectedValue(new Error('boom'))
|
||||
const { toast } = setup([poll()])
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'collab.polls.delete' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.delete' }))
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('common.error'))
|
||||
expect(screen.getByText('Where to eat?')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-020: applies created / voted / closed / deleted websocket events', async () => {
|
||||
setup([poll()])
|
||||
await screen.findByText('Where to eat?')
|
||||
const handler = wsHandler()
|
||||
|
||||
act(() => handler({ type: 'collab:poll:created', tripId: '1', poll: poll({ id: 2, question: 'Which hotel?' }) }))
|
||||
expect(screen.getByText('Which hotel?')).toBeInTheDocument()
|
||||
|
||||
// same id again → no duplicate row
|
||||
act(() => handler({ type: 'collab:poll:created', tripId: '1', poll: poll({ id: 2, question: 'Which hotel?' }) }))
|
||||
expect(screen.getAllByText('Which hotel?')).toHaveLength(1)
|
||||
|
||||
act(() => handler({
|
||||
type: 'collab:poll:voted',
|
||||
tripId: '1',
|
||||
poll: poll({ id: 2, question: 'Which hotel?', options: [
|
||||
{ text: 'Ramen', label: 'Ramen', voters: [voter(9, 'lea')] },
|
||||
{ text: 'Sushi', label: 'Sushi', voters: [] },
|
||||
] }),
|
||||
}))
|
||||
expect(screen.getByText('collab.polls.vote:1')).toBeInTheDocument()
|
||||
|
||||
act(() => handler({ type: 'collab:poll:closed', tripId: '1', poll: poll({ id: 2, question: 'Which hotel?', is_closed: true }) }))
|
||||
expect(screen.getByText('collab.polls.closed')).toBeInTheDocument()
|
||||
|
||||
act(() => handler({ type: 'collab:poll:deleted', tripId: '1', pollId: 2 }))
|
||||
expect(screen.queryByText('Which hotel?')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-021: ignores websocket events for another trip and drops the listener on unmount', async () => {
|
||||
const { unmount } = setup([poll()])
|
||||
await screen.findByText('Where to eat?')
|
||||
const handler = wsHandler()
|
||||
|
||||
act(() => handler({ type: 'collab:poll:created', tripId: '99', poll: poll({ id: 3, question: 'Other trip' }) }))
|
||||
expect(screen.queryByText('Other trip')).not.toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
expect(removeListener).toHaveBeenCalledWith(handler)
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-022: creates a poll with question, options, multi-choice and deadline', async () => {
|
||||
const created = poll({ id: 5, question: 'Which hotel?' })
|
||||
const createPoll = vi.spyOn(collabApi, 'createPoll').mockResolvedValue({ poll: created })
|
||||
setup([])
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'collab.polls.new' }))
|
||||
fireEvent.change(await screen.findByPlaceholderText('collab.polls.questionPlaceholder'), { target: { value: ' Which hotel? ' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('collab.polls.optionPlaceholder:1'), { target: { value: 'Ryokan' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('collab.polls.optionPlaceholder:2'), { target: { value: ' Capsule ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.polls.multiChoice' }))
|
||||
fireEvent.change(deadlineInput(), { target: { value: '2026-08-01T12:00' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.polls.create' }))
|
||||
|
||||
await waitFor(() => expect(createPoll).toHaveBeenCalledWith(1, {
|
||||
question: 'Which hotel?',
|
||||
options: ['Ryokan', 'Capsule'],
|
||||
multiple: true,
|
||||
multiple_choice: true,
|
||||
deadline: new Date('2026-08-01T12:00').toISOString(),
|
||||
}))
|
||||
expect(await screen.findByText('Which hotel?')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-023: gates the submit button on a question plus two non-blank options', async () => {
|
||||
setup([])
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'collab.polls.new' }))
|
||||
const submit = await screen.findByRole('button', { name: 'collab.polls.create' })
|
||||
expect(submit).toBeDisabled()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('collab.polls.questionPlaceholder'), { target: { value: ' ' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('collab.polls.optionPlaceholder:1'), { target: { value: 'A' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('collab.polls.optionPlaceholder:2'), { target: { value: 'B' } })
|
||||
expect(submit).toBeDisabled()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('collab.polls.questionPlaceholder'), { target: { value: 'Q' } })
|
||||
expect(submit).toBeEnabled()
|
||||
|
||||
// blank options don't count towards the two-option minimum
|
||||
fireEvent.change(screen.getByPlaceholderText('collab.polls.optionPlaceholder:2'), { target: { value: ' ' } })
|
||||
expect(submit).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-024: adds, removes and clears the form fields', async () => {
|
||||
setup([])
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'collab.polls.new' }))
|
||||
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /collab.polls.addOption/ }))
|
||||
expect(screen.getByPlaceholderText('collab.polls.optionPlaceholder:3')).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('button', { name: 'common.delete' })).toHaveLength(3)
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'common.delete' })[0])
|
||||
expect(screen.queryByPlaceholderText('collab.polls.optionPlaceholder:3')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.change(deadlineInput(), { target: { value: '2026-08-01T12:00' } })
|
||||
expect(deadlineInput()).toHaveValue('2026-08-01T12:00')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.polls.clearDeadline' }))
|
||||
expect(deadlineInput()).toHaveValue('')
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-025: keeps the form open and toasts when creating fails', async () => {
|
||||
vi.spyOn(collabApi, 'createPoll').mockRejectedValue(new Error('boom'))
|
||||
const { toast } = setup([])
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'collab.polls.new' }))
|
||||
fireEvent.change(await screen.findByPlaceholderText('collab.polls.questionPlaceholder'), { target: { value: 'Q' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('collab.polls.optionPlaceholder:1'), { target: { value: 'A' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('collab.polls.optionPlaceholder:2'), { target: { value: 'B' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.polls.create' }))
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('common.error'))
|
||||
expect(screen.getByRole('button', { name: 'collab.polls.create' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-026: resets the form fields on every reopen', async () => {
|
||||
setup([])
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'collab.polls.new' }))
|
||||
fireEvent.change(await screen.findByPlaceholderText('collab.polls.questionPlaceholder'), { target: { value: 'Draft' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.polls.new' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('collab.polls.questionPlaceholder')).toHaveValue(''))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import MCollabTab from '../../../../src/mobile/screens/trip/tabs/MCollabTab'
|
||||
import type { MTripCollabTab, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { render, screen } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-CTAB-001 to FE-MOB-CTAB-009
|
||||
|
||||
// The three sub-panels are self-contained (own API calls + WebSocket listener)
|
||||
// and covered by their own suites — here only the routing is under test.
|
||||
vi.mock('../../../../src/mobile/screens/trip/tabs/MCollabChat', () => ({
|
||||
default: ({ planner }: { planner: TripPlanner }) => <div data-testid="chat">{planner.tripId}</div>,
|
||||
}))
|
||||
vi.mock('../../../../src/mobile/screens/trip/tabs/MCollabNotes', () => ({
|
||||
default: ({ planner }: { planner: TripPlanner }) => <div data-testid="notes">{planner.tripId}</div>,
|
||||
}))
|
||||
vi.mock('../../../../src/mobile/screens/trip/tabs/MCollabPolls', () => ({
|
||||
default: ({ planner }: { planner: TripPlanner }) => <div data-testid="polls">{planner.tripId}</div>,
|
||||
}))
|
||||
|
||||
function renderTab(collabTab: MTripCollabTab, features: Partial<Record<'chat' | 'notes' | 'polls', boolean>> = {}) {
|
||||
const planner = buildPlanner({
|
||||
collabFeatures: { chat: true, notes: true, polls: true, ...features } as TripPlanner['collabFeatures'],
|
||||
})
|
||||
return render(<MCollabTab planner={planner} shell={buildShell({ collabTab })} />)
|
||||
}
|
||||
|
||||
describe('MCollabTab', () => {
|
||||
it('FE-MOB-CTAB-001: routes the chat sub-tab to MCollabChat', () => {
|
||||
renderTab('chat')
|
||||
|
||||
expect(screen.getByTestId('chat')).toHaveTextContent('1')
|
||||
expect(screen.queryByTestId('notes')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('polls')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CTAB-002: routes the notes sub-tab to MCollabNotes', () => {
|
||||
renderTab('notes')
|
||||
|
||||
expect(screen.getByTestId('notes')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('chat')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CTAB-003: routes the polls sub-tab to MCollabPolls', () => {
|
||||
renderTab('polls')
|
||||
|
||||
expect(screen.getByTestId('polls')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('chat')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CTAB-004: shows the disabled notice instead of the chat panel', () => {
|
||||
renderTab('chat', { chat: false })
|
||||
|
||||
expect(screen.getByText('mobileTrip.collabFeatureDisabled')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('chat')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CTAB-005: shows the disabled notice instead of the notes panel', () => {
|
||||
renderTab('notes', { notes: false })
|
||||
|
||||
expect(screen.getByText('mobileTrip.collabFeatureDisabled')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('notes')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CTAB-006: shows the disabled notice instead of the polls panel', () => {
|
||||
renderTab('polls', { polls: false })
|
||||
|
||||
expect(screen.getByText('mobileTrip.collabFeatureDisabled')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('polls')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CTAB-007: gates each sub-tab independently', () => {
|
||||
renderTab('polls', { chat: false, notes: false })
|
||||
|
||||
expect(screen.getByTestId('polls')).toBeInTheDocument()
|
||||
expect(screen.queryByText('mobileTrip.collabFeatureDisabled')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-CTAB-008: renders nothing for a sub-tab it does not handle', () => {
|
||||
const { container } = renderTab('whatsnext' as MTripCollabTab)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
expect(screen.queryByText('mobileTrip.collabFeatureDisabled')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,525 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MCostSheet from '../../../../src/mobile/screens/trip/sheets/MCostSheet'
|
||||
import type { TripMember } from '../../../../src/components/Budget/BudgetPanelMemberChips'
|
||||
import { clearExchangeRateCache } from '../../../../src/hooks/useExchangeRates'
|
||||
import { useTripStore, type TripStoreState } from '../../../../src/store/tripStore'
|
||||
import type { BudgetItem, BudgetItemMember } from '../../../../src/types'
|
||||
import { buildBudgetItem } from '../../../helpers/factories'
|
||||
import { resetAllStores } from '../../../helpers/store'
|
||||
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-COSTSH-001 to FE-MOB-COSTSH-030
|
||||
// The sheet reads its copy from useTranslation(), so assertions are English.
|
||||
// EUR formats de-DE style: displayed amounts and inputs use a comma.
|
||||
|
||||
const PEOPLE = [
|
||||
{ id: 1, username: 'alice', avatar_url: null },
|
||||
{ id: 2, username: 'bob', avatar_url: null },
|
||||
] as TripMember[]
|
||||
|
||||
const TODAY = new Date().toISOString().slice(0, 10)
|
||||
|
||||
function member(user_id: number, amount: number | null): BudgetItemMember {
|
||||
return { user_id, paid: 0, username: user_id === 1 ? 'alice' : 'bob', amount }
|
||||
}
|
||||
|
||||
let addBudgetItem: ReturnType<typeof vi.fn>
|
||||
let updateBudgetItem: ReturnType<typeof vi.fn>
|
||||
let deleteBudgetItem: ReturnType<typeof vi.fn>
|
||||
let addToast: ReturnType<typeof vi.fn>
|
||||
|
||||
interface SheetOverrides {
|
||||
people?: TripMember[]
|
||||
me?: number
|
||||
base?: string
|
||||
editing?: BudgetItem | null
|
||||
prefill?: { name?: string; category?: string; amount?: number; reservationId?: number }
|
||||
}
|
||||
|
||||
function renderSheet(overrides: SheetOverrides = {}) {
|
||||
const onClose = vi.fn()
|
||||
const onSaved = vi.fn()
|
||||
const view = render(
|
||||
<MCostSheet
|
||||
tripId={1}
|
||||
base={overrides.base ?? 'EUR'}
|
||||
people={overrides.people ?? PEOPLE}
|
||||
me={overrides.me ?? 1}
|
||||
editing={overrides.editing ?? null}
|
||||
prefill={overrides.prefill}
|
||||
onClose={onClose}
|
||||
onSaved={onSaved}
|
||||
/>,
|
||||
)
|
||||
return { ...view, onClose, onSaved }
|
||||
}
|
||||
|
||||
const nameField = () => screen.getByPlaceholderText('e.g. Dinner, souvenirs, gas…')
|
||||
const totalField = () => screen.getAllByPlaceholderText('0,00')[0]
|
||||
const submit = () => screen.getByRole('button', { name: 'Add' })
|
||||
const saveBtn = () => screen.getByRole('button', { name: 'Save' })
|
||||
|
||||
function fillBasics(name: string, total: string) {
|
||||
fireEvent.change(nameField(), { target: { value: name } })
|
||||
fireEvent.change(totalField(), { target: { value: total } })
|
||||
}
|
||||
|
||||
describe('MCostSheet', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
clearExchangeRateCache()
|
||||
// Seeded FX cache, fresh enough that the hook never reaches the network.
|
||||
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.25 }, ts: Date.now() }))
|
||||
addBudgetItem = vi.fn(async () => buildBudgetItem({ id: 9 }))
|
||||
updateBudgetItem = vi.fn(async () => buildBudgetItem({ id: 9 }))
|
||||
deleteBudgetItem = vi.fn(async () => undefined)
|
||||
useTripStore.setState(
|
||||
{ addBudgetItem, updateBudgetItem, deleteBudgetItem } as unknown as Partial<TripStoreState>,
|
||||
)
|
||||
addToast = vi.fn()
|
||||
;(window as unknown as { __addToast: unknown }).__addToast = addToast
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { __addToast?: unknown }).__addToast
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-001: opens in add mode with everyone in an equal split and a blocked submit', () => {
|
||||
renderSheet()
|
||||
expect(screen.getByRole('dialog', { name: 'Add expense' })).toBeInTheDocument()
|
||||
expect(nameField()).toHaveValue('')
|
||||
expect(screen.getByRole('button', { name: 'Food & drink' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByRole('button', { name: 'Y You' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'B bob' })).toBeInTheDocument()
|
||||
expect(submit()).toBeDisabled()
|
||||
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-002: splits the typed total equally and saves the expense', async () => {
|
||||
const { onSaved } = renderSheet()
|
||||
// A comma is normalised to a dot before it reaches the state.
|
||||
fillBasics('Dinner', '85,50')
|
||||
|
||||
expect(screen.getAllByText('€42.75')).toHaveLength(2)
|
||||
expect(screen.getByText('Split 2 ways · €42.75 each')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
|
||||
expect(addBudgetItem).toHaveBeenCalledWith(1, {
|
||||
name: 'Dinner',
|
||||
category: 'food',
|
||||
currency: 'EUR',
|
||||
payers: [{ user_id: 1, amount: 85.5 }],
|
||||
members: [{ user_id: 1, amount: null }, { user_id: 2, amount: null }],
|
||||
member_ids: [1, 2],
|
||||
expense_date: TODAY,
|
||||
total_price: 85.5,
|
||||
note: null,
|
||||
})
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-003: adopts the prefill and carries the reservation link into the payload', async () => {
|
||||
renderSheet({ prefill: { name: 'Ryokan', category: 'accommodation', amount: 240, reservationId: 77 } })
|
||||
expect(nameField()).toHaveValue('Ryokan')
|
||||
expect(totalField()).toHaveValue('240')
|
||||
expect(screen.getByRole('button', { name: 'Accommodation' })).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
|
||||
expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
|
||||
name: 'Ryokan', category: 'accommodation', total_price: 240, reservation_id: 77,
|
||||
}))
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-004: dropping a participant re-splits and shrinks the member list', async () => {
|
||||
renderSheet()
|
||||
fillBasics('Taxi', '30')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'B bob' }))
|
||||
|
||||
expect(screen.getByText('Split 1 ways · €30.00 each')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Tap to include')).toHaveLength(1)
|
||||
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
|
||||
expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
|
||||
members: [{ user_id: 1, amount: null }],
|
||||
member_ids: [1],
|
||||
payers: [{ user_id: 1, amount: 30 }],
|
||||
}))
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-005: an expense nobody shares is saved without payers or members', async () => {
|
||||
renderSheet()
|
||||
fillBasics('Souvenir', '12')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Y You' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'B bob' }))
|
||||
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
|
||||
expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({ payers: [], members: [], member_ids: [] }))
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-006: switching the category updates the pressed pill and the payload', async () => {
|
||||
renderSheet()
|
||||
fillBasics('Metro pass', '20')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Transport' }))
|
||||
expect(screen.getByRole('button', { name: 'Transport' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByRole('button', { name: 'Food & drink' })).toHaveAttribute('aria-pressed', 'false')
|
||||
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({ category: 'transport' })))
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-007: a foreign currency shows the live conversion into the base', () => {
|
||||
renderSheet()
|
||||
fillBasics('Hotel', '100')
|
||||
expect(screen.queryByText(/live rate/)).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'EUR €' }))
|
||||
fireEvent.change(screen.getByPlaceholderText('...'), { target: { value: 'USD' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'USD $' }))
|
||||
|
||||
expect(screen.getByText('$100.00')).toBeInTheDocument()
|
||||
// 100 USD at 1.25 USD per EUR = 80 EUR.
|
||||
expect(screen.getByRole('dialog').textContent).toContain('80,00')
|
||||
expect(screen.getByText(/live rate/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-008: "No one paid yet" saves a planning entry without payers', async () => {
|
||||
renderSheet()
|
||||
fillBasics('Tickets', '60')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'You' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'No one paid yet' }))
|
||||
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({ payers: [] })))
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-009: multiple payers start out balanced on the current payer', () => {
|
||||
renderSheet()
|
||||
fillBasics('Group ticket', '100')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Multiple people paid' }))
|
||||
|
||||
const amounts = screen.getAllByPlaceholderText('0,00')
|
||||
// Total field plus the single active payer row.
|
||||
expect(amounts).toHaveLength(2)
|
||||
expect(amounts[1]).toHaveValue('100,00')
|
||||
|
||||
// The hint next to an inactive payer is a second way into the same toggle.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Tap to include' }))
|
||||
expect(screen.getAllByPlaceholderText('0,00')[2]).toHaveValue('50,00')
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-010: adding a second payer spreads the total and saves both', async () => {
|
||||
renderSheet()
|
||||
fillBasics('Group ticket', '100')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Multiple people paid' }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'B bob' })[0])
|
||||
|
||||
const amounts = screen.getAllByPlaceholderText('0,00')
|
||||
expect(amounts[1]).toHaveValue('50,00')
|
||||
expect(amounts[2]).toHaveValue('50,00')
|
||||
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
|
||||
payers: [{ user_id: 1, amount: 50 }, { user_id: 2, amount: 50 }],
|
||||
})))
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-011: a typed payer amount is pinned and the rest absorbs the remainder', () => {
|
||||
renderSheet()
|
||||
fillBasics('Group ticket', '100')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Multiple people paid' }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'B bob' })[0])
|
||||
|
||||
fireEvent.change(screen.getAllByPlaceholderText('0,00')[1], { target: { value: '30' } })
|
||||
const amounts = screen.getAllByPlaceholderText('0,00')
|
||||
expect(amounts[1]).toHaveValue('30')
|
||||
expect(amounts[2]).toHaveValue('70,00')
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-012: payers that do not add up block the save and explain why', () => {
|
||||
renderSheet()
|
||||
fillBasics('Group ticket', '100')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Multiple people paid' }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'B bob' })[0])
|
||||
|
||||
fireEvent.change(screen.getAllByPlaceholderText('0,00')[1], { target: { value: '30' } })
|
||||
fireEvent.change(screen.getAllByPlaceholderText('0,00')[2], { target: { value: '30' } })
|
||||
|
||||
expect(screen.getByText(/Payer amounts must add up to/)).toBeInTheDocument()
|
||||
expect(submit()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-013: going back to a single payer keeps the remaining one selected', () => {
|
||||
renderSheet()
|
||||
fillBasics('Group ticket', '100')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Multiple people paid' }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'B bob' })[0])
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Y You' })[0])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'One person paid' }))
|
||||
|
||||
expect(screen.getByRole('button', { name: 'bob' })).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Payer amounts must add up to/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-014: a custom split must match the total before it can be saved', async () => {
|
||||
renderSheet()
|
||||
fillBasics('Dinner', '100')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Custom' }))
|
||||
|
||||
const shares = screen.getAllByPlaceholderText('50,00')
|
||||
expect(shares).toHaveLength(2)
|
||||
fireEvent.change(shares[0], { target: { value: '30' } })
|
||||
expect(screen.getByText('€30.00 / €100.00')).toBeInTheDocument()
|
||||
expect(submit()).toBeDisabled()
|
||||
|
||||
// The remaining share now suggests what is left of the total.
|
||||
fireEvent.change(screen.getByPlaceholderText('70,00'), { target: { value: '70' } })
|
||||
expect(screen.getByText('Split 2 ways · €50.00 each')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
|
||||
members: [{ user_id: 1, amount: 30 }, { user_id: 2, amount: 70 }],
|
||||
})))
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-015: a custom share rejects a third decimal and is cleared when the member drops out', () => {
|
||||
renderSheet()
|
||||
fillBasics('Dinner', '100')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Custom' }))
|
||||
|
||||
const share = screen.getAllByPlaceholderText('50,00')[0]
|
||||
fireEvent.change(share, { target: { value: '30.123' } })
|
||||
expect(share).toHaveValue('')
|
||||
fireEvent.change(share, { target: { value: '30,5' } })
|
||||
expect(screen.getByDisplayValue('30,5')).toBe(share)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Y You' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Tap to include' }))
|
||||
expect(screen.getAllByPlaceholderText('50,00')[0]).toHaveValue('')
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-016: ticket mode derives the total and the per-person shares from the items', async () => {
|
||||
renderSheet()
|
||||
fireEvent.change(nameField(), { target: { value: 'Izakaya' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Ticket' }))
|
||||
expect(totalField()).toBeDisabled()
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Add' })[0])
|
||||
fireEvent.change(screen.getByPlaceholderText('What was it for?'), { target: { value: 'Ramen' } })
|
||||
fireEvent.change(screen.getAllByPlaceholderText('0,00')[1], { target: { value: '20' } })
|
||||
|
||||
expect(totalField()).toHaveValue('20,00')
|
||||
expect(screen.getAllByText('€10.00')).toHaveLength(2)
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Add' })[1])
|
||||
await waitFor(() => expect(addBudgetItem).toHaveBeenCalledTimes(1))
|
||||
expect(addBudgetItem).toHaveBeenCalledWith(1, expect.objectContaining({
|
||||
total_price: 20,
|
||||
members: [{ user_id: 1, amount: 10 }, { user_id: 2, amount: 10 }],
|
||||
note: 'TICKETJSON:{"items":[{"name":"Ramen","price":"20","parts":[1,2]}]}',
|
||||
}))
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-017: a ticket item without a name or a participant blocks the save', () => {
|
||||
renderSheet()
|
||||
fireEvent.change(nameField(), { target: { value: 'Izakaya' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Ticket' }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Add' })[0])
|
||||
|
||||
const footerSubmit = () => screen.getAllByRole('button', { name: 'Add' })[1]
|
||||
fireEvent.change(screen.getAllByPlaceholderText('0,00')[1], { target: { value: '20' } })
|
||||
expect(footerSubmit()).toBeDisabled()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('What was it for?'), { target: { value: 'Ramen' } })
|
||||
expect(footerSubmit()).toBeEnabled()
|
||||
|
||||
// Un-ticking everyone makes the item unsplittable again.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Y You' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'B bob' }))
|
||||
expect(footerSubmit()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-018: removing the last ticket item drops the share breakdown', () => {
|
||||
renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Ticket' }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Add' })[0])
|
||||
fireEvent.change(screen.getByPlaceholderText('What was it for?'), { target: { value: 'Ramen' } })
|
||||
expect(screen.getAllByText('Split')).toHaveLength(2)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
expect(screen.queryByPlaceholderText('What was it for?')).not.toBeInTheDocument()
|
||||
expect(screen.getAllByText('Split')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-019: editing loads name, currency, legacy category and the custom shares', () => {
|
||||
const editing = buildBudgetItem({
|
||||
id: 5, name: 'Flight to Osaka', category: 'Flight', currency: 'usd', total_price: 60,
|
||||
expense_date: '2026-03-04',
|
||||
members: [member(1, 20), member(2, 40)],
|
||||
payers: [{ user_id: 2, amount: 60 }],
|
||||
})
|
||||
renderSheet({ editing })
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Edit expense' })).toBeInTheDocument()
|
||||
expect(nameField()).toHaveValue('Flight to Osaka')
|
||||
expect(screen.getByRole('button', { name: 'Flights' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByRole('button', { name: 'USD $' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'bob' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Custom' })).toHaveClass('bg-m-act')
|
||||
// USD renders with a dot, so the total and both shares share this placeholder.
|
||||
const amounts = screen.getAllByPlaceholderText('0.00')
|
||||
expect(amounts[0]).toHaveValue('60')
|
||||
expect(amounts[1]).toHaveValue('20')
|
||||
expect(amounts[2]).toHaveValue('40')
|
||||
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-020: saving an edit goes through updateBudgetItem', async () => {
|
||||
const editing = buildBudgetItem({
|
||||
id: 5, name: 'Dinner', category: 'food', currency: 'EUR', total_price: 60,
|
||||
members: [member(1, null), member(2, null)],
|
||||
payers: [{ user_id: 1, amount: 60 }],
|
||||
})
|
||||
const { onSaved } = renderSheet({ editing })
|
||||
fireEvent.change(nameField(), { target: { value: 'Dinner & drinks' } })
|
||||
fireEvent.click(saveBtn())
|
||||
|
||||
await waitFor(() => expect(updateBudgetItem).toHaveBeenCalledTimes(1))
|
||||
expect(updateBudgetItem).toHaveBeenCalledWith(1, 5, expect.objectContaining({
|
||||
name: 'Dinner & drinks',
|
||||
total_price: 60,
|
||||
members: [{ user_id: 1, amount: null }, { user_id: 2, amount: null }],
|
||||
}))
|
||||
expect(addBudgetItem).not.toHaveBeenCalled()
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-021: an expense with two payers reopens in multi-payer mode', () => {
|
||||
const editing = buildBudgetItem({
|
||||
id: 6, name: 'Rental car', category: 'transport', currency: 'EUR', total_price: 100,
|
||||
members: [member(1, null), member(2, null)],
|
||||
payers: [{ user_id: 1, amount: 40 }, { user_id: 2, amount: 60 }],
|
||||
})
|
||||
renderSheet({ editing })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'One person paid' })).toBeInTheDocument()
|
||||
const amounts = screen.getAllByPlaceholderText('0,00')
|
||||
expect(amounts[1]).toHaveValue('40')
|
||||
expect(amounts[2]).toHaveValue('60')
|
||||
expect(screen.queryByText(/Payer amounts must add up to/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-022: an expense nobody paid reopens on the placeholder payer', () => {
|
||||
const editing = buildBudgetItem({
|
||||
id: 7, name: 'Museum', category: 'sightseeing', currency: 'EUR', total_price: 0,
|
||||
members: [], payers: [],
|
||||
})
|
||||
renderSheet({ editing })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'No one paid yet' })).toBeInTheDocument()
|
||||
expect(totalField()).toHaveValue('')
|
||||
expect(saveBtn()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-023: a stored ticket note reopens as editable ticket rows', () => {
|
||||
const editing = buildBudgetItem({
|
||||
id: 8, name: 'Izakaya', category: 'food', currency: 'EUR', total_price: 20,
|
||||
members: [member(1, 10), member(2, 10)],
|
||||
note: 'TICKETJSON:{"items":[{"name":"Ramen","price":"20","parts":[1,2]}]}',
|
||||
})
|
||||
renderSheet({ editing })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Ticket' })).toHaveClass('bg-m-act')
|
||||
expect(screen.getByPlaceholderText('What was it for?')).toHaveValue('Ramen')
|
||||
expect(screen.getAllByPlaceholderText('0,00')[1]).toHaveValue('20')
|
||||
expect(totalField()).toHaveValue('20,00')
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-024: a corrupt ticket note still opens the sheet with an empty item list', () => {
|
||||
const editing = buildBudgetItem({
|
||||
id: 9, name: 'Izakaya', category: 'food', currency: 'EUR', total_price: 20,
|
||||
members: [], note: 'TICKETJSON:{not json',
|
||||
})
|
||||
renderSheet({ editing })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Ticket' })).toHaveClass('bg-m-act')
|
||||
expect(screen.queryByPlaceholderText('What was it for?')).not.toBeInTheDocument()
|
||||
expect(saveBtn()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-025: deleting takes two taps and warns in between', async () => {
|
||||
const editing = buildBudgetItem({ id: 5, name: 'Dinner', category: 'food', currency: 'EUR', total_price: 60, members: [] })
|
||||
const { onSaved } = renderSheet({ editing })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
expect(addToast).toHaveBeenCalledWith('Tap again to delete', 'warning', undefined)
|
||||
expect(deleteBudgetItem).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: 'Delete' }).className).toContain('--m-st-danger')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
await waitFor(() => expect(deleteBudgetItem).toHaveBeenCalledWith(1, 5))
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-026: a failing delete disarms the button and reports the error', async () => {
|
||||
deleteBudgetItem.mockRejectedValueOnce(new Error('locked'))
|
||||
const editing = buildBudgetItem({ id: 5, name: 'Dinner', category: 'food', currency: 'EUR', total_price: 60, members: [] })
|
||||
const { onSaved } = renderSheet({ editing })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
|
||||
expect(onSaved).not.toHaveBeenCalled()
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Delete' }).className).not.toContain('--m-st-danger'))
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-027: a failing save reports the error and lets the user retry', async () => {
|
||||
addBudgetItem.mockRejectedValueOnce(new Error('offline'))
|
||||
const { onSaved } = renderSheet()
|
||||
fillBasics('Dinner', '20')
|
||||
fireEvent.click(submit())
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
|
||||
expect(onSaved).not.toHaveBeenCalled()
|
||||
await waitFor(() => expect(submit()).toBeEnabled())
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-028: cancel reports the close only after the exit animation', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { onClose } = renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
act(() => { vi.advanceTimersByTime(280) })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-029: renders member avatars and flags guests', () => {
|
||||
const people = [
|
||||
{ id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.png' },
|
||||
{ id: 2, username: 'bob', avatar_url: null, is_guest: true },
|
||||
] as TripMember[]
|
||||
renderSheet({ people })
|
||||
|
||||
// The sheet lives in a portal, so query from the document root.
|
||||
expect(document.querySelector('img[src="/uploads/avatars/alice.png"]')).not.toBeNull()
|
||||
expect(screen.getByText('Guest')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTSH-030: a currency without a known symbol falls back to its code', () => {
|
||||
localStorage.setItem('trek_fx_XTS', JSON.stringify({ rates: { XTS: 1 }, ts: Date.now() }))
|
||||
renderSheet({ base: 'XTS' })
|
||||
fireEvent.change(nameField(), { target: { value: 'Test purchase' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('0.00'), { target: { value: '10' } })
|
||||
|
||||
expect(screen.getAllByText('XTS 5.00')).toHaveLength(2)
|
||||
expect(screen.getByText('Split 2 ways · XTS 5.00 each')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,557 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import MCostsTab from '../../../../src/mobile/screens/trip/tabs/MCostsTab'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { budgetApi } from '../../../../src/api/client'
|
||||
import { clearExchangeRateCache } from '../../../../src/hooks/useExchangeRates'
|
||||
import { useAuthStore } from '../../../../src/store/authStore'
|
||||
import { useSettingsStore } from '../../../../src/store/settingsStore'
|
||||
import type { BudgetItem, Day, Trip } from '../../../../src/types'
|
||||
import { buildTrip, buildUser } from '../../../helpers/factories'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { server } from '../../../helpers/msw/server'
|
||||
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-COSTT-001 to FE-MOB-COSTT-037
|
||||
|
||||
// The add/edit expense sheet is the shared desktop-sized form; the panel only
|
||||
// owns when it opens and what happens on save, so it is stubbed here.
|
||||
vi.mock('../../../../src/mobile/screens/trip/sheets/MCostSheet', () => ({
|
||||
default: ({ base, editing, onClose, onSaved }: {
|
||||
base: string
|
||||
editing: { id: number } | null
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) => (
|
||||
<div>
|
||||
<span>{editing ? `sheet editing ${editing.id}` : `sheet new ${base}`}</span>
|
||||
<button type="button" onClick={onSaved}>sheet save</button>
|
||||
<button type="button" onClick={onClose}>sheet close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const TRIP = buildTrip({ id: 7, title: 'Tokyo Trip', currency: 'USD' }) as Trip
|
||||
|
||||
const DAYS = [
|
||||
{ id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null },
|
||||
{ id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: 'Kyoto' },
|
||||
] as unknown as Day[]
|
||||
|
||||
const MEMBERS = [
|
||||
{ id: 1, username: 'Me', avatar_url: null },
|
||||
{ id: 2, username: 'Ada', avatar_url: '/uploads/avatars/ada.png' },
|
||||
{ id: 3, username: 'Bob', avatar_url: null },
|
||||
]
|
||||
|
||||
const RAMEN = {
|
||||
id: 11, trip_id: 7, name: 'Ramen', category: 'food', total_price: 80, currency: 'USD',
|
||||
expense_date: '2026-05-02', note: null,
|
||||
members: [
|
||||
{ user_id: 1, paid: 1, username: 'Me', amount: null, avatar_url: null },
|
||||
{ user_id: 2, paid: 0, username: 'Ada', amount: null, avatar_url: '/uploads/avatars/ada.png' },
|
||||
],
|
||||
payers: [{ user_id: 1, amount: 80, username: 'Me' }],
|
||||
} as unknown as BudgetItem
|
||||
|
||||
/** Foreign currency + explicit custom shares — exercises the conversion line. */
|
||||
const MUSEUM = {
|
||||
id: 12, trip_id: 7, name: 'Museum', category: 'activities', total_price: 30, currency: 'GBP',
|
||||
expense_date: '2026-04-30', note: 'Tickets; adult',
|
||||
members: [
|
||||
{ user_id: 1, paid: 0, username: 'Me', amount: 10, avatar_url: null },
|
||||
{ user_id: 3, paid: 0, username: 'Bob', amount: 20, avatar_url: null },
|
||||
],
|
||||
payers: [{ user_id: 3, amount: 30, username: 'Bob' }],
|
||||
} as unknown as BudgetItem
|
||||
|
||||
/** No currency (falls back to the trip's), no members, no payer → the "unfinished" case. */
|
||||
const SOUVENIR = {
|
||||
id: 13, trip_id: 7, name: 'Souvenirs', category: 'shopping', total_price: 25, currency: null,
|
||||
expense_date: null, note: null, payers: [],
|
||||
} as unknown as BudgetItem
|
||||
|
||||
/** I paid exactly my own share — counts as "mine" but never as "owed". */
|
||||
const TAXI = {
|
||||
id: 14, trip_id: 7, name: 'Taxi', category: 'transport', total_price: 20, currency: 'USD',
|
||||
expense_date: '2026-05-02', note: 'TICKETJSON:{"items":[]}',
|
||||
members: [{ user_id: 1, paid: 1, username: 'Me', amount: 20, avatar_url: null }],
|
||||
payers: [{ user_id: 1, amount: 20, username: 'Me' }],
|
||||
} as unknown as BudgetItem
|
||||
|
||||
const ITEMS = [RAMEN, MUSEUM, SOUVENIR, TAXI]
|
||||
|
||||
const SETTLEMENT = {
|
||||
balances: [
|
||||
{ user_id: 1, username: 'Me', avatar_url: null, balance: 12.5 },
|
||||
{ user_id: 2, username: 'Ada', avatar_url: '/uploads/avatars/ada.png', balance: -7.25 },
|
||||
],
|
||||
flows: [
|
||||
{ from: { user_id: 1, username: 'Me' }, to: { user_id: 2, username: 'Ada' }, amount: 40 },
|
||||
{ from: { user_id: 3, username: 'Bob' }, to: { user_id: 1, username: 'Me' }, amount: 15 },
|
||||
{ from: { user_id: 99, username: 'Ghost' }, to: { user_id: 3, username: 'Bob' }, amount: 5 },
|
||||
],
|
||||
}
|
||||
|
||||
let settlementBases: string[] = []
|
||||
|
||||
function serveSettlement(body: unknown = SETTLEMENT, fail = false) {
|
||||
server.use(
|
||||
http.get('/api/trips/:id/budget/settlement', ({ request }) => {
|
||||
settlementBases.push(new URL(request.url).searchParams.get('base') ?? '')
|
||||
return fail ? HttpResponse.error() : HttpResponse.json(body)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function planner(overrides: Partial<TripPlanner> = {}) {
|
||||
return buildPlanner({
|
||||
tripId: 7,
|
||||
trip: TRIP,
|
||||
days: DAYS,
|
||||
budgetItems: ITEMS,
|
||||
tripMembers: MEMBERS as unknown as TripPlanner['tripMembers'],
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
/** Renders and waits until the settlement response has landed in state. */
|
||||
async function renderTab(p: TripPlanner = planner(), shell: MTripShellApi = buildShell()) {
|
||||
const view = render(<MCostsTab planner={p} shell={shell} />)
|
||||
await screen.findByText('+$12.50')
|
||||
return { ...view, planner: p, shell }
|
||||
}
|
||||
|
||||
/** Walks up `n` levels — the panel's blocks are plain divs without test ids. */
|
||||
function up(el: HTMLElement, n: number): HTMLElement {
|
||||
let node: HTMLElement = el
|
||||
for (let i = 0; i < n; i++) node = node.parentElement as HTMLElement
|
||||
return node
|
||||
}
|
||||
|
||||
/** The expense card of a row, found from its name. */
|
||||
function cardOf(name: string): HTMLElement {
|
||||
let el = screen.getByText(name).parentElement
|
||||
while (el && !el.className.includes('rounded-2xl')) el = el.parentElement
|
||||
if (!el) throw new Error(`no card for ${name}`)
|
||||
return el
|
||||
}
|
||||
|
||||
/** Card plus the edit/delete stack that sits next to it. */
|
||||
function rowOf(name: string): HTMLElement {
|
||||
return up(cardOf(name), 1)
|
||||
}
|
||||
|
||||
describe('MCostsTab', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
clearExchangeRateCache()
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'Me' }) })
|
||||
localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, GBP: 0.5 }, ts: Date.now() }))
|
||||
localStorage.setItem('trek_fx_GBP', JSON.stringify({ rates: { GBP: 1, USD: 2 }, ts: Date.now() }))
|
||||
settlementBases = []
|
||||
serveSettlement()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-001: loads the items and the settlement for the display currency', async () => {
|
||||
const { planner: p } = await renderTab()
|
||||
expect(p.tripActions.loadBudgetItems).toHaveBeenCalledWith(7)
|
||||
expect(settlementBases).toEqual(['USD'])
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-002: sums the hero totals across currencies', async () => {
|
||||
await renderTab()
|
||||
const hero = up(screen.getByText('costs.totalSpend'), 1)
|
||||
expect(hero.textContent).toContain('$185.00')
|
||||
expect(hero.textContent).toContain('costs.yourShare')
|
||||
expect(hero.textContent).toContain('$80.00')
|
||||
expect(hero.textContent).toContain('costs.youPaid')
|
||||
expect(hero.textContent).toContain('$100.00')
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-003: derives owe / owed from the settlement flows', async () => {
|
||||
await renderTab()
|
||||
expect(up(screen.getByText('costs.youOwe'), 1).textContent).toContain('$40.00')
|
||||
expect(up(screen.getByText('costs.youreOwed'), 1).textContent).toContain('$15.00')
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-004: reports the unpaid expenses as outstanding', async () => {
|
||||
await renderTab()
|
||||
const block = up(screen.getByText('costs.outstanding'), 2)
|
||||
expect(block.textContent).toContain('1')
|
||||
expect(block.textContent).toContain('$25.00')
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-005: hides the outstanding block when everything is paid', async () => {
|
||||
await renderTab(planner({ budgetItems: [RAMEN, TAXI] }))
|
||||
expect(screen.queryByText('costs.outstanding')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-006: lists the settle-up flows and names the participants', async () => {
|
||||
await renderTab()
|
||||
const settle = up(screen.getByText('costs.settleUp'), 2)
|
||||
expect(within(settle).getByText('$40.00')).toBeInTheDocument()
|
||||
expect(within(settle).getByText('$15.00')).toBeInTheDocument()
|
||||
expect(within(settle).getByText('$5.00')).toBeInTheDocument()
|
||||
expect(within(settle).getByText('?')).toBeInTheDocument()
|
||||
expect(within(settle).getAllByText('costs.you')).toHaveLength(3)
|
||||
expect(within(settle).getAllByText('Ada')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-007: shows each member balance with its sign, zero for unknown members', async () => {
|
||||
await renderTab()
|
||||
const settle = up(screen.getByText('costs.settleUp'), 2)
|
||||
expect(within(settle).getByText('+$12.50')).toBeInTheDocument()
|
||||
expect(within(settle).getByText('−$7.25')).toBeInTheDocument()
|
||||
expect(within(settle).getByText('$0.00')).toBeInTheDocument()
|
||||
expect(within(settle).getByAltText('')).toHaveAttribute('src', '/uploads/avatars/ada.png')
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-008: collapses and reopens the settle-up card by click and by keyboard', async () => {
|
||||
await renderTab()
|
||||
const head = screen.getByRole('button', { expanded: true })
|
||||
fireEvent.click(head)
|
||||
expect(screen.queryByText('costs.balances')).not.toBeInTheDocument()
|
||||
fireEvent.keyDown(head, { key: 'Enter' })
|
||||
expect(screen.getByText('costs.balances')).toBeInTheDocument()
|
||||
fireEvent.keyDown(head, { key: ' ' })
|
||||
expect(screen.queryByText('costs.balances')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-009: celebrates a settlement without open flows', async () => {
|
||||
serveSettlement({ balances: SETTLEMENT.balances, flows: [] })
|
||||
await renderTab()
|
||||
expect(screen.getByText('costs.everyoneSquare')).toBeInTheDocument()
|
||||
expect(screen.getByText('costs.nothingOutstanding')).toBeInTheDocument()
|
||||
expect(up(screen.getByText('costs.youOwe'), 1).textContent).toContain('$0.00')
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-010: survives a failing settlement request', async () => {
|
||||
serveSettlement(null, true)
|
||||
render(<MCostsTab planner={planner()} shell={buildShell()} />)
|
||||
await waitFor(() => expect(settlementBases).toEqual(['USD']))
|
||||
expect(screen.getByText('costs.everyoneSquare')).toBeInTheDocument()
|
||||
const settle = up(screen.getByText('costs.settleUp'), 2)
|
||||
expect(within(settle).getAllByText('$0.00')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-011: breaks the spend down by category, largest bar first', async () => {
|
||||
await renderTab()
|
||||
const block = up(screen.getByText('costs.byCategory'), 1)
|
||||
const labels = within(block).getAllByText(/^costs\.cat\./).map(n => n.textContent)
|
||||
expect(labels).toEqual(['costs.cat.food', 'costs.cat.activities', 'costs.cat.shopping', 'costs.cat.transport'])
|
||||
expect(within(block).getByText('$60.00')).toBeInTheDocument()
|
||||
expect(within(block).getByText('$20.00')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-012: shows the empty panel without any expense', async () => {
|
||||
await renderTab(planner({ budgetItems: [] }))
|
||||
expect(screen.getByText('costs.noCategories')).toBeInTheDocument()
|
||||
expect(screen.getByText('costs.emptyText')).toBeInTheDocument()
|
||||
expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('$0.00')
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-013: groups the expenses by day with a per-day total', async () => {
|
||||
await renderTab()
|
||||
expect(screen.getByText('Sat, May 2')).toBeInTheDocument()
|
||||
expect(screen.getByText('Thu, Apr 30')).toBeInTheDocument()
|
||||
expect(screen.getByText('costs.noDate')).toBeInTheDocument()
|
||||
expect(screen.getByText('costs.spent:$100.00')).toBeInTheDocument()
|
||||
expect(screen.getByText('costs.spent:$60.00')).toBeInTheDocument()
|
||||
expect(screen.getByText('costs.spent:$25.00')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-014: converts a foreign expense and shows both amounts', async () => {
|
||||
await renderTab()
|
||||
const card = cardOf('Museum')
|
||||
expect(within(card).getByText('£30.00 → $60.00')).toBeInTheDocument()
|
||||
expect(within(card).getByText('costs.cat.activities')).toBeInTheDocument()
|
||||
expect(within(card).getByText('$40.00')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-015: marks an expense nobody paid as unfinished', async () => {
|
||||
await renderTab()
|
||||
expect(within(cardOf('Souvenirs')).getByText('costs.unfinished')).toBeInTheDocument()
|
||||
expect(within(cardOf('Ramen')).queryByText('costs.unfinished')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-016: renders the member chips with their share and paid state', async () => {
|
||||
await renderTab()
|
||||
const card = cardOf('Ramen')
|
||||
const chips = within(card).getAllByRole('button')
|
||||
expect(chips).toHaveLength(2)
|
||||
expect(chips[0]).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(chips[1]).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(chips.map(c => c.textContent)).toEqual(['costs.youShort$40.00', '$40.00'])
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-017: toggles a member chip to paid', async () => {
|
||||
const p = planner()
|
||||
await renderTab(p)
|
||||
fireEvent.click(within(cardOf('Ramen')).getByRole('button', { pressed: false }))
|
||||
await waitFor(() => expect(p.tripActions.toggleBudgetMemberPaid).toHaveBeenCalledWith(7, 11, 2, true))
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-018: toasts when the paid toggle fails', async () => {
|
||||
const p = planner()
|
||||
vi.mocked(p.tripActions.toggleBudgetMemberPaid).mockRejectedValueOnce(new Error('offline'))
|
||||
await renderTab(p)
|
||||
fireEvent.click(within(cardOf('Ramen')).getByRole('button', { pressed: false }))
|
||||
await waitFor(() => expect(p.toast.error).toHaveBeenCalledWith('common.unknownError'))
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-019: filters the list by the search field', async () => {
|
||||
await renderTab()
|
||||
const search = screen.getByLabelText('costs.searchPlaceholder')
|
||||
fireEvent.change(search, { target: { value: 'muse' } })
|
||||
expect(screen.getByText('Museum')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Ramen')).not.toBeInTheDocument()
|
||||
fireEvent.change(search, { target: { value: 'zzz' } })
|
||||
expect(screen.getByText('costs.noMatch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-020: segments the list into mine and owed', async () => {
|
||||
await renderTab()
|
||||
const mine = screen.getByRole('button', { name: 'costs.filter.mine' })
|
||||
fireEvent.click(mine)
|
||||
expect(mine).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByText('Ramen')).toBeInTheDocument()
|
||||
expect(screen.getByText('Taxi')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Museum')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'costs.filter.owed' }))
|
||||
expect(screen.getByText('Ramen')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Taxi')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'costs.filter.all' }))
|
||||
expect(screen.getByText('Museum')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-021: filters by a category that is actually in use', async () => {
|
||||
await renderTab()
|
||||
const trigger = screen.getByText('costs.filter.allCategories').closest('button') as HTMLElement
|
||||
fireEvent.click(trigger)
|
||||
const menu = up(trigger, 1).nextElementSibling as HTMLElement
|
||||
const options = within(menu).getAllByRole('button').map(b => b.textContent)
|
||||
expect(options).toEqual([
|
||||
'costs.filter.allCategories', 'costs.cat.food', 'costs.cat.transport', 'costs.cat.activities', 'costs.cat.shopping',
|
||||
])
|
||||
|
||||
fireEvent.click(within(menu).getByText('costs.cat.activities'))
|
||||
expect(screen.getByText('Museum')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Ramen')).not.toBeInTheDocument()
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(within(up(trigger, 1).nextElementSibling as HTMLElement).getByText('costs.filter.allCategories'))
|
||||
expect(screen.getByText('Ramen')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-022: filters by expense day, labelling known days with their number', async () => {
|
||||
await renderTab()
|
||||
const trigger = screen.getByText('costs.filter.allDays').closest('button') as HTMLElement
|
||||
fireEvent.click(trigger)
|
||||
const menu = up(trigger, 1).nextElementSibling as HTMLElement
|
||||
expect(within(menu).getAllByRole('button').map(b => b.textContent)).toEqual([
|
||||
'costs.filter.allDays', 'Apr 30', 'dayplan.dayN:2 · May 2',
|
||||
])
|
||||
|
||||
fireEvent.click(within(menu).getByText('dayplan.dayN:2 · May 2'))
|
||||
expect(screen.getByText('Ramen')).toBeInTheDocument()
|
||||
expect(screen.getByText('Taxi')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Museum')).not.toBeInTheDocument()
|
||||
expect(trigger.textContent).toBe('dayplan.dayN:2 · May 2')
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-023: only one filter menu is open at a time', async () => {
|
||||
await renderTab()
|
||||
const catTrigger = screen.getByText('costs.filter.allCategories').closest('button') as HTMLElement
|
||||
const dayTrigger = screen.getByText('costs.filter.allDays').closest('button') as HTMLElement
|
||||
fireEvent.click(catTrigger)
|
||||
fireEvent.click(dayTrigger)
|
||||
expect(catTrigger).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(dayTrigger).toHaveAttribute('aria-expanded', 'true')
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-024: opens the edit sheet from a row and reloads on save', async () => {
|
||||
const p = planner()
|
||||
await renderTab(p)
|
||||
fireEvent.click(within(rowOf('Ramen')).getByRole('button', { name: 'common.edit' }))
|
||||
expect(screen.getByText('sheet editing 11')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('sheet save'))
|
||||
expect(screen.queryByText('sheet editing 11')).not.toBeInTheDocument()
|
||||
expect(p.tripActions.loadBudgetItems).toHaveBeenCalledTimes(2)
|
||||
await waitFor(() => expect(settlementBases).toEqual(['USD', 'USD']))
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-025: deletes an expense after confirming and refreshes the settlement', async () => {
|
||||
const p = planner()
|
||||
await renderTab(p)
|
||||
fireEvent.click(within(rowOf('Ramen')).getByRole('button', { name: 'common.delete' }))
|
||||
const dialog = screen.getByRole('dialog', { name: 'costs.confirm.deleteTitle' })
|
||||
expect(within(dialog).getByText('costs.confirm.deleteBody:Ramen')).toBeInTheDocument()
|
||||
fireEvent.click(within(dialog).getByText('common.delete'))
|
||||
await waitFor(() => expect(p.tripActions.deleteBudgetItem).toHaveBeenCalledWith(7, 11))
|
||||
await waitFor(() => expect(settlementBases).toHaveLength(2))
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-026: toasts when the delete fails', async () => {
|
||||
const p = planner()
|
||||
vi.mocked(p.tripActions.deleteBudgetItem).mockRejectedValueOnce(new Error('offline'))
|
||||
await renderTab(p)
|
||||
fireEvent.click(within(rowOf('Ramen')).getByRole('button', { name: 'common.delete' }))
|
||||
fireEvent.click(within(screen.getByRole('dialog')).getByText('common.delete'))
|
||||
await waitFor(() => expect(p.toast.error).toHaveBeenCalledWith('common.unknownError'))
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-027: opens a blank expense sheet when the header raises the add signal', async () => {
|
||||
const p = planner()
|
||||
const { rerender } = await renderTab(p)
|
||||
expect(screen.queryByText('sheet new USD')).not.toBeInTheDocument()
|
||||
|
||||
rerender(<MCostsTab planner={p} shell={buildShell({ addExpenseSignal: 1 })} />)
|
||||
expect(screen.getByText('sheet new USD')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('sheet close'))
|
||||
expect(screen.queryByText('sheet new USD')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-028: exports the CSV when the header raises the export signal', async () => {
|
||||
const blobs: Blob[] = []
|
||||
const createObjectURL = vi.fn((b: Blob) => { blobs.push(b); return 'blob:costs' })
|
||||
const revokeObjectURL = vi.fn()
|
||||
Object.defineProperty(URL, 'createObjectURL', { writable: true, configurable: true, value: createObjectURL })
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { writable: true, configurable: true, value: revokeObjectURL })
|
||||
let downloadName = ''
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
|
||||
downloadName = this.download
|
||||
})
|
||||
|
||||
const p = planner()
|
||||
const { rerender } = await renderTab(p)
|
||||
expect(createObjectURL).not.toHaveBeenCalled()
|
||||
|
||||
rerender(<MCostsTab planner={p} shell={buildShell({ exportCostsCsvSignal: 1 })} />)
|
||||
expect(downloadName).toBe('costs-Tokyo Trip.csv')
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:costs')
|
||||
|
||||
const csv = await blobs[0].text()
|
||||
const lines = csv.replace('', '').split('\r\n')
|
||||
expect(lines[0]).toBe('Date;Name;Category;Amount;Currency;Amount (USD);Note')
|
||||
expect(lines[1]).toBe(';Souvenirs;costs.cat.shopping;25.00;USD;25.00;')
|
||||
expect(lines[2]).toBe('04/30/2026;Museum;costs.cat.activities;30.00;GBP;60.00;"Tickets; adult"')
|
||||
// The ticket JSON note never leaves the app as a "note".
|
||||
expect(lines[4]).toBe('05/02/2026;Taxi;costs.cat.transport;20.00;USD;20.00;')
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-029: records a manual payment and refreshes the settlement', async () => {
|
||||
const create = vi.spyOn(budgetApi, 'createSettlement').mockResolvedValue({})
|
||||
await renderTab()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'costs.addPayment' }))
|
||||
const dialog = screen.getByRole('dialog', { name: 'costs.addPayment' })
|
||||
const submit = within(dialog).getByRole('button', { name: 'costs.addPayment' })
|
||||
expect(submit).toBeDisabled()
|
||||
|
||||
fireEvent.change(within(dialog).getByPlaceholderText('0.00'), { target: { value: '12,5' } })
|
||||
expect(submit).toBeEnabled()
|
||||
fireEvent.click(submit)
|
||||
expect(create).toHaveBeenCalledWith(7, { from_user_id: 1, to_user_id: 2, amount: 12.5, currency: 'USD' })
|
||||
await waitFor(() => expect(settlementBases).toHaveLength(2))
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-030: refuses a payment to yourself and toasts a failed one', async () => {
|
||||
const create = vi.spyOn(budgetApi, 'createSettlement').mockRejectedValue(new Error('nope'))
|
||||
const p = planner()
|
||||
await renderTab(p)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'costs.addPayment' }))
|
||||
const dialog = screen.getByRole('dialog', { name: 'costs.addPayment' })
|
||||
const submit = within(dialog).getByRole('button', { name: 'costs.addPayment' })
|
||||
fireEvent.change(within(dialog).getByPlaceholderText('0.00'), { target: { value: '5' } })
|
||||
|
||||
const toGroup = within(dialog).getByText('costs.to').nextElementSibling as HTMLElement
|
||||
fireEvent.click(within(toGroup).getByText('costs.you'))
|
||||
expect(submit).toBeDisabled()
|
||||
|
||||
fireEvent.click(within(within(dialog).getByText('costs.to').nextElementSibling as HTMLElement).getByText('Ada'))
|
||||
expect(submit).toBeEnabled()
|
||||
fireEvent.click(submit)
|
||||
await waitFor(() => expect(p.toast.error).toHaveBeenCalledWith('common.unknownError'))
|
||||
expect(create).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-031: a solo trip can never send a payment', async () => {
|
||||
await renderTab(planner({ tripMembers: [MEMBERS[0]] as unknown as TripPlanner['tripMembers'] }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'costs.addPayment' }))
|
||||
const dialog = screen.getByRole('dialog', { name: 'costs.addPayment' })
|
||||
fireEvent.change(within(dialog).getByPlaceholderText('0.00'), { target: { value: '5' } })
|
||||
expect(within(dialog).getByRole('button', { name: 'costs.addPayment' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-032: a read-only member gets no add, edit or delete affordances', async () => {
|
||||
await renderTab(planner({ can: vi.fn(() => false) as unknown as TripPlanner['can'] }))
|
||||
expect(screen.queryByRole('button', { name: 'costs.addPayment' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.edit' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
|
||||
expect(within(cardOf('Ramen')).getByRole('button', { pressed: false })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-033: resets the day filter back to all days', async () => {
|
||||
await renderTab()
|
||||
const trigger = screen.getByText('costs.filter.allDays').closest('button') as HTMLElement
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(within(up(trigger, 1).nextElementSibling as HTMLElement).getByText('Apr 30'))
|
||||
expect(screen.queryByText('Ramen')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(within(up(trigger, 1).nextElementSibling as HTMLElement).getByText('costs.filter.allDays'))
|
||||
expect(screen.getByText('Ramen')).toBeInTheDocument()
|
||||
expect(trigger.textContent).toBe('costs.filter.allDays')
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-034: dismisses the payment sheet without recording anything', async () => {
|
||||
const create = vi.spyOn(budgetApi, 'createSettlement')
|
||||
await renderTab()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'costs.addPayment' }))
|
||||
const dialog = screen.getByRole('dialog', { name: 'costs.addPayment' })
|
||||
const fromGroup = within(dialog).getByText('costs.from').nextElementSibling as HTMLElement
|
||||
fireEvent.click(within(fromGroup).getByText('Bob'))
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'common.cancel' }))
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
expect(settlementBases).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-035: cancelling the confirm keeps the expense', async () => {
|
||||
const p = planner()
|
||||
await renderTab(p)
|
||||
fireEvent.click(within(rowOf('Ramen')).getByRole('button', { name: 'common.delete' }))
|
||||
fireEvent.click(within(screen.getByRole('dialog')).getByText('common.cancel'))
|
||||
expect(p.tripActions.deleteBudgetItem).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Ramen')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-036: falls back to EUR and a neutral avatar without a user or trip currency', async () => {
|
||||
seedStore(useAuthStore, { user: null })
|
||||
localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1 }, ts: Date.now() }))
|
||||
const solo = [{ id: 1, username: '', avatar_url: null }] as unknown as TripPlanner['tripMembers']
|
||||
render(<MCostsTab planner={planner({ trip: null as unknown as TripPlanner['trip'], tripMembers: solo })} shell={buildShell()} />)
|
||||
await screen.findByText('+12,50 €')
|
||||
expect(settlementBases).toEqual(['EUR'])
|
||||
// Nameless member → the avatar and the flow names both fall back to '?'.
|
||||
expect(screen.getAllByText('?').length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-COSTT-037: follows the user display currency instead of the trip currency', async () => {
|
||||
seedStore(useSettingsStore, {
|
||||
settings: { ...useSettingsStore.getState().settings, default_currency: 'GBP' },
|
||||
})
|
||||
render(<MCostsTab planner={planner()} shell={buildShell()} />)
|
||||
await screen.findByText('+£12.50')
|
||||
expect(settlementBases).toEqual(['GBP'])
|
||||
expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('£92.50')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,392 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WeatherResult } from '@trek/shared'
|
||||
import { weatherApi } from '../../../../src/api/client'
|
||||
import MDaySheet from '../../../../src/mobile/screens/trip/sheets/MDaySheet'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { usePluginStore } from '../../../../src/store/pluginStore'
|
||||
import { useSettingsStore } from '../../../../src/store/settingsStore'
|
||||
import { useTripStore } from '../../../../src/store/tripStore'
|
||||
import type { Accommodation, Assignment, Day, DayNote, Reservation } from '../../../../src/types'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-DAYSH-001 to FE-MOB-DAYSH-027
|
||||
|
||||
const DAYS = [
|
||||
{ id: 1, trip_id: 5, day_number: 1, date: '2026-05-01', title: null },
|
||||
{ id: 2, trip_id: 5, day_number: 2, date: '2026-05-02', title: 'Old Town' },
|
||||
{ id: 3, trip_id: 5, day_number: 3, date: '2026-05-03', title: null },
|
||||
] as unknown as Day[]
|
||||
|
||||
function assignment(id: number, placeId: number, name: string, order: number, lat: number, lng: number) {
|
||||
return {
|
||||
id,
|
||||
day_id: 2,
|
||||
place_id: placeId,
|
||||
order_index: order,
|
||||
place: { id: placeId, name, lat, lng },
|
||||
} as unknown as Assignment
|
||||
}
|
||||
|
||||
const MUSEUM = assignment(11, 101, 'Museum', 0, 48.21, 16.36)
|
||||
const PARK = assignment(12, 102, 'Park', 1, 48.22, 16.37)
|
||||
const CAFE = assignment(13, 103, 'Cafe', 2, 48.23, 16.38)
|
||||
|
||||
// Deliberately out of order — the sheet sorts by order_index.
|
||||
const ASSIGNMENTS: Record<string, Assignment[]> = { '2': [CAFE, MUSEUM, PARK] }
|
||||
|
||||
const RESERVATIONS = [
|
||||
{
|
||||
id: 21, trip_id: 5, type: 'restaurant', title: 'Dinner at Figlmueller', day_id: 2,
|
||||
reservation_time: '2026-05-02T19:30', reservation_end_time: '2026-05-02T21:00',
|
||||
},
|
||||
{
|
||||
id: 22, trip_id: 5, type: 'train', title: 'Train to Salzburg', day_id: 2,
|
||||
reservation_time: '2026-05-02T08:00',
|
||||
},
|
||||
{
|
||||
id: 23, trip_id: 5, type: 'hotel', title: 'Hotel Sacher booking', day_id: 2,
|
||||
accommodation_id: 31, status: 'confirmed', confirmation_number: 'X9',
|
||||
},
|
||||
{ id: 24, trip_id: 5, type: 'event', title: 'Opera (other day)', day_id: 3 },
|
||||
] as unknown as Reservation[]
|
||||
|
||||
const ACCOMMODATIONS = [
|
||||
{
|
||||
id: 31, trip_id: 5, place_id: 55, start_day_id: 2, end_day_id: 3,
|
||||
check_in: '15:00', check_in_end: '18:00', check_out: '11:00', confirmation: 'ABC123',
|
||||
place_name: 'Hotel Sacher', place_address: 'Philharmonikerstrasse 4',
|
||||
place_lat: 48.2, place_lng: 16.35,
|
||||
},
|
||||
] as unknown as Accommodation[]
|
||||
|
||||
const DAY_NOTES = [
|
||||
{ id: 42, day_id: 2, text: 'Pack the day bag', time: 'before leaving', sort_order: 0 },
|
||||
{ id: 41, day_id: 2, text: 'Buy museum tickets', time: '09:30 at the kiosk', sort_order: 1 },
|
||||
] as unknown as DayNote[]
|
||||
|
||||
const FORECAST: WeatherResult = {
|
||||
temp: 20, temp_max: 24, temp_min: 12, main: 'Rain', description: 'light rain', type: 'forecast',
|
||||
}
|
||||
|
||||
function makeShell(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
view: 'plan',
|
||||
mode: 'go',
|
||||
trTab: 'plan',
|
||||
sheet: { id: 'day', payload: { dayId: 2 } },
|
||||
openSheet: vi.fn(),
|
||||
closeSheet: vi.fn(),
|
||||
setTrTab: vi.fn(),
|
||||
toggleView: vi.fn(),
|
||||
setTravelMode: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as MTripShellApi
|
||||
}
|
||||
|
||||
function makePlanner(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
tripId: 5,
|
||||
trip: { id: 5, title: 'Vienna', start_date: '2026-05-01', end_date: '2026-05-03' },
|
||||
language: 'en',
|
||||
days: DAYS,
|
||||
places: [],
|
||||
assignments: ASSIGNMENTS,
|
||||
reservations: RESERVATIONS,
|
||||
tripAccommodations: ACCOMMODATIONS,
|
||||
selectedDayId: 2,
|
||||
routeShown: false,
|
||||
setRouteShown: vi.fn(),
|
||||
routeProfile: 'driving',
|
||||
setRouteProfile: vi.fn(),
|
||||
handleSelectDay: vi.fn(),
|
||||
handleUpdateDayTitle: vi.fn(),
|
||||
handleReorder: vi.fn(),
|
||||
handlePlaceClick: vi.fn(),
|
||||
setEditingTransport: vi.fn(),
|
||||
setTransportModalDayId: vi.fn(),
|
||||
setShowTransportModal: vi.fn(),
|
||||
setEditingReservation: vi.fn(),
|
||||
setShowReservationModal: vi.fn(),
|
||||
setTransitPrefill: vi.fn(),
|
||||
setTransportModalAutomated: vi.fn(),
|
||||
TRANSPORT_TYPES: new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']),
|
||||
can: vi.fn(() => true),
|
||||
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
...overrides,
|
||||
} as unknown as TripPlanner
|
||||
}
|
||||
|
||||
/** Renders and settles the weather request so no state update lands outside act(). */
|
||||
async function renderSheet(planner: TripPlanner = makePlanner(), shell: MTripShellApi = makeShell()) {
|
||||
const view = render(<MDaySheet planner={planner} shell={shell} />)
|
||||
await act(async () => { await Promise.resolve() })
|
||||
await waitFor(() => expect(document.querySelector('.animate-pulse')).toBeNull())
|
||||
return { ...view, planner, shell }
|
||||
}
|
||||
|
||||
describe('MDaySheet', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
usePluginStore.setState({ plugins: [] })
|
||||
seedStore(useTripStore, { dayNotes: { '2': DAY_NOTES } })
|
||||
vi.spyOn(weatherApi, 'getDetailed').mockResolvedValue(FORECAST)
|
||||
vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSH-001: stays closed while another sheet id is active', () => {
|
||||
render(<MDaySheet planner={makePlanner()} shell={makeShell({ sheet: { id: 'mehr' } })} />)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(weatherApi.getDetailed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSH-002: falls back to the selected day when the payload carries no dayId', async () => {
|
||||
await renderSheet(makePlanner({ selectedDayId: 1 }), makeShell({ sheet: { id: 'day' } }))
|
||||
expect(screen.getByRole('dialog', { name: 'Day 1' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Friday, May 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSH-003: renders the day title and its formatted date', async () => {
|
||||
await renderSheet()
|
||||
expect(screen.getByRole('dialog', { name: 'Old Town' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Old Town')).toBeInTheDocument()
|
||||
expect(screen.getByText('Saturday, May 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSH-004: requests the forecast for the first located stop and shows it', async () => {
|
||||
await renderSheet()
|
||||
expect(weatherApi.getDetailed).toHaveBeenCalledWith(48.21, 16.36, '2026-05-02', 'en')
|
||||
expect(screen.getByText('20°')).toBeInTheDocument()
|
||||
expect(screen.getByRole('dialog').textContent).toContain('24° / 12° ·')
|
||||
expect(screen.getByText('light rain')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSH-005: converts to Fahrenheit when the user asked for it', async () => {
|
||||
seedStore(useSettingsStore, {
|
||||
settings: { ...useSettingsStore.getState().settings, temperature_unit: 'fahrenheit' },
|
||||
})
|
||||
await renderSheet()
|
||||
expect(screen.getByText('68°')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSH-006: marks a climate fallback with the average sign and the hint', async () => {
|
||||
vi.mocked(weatherApi.getDetailed).mockResolvedValue({ ...FORECAST, type: 'climate' })
|
||||
await renderSheet()
|
||||
expect(screen.getByText('Ø 20°')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Historical averages/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSH-007: shows the empty state when the service reports an error', async () => {
|
||||
vi.mocked(weatherApi.getDetailed).mockResolvedValue({ ...FORECAST, error: 'no_forecast' })
|
||||
await renderSheet()
|
||||
expect(screen.getByText('No weather data available. Add a place with coordinates.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSH-008: shows the empty state when the request rejects', async () => {
|
||||
vi.mocked(weatherApi.getDetailed).mockRejectedValue(new Error('offline'))
|
||||
await renderSheet()
|
||||
expect(screen.getByText('No weather data available. Add a place with coordinates.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSH-009: skips the weather block entirely without coordinates', async () => {
|
||||
await renderSheet(makePlanner({ assignments: {}, places: [] }))
|
||||
expect(weatherApi.getDetailed).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText('No weather data available. Add a place with coordinates.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSH-010: renames the day inline and commits the trimmed title on Enter', async () => {
|
||||
const { planner } = await renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rename day' }))
|
||||
const input = screen.getByPlaceholderText('Day 2')
|
||||
expect(input).toHaveValue('Old Town')
|
||||
fireEvent.change(input, { target: { value: ' Ringstrasse ' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(planner.handleUpdateDayTitle).toHaveBeenCalledWith(2, 'Ringstrasse')
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSH-011: Escape leaves the rename without saving', async () => {
|
||||
const { planner } = await renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rename day' }))
|
||||
const input = screen.getByPlaceholderText('Day 2')
|
||||
fireEvent.change(input, { target: { value: 'Nope' } })
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
expect(planner.handleUpdateDayTitle).not.toHaveBeenCalled()
|
||||
expect(screen.queryByPlaceholderText('Day 2')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSH-012: toggles the route for the already selected day', async () => {
|
||||
const { planner } = await renderSheet()
|
||||
const routeBtn = screen.getByRole('button', { name: 'Route' })
|
||||
expect(routeBtn).toHaveAttribute('aria-pressed', 'false')
|
||||
fireEvent.click(routeBtn)
|
||||
expect(planner.setRouteShown).toHaveBeenCalledTimes(1)
|
||||
expect(planner.handleSelectDay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSH-013: selects the day first when the route is toggled from another one', async () => {
|
||||
const planner = makePlanner({ selectedDayId: 1, routeShown: false })
|
||||
await renderSheet(planner)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Route' }))
|
||||
expect(planner.handleSelectDay).toHaveBeenCalledWith(2, true)
|
||||
expect(planner.setRouteShown).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSH-014: marks the route pill active when it is drawn for this day', async () => {
|
||||
await renderSheet(makePlanner({ routeShown: true }))
|
||||
expect(screen.getByRole('button', { name: 'Route' })).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSH-015: offers plugin route profiles next to the built-ins', async () => {
|
||||
usePluginStore.setState({
|
||||
plugins: [{
|
||||
id: 'brouter', name: 'BRouter', type: 'integration', icon: null,
|
||||
routeProfiles: [{ id: 'bike', label: 'Bike' }],
|
||||
}],
|
||||
})
|
||||
const { planner } = await renderSheet()
|
||||
expect(screen.getByRole('button', { name: 'Driving' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByRole('button', { name: 'Walking' })).toHaveAttribute('aria-pressed', 'false')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Bike' }))
|
||||
expect(planner.setRouteProfile).toHaveBeenCalledWith('plugin:brouter/bike')
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSH-016: exports the day stops plus the evening hotel to Google Maps', async () => {
|
||||
await renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open in Google Maps' }))
|
||||
expect(window.open).toHaveBeenCalledTimes(1)
|
||||
const url = String(vi.mocked(window.open).mock.calls[0][0])
|
||||
expect(url).toContain('https://www.google.com/maps/dir/48.21,16.36/48.22,16.37/48.23,16.38')
|
||||
expect(url).toContain('/48.2,16.35')
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSH-017: optimizes the day order and toasts the hotel variant', async () => {
|
||||
const { planner } = await renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Optimize' }))
|
||||
expect(planner.handleReorder).toHaveBeenCalledTimes(1)
|
||||
const [dayId, order] = vi.mocked(planner.handleReorder).mock.calls[0]
|
||||
expect(dayId).toBe(2)
|
||||
expect([...(order as unknown as number[])].sort()).toEqual([11, 12, 13])
|
||||
expect(planner.toast.success).toHaveBeenCalledWith('Route optimized from your accommodation')
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSH-018: hides Optimize below three stops but keeps the route pill', async () => {
|
||||
await renderSheet(makePlanner({ assignments: { '2': [MUSEUM, PARK] } }))
|
||||
expect(screen.queryByRole('button', { name: 'Optimize' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Route' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSH-019: opens the automated transport modal for public transit', async () => {
|
||||
const { planner, shell } = await renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Public transit' }))
|
||||
expect(planner.setTransportModalDayId).toHaveBeenCalledWith(2)
|
||||
expect(planner.setEditingTransport).toHaveBeenCalledWith(null)
|
||||
expect(planner.setTransitPrefill).toHaveBeenCalledWith(null)
|
||||
expect(planner.setTransportModalAutomated).toHaveBeenCalledWith(true)
|
||||
expect(planner.setShowTransportModal).toHaveBeenCalledWith(true)
|
||||
expect(shell.closeSheet).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSH-020: lists the day bookings without hotels and opens the matching editor', async () => {
|
||||
const { planner, shell } = await renderSheet()
|
||||
expect(screen.getByText('Dinner at Figlmueller')).toBeInTheDocument()
|
||||
expect(screen.getByText('19:30 – 21:00')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Opera (other day)')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Hotel Sacher booking')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Train to Salzburg'))
|
||||
expect(planner.setEditingTransport).toHaveBeenCalledWith(expect.objectContaining({ id: 22 }))
|
||||
expect(planner.setShowTransportModal).toHaveBeenCalledWith(true)
|
||||
|
||||
fireEvent.click(screen.getByText('Dinner at Figlmueller'))
|
||||
expect(planner.setEditingReservation).toHaveBeenCalledWith(expect.objectContaining({ id: 21 }))
|
||||
expect(planner.setShowReservationModal).toHaveBeenCalledWith(true)
|
||||
expect(shell.closeSheet).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSH-021: a booking row does nothing without the matching permission', async () => {
|
||||
const planner = makePlanner({ can: (perm: string) => perm !== 'day_edit' && perm !== 'reservation_edit' })
|
||||
const { shell } = await renderSheet(planner)
|
||||
fireEvent.click(screen.getByText('Dinner at Figlmueller'))
|
||||
fireEvent.click(screen.getByText('Train to Salzburg'))
|
||||
expect(planner.setShowReservationModal).not.toHaveBeenCalled()
|
||||
expect(planner.setShowTransportModal).not.toHaveBeenCalled()
|
||||
expect(shell.closeSheet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSH-022: renders notes sorted, splits a leading time off the detail line, and routes taps', async () => {
|
||||
const { shell } = await renderSheet()
|
||||
const notes = screen.getAllByText(/Pack the day bag|Buy museum tickets/)
|
||||
expect(notes.map(n => n.textContent)).toEqual(['Pack the day bag', 'Buy museum tickets'])
|
||||
expect(screen.getByText('at the kiosk')).toBeInTheDocument()
|
||||
expect(screen.getByText('09:30')).toBeInTheDocument()
|
||||
expect(screen.getByText('before leaving')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Note' }))
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('note', { dayId: 2 })
|
||||
|
||||
fireEvent.click(screen.getByText('Buy museum tickets'))
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('note', { dayId: 2, note: expect.objectContaining({ id: 41 }) })
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSH-023: shows the stay with its times, code and linked booking status', async () => {
|
||||
const { planner, shell } = await renderSheet()
|
||||
expect(screen.getByText('Hotel Sacher')).toBeInTheDocument()
|
||||
expect(screen.getByText('Philharmonikerstrasse 4')).toBeInTheDocument()
|
||||
expect(screen.getByText('15:00 – 18:00')).toBeInTheDocument()
|
||||
expect(screen.getByText('11:00')).toBeInTheDocument()
|
||||
expect(screen.getByText('ABC123')).toBeInTheDocument()
|
||||
expect(screen.getByText('Confirmed · #X9')).toBeInTheDocument()
|
||||
// start_day_id is this day, end_day_id a later one → check-in badge plus the stat label.
|
||||
expect(screen.getAllByText('Check-in')).toHaveLength(2)
|
||||
|
||||
fireEvent.click(screen.getByText('Hotel Sacher'))
|
||||
expect(shell.closeSheet).toHaveBeenCalled()
|
||||
expect(planner.handlePlaceClick).toHaveBeenCalledWith(55)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add accommodation/ }))
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('accommodation', { dayId: 2 })
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSH-024: labels a stay that both starts and ends on this day', async () => {
|
||||
const acc = [{ ...ACCOMMODATIONS[0], end_day_id: 2 }] as unknown as Accommodation[]
|
||||
await renderSheet(makePlanner({ tripAccommodations: acc }))
|
||||
expect(screen.getByText('Check-in & Check-out')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSH-025: labels the check-out day and a plain mid-stay day', async () => {
|
||||
const checkOutDay = [{ ...ACCOMMODATIONS[0], start_day_id: 1, end_day_id: 2 }] as unknown as Accommodation[]
|
||||
const { unmount } = await renderSheet(makePlanner({ tripAccommodations: checkOutDay }))
|
||||
// Badge plus the check-out stat label.
|
||||
expect(screen.getAllByText('Check-out')).toHaveLength(2)
|
||||
expect(screen.queryByText('Stay')).not.toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
const midStay = [{ ...ACCOMMODATIONS[0], start_day_id: 1, end_day_id: 3 }] as unknown as Accommodation[]
|
||||
await renderSheet(makePlanner({ tripAccommodations: midStay }))
|
||||
expect(screen.getByText('Stay')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSH-026: falls back to any trip place with coordinates as the weather anchor', async () => {
|
||||
await renderSheet(makePlanner({
|
||||
assignments: {},
|
||||
places: [{ id: 200, name: 'Prater', lat: 48.31, lng: 16.41 }],
|
||||
}))
|
||||
expect(weatherApi.getDetailed).toHaveBeenCalledWith(48.31, 16.41, '2026-05-02', 'en')
|
||||
expect(screen.getByText('20°')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSH-027: hides every editing affordance for a read-only member', async () => {
|
||||
await renderSheet(makePlanner({ can: () => false }))
|
||||
expect(screen.queryByRole('button', { name: 'Rename day' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Optimize' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Public transit' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Add Note' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Add accommodation/ })).not.toBeInTheDocument()
|
||||
// The route pill is a view toggle, not an edit — it survives.
|
||||
expect(screen.getByRole('button', { name: 'Route' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MDaysSheet from '../../../../src/mobile/screens/trip/sheets/MDaysSheet'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import type { Day } from '../../../../src/types'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-DAYSS-001 to FE-MOB-DAYSS-012
|
||||
//
|
||||
// The sheet reads its copy from the real TranslationProvider (useTranslation),
|
||||
// not from planner.t — assertions therefore go against the English strings.
|
||||
|
||||
// Deliberately unsorted: the sheet orders by day_number.
|
||||
const DAYS = [
|
||||
{ id: 3, trip_id: 1, day_number: 3, date: '2026-05-03', title: null },
|
||||
{ id: 1, trip_id: 1, day_number: 1, date: '2026-05-01', title: null },
|
||||
{ id: 2, trip_id: 1, day_number: 2, date: '2026-05-02', title: 'Old Town' },
|
||||
] as unknown as Day[]
|
||||
|
||||
function renderSheet(plannerOverrides: Partial<TripPlanner> = {}, shellOverrides: Partial<MTripShellApi> = {}) {
|
||||
const planner = buildPlanner({ days: DAYS, ...plannerOverrides })
|
||||
const shell = buildShell({ sheet: { id: 'days' }, ...shellOverrides })
|
||||
render(<MDaysSheet planner={planner} shell={shell} />)
|
||||
return { planner, shell }
|
||||
}
|
||||
|
||||
describe('MDaysSheet', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSS-001: stays closed while another sheet id is active', () => {
|
||||
renderSheet({}, { sheet: { id: 'mehr' } })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSS-002: stays closed when no sheet is open at all', () => {
|
||||
renderSheet({}, { sheet: null })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSS-003: renders the reorder header with its hint', () => {
|
||||
renderSheet()
|
||||
expect(screen.getByRole('dialog', { name: 'Reorder days' })).toBeInTheDocument()
|
||||
expect(screen.getByText("A day's places, notes and bookings move with it.")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSS-004: lists the days in day_number order and numbers the rows', () => {
|
||||
renderSheet()
|
||||
const positions = screen.getAllByText(/^[123]$/).map(el => el.textContent)
|
||||
expect(positions).toEqual(['1', '2', '3'])
|
||||
const labels = ['Fri, May 1', 'Old Town', 'Sun, May 3']
|
||||
for (const label of labels) expect(screen.getByText(label)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSS-005: falls back to "Day n" when neither title nor a usable date exists', () => {
|
||||
const days = [
|
||||
{ id: 7, trip_id: 1, day_number: 1, date: null, title: null },
|
||||
{ id: 8, trip_id: 1, day_number: 2, date: 'not-a-date', title: '' },
|
||||
] as unknown as Day[]
|
||||
renderSheet({ days })
|
||||
expect(screen.getByText('Day 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Day 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSS-006: numbers a day without day_number by its list position', () => {
|
||||
const days = [{ id: 9, trip_id: 1, day_number: null, date: null, title: null }] as unknown as Day[]
|
||||
renderSheet({ days })
|
||||
expect(screen.getByText('Day 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSS-007: moving a day up sends the whole reordered id list', () => {
|
||||
const { planner } = renderSheet()
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Move up' })[1])
|
||||
expect(planner.handleReorderDays).toHaveBeenCalledWith([2, 1, 3])
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSS-008: moving a day down sends the whole reordered id list', () => {
|
||||
const { planner } = renderSheet()
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Move down' })[0])
|
||||
expect(planner.handleReorderDays).toHaveBeenCalledWith([2, 1, 3])
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSS-009: disables the moves that would leave the list', () => {
|
||||
renderSheet()
|
||||
const up = screen.getAllByRole('button', { name: 'Move up' })
|
||||
const down = screen.getAllByRole('button', { name: 'Move down' })
|
||||
expect(up[0]).toBeDisabled()
|
||||
expect(up[2]).toBeEnabled()
|
||||
expect(down[2]).toBeDisabled()
|
||||
expect(down[0]).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSS-010: appends a day', () => {
|
||||
const { planner } = renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add day' }))
|
||||
expect(planner.handleAddDay).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSS-011: hides every edit affordance for a read-only member', () => {
|
||||
const { planner } = renderSheet({ can: vi.fn(() => false) })
|
||||
expect(planner.can).toHaveBeenCalledWith('day_edit', planner.trip)
|
||||
expect(screen.queryByRole('button', { name: 'Move up' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Add day' })).not.toBeInTheDocument()
|
||||
// The list itself stays readable.
|
||||
expect(screen.getByText('Old Town')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSS-012: the header close button closes the sheet', () => {
|
||||
const { shell } = renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,191 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import MExportSheet from '../../../../src/mobile/screens/trip/sheets/MExportSheet'
|
||||
import { downloadTripPDF } from '../../../../src/components/PDF/TripPDF'
|
||||
import { useTripStore } from '../../../../src/store/tripStore'
|
||||
import type { Day, DayNote } from '../../../../src/types'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { server } from '../../../helpers/msw/server'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-EXPSH-001 to FE-MOB-EXPSH-013
|
||||
// This sheet reads its copy from useTranslation(), so the assertions are English.
|
||||
|
||||
vi.mock('../../../../src/components/PDF/TripPDF', () => ({ downloadTripPDF: vi.fn() }))
|
||||
|
||||
vi.mock('../../../../src/components/Planner/IcsSubscribeModal', () => ({
|
||||
IcsSubscribeModal: ({ endpoint, onClose }: { endpoint: string; onClose: () => void }) => (
|
||||
<div data-testid="ics-subscribe" data-endpoint={endpoint}>
|
||||
<button type="button" onClick={onClose}>dismiss subscribe</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const DAYS = [{ id: 2, trip_id: 1, day_number: 1, date: '2026-05-01', title: null }] as unknown as Day[]
|
||||
|
||||
const NOTES: Record<string, DayNote[]> = {
|
||||
'2': [{ id: 41, day_id: 2, text: 'Pack the day bag' }] as unknown as DayNote[],
|
||||
'3': [{ id: 42, day_id: 3, text: 'Return the keys' }] as unknown as DayNote[],
|
||||
}
|
||||
|
||||
function renderSheet(plannerOverrides: Record<string, unknown> = {}, sheetId: string | null = 'export') {
|
||||
const planner = buildPlanner({ days: DAYS, ...plannerOverrides })
|
||||
const shell = buildShell({ sheet: sheetId ? { id: sheetId } : null })
|
||||
const view = render(<MExportSheet planner={planner} shell={shell} />)
|
||||
return { ...view, planner, shell }
|
||||
}
|
||||
|
||||
describe('MExportSheet', () => {
|
||||
// The download anchor is never attached to the DOM, so the click spy's
|
||||
// recorded `this` is the only handle on it.
|
||||
let clickSpy: MockInstance<() => void>
|
||||
const clickedAnchor = () => clickSpy.mock.contexts[0] as HTMLAnchorElement
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
seedStore(useTripStore, { dayNotes: NOTES })
|
||||
vi.mocked(downloadTripPDF).mockReset().mockResolvedValue(undefined)
|
||||
clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:trek/ics')
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('FE-MOB-EXPSH-001: stays closed while another sheet id is active', () => {
|
||||
renderSheet({}, 'mehr')
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-EXPSH-002: lists the three export routes and names the ics after the trip', () => {
|
||||
renderSheet()
|
||||
expect(screen.getByRole('dialog', { name: 'Export Calendar' })).toBeInTheDocument()
|
||||
expect(screen.getByText('PDF')).toBeInTheDocument()
|
||||
expect(screen.getByText('Export day plan as PDF')).toBeInTheDocument()
|
||||
expect(screen.getByText('Download .ics')).toBeInTheDocument()
|
||||
expect(screen.getByText('Japan 2026.ics')).toBeInTheDocument()
|
||||
expect(screen.getByText('Subscribe to calendar')).toBeInTheDocument()
|
||||
expect(screen.getByText('Auto-updates in your calendar app')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-EXPSH-003: hands the planner slices and the flattened day notes to the PDF export', async () => {
|
||||
const { planner } = renderSheet({
|
||||
places: [{ id: 9, name: 'Fushimi Inari' }],
|
||||
categories: [{ id: 3, name: 'Sights' }],
|
||||
reservations: [{ id: 4, title: 'Ryokan' }],
|
||||
})
|
||||
fireEvent.click(screen.getByText('PDF'))
|
||||
|
||||
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
|
||||
const arg = vi.mocked(downloadTripPDF).mock.calls[0][0]
|
||||
expect(arg.trip).toBe(planner.trip)
|
||||
expect(arg.days).toBe(planner.days)
|
||||
expect(arg.places).toBe(planner.places)
|
||||
expect(arg.reservations).toBe(planner.reservations)
|
||||
expect(arg.categories).toBe(planner.categories)
|
||||
// dayNotes come out of the store keyed by day, flattened with a numeric day_id.
|
||||
expect(arg.dayNotes).toEqual([
|
||||
{ id: 41, day_id: 2, text: 'Pack the day bag' },
|
||||
{ id: 42, day_id: 3, text: 'Return the keys' },
|
||||
])
|
||||
expect(typeof arg.t).toBe('function')
|
||||
})
|
||||
|
||||
it('FE-MOB-EXPSH-004: shows the busy label and refuses a second export while one runs', async () => {
|
||||
let release: () => void = () => {}
|
||||
vi.mocked(downloadTripPDF).mockImplementation(() => new Promise<void>(res => { release = () => res() }))
|
||||
renderSheet()
|
||||
|
||||
fireEvent.click(screen.getByText('PDF'))
|
||||
const busy = await screen.findByText('Loading...')
|
||||
fireEvent.click(busy)
|
||||
expect(downloadTripPDF).toHaveBeenCalledTimes(1)
|
||||
|
||||
release()
|
||||
await waitFor(() => expect(screen.getByText('PDF')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-MOB-EXPSH-005: reports a failing PDF export with the underlying message', async () => {
|
||||
vi.mocked(downloadTripPDF).mockRejectedValue(new Error('font missing'))
|
||||
const { planner } = renderSheet()
|
||||
fireEvent.click(screen.getByText('PDF'))
|
||||
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Failed to export PDF: font missing'))
|
||||
// The busy flag is released in the finally branch.
|
||||
expect(screen.getByText('PDF')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-EXPSH-006: stringifies a non-Error rejection in the toast', async () => {
|
||||
vi.mocked(downloadTripPDF).mockRejectedValue('renderer gone')
|
||||
const { planner } = renderSheet()
|
||||
fireEvent.click(screen.getByText('PDF'))
|
||||
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Failed to export PDF: renderer gone'))
|
||||
})
|
||||
|
||||
it('FE-MOB-EXPSH-007: does not export without a loaded trip', () => {
|
||||
renderSheet({ trip: null })
|
||||
expect(screen.getByText('trip.ics')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('PDF'))
|
||||
expect(downloadTripPDF).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-EXPSH-008: downloads the ics through a named anchor and closes the sheet', async () => {
|
||||
server.use(http.get('/api/trips/1/export.ics', () => HttpResponse.text('BEGIN:VCALENDAR')))
|
||||
const { shell } = renderSheet()
|
||||
fireEvent.click(screen.getByText('Download .ics'))
|
||||
|
||||
await waitFor(() => expect(clickSpy).toHaveBeenCalledTimes(1))
|
||||
expect(clickedAnchor().getAttribute('href')).toBe('blob:trek/ics')
|
||||
expect(clickedAnchor().download).toBe('Japan 2026.ics')
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:trek/ics')
|
||||
await waitFor(() => expect(shell.closeSheet).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('FE-MOB-EXPSH-009: falls back to a generic filename without a trip title', async () => {
|
||||
server.use(http.get('/api/trips/1/export.ics', () => HttpResponse.text('BEGIN:VCALENDAR')))
|
||||
renderSheet({ trip: null })
|
||||
fireEvent.click(screen.getByText('Download .ics'))
|
||||
|
||||
await waitFor(() => expect(clickSpy).toHaveBeenCalledTimes(1))
|
||||
expect(clickedAnchor().download).toBe('trip.ics')
|
||||
})
|
||||
|
||||
it('FE-MOB-EXPSH-010: toasts and keeps the sheet open when the ics endpoint errors', async () => {
|
||||
server.use(http.get('/api/trips/1/export.ics', () => new HttpResponse(null, { status: 500 })))
|
||||
const { planner, shell } = renderSheet()
|
||||
fireEvent.click(screen.getByText('Download .ics'))
|
||||
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('ICS export failed'))
|
||||
expect(shell.closeSheet).not.toHaveBeenCalled()
|
||||
expect(clickSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-EXPSH-011: toasts when the ics request never reaches the server', async () => {
|
||||
server.use(http.get('/api/trips/1/export.ics', () => HttpResponse.error()))
|
||||
const { planner } = renderSheet()
|
||||
fireEvent.click(screen.getByText('Download .ics'))
|
||||
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('ICS export failed'))
|
||||
})
|
||||
|
||||
it('FE-MOB-EXPSH-012: opens the subscribe dialog on the trip feed endpoint and dismisses it again', () => {
|
||||
renderSheet()
|
||||
expect(screen.queryByTestId('ics-subscribe')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Subscribe to calendar'))
|
||||
expect(screen.getByTestId('ics-subscribe')).toHaveAttribute('data-endpoint', '/api/trips/1/feed')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'dismiss subscribe' }))
|
||||
expect(screen.queryByTestId('ics-subscribe')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-EXPSH-013: the header close hands the dismissal back to the shell', () => {
|
||||
const { shell } = renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,208 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MFileLightbox from '../../../../src/mobile/screens/trip/tabs/MFileLightbox'
|
||||
import { getAuthUrl } from '../../../../src/api/authUrl'
|
||||
import { downloadFile, openFile } from '../../../../src/utils/fileDownload'
|
||||
import type { TranslationFn, TripFile } from '../../../../src/types'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-FLBOX-001 to FE-MOB-FLBOX-017
|
||||
|
||||
vi.mock('../../../../src/api/authUrl', () => ({
|
||||
getAuthUrl: vi.fn(async (url: string) => `${url}?token=t1`),
|
||||
}))
|
||||
vi.mock('../../../../src/utils/fileDownload', () => ({
|
||||
downloadFile: vi.fn(async () => undefined),
|
||||
openFile: vi.fn(async () => undefined),
|
||||
}))
|
||||
vi.mock('../../../../src/components/Journey/VideoPlayer', () => ({
|
||||
default: ({ src }: { src: string }) => <div data-testid="video-player" data-src={src} />,
|
||||
}))
|
||||
|
||||
const t: TranslationFn = key => key
|
||||
|
||||
function file(id: number, name: string, mime = 'image/jpeg'): TripFile {
|
||||
return {
|
||||
id, trip_id: 1, filename: name, original_name: name, mime_type: mime,
|
||||
url: `/api/trips/1/files/${id}/download`, created_at: '2026-05-01T10:00:00.000Z',
|
||||
} as unknown as TripFile
|
||||
}
|
||||
|
||||
const FILES = [file(1, 'beach.jpg'), file(2, 'castle.png', 'image/png'), file(3, 'clip.mp4', 'video/mp4')]
|
||||
|
||||
function renderBox(index: number, overrides: { files?: TripFile[] } = {}) {
|
||||
const onIndexChange = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
const view = render(
|
||||
<MFileLightbox
|
||||
files={overrides.files ?? FILES}
|
||||
index={index}
|
||||
onIndexChange={onIndexChange}
|
||||
onClose={onClose}
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
return { ...view, onIndexChange, onClose }
|
||||
}
|
||||
|
||||
describe('MFileLightbox', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.style.overflow = ''
|
||||
document.getElementById('m-sheet-root')?.remove()
|
||||
})
|
||||
|
||||
it('FE-MOB-FLBOX-001: shows the file name, the position counter and the signed image url', async () => {
|
||||
renderBox(0)
|
||||
expect(screen.getByText('beach.jpg')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 / 3')).toBeInTheDocument()
|
||||
expect(getAuthUrl).toHaveBeenCalledWith('/api/trips/1/files/1/download', 'download')
|
||||
await waitFor(() =>
|
||||
expect(screen.getByAltText('beach.jpg')).toHaveAttribute('src', '/api/trips/1/files/1/download?token=t1'),
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-MOB-FLBOX-002: renders nothing when the index points past the list', () => {
|
||||
const { container } = renderBox(5)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
expect(document.querySelector('.m-root')).toBeNull()
|
||||
expect(getAuthUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-FLBOX-003: hides the prev arrow on the first file and the next arrow on the last', () => {
|
||||
renderBox(0)
|
||||
expect(screen.queryByRole('button', { name: 'mobileTrip.filesPrev' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'mobileTrip.filesNext' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-FLBOX-004: steps forward and back without bubbling the close click', () => {
|
||||
const { onIndexChange, onClose } = renderBox(1)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.filesNext' }))
|
||||
expect(onIndexChange).toHaveBeenCalledWith(2)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.filesPrev' }))
|
||||
expect(onIndexChange).toHaveBeenCalledWith(0)
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-FLBOX-005: arrow keys navigate and Escape closes', () => {
|
||||
const { onIndexChange, onClose } = renderBox(1)
|
||||
fireEvent.keyDown(window, { key: 'ArrowRight' })
|
||||
expect(onIndexChange).toHaveBeenCalledWith(2)
|
||||
fireEvent.keyDown(window, { key: 'ArrowLeft' })
|
||||
expect(onIndexChange).toHaveBeenCalledWith(0)
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-FLBOX-006: arrow keys stop at both ends', () => {
|
||||
const { onIndexChange, unmount } = renderBox(0)
|
||||
fireEvent.keyDown(window, { key: 'ArrowLeft' })
|
||||
expect(onIndexChange).not.toHaveBeenCalled()
|
||||
unmount()
|
||||
|
||||
const last = renderBox(FILES.length - 1)
|
||||
fireEvent.keyDown(window, { key: 'ArrowRight' })
|
||||
expect(last.onIndexChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-FLBOX-007: unregisters the key handler on unmount', () => {
|
||||
const { onClose, unmount } = renderBox(0)
|
||||
unmount()
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-FLBOX-008: a swipe to the right goes back, a swipe to the left goes forward', () => {
|
||||
const { onIndexChange } = renderBox(1)
|
||||
const overlay = document.querySelector('.m-root') as HTMLElement
|
||||
fireEvent.touchStart(overlay, { touches: [{ clientX: 200 }] })
|
||||
fireEvent.touchEnd(overlay, { changedTouches: [{ clientX: 300 }] })
|
||||
expect(onIndexChange).toHaveBeenLastCalledWith(0)
|
||||
|
||||
fireEvent.touchStart(overlay, { touches: [{ clientX: 300 }] })
|
||||
fireEvent.touchEnd(overlay, { changedTouches: [{ clientX: 200 }] })
|
||||
expect(onIndexChange).toHaveBeenLastCalledWith(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-FLBOX-009: ignores a short drag and a touchend without a start', () => {
|
||||
const { onIndexChange } = renderBox(1)
|
||||
const overlay = document.querySelector('.m-root') as HTMLElement
|
||||
fireEvent.touchEnd(overlay, { changedTouches: [{ clientX: 900 }] })
|
||||
expect(onIndexChange).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.touchStart(overlay, { touches: [{ clientX: 200 }] })
|
||||
fireEvent.touchEnd(overlay, { changedTouches: [{ clientX: 210 }] })
|
||||
expect(onIndexChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-FLBOX-010: tapping the backdrop closes, tapping the header does not', () => {
|
||||
const { onClose } = renderBox(0)
|
||||
const overlay = document.querySelector('.m-root') as HTMLElement
|
||||
fireEvent.click(screen.getByText('beach.jpg'))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
fireEvent.click(overlay)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-FLBOX-011: tapping the empty media area closes but tapping the image does not', async () => {
|
||||
const { onClose } = renderBox(0)
|
||||
const img = await screen.findByAltText('beach.jpg')
|
||||
fireEvent.click(img)
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
fireEvent.click(img.parentElement as HTMLElement)
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-FLBOX-012: the close button closes', () => {
|
||||
const { onClose } = renderBox(0)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-FLBOX-013: opens and downloads through the protected file helpers', () => {
|
||||
renderBox(1)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'files.openTab' }))
|
||||
expect(openFile).toHaveBeenCalledWith('/api/trips/1/files/2/download', 'castle.png')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'files.download' }))
|
||||
expect(downloadFile).toHaveBeenCalledWith('/api/trips/1/files/2/download', 'castle.png')
|
||||
})
|
||||
|
||||
it('FE-MOB-FLBOX-014: swallows a failing open or download', async () => {
|
||||
vi.mocked(openFile).mockRejectedValueOnce(new Error('offline'))
|
||||
vi.mocked(downloadFile).mockRejectedValueOnce(new Error('offline'))
|
||||
renderBox(0)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'files.openTab' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'files.download' }))
|
||||
await waitFor(() => expect(downloadFile).toHaveBeenCalled())
|
||||
expect(screen.getByText('beach.jpg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-FLBOX-015: a video plays in the player instead of resolving a signed image url', () => {
|
||||
renderBox(2)
|
||||
expect(screen.getByTestId('video-player')).toHaveAttribute('data-src', '/api/trips/1/files/3/download')
|
||||
expect(screen.queryByAltText('clip.mp4')).not.toBeInTheDocument()
|
||||
expect(getAuthUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-FLBOX-016: locks the body scroll while open and mounts into the sheet root', () => {
|
||||
const root = document.createElement('div')
|
||||
root.id = 'm-sheet-root'
|
||||
document.body.appendChild(root)
|
||||
document.body.style.overflow = 'auto'
|
||||
|
||||
const { unmount } = renderBox(0)
|
||||
expect(document.body.style.overflow).toBe('hidden')
|
||||
expect(root.querySelector('.m-root')).not.toBeNull()
|
||||
|
||||
unmount()
|
||||
expect(document.body.style.overflow).toBe('auto')
|
||||
})
|
||||
|
||||
it('FE-MOB-FLBOX-017: tapping the player does not dismiss the viewer', () => {
|
||||
const { onClose } = renderBox(2)
|
||||
fireEvent.click(screen.getByTestId('video-player').parentElement as HTMLElement)
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MFileLinkSheet from '../../../../src/mobile/screens/trip/tabs/MFileLinkSheet'
|
||||
import { filesApi } from '../../../../src/api/client'
|
||||
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import type { Place, Reservation, TripFile } from '../../../../src/types'
|
||||
import { buildPlanner } from '../../../helpers/mobileTrip'
|
||||
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-FLINK-001 to FE-MOB-FLINK-018
|
||||
|
||||
const PLACES = [
|
||||
{ id: 11, name: 'Fushimi Inari' },
|
||||
{ id: 12, name: 'Nara Park' },
|
||||
] as unknown as Place[]
|
||||
|
||||
const RESERVATIONS = [
|
||||
{ id: 21, type: 'hotel', title: 'Hotel Granvia' },
|
||||
{ id: 22, type: 'train', title: 'Shinkansen Nozomi 21' },
|
||||
] as unknown as Reservation[]
|
||||
|
||||
function file(overrides: Partial<TripFile> = {}): TripFile {
|
||||
return {
|
||||
id: 7, trip_id: 1, filename: 'ticket.pdf', original_name: 'ticket.pdf',
|
||||
mime_type: 'application/pdf', url: '/api/trips/1/files/7/download',
|
||||
created_at: '2026-05-01T12:00:00.000Z',
|
||||
...overrides,
|
||||
} as unknown as TripFile
|
||||
}
|
||||
|
||||
function renderSheet(fileArg: TripFile | null = file(), plannerOverrides: Partial<TripPlanner> = {}) {
|
||||
const planner = buildPlanner({
|
||||
places: PLACES, reservations: RESERVATIONS, ...plannerOverrides,
|
||||
} as unknown as Partial<TripPlanner>)
|
||||
const onClose = vi.fn()
|
||||
const view = render(<MFileLinkSheet planner={planner} file={fileArg} onClose={onClose} />)
|
||||
return { ...view, planner, onClose }
|
||||
}
|
||||
|
||||
describe('MFileLinkSheet', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(filesApi, 'update').mockResolvedValue({})
|
||||
vi.spyOn(filesApi, 'addLink').mockResolvedValue({})
|
||||
vi.spyOn(filesApi, 'removeLink').mockResolvedValue({})
|
||||
vi.spyOn(filesApi, 'getLinks').mockResolvedValue({ links: [] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('FE-MOB-FLINK-001: stays closed without a file', () => {
|
||||
renderSheet(null)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-FLINK-002: heads the sheet with the file name and lists both sections', () => {
|
||||
renderSheet()
|
||||
expect(screen.getByRole('dialog', { name: 'files.linkTitle' })).toBeInTheDocument()
|
||||
expect(screen.getByText('ticket.pdf')).toBeInTheDocument()
|
||||
expect(screen.getByText('files.assignPlace')).toBeInTheDocument()
|
||||
expect(screen.getByText('files.assignBooking')).toBeInTheDocument()
|
||||
expect(screen.getByText('files.assignTransport')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Fushimi Inari' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Hotel Granvia' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Shinkansen Nozomi 21' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-FLINK-003: shows the empty hint when the trip has nothing to link to', () => {
|
||||
renderSheet(file(), { places: [], reservations: [] } as unknown as Partial<TripPlanner>)
|
||||
expect(screen.getByText('files.linkEmpty')).toBeInTheDocument()
|
||||
expect(screen.queryByText('files.assignPlace')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-FLINK-004: marks the primary and the extra links as active', () => {
|
||||
renderSheet(file({ place_id: 11, linked_place_ids: [12], reservation_id: 21, linked_reservation_ids: [22] }))
|
||||
for (const name of ['Fushimi Inari', 'Nara Park', 'Hotel Granvia', 'Shinkansen Nozomi 21']) {
|
||||
expect(screen.getByRole('button', { name }).querySelector('.lucide-check')).not.toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-MOB-FLINK-005: the first place link goes into place_id', async () => {
|
||||
const { planner } = renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Fushimi Inari' }))
|
||||
expect(filesApi.update).toHaveBeenCalledWith(1, 7, { place_id: 11 })
|
||||
await waitFor(() => expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1))
|
||||
expect(filesApi.addLink).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-FLINK-006: a further place becomes a file_link record', async () => {
|
||||
renderSheet(file({ place_id: 11 }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Nara Park' }))
|
||||
await waitFor(() => expect(filesApi.addLink).toHaveBeenCalledWith(1, 7, { place_id: 12 }))
|
||||
expect(filesApi.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-FLINK-007: unlinking the primary place clears place_id', async () => {
|
||||
renderSheet(file({ place_id: 11 }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Fushimi Inari' }))
|
||||
await waitFor(() => expect(filesApi.update).toHaveBeenCalledWith(1, 7, { place_id: null }))
|
||||
})
|
||||
|
||||
it('FE-MOB-FLINK-008: unlinking an extra place removes its link record', async () => {
|
||||
vi.mocked(filesApi.getLinks).mockResolvedValue({ links: [{ id: 99, place_id: '12' }] })
|
||||
renderSheet(file({ place_id: 11, linked_place_ids: [12] }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Nara Park' }))
|
||||
await waitFor(() => expect(filesApi.removeLink).toHaveBeenCalledWith(1, 7, 99))
|
||||
expect(filesApi.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-FLINK-009: a missing link record is a no-op that still refreshes', async () => {
|
||||
vi.mocked(filesApi.getLinks).mockResolvedValue({})
|
||||
const { planner } = renderSheet(file({ place_id: 11, linked_place_ids: [12] }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Nara Park' }))
|
||||
await waitFor(() => expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1))
|
||||
expect(filesApi.removeLink).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-FLINK-010: the first reservation link goes into reservation_id', async () => {
|
||||
const { planner } = renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hotel Granvia' }))
|
||||
expect(filesApi.update).toHaveBeenCalledWith(1, 7, { reservation_id: 21 })
|
||||
await waitFor(() => expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1))
|
||||
})
|
||||
|
||||
it('FE-MOB-FLINK-011: a further reservation becomes a file_link record', async () => {
|
||||
renderSheet(file({ reservation_id: 21 }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Shinkansen Nozomi 21' }))
|
||||
await waitFor(() => expect(filesApi.addLink).toHaveBeenCalledWith(1, 7, { reservation_id: 22 }))
|
||||
})
|
||||
|
||||
it('FE-MOB-FLINK-012: unlinking the primary reservation clears reservation_id', async () => {
|
||||
renderSheet(file({ reservation_id: 21 }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hotel Granvia' }))
|
||||
await waitFor(() => expect(filesApi.update).toHaveBeenCalledWith(1, 7, { reservation_id: null }))
|
||||
})
|
||||
|
||||
it('FE-MOB-FLINK-013: unlinking an extra reservation removes its link record', async () => {
|
||||
vi.mocked(filesApi.getLinks).mockResolvedValue({ links: [{ id: 77, reservation_id: 22 }] })
|
||||
renderSheet(file({ reservation_id: 21, linked_reservation_ids: [22] }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Shinkansen Nozomi 21' }))
|
||||
await waitFor(() => expect(filesApi.removeLink).toHaveBeenCalledWith(1, 7, 77))
|
||||
})
|
||||
|
||||
it('FE-MOB-FLINK-014: a failing toggle surfaces the assign error for both sections', async () => {
|
||||
vi.mocked(filesApi.update).mockRejectedValue(new Error('boom'))
|
||||
const { planner } = renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Fushimi Inari' }))
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('files.toast.assignError'))
|
||||
expect(planner.tripActions.loadFiles).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hotel Granvia' }))
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
it('FE-MOB-FLINK-015: one toggle at a time — the row spins and the others are ignored', async () => {
|
||||
let release: () => void = () => {}
|
||||
vi.mocked(filesApi.update).mockImplementation(
|
||||
() => new Promise(resolve => { release = () => resolve({}) }),
|
||||
)
|
||||
renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Fushimi Inari' }))
|
||||
const row = screen.getByRole('button', { name: 'Fushimi Inari' })
|
||||
await waitFor(() => expect(row).toBeDisabled())
|
||||
expect(row.querySelector('.animate-spin')).not.toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Nara Park' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hotel Granvia' }))
|
||||
expect(filesApi.update).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => { release() })
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Fushimi Inari' })).not.toBeDisabled())
|
||||
})
|
||||
|
||||
it('FE-MOB-FLINK-016: skips null entries in the linked id arrays', () => {
|
||||
renderSheet(file({ linked_place_ids: [null, 12], linked_reservation_ids: [null, 22] }))
|
||||
expect(screen.getByRole('button', { name: 'Nara Park' }).querySelector('.lucide-check')).not.toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Shinkansen Nozomi 21' }).querySelector('.lucide-check')).not.toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Fushimi Inari' }).querySelector('.lucide-check')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-FLINK-017: a links response without a links array is treated as empty', async () => {
|
||||
vi.mocked(filesApi.getLinks).mockResolvedValue({})
|
||||
const { planner } = renderSheet(file({ reservation_id: 21, linked_reservation_ids: [22] }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Shinkansen Nozomi 21' }))
|
||||
await waitFor(() => expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1))
|
||||
expect(filesApi.removeLink).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-FLINK-018: keeps the file mounted through the exit animation', () => {
|
||||
const planner = buildPlanner({ places: PLACES, reservations: RESERVATIONS } as unknown as Partial<TripPlanner>)
|
||||
const { rerender } = render(<MFileLinkSheet planner={planner} file={file()} onClose={vi.fn()} />)
|
||||
rerender(<MFileLinkSheet planner={planner} file={null} onClose={vi.fn()} />)
|
||||
expect(screen.getByText('ticket.pdf')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MFileMenuSheet from '../../../../src/mobile/screens/trip/tabs/MFileMenuSheet'
|
||||
import { filesApi } from '../../../../src/api/client'
|
||||
import { downloadFile } from '../../../../src/utils/fileDownload'
|
||||
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import type { TripFile } from '../../../../src/types'
|
||||
import { buildPlanner, buildTripActions } from '../../../helpers/mobileTrip'
|
||||
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-FMENU-001 to FE-MOB-FMENU-015
|
||||
|
||||
vi.mock('../../../../src/utils/fileDownload', () => ({
|
||||
downloadFile: vi.fn(async () => undefined),
|
||||
openFile: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
function file(overrides: Partial<TripFile> = {}): TripFile {
|
||||
return {
|
||||
id: 7, trip_id: 1, filename: 'ticket.pdf', original_name: 'ticket.pdf',
|
||||
mime_type: 'application/pdf', file_size: 2048, description: 'Seat 14A',
|
||||
url: '/api/trips/1/files/7/download', created_at: '2026-05-01T12:00:00.000Z',
|
||||
...overrides,
|
||||
} as unknown as TripFile
|
||||
}
|
||||
|
||||
function renderSheet(fileArg: TripFile | null = file(), plannerOverrides: Partial<TripPlanner> = {}) {
|
||||
const planner = buildPlanner(plannerOverrides)
|
||||
const onClose = vi.fn()
|
||||
const onOpenLinks = vi.fn()
|
||||
const view = render(
|
||||
<MFileMenuSheet planner={planner} file={fileArg} onClose={onClose} onOpenLinks={onOpenLinks} />,
|
||||
)
|
||||
return { ...view, planner, onClose, onOpenLinks }
|
||||
}
|
||||
|
||||
describe('MFileMenuSheet', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(filesApi, 'update').mockResolvedValue({})
|
||||
vi.mocked(downloadFile).mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('FE-MOB-FMENU-001: stays closed while no file is selected', () => {
|
||||
renderSheet(null)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-FMENU-002: heads the sheet with the file name, size and date', () => {
|
||||
renderSheet()
|
||||
const dialog = screen.getByRole('dialog', { name: 'ticket.pdf' })
|
||||
expect(within(dialog).getByText('ticket.pdf')).toBeInTheDocument()
|
||||
expect(within(dialog).getByText(/2\.0 KB · May 1/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-FMENU-003: seeds the note field from the file description', () => {
|
||||
renderSheet()
|
||||
expect(screen.getByPlaceholderText('files.notePlaceholder')).toHaveValue('Seat 14A')
|
||||
})
|
||||
|
||||
it('FE-MOB-FMENU-004: saves a changed note on blur and refreshes the store', async () => {
|
||||
const { planner } = renderSheet()
|
||||
const input = screen.getByPlaceholderText('files.notePlaceholder')
|
||||
fireEvent.change(input, { target: { value: ' Seat 22C ' } })
|
||||
fireEvent.blur(input)
|
||||
expect(filesApi.update).toHaveBeenCalledWith(1, 7, { description: 'Seat 22C' })
|
||||
await waitFor(() => expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1))
|
||||
})
|
||||
|
||||
it('FE-MOB-FMENU-005: Enter commits the note by blurring the field', () => {
|
||||
renderSheet()
|
||||
const input = screen.getByPlaceholderText('files.notePlaceholder')
|
||||
input.focus()
|
||||
fireEvent.change(input, { target: { value: 'Gate B' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(filesApi.update).toHaveBeenCalledWith(1, 7, { description: 'Gate B' })
|
||||
})
|
||||
|
||||
it('FE-MOB-FMENU-006: an unchanged note is not sent', () => {
|
||||
renderSheet()
|
||||
const input = screen.getByPlaceholderText('files.notePlaceholder')
|
||||
fireEvent.change(input, { target: { value: ' Seat 14A ' } })
|
||||
fireEvent.blur(input)
|
||||
expect(filesApi.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-FMENU-007: reports a failed note save', async () => {
|
||||
vi.mocked(filesApi.update).mockRejectedValue(new Error('boom'))
|
||||
const { planner } = renderSheet()
|
||||
fireEvent.change(screen.getByPlaceholderText('files.notePlaceholder'), { target: { value: 'x' } })
|
||||
fireEvent.blur(screen.getByPlaceholderText('files.notePlaceholder'))
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('files.toast.assignError'))
|
||||
expect(planner.tripActions.loadFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-FMENU-008: an empty description starts blank and stays unsent while untouched', () => {
|
||||
renderSheet(file({ description: null }))
|
||||
const input = screen.getByPlaceholderText('files.notePlaceholder')
|
||||
expect(input).toHaveValue('')
|
||||
fireEvent.keyDown(input, { key: 'a' })
|
||||
fireEvent.blur(input)
|
||||
expect(filesApi.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-FMENU-009: downloads through the cookie-authenticated helper', () => {
|
||||
renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'files.download' }))
|
||||
expect(downloadFile).toHaveBeenCalledWith('/api/trips/1/files/7/download', 'ticket.pdf')
|
||||
})
|
||||
|
||||
it('FE-MOB-FMENU-010: hands the file to the link picker', () => {
|
||||
const { onOpenLinks } = renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'files.link' }))
|
||||
expect(onOpenLinks).toHaveBeenCalledWith(expect.objectContaining({ id: 7 }))
|
||||
})
|
||||
|
||||
it('FE-MOB-FMENU-011: deleting asks first, then trashes the file and closes', async () => {
|
||||
const { planner, onClose } = renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
|
||||
const confirm = screen.getByRole('dialog', { name: 'common.delete' })
|
||||
expect(within(confirm).getByText('files.confirm.delete')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(within(confirm).getByRole('button', { name: 'common.delete' }))
|
||||
expect(planner.tripActions.deleteFile).toHaveBeenCalledWith(1, 7)
|
||||
await waitFor(() => expect(planner.toast.success).toHaveBeenCalledWith('files.toast.trashed'))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-FMENU-012: reports a failed delete and keeps the sheet open', async () => {
|
||||
const tripActions = buildTripActions()
|
||||
tripActions.deleteFile.mockRejectedValue(new Error('nope'))
|
||||
const { planner, onClose } = renderSheet(file(), { tripActions } as unknown as Partial<TripPlanner>)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
|
||||
const confirm = screen.getByRole('dialog', { name: 'common.delete' })
|
||||
fireEvent.click(within(confirm).getByRole('button', { name: 'common.delete' }))
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('files.toast.deleteError'))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-FMENU-013: cancelling the confirm leaves the file alone', () => {
|
||||
const { planner } = renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
|
||||
const confirm = screen.getByRole('dialog', { name: 'common.delete' })
|
||||
fireEvent.click(within(confirm).getByRole('button', { name: 'common.cancel' }))
|
||||
expect(planner.tripActions.deleteFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-FMENU-014: a read-only member only sees the download row', () => {
|
||||
renderSheet(file(), { can: () => false } as unknown as Partial<TripPlanner>)
|
||||
expect(screen.getByRole('button', { name: 'files.download' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'files.link' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('files.notePlaceholder')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-FMENU-015: keeps the last file mounted through the exit animation and re-seeds on a new one', () => {
|
||||
const planner = buildPlanner()
|
||||
const { rerender } = render(
|
||||
<MFileMenuSheet planner={planner} file={file()} onClose={vi.fn()} onOpenLinks={vi.fn()} />,
|
||||
)
|
||||
rerender(<MFileMenuSheet planner={planner} file={null} onClose={vi.fn()} onOpenLinks={vi.fn()} />)
|
||||
expect(screen.getByText('ticket.pdf')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<MFileMenuSheet
|
||||
planner={planner}
|
||||
file={file({ id: 8, original_name: 'map.png', description: 'Old town' })}
|
||||
onClose={vi.fn()}
|
||||
onOpenLinks={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('map.png')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('files.notePlaceholder')).toHaveValue('Old town')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MFileTrashSheet from '../../../../src/mobile/screens/trip/tabs/MFileTrashSheet'
|
||||
import { filesApi } from '../../../../src/api/client'
|
||||
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import type { TripFile } from '../../../../src/types'
|
||||
import { buildPlanner } from '../../../helpers/mobileTrip'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-FTRASH-001 to FE-MOB-FTRASH-016
|
||||
|
||||
const TRASHED = [
|
||||
{
|
||||
id: 31, trip_id: 1, filename: 'old.pdf', original_name: 'old.pdf', mime_type: 'application/pdf',
|
||||
file_size: 1024, url: '/api/trips/1/files/31/download', created_at: '2026-05-01T12:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 32, trip_id: 1, filename: 'shot.png', original_name: 'shot.png', mime_type: 'image/png',
|
||||
url: '/api/trips/1/files/32/download', created_at: '2026-05-02T12:00:00.000Z',
|
||||
},
|
||||
] as unknown as TripFile[]
|
||||
|
||||
function renderSheet(open = true, plannerOverrides: Partial<TripPlanner> = {}) {
|
||||
const planner = buildPlanner(plannerOverrides)
|
||||
const onClose = vi.fn()
|
||||
const view = render(<MFileTrashSheet planner={planner} open={open} onClose={onClose} />)
|
||||
return { ...view, planner, onClose }
|
||||
}
|
||||
|
||||
/** Waits for the lazy trash fetch to settle. */
|
||||
async function settled() {
|
||||
await waitFor(() => expect(document.querySelector('.animate-spin')).toBeNull())
|
||||
}
|
||||
|
||||
describe('MFileTrashSheet', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(filesApi, 'list').mockResolvedValue({ files: TRASHED })
|
||||
vi.spyOn(filesApi, 'restore').mockResolvedValue({})
|
||||
vi.spyOn(filesApi, 'permanentDelete').mockResolvedValue({})
|
||||
vi.spyOn(filesApi, 'emptyTrash').mockResolvedValue({})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('FE-MOB-FTRASH-001: fetches nothing while closed', () => {
|
||||
renderSheet(false)
|
||||
expect(filesApi.list).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-FTRASH-002: loads the trashed files on open and lists them with size and date', async () => {
|
||||
renderSheet()
|
||||
expect(filesApi.list).toHaveBeenCalledWith(1, true)
|
||||
expect(document.querySelector('.animate-spin')).not.toBeNull()
|
||||
|
||||
await settled()
|
||||
expect(screen.getByRole('dialog', { name: 'files.trash' })).toBeInTheDocument()
|
||||
expect(screen.getByText('old.pdf')).toBeInTheDocument()
|
||||
expect(screen.getByText('1.0 KB · May 1')).toBeInTheDocument()
|
||||
// No file_size → only the date remains.
|
||||
expect(screen.getByText('May 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-FTRASH-003: shows the empty hint for an empty trash', async () => {
|
||||
vi.mocked(filesApi.list).mockResolvedValue({ files: [] })
|
||||
renderSheet()
|
||||
await settled()
|
||||
expect(screen.getByText('files.trashEmpty')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'files.emptyTrash' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-FTRASH-004: survives a failing fetch', async () => {
|
||||
vi.mocked(filesApi.list).mockRejectedValue(new Error('offline'))
|
||||
renderSheet()
|
||||
await settled()
|
||||
expect(screen.getByText('files.trashEmpty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-FTRASH-005: tolerates a response without a files array', async () => {
|
||||
vi.mocked(filesApi.list).mockResolvedValue({})
|
||||
renderSheet()
|
||||
await settled()
|
||||
expect(screen.getByText('files.trashEmpty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-FTRASH-006: restores a file, drops the row and refreshes the store', async () => {
|
||||
const { planner } = renderSheet()
|
||||
await settled()
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'files.restore' })[0])
|
||||
expect(filesApi.restore).toHaveBeenCalledWith(1, 31)
|
||||
await waitFor(() => expect(screen.queryByText('old.pdf')).not.toBeInTheDocument())
|
||||
expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1)
|
||||
expect(planner.toast.success).toHaveBeenCalledWith('files.toast.restored')
|
||||
})
|
||||
|
||||
it('FE-MOB-FTRASH-007: reports a failed restore and keeps the row', async () => {
|
||||
vi.mocked(filesApi.restore).mockRejectedValue(new Error('nope'))
|
||||
const { planner } = renderSheet()
|
||||
await settled()
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'files.restore' })[0])
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('files.toast.restoreError'))
|
||||
expect(screen.getByText('old.pdf')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-FTRASH-008: permanently deletes only after the confirm', async () => {
|
||||
const { planner } = renderSheet()
|
||||
await settled()
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'common.delete' })[0])
|
||||
expect(filesApi.permanentDelete).not.toHaveBeenCalled()
|
||||
|
||||
const confirm = screen.getByRole('dialog', { name: 'common.delete' })
|
||||
expect(within(confirm).getByText('files.confirm.permanentDelete')).toBeInTheDocument()
|
||||
fireEvent.click(within(confirm).getByRole('button', { name: 'common.delete' }))
|
||||
expect(filesApi.permanentDelete).toHaveBeenCalledWith(1, 31)
|
||||
await waitFor(() => expect(screen.queryByText('old.pdf')).not.toBeInTheDocument())
|
||||
expect(planner.toast.success).toHaveBeenCalledWith('files.toast.deleted')
|
||||
expect(planner.tripActions.loadFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-FTRASH-009: cancelling the confirm keeps the file', async () => {
|
||||
renderSheet()
|
||||
await settled()
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'common.delete' })[0])
|
||||
const confirm = screen.getByRole('dialog', { name: 'common.delete' })
|
||||
fireEvent.click(within(confirm).getByRole('button', { name: 'common.cancel' }))
|
||||
expect(filesApi.permanentDelete).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('old.pdf')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-FTRASH-010: reports a failed permanent delete', async () => {
|
||||
vi.mocked(filesApi.permanentDelete).mockRejectedValue(new Error('nope'))
|
||||
const { planner } = renderSheet()
|
||||
await settled()
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'common.delete' })[0])
|
||||
const confirm = screen.getByRole('dialog', { name: 'common.delete' })
|
||||
fireEvent.click(within(confirm).getByRole('button', { name: 'common.delete' }))
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('files.toast.deleteError'))
|
||||
expect(screen.getByText('old.pdf')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-FTRASH-011: empties the whole trash after the confirm', async () => {
|
||||
const { planner } = renderSheet()
|
||||
await settled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'files.emptyTrash' }))
|
||||
const confirm = screen.getByRole('dialog', { name: 'files.emptyTrash' })
|
||||
expect(within(confirm).getByText('files.confirm.emptyTrash')).toBeInTheDocument()
|
||||
fireEvent.click(within(confirm).getByRole('button', { name: 'files.emptyTrash' }))
|
||||
expect(filesApi.emptyTrash).toHaveBeenCalledWith(1)
|
||||
await waitFor(() => expect(screen.getByText('files.trashEmpty')).toBeInTheDocument())
|
||||
expect(planner.toast.success).toHaveBeenCalledWith('files.toast.trashEmptied')
|
||||
})
|
||||
|
||||
it('FE-MOB-FTRASH-012: reports a failed empty', async () => {
|
||||
vi.mocked(filesApi.emptyTrash).mockRejectedValue(new Error('nope'))
|
||||
const { planner } = renderSheet()
|
||||
await settled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'files.emptyTrash' }))
|
||||
const confirm = screen.getByRole('dialog', { name: 'files.emptyTrash' })
|
||||
fireEvent.click(within(confirm).getByRole('button', { name: 'files.emptyTrash' }))
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('files.toast.deleteError'))
|
||||
expect(screen.getByText('old.pdf')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-FTRASH-013: a member without file_delete only gets to look', async () => {
|
||||
renderSheet(true, { can: (perm: string) => perm !== 'file_delete' } as unknown as Partial<TripPlanner>)
|
||||
await settled()
|
||||
expect(screen.getByText('old.pdf')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'files.restore' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'files.emptyTrash' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-FTRASH-014: closes through the header button', async () => {
|
||||
const { onClose } = renderSheet()
|
||||
await settled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-FTRASH-015: a fetch that lands after unmount is dropped', async () => {
|
||||
let release: (data: { files: TripFile[] }) => void = () => {}
|
||||
vi.mocked(filesApi.list).mockImplementation(() => new Promise(resolve => { release = resolve }))
|
||||
const { unmount } = renderSheet()
|
||||
unmount()
|
||||
await act(async () => { release({ files: TRASHED }) })
|
||||
expect(screen.queryByText('old.pdf')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-FTRASH-016: cancelling the empty-trash confirm keeps everything', async () => {
|
||||
renderSheet()
|
||||
await settled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'files.emptyTrash' }))
|
||||
const confirm = screen.getByRole('dialog', { name: 'files.emptyTrash' })
|
||||
fireEvent.click(within(confirm).getByRole('button', { name: 'common.cancel' }))
|
||||
expect(filesApi.emptyTrash).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('old.pdf')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,375 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MFilesTab from '../../../../src/mobile/screens/trip/tabs/MFilesTab'
|
||||
import { filesApi } from '../../../../src/api/client'
|
||||
import { openFile } from '../../../../src/utils/fileDownload'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import type { Place, TripFile } from '../../../../src/types'
|
||||
import { buildPlanner, buildShell, buildTripActions } from '../../../helpers/mobileTrip'
|
||||
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-FTAB-001 to FE-MOB-FTAB-025
|
||||
|
||||
vi.mock('../../../../src/utils/fileDownload', () => ({
|
||||
downloadFile: vi.fn(async () => undefined),
|
||||
openFile: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
// The four overlays are covered by their own suites — stubbed here so this one
|
||||
// asserts the tab's own wiring.
|
||||
vi.mock('../../../../src/mobile/screens/trip/tabs/MFileMenuSheet', () => ({
|
||||
default: ({ file, onClose, onOpenLinks }: {
|
||||
file: TripFile | null
|
||||
onClose: () => void
|
||||
onOpenLinks: (f: TripFile) => void
|
||||
}) => file ? (
|
||||
<div data-testid="menu-sheet" data-file={String(file.id)}>
|
||||
<button type="button" onClick={onClose}>menu-close</button>
|
||||
<button type="button" onClick={() => onOpenLinks(file)}>menu-links</button>
|
||||
</div>
|
||||
) : null,
|
||||
}))
|
||||
vi.mock('../../../../src/mobile/screens/trip/tabs/MFileLinkSheet', () => ({
|
||||
default: ({ file, onClose }: { file: TripFile | null; onClose: () => void }) => file ? (
|
||||
<div data-testid="link-sheet" data-file={String(file.id)}>
|
||||
<button type="button" onClick={onClose}>link-close</button>
|
||||
</div>
|
||||
) : null,
|
||||
}))
|
||||
vi.mock('../../../../src/mobile/screens/trip/tabs/MFileTrashSheet', () => ({
|
||||
default: ({ open, onClose }: { open: boolean; onClose: () => void }) => open ? (
|
||||
<div data-testid="trash-sheet">
|
||||
<button type="button" onClick={onClose}>trash-close</button>
|
||||
</div>
|
||||
) : null,
|
||||
}))
|
||||
vi.mock('../../../../src/mobile/screens/trip/tabs/MFileLightbox', () => ({
|
||||
default: ({ files, index, onIndexChange, onClose }: {
|
||||
files: TripFile[]
|
||||
index: number
|
||||
onIndexChange: (i: number) => void
|
||||
onClose: () => void
|
||||
}) => (
|
||||
<div data-testid="lightbox" data-index={String(index)} data-count={String(files.length)}>
|
||||
<span>{files[index]?.original_name}</span>
|
||||
<button type="button" onClick={() => onIndexChange(index + 1)}>lb-next</button>
|
||||
<button type="button" onClick={onClose}>lb-close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const PLACES = [{ id: 11, name: 'Fushimi Inari' }] as unknown as Place[]
|
||||
|
||||
function tripFile(overrides: Partial<TripFile>): TripFile {
|
||||
return {
|
||||
trip_id: 1, filename: 'f', original_name: 'f', mime_type: 'application/octet-stream',
|
||||
url: '/api/trips/1/files/0/download', created_at: '2026-05-01T12:00:00.000Z',
|
||||
...overrides,
|
||||
} as unknown as TripFile
|
||||
}
|
||||
|
||||
const GUIDE = tripFile({
|
||||
id: 1, filename: 'guide.pdf', original_name: 'guide.pdf', mime_type: 'application/pdf',
|
||||
file_size: 2048, uploaded_by_name: 'ada', place_id: 11, url: '/api/trips/1/files/1/download',
|
||||
})
|
||||
const BEACH = tripFile({
|
||||
id: 2, filename: 'beach.jpg', original_name: 'beach.jpg', mime_type: 'image/jpeg',
|
||||
starred: 1, url: '/api/trips/1/files/2/download',
|
||||
})
|
||||
const NOTES = tripFile({ id: 3, filename: 'notes.txt', original_name: 'notes.txt', mime_type: 'text/plain' })
|
||||
const PASS = tripFile({ id: 4, filename: 'board.pkpass', original_name: 'board.pkpass', mime_type: '' })
|
||||
const IDEA = tripFile({
|
||||
id: 5, filename: 'idea.png', original_name: 'idea.png', mime_type: 'image/png', note_id: 9,
|
||||
})
|
||||
|
||||
const FILES = [GUIDE, BEACH, NOTES, PASS, IDEA]
|
||||
const NAME_RE = /^(guide\.pdf|beach\.jpg|notes\.txt|board\.pkpass|idea\.png)$/
|
||||
|
||||
function renderTab(plannerOverrides: Partial<TripPlanner> = {}, shellOverrides: Partial<MTripShellApi> = {}) {
|
||||
const planner = buildPlanner({ files: FILES, places: PLACES, ...plannerOverrides } as unknown as Partial<TripPlanner>)
|
||||
const shell = buildShell(shellOverrides)
|
||||
const view = render(<MFilesTab planner={planner} shell={shell} />)
|
||||
const rerenderWith = (nextShell: Partial<MTripShellApi>) =>
|
||||
view.rerender(<MFilesTab planner={planner} shell={buildShell({ ...shellOverrides, ...nextShell })} />)
|
||||
return { ...view, planner, shell, rerenderWith }
|
||||
}
|
||||
|
||||
function tile(labelKey: string): HTMLButtonElement {
|
||||
return screen.getByText(labelKey).closest('button') as HTMLButtonElement
|
||||
}
|
||||
|
||||
function rowNames(): string[] {
|
||||
return screen.queryAllByText(NAME_RE).map(n => n.textContent as string)
|
||||
}
|
||||
|
||||
function hiddenInput(container: HTMLElement): HTMLInputElement {
|
||||
return container.querySelector('input[type="file"]') as HTMLInputElement
|
||||
}
|
||||
|
||||
function pasteTarget(container: HTMLElement): HTMLElement {
|
||||
return container.querySelector('[tabindex="-1"]') as HTMLElement
|
||||
}
|
||||
|
||||
function makeFile(name: string, type: string, size = 10): File {
|
||||
const f = new File(['x'], name, { type })
|
||||
Object.defineProperty(f, 'size', { value: size })
|
||||
return f
|
||||
}
|
||||
|
||||
describe('MFilesTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(filesApi, 'toggleStar').mockResolvedValue({})
|
||||
vi.mocked(openFile).mockClear()
|
||||
vi.mocked(openFile).mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('FE-MOB-FTAB-001: shows the mascot empty state and no filter grid without files', () => {
|
||||
renderTab({ files: [] } as unknown as Partial<TripPlanner>)
|
||||
expect(screen.getByText('mobileTrip.filesEmpty')).toBeInTheDocument()
|
||||
expect(screen.queryByText('files.filterAll')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-FTAB-002: counts every filter tile over the whole file list', () => {
|
||||
renderTab()
|
||||
expect(tile('files.filterAll').textContent).toBe('files.filterAll5')
|
||||
expect(tile('files.filterPdf').textContent).toBe('files.filterPdf1')
|
||||
expect(tile('files.filterImages').textContent).toBe('files.filterImages2')
|
||||
expect(tile('files.filterDocs').textContent).toBe('files.filterDocs2')
|
||||
expect(tile('files.filterStarred').textContent).toBe('files.filterStarred1')
|
||||
expect(tile('files.filterCollab').textContent).toBe('files.filterCollab1')
|
||||
})
|
||||
|
||||
it('FE-MOB-FTAB-003: drops the collab tile when no file came from a note', () => {
|
||||
renderTab({ files: [GUIDE, BEACH] } as unknown as Partial<TripPlanner>)
|
||||
expect(screen.queryByText('files.filterCollab')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('files.filterAll')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-FTAB-004: sorts starred files to the top', () => {
|
||||
renderTab()
|
||||
expect(rowNames()).toEqual(['beach.jpg', 'guide.pdf', 'notes.txt', 'board.pkpass', 'idea.png'])
|
||||
})
|
||||
|
||||
it('FE-MOB-FTAB-005: a filter narrows the list to its bucket', () => {
|
||||
renderTab()
|
||||
fireEvent.click(tile('files.filterDocs'))
|
||||
expect(rowNames()).toEqual(['notes.txt', 'board.pkpass'])
|
||||
|
||||
fireEvent.click(tile('files.filterImages'))
|
||||
expect(rowNames()).toEqual(['beach.jpg', 'idea.png'])
|
||||
|
||||
fireEvent.click(tile('files.filterStarred'))
|
||||
expect(rowNames()).toEqual(['beach.jpg'])
|
||||
|
||||
fireEvent.click(tile('files.filterCollab'))
|
||||
expect(rowNames()).toEqual(['idea.png'])
|
||||
})
|
||||
|
||||
it('FE-MOB-FTAB-006: an empty filter result keeps the grid and shows the hint', () => {
|
||||
renderTab({ files: [BEACH] } as unknown as Partial<TripPlanner>)
|
||||
fireEvent.click(tile('files.filterPdf'))
|
||||
expect(rowNames()).toEqual([])
|
||||
expect(screen.getByText('mobileTrip.filesEmpty')).toBeInTheDocument()
|
||||
expect(screen.getByText('files.filterPdf')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-FTAB-007: a row carries size, date, uploader and its link labels', () => {
|
||||
renderTab()
|
||||
expect(screen.getByText('2.0 KB')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('May 1').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('ada')).toBeInTheDocument()
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
expect(screen.getByText('files.sourcePlan · Fushimi Inari')).toBeInTheDocument()
|
||||
expect(screen.getByText('files.sourceCollab')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-FTAB-008: starring calls the api and refreshes the store', async () => {
|
||||
const { planner } = renderTab()
|
||||
expect(screen.getByRole('button', { name: 'files.unstar' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'files.star' })[0])
|
||||
expect(filesApi.toggleStar).toHaveBeenCalledWith(1, 1)
|
||||
await waitFor(() => expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1))
|
||||
})
|
||||
|
||||
it('FE-MOB-FTAB-009: a failing star toggle is reported', async () => {
|
||||
vi.mocked(filesApi.toggleStar).mockRejectedValue(new Error('boom'))
|
||||
const { planner } = renderTab()
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'files.star' })[0])
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('files.toast.assignError'))
|
||||
})
|
||||
|
||||
it('FE-MOB-FTAB-010: opening a media row puts it in the lightbox at its media index', () => {
|
||||
renderTab()
|
||||
expect(screen.queryByTestId('lightbox')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('idea.png'))
|
||||
const box = screen.getByTestId('lightbox')
|
||||
expect(box).toHaveAttribute('data-index', '1')
|
||||
expect(box).toHaveAttribute('data-count', '2')
|
||||
|
||||
fireEvent.click(screen.getByText('lb-close'))
|
||||
expect(screen.queryByTestId('lightbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-FTAB-011: the lightbox index is clamped to the media it is given', () => {
|
||||
renderTab()
|
||||
fireEvent.click(screen.getByText('beach.jpg'))
|
||||
expect(screen.getByTestId('lightbox')).toHaveAttribute('data-index', '0')
|
||||
fireEvent.click(screen.getByText('lb-next'))
|
||||
expect(screen.getByTestId('lightbox')).toHaveAttribute('data-index', '1')
|
||||
// Past the end the panel clamps instead of unmounting the viewer.
|
||||
fireEvent.click(screen.getByText('lb-next'))
|
||||
expect(screen.getByTestId('lightbox')).toHaveAttribute('data-index', '1')
|
||||
})
|
||||
|
||||
it('FE-MOB-FTAB-012: a non-media row opens through the browser-native helper', () => {
|
||||
renderTab()
|
||||
fireEvent.click(screen.getByText('guide.pdf'))
|
||||
expect(openFile).toHaveBeenCalledWith('/api/trips/1/files/1/download', 'guide.pdf')
|
||||
expect(screen.queryByTestId('lightbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-FTAB-013: a failing open is reported', async () => {
|
||||
vi.mocked(openFile).mockRejectedValue(new Error('offline'))
|
||||
const { planner } = renderTab()
|
||||
fireEvent.click(screen.getByText('guide.pdf'))
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('files.openError'))
|
||||
})
|
||||
|
||||
it('FE-MOB-FTAB-014: the kebab opens the menu sheet and hands over to the link picker', () => {
|
||||
renderTab()
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'files.menu' })[0])
|
||||
expect(screen.getByTestId('menu-sheet')).toHaveAttribute('data-file', '2')
|
||||
|
||||
fireEvent.click(screen.getByText('menu-links'))
|
||||
expect(screen.queryByTestId('menu-sheet')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('link-sheet')).toHaveAttribute('data-file', '2')
|
||||
|
||||
fireEvent.click(screen.getByText('link-close'))
|
||||
expect(screen.queryByTestId('link-sheet')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-FTAB-015: closing the menu sheet clears the selection', () => {
|
||||
renderTab()
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'files.menu' })[1])
|
||||
expect(screen.getByTestId('menu-sheet')).toHaveAttribute('data-file', '1')
|
||||
fireEvent.click(screen.getByText('menu-close'))
|
||||
expect(screen.queryByTestId('menu-sheet')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-FTAB-016: the header upload signal opens the file picker', () => {
|
||||
const click = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {})
|
||||
const { rerenderWith } = renderTab()
|
||||
expect(click).not.toHaveBeenCalled()
|
||||
rerenderWith({ uploadFilesSignal: 1 })
|
||||
expect(click).toHaveBeenCalledTimes(1)
|
||||
// Re-render without a new signal must not re-open the picker.
|
||||
rerenderWith({ uploadFilesSignal: 1 })
|
||||
expect(click).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-FTAB-017: uploads every picked file and reports the count', async () => {
|
||||
const { container, planner } = renderTab()
|
||||
const input = hiddenInput(container)
|
||||
fireEvent.change(input, {
|
||||
target: { files: [makeFile('a.pdf', 'application/pdf'), makeFile('b.pdf', 'application/pdf')] },
|
||||
})
|
||||
await waitFor(() => expect(planner.toast.success).toHaveBeenCalledWith('files.uploaded:2'))
|
||||
expect(planner.tripActions.addFile).toHaveBeenCalledTimes(2)
|
||||
expect(planner.tripActions.addFile).toHaveBeenCalledWith(1, expect.any(FormData))
|
||||
expect(input.value).toBe('')
|
||||
})
|
||||
|
||||
it('FE-MOB-FTAB-018: an empty pick does nothing', () => {
|
||||
const { container, planner } = renderTab()
|
||||
fireEvent.change(hiddenInput(container), { target: { files: [] } })
|
||||
expect(planner.tripActions.addFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-FTAB-019: a file over the 50 MB cap is rejected outright', () => {
|
||||
const { container, planner } = renderTab()
|
||||
fireEvent.change(hiddenInput(container), {
|
||||
target: { files: [makeFile('huge.pdf', 'application/pdf', 60 * 1024 * 1024)] },
|
||||
})
|
||||
expect(planner.toast.error).toHaveBeenCalledWith('files.uploadErrorSize')
|
||||
expect(planner.tripActions.addFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-FTAB-020: an oversized file in a batch is skipped, the rest still uploads', async () => {
|
||||
const { container, planner } = renderTab()
|
||||
fireEvent.change(hiddenInput(container), {
|
||||
target: {
|
||||
files: [
|
||||
makeFile('huge.pdf', 'application/pdf', 60 * 1024 * 1024),
|
||||
makeFile('small.pdf', 'application/pdf'),
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(planner.toast.error).toHaveBeenCalledWith('files.uploadErrorSize')
|
||||
await waitFor(() => expect(planner.toast.success).toHaveBeenCalledWith('files.uploaded:1'))
|
||||
expect(planner.tripActions.addFile).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-FTAB-021: a rejected upload falls back to the generic upload error', async () => {
|
||||
const tripActions = buildTripActions()
|
||||
tripActions.addFile.mockRejectedValue(new Error('server-500'))
|
||||
const { container, planner } = renderTab({ tripActions } as unknown as Partial<TripPlanner>)
|
||||
fireEvent.change(hiddenInput(container), { target: { files: [makeFile('a.pdf', 'application/pdf')] } })
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('files.uploadError'))
|
||||
expect(planner.toast.success).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-FTAB-022: the uploading pill shows while the request is in flight', async () => {
|
||||
let release: () => void = () => {}
|
||||
const tripActions = buildTripActions()
|
||||
tripActions.addFile.mockImplementation(() => new Promise(resolve => { release = () => resolve(undefined) }))
|
||||
const { container } = renderTab({ tripActions } as unknown as Partial<TripPlanner>)
|
||||
fireEvent.change(hiddenInput(container), { target: { files: [makeFile('a.pdf', 'application/pdf')] } })
|
||||
await waitFor(() => expect(screen.getByText('files.uploading')).toBeInTheDocument())
|
||||
|
||||
await act(async () => { release() })
|
||||
await waitFor(() => expect(screen.queryByText('files.uploading')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-MOB-FTAB-023: pasted files upload, and nothing happens without the permission', async () => {
|
||||
const { container, planner } = renderTab()
|
||||
const pasted = makeFile('screenshot.png', 'image/png')
|
||||
fireEvent.paste(pasteTarget(container), {
|
||||
clipboardData: {
|
||||
items: [
|
||||
{ kind: 'file', getAsFile: () => pasted },
|
||||
{ kind: 'file', getAsFile: () => null },
|
||||
{ kind: 'string', getAsFile: () => null },
|
||||
],
|
||||
},
|
||||
})
|
||||
await waitFor(() => expect(planner.tripActions.addFile).toHaveBeenCalledTimes(1))
|
||||
|
||||
const readOnly = renderTab({ canUploadFiles: false } as unknown as Partial<TripPlanner>)
|
||||
fireEvent.paste(pasteTarget(readOnly.container), {
|
||||
clipboardData: { items: [{ kind: 'file', getAsFile: () => pasted }] },
|
||||
})
|
||||
expect(readOnly.planner.tripActions.addFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-FTAB-024: a paste that carries no file at all is left to the browser', () => {
|
||||
const { container, planner } = renderTab()
|
||||
fireEvent.paste(pasteTarget(container), { clipboardData: { items: [{ kind: 'string', getAsFile: () => null }] } })
|
||||
expect(planner.tripActions.addFile).not.toHaveBeenCalled()
|
||||
|
||||
// A paste event without clipboardData at all must not throw either.
|
||||
fireEvent.paste(pasteTarget(container))
|
||||
expect(planner.tripActions.addFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-FTAB-025: the header trash signal opens the trash sheet', () => {
|
||||
const { rerenderWith } = renderTab()
|
||||
expect(screen.queryByTestId('trash-sheet')).not.toBeInTheDocument()
|
||||
rerenderWith({ openFilesTrashSignal: 1 })
|
||||
expect(screen.getByTestId('trash-sheet')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('trash-close'))
|
||||
expect(screen.queryByTestId('trash-sheet')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { placesApi } from '../../../../src/api/client'
|
||||
import MImportSheet from '../../../../src/mobile/screens/trip/sheets/MImportSheet'
|
||||
import { buildPlanner } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-IMPS-001 to FE-MOB-IMPS-010
|
||||
//
|
||||
// planner.t echoes its key — assertions go against keys, not English copy.
|
||||
|
||||
function renderSheet(open = true) {
|
||||
const planner = buildPlanner()
|
||||
const onClose = vi.fn()
|
||||
const view = render(<MImportSheet planner={planner} open={open} onClose={onClose} />)
|
||||
return { ...view, planner, onClose }
|
||||
}
|
||||
|
||||
const menuRow = (title: string) => screen.getByRole('button', { name: new RegExp(`^${title}`) })
|
||||
|
||||
describe('MImportSheet', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPS-001: renders nothing while closed', () => {
|
||||
renderSheet(false)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPS-002: opens on the menu with both import options', () => {
|
||||
renderSheet()
|
||||
const dialog = screen.getByRole('dialog', { name: 'mobileTrip.importPlaces' })
|
||||
expect(dialog).toBeInTheDocument()
|
||||
expect(menuRow('places.importFile')).toHaveTextContent('GPX · KML · KMZ')
|
||||
expect(menuRow('places.importList')).toHaveTextContent('mobileTrip.importListSub')
|
||||
expect(screen.queryByRole('button', { name: 'common.back' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPS-003: the close button reports the sheet closed', () => {
|
||||
const { onClose } = renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPS-004: the file option opens the file step under its own title', () => {
|
||||
renderSheet()
|
||||
fireEvent.click(menuRow('places.importFile'))
|
||||
expect(screen.getByText('places.importFileHint')).toBeInTheDocument()
|
||||
expect(screen.getByText('places.importFileDropHere')).toBeInTheDocument()
|
||||
expect(screen.queryByText('mobileTrip.importListSub')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPS-005: the list option opens the shared-list step', () => {
|
||||
renderSheet()
|
||||
fireEvent.click(menuRow('places.importList'))
|
||||
expect(screen.getByText('places.googleListHint')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPS-006: the header back button returns to the menu', () => {
|
||||
renderSheet()
|
||||
fireEvent.click(menuRow('places.importList'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.back' }))
|
||||
expect(menuRow('places.importFile')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPS-007: the footer cancel of either step returns to the menu', () => {
|
||||
renderSheet()
|
||||
fireEvent.click(menuRow('places.importFile'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
|
||||
expect(menuRow('places.importList')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(menuRow('places.importList'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
|
||||
expect(menuRow('places.importFile')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPS-008: reopening the sheet resets it to the menu', () => {
|
||||
const { rerender, planner, onClose } = renderSheet()
|
||||
fireEvent.click(menuRow('places.importList'))
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
|
||||
rerender(<MImportSheet planner={planner} open={false} onClose={onClose} />)
|
||||
rerender(<MImportSheet planner={planner} open onClose={onClose} />)
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
expect(menuRow('places.importFile')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPS-009: a finished list import closes the whole sheet', async () => {
|
||||
vi.spyOn(placesApi, 'importGoogleList').mockResolvedValue({ count: 2, skipped: 0, listName: 'L', places: [] })
|
||||
const { onClose } = renderSheet()
|
||||
fireEvent.click(menuRow('places.importList'))
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'https://maps.app.goo.gl/abc' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.import' }))
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('FE-MOB-IMPS-010: a finished file import closes the whole sheet', async () => {
|
||||
vi.spyOn(placesApi, 'importGpx').mockResolvedValue({ count: 1, skipped: 0, places: [] })
|
||||
const { onClose } = renderSheet()
|
||||
fireEvent.click(menuRow('places.importFile'))
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { files: [new File(['<gpx/>'], 'route.gpx')] } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.import' }))
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { FolderOpen, Users } from 'lucide-react'
|
||||
import MMehrSheet from '../../../../src/mobile/screens/trip/sheets/MMehrSheet'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import type { TripFile, TripMember } from '../../../../src/types'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-MEHR-001 to FE-MOB-MEHR-012
|
||||
//
|
||||
// The sheet takes its copy from the real TranslationProvider, so the visible
|
||||
// strings are asserted in English.
|
||||
|
||||
const TABS = [
|
||||
{ id: 'plan', label: 'Plan', icon: FolderOpen },
|
||||
{ id: 'transports', label: 'Transport', icon: FolderOpen },
|
||||
{ id: 'buchungen', label: 'Bookings', icon: FolderOpen },
|
||||
{ id: 'finanzplan', label: 'Budget', icon: FolderOpen },
|
||||
{ id: 'listen', label: 'Lists', icon: FolderOpen },
|
||||
{ id: 'dateien', label: 'Files', icon: FolderOpen },
|
||||
{ id: 'collab', label: 'Collaboration', icon: Users },
|
||||
]
|
||||
|
||||
const FILES = [
|
||||
{ id: 1, trip_id: 1, filename: 'a.pdf', deleted_at: null },
|
||||
{ id: 2, trip_id: 1, filename: 'b.pdf', deleted_at: null },
|
||||
{ id: 3, trip_id: 1, filename: 'gone.pdf', deleted_at: '2026-05-01T10:00:00Z' },
|
||||
] as unknown as TripFile[]
|
||||
|
||||
const MEMBERS = [
|
||||
{ user_id: 1, username: 'maurice', role: 'owner' },
|
||||
{ user_id: 2, username: 'julien', role: 'editor' },
|
||||
{ user_id: 3, username: 'guest', role: 'viewer' },
|
||||
] as unknown as TripMember[]
|
||||
|
||||
function renderSheet(plannerOverrides: Partial<TripPlanner> = {}, shellOverrides: Partial<MTripShellApi> = {}) {
|
||||
const planner = buildPlanner({
|
||||
TRIP_TABS: TABS as TripPlanner['TRIP_TABS'],
|
||||
files: FILES,
|
||||
tripMembers: MEMBERS,
|
||||
...plannerOverrides,
|
||||
})
|
||||
const shell = buildShell({ sheet: { id: 'mehr' }, ...shellOverrides })
|
||||
render(<MMehrSheet planner={planner} shell={shell} />)
|
||||
return { planner, shell }
|
||||
}
|
||||
|
||||
describe('MMehrSheet', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
})
|
||||
|
||||
it('FE-MOB-MEHR-001: stays closed while another sheet id is active', () => {
|
||||
renderSheet({}, { sheet: { id: 'days' } })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-MEHR-002: opens as the "More" panel', () => {
|
||||
renderSheet()
|
||||
expect(screen.getByRole('dialog', { name: 'More' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-MEHR-003: only tiles the sections that are not in the dock', () => {
|
||||
renderSheet()
|
||||
expect(screen.getByRole('button', { name: /Files/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Collaboration/ })).toBeInTheDocument()
|
||||
for (const dockLabel of ['Plan', 'Transport', 'Bookings', 'Budget', 'Lists']) {
|
||||
expect(screen.queryByRole('button', { name: new RegExp(`^${dockLabel}`) })).not.toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-MOB-MEHR-004: counts only the files that are not in the trash', () => {
|
||||
renderSheet()
|
||||
expect(screen.getByText('2 files')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-MEHR-005: counts the trip members on the collab tile', () => {
|
||||
renderSheet()
|
||||
expect(screen.getByText('3 people')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-MEHR-006: a plugin tile gets the neutral tint and no stat line', () => {
|
||||
renderSheet({
|
||||
TRIP_TABS: [...TABS, { id: 'plugin:todos', label: 'Trip To-Dos', icon: FolderOpen }] as TripPlanner['TRIP_TABS'],
|
||||
})
|
||||
const tile = screen.getByRole('button', { name: /Trip To-Dos/ })
|
||||
expect(tile.textContent).toBe('Trip To-Dos')
|
||||
expect(tile.querySelector('[style]')).toHaveStyle({ color: '#68686F' })
|
||||
})
|
||||
|
||||
it('FE-MOB-MEHR-007: survives a tab entry without an icon component', () => {
|
||||
renderSheet({ TRIP_TABS: [{ id: 'plugin:bare', label: 'Bare Plugin', icon: undefined }] as unknown as TripPlanner['TRIP_TABS'] })
|
||||
const tile = screen.getByRole('button', { name: 'Bare Plugin' })
|
||||
expect(tile.querySelector('svg')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-MEHR-008: opening a section closes the sheet and switches the trip tab', () => {
|
||||
const { shell } = renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: /Files/ }))
|
||||
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
|
||||
expect(shell.setTrTab).toHaveBeenCalledWith('dateien')
|
||||
})
|
||||
|
||||
it('FE-MOB-MEHR-009: the action rows open the share, export and edit sheets', () => {
|
||||
const { shell } = renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Share Trip' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Export Calendar' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit Trip' }))
|
||||
expect(shell.openSheet).toHaveBeenNthCalledWith(1, 'members')
|
||||
expect(shell.openSheet).toHaveBeenNthCalledWith(2, 'export')
|
||||
expect(shell.openSheet).toHaveBeenNthCalledWith(3, 'tripedit')
|
||||
})
|
||||
|
||||
it('FE-MOB-MEHR-010: drops the edit row without the trip_edit permission', () => {
|
||||
const { planner } = renderSheet({ can: vi.fn(() => false) })
|
||||
expect(planner.can).toHaveBeenCalledWith('trip_edit', planner.trip)
|
||||
expect(screen.queryByRole('button', { name: 'Edit Trip' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Share Trip' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-MEHR-011: renders only the action rows when every section sits in the dock', () => {
|
||||
renderSheet({
|
||||
TRIP_TABS: TABS.filter(tab => tab.id !== 'dateien' && tab.id !== 'collab') as TripPlanner['TRIP_TABS'],
|
||||
})
|
||||
expect(screen.queryByText(/files$/)).not.toBeInTheDocument()
|
||||
const rows = screen.getByRole('button', { name: 'Share Trip' }).parentElement
|
||||
expect(rows).not.toHaveClass('mt-2')
|
||||
})
|
||||
|
||||
it('FE-MOB-MEHR-012: separates the action rows from the tile grid when both are present', () => {
|
||||
renderSheet()
|
||||
const rows = screen.getByRole('button', { name: 'Share Trip' }).parentElement
|
||||
expect(rows).toHaveClass('mt-2')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,245 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MNoteSheet, { type MNoteSheetPayload } from '../../../../src/mobile/screens/trip/sheets/MNoteSheet'
|
||||
import { useTripStore } from '../../../../src/store/tripStore'
|
||||
import type { Assignment, DayNote } from '../../../../src/types'
|
||||
import { buildPlanner } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-NOTESH-001 to FE-MOB-NOTESH-018
|
||||
// planner.t echoes the key, so every assertion here is against keys.
|
||||
|
||||
const NOTE = {
|
||||
id: 88,
|
||||
day_id: 7,
|
||||
text: 'Buy museum tickets',
|
||||
time: '09:30 at the kiosk',
|
||||
icon: 'Ticket',
|
||||
sort_order: 2,
|
||||
} as unknown as DayNote
|
||||
|
||||
function renderSheet(overrides: {
|
||||
planner?: ReturnType<typeof buildPlanner>
|
||||
open?: boolean
|
||||
payload?: MNoteSheetPayload
|
||||
} = {}) {
|
||||
const planner = overrides.planner ?? buildPlanner({ tripId: 3, selectedDayId: 7 })
|
||||
const onClose = vi.fn()
|
||||
const payload = 'payload' in overrides ? overrides.payload : { dayId: 7 }
|
||||
const view = render(
|
||||
<MNoteSheet planner={planner} open={overrides.open ?? true} payload={payload} onClose={onClose} />,
|
||||
)
|
||||
return { ...view, planner, onClose, payload }
|
||||
}
|
||||
|
||||
function typeTitle(value: string) {
|
||||
fireEvent.change(screen.getByPlaceholderText('dayplan.noteTitle *'), { target: { value } })
|
||||
}
|
||||
|
||||
describe('MNoteSheet', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
})
|
||||
|
||||
it('FE-MOB-NOTESH-001: renders nothing while the sheet is closed', () => {
|
||||
renderSheet({ open: false })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-NOTESH-002: opens in create mode with the default icon and no delete action', () => {
|
||||
renderSheet()
|
||||
expect(screen.getByRole('dialog', { name: 'dayplan.noteAdd' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.add' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'FileText' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByRole('button', { name: 'Train' })).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('FE-MOB-NOTESH-003: picking an icon moves the pressed state', () => {
|
||||
renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Coffee' }))
|
||||
expect(screen.getByRole('button', { name: 'Coffee' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByRole('button', { name: 'FileText' })).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('FE-MOB-NOTESH-004: keeps the submit disabled until a non-blank title is typed', () => {
|
||||
renderSheet()
|
||||
const submit = screen.getByRole('button', { name: 'common.add' })
|
||||
expect(submit).toBeDisabled()
|
||||
typeTitle(' ')
|
||||
expect(submit).toBeDisabled()
|
||||
typeTitle('Ferry tickets')
|
||||
expect(submit).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-NOTESH-005: appends the new note behind the last assignment and note of the day', async () => {
|
||||
seedStore(useTripStore, {
|
||||
assignments: { '7': [{ order_index: 3 }, { order_index: 1 }] as unknown as Assignment[] },
|
||||
dayNotes: { '7': [{ sort_order: 5 }] as unknown as DayNote[] },
|
||||
})
|
||||
const { planner, onClose } = renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Plane' }))
|
||||
typeTitle(' Check in online ')
|
||||
fireEvent.change(screen.getByPlaceholderText('dayplan.noteSubtitle'), { target: { value: '18:00 at the gate' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
|
||||
|
||||
await waitFor(() => expect(planner.tripActions.addDayNote).toHaveBeenCalledTimes(1))
|
||||
expect(planner.tripActions.addDayNote).toHaveBeenCalledWith(3, 7, {
|
||||
text: 'Check in online',
|
||||
time: '18:00 at the gate',
|
||||
icon: 'Plane',
|
||||
sort_order: 6,
|
||||
})
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('FE-MOB-NOTESH-006: starts at sort order 0 on an empty day and stores an empty detail as null', async () => {
|
||||
const { planner } = renderSheet()
|
||||
typeTitle('Sunrise walk')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
|
||||
|
||||
await waitFor(() => expect(planner.tripActions.addDayNote).toHaveBeenCalledTimes(1))
|
||||
expect(planner.tripActions.addDayNote).toHaveBeenCalledWith(3, 7, {
|
||||
text: 'Sunrise walk',
|
||||
time: null,
|
||||
icon: 'FileText',
|
||||
sort_order: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-MOB-NOTESH-007: falls back to the selected day when the payload carries no dayId', async () => {
|
||||
const planner = buildPlanner({ tripId: 3, selectedDayId: 12 })
|
||||
renderSheet({ planner, payload: {} })
|
||||
typeTitle('Laundry')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
|
||||
|
||||
await waitFor(() => expect(planner.tripActions.addDayNote).toHaveBeenCalledTimes(1))
|
||||
expect(planner.tripActions.addDayNote).toHaveBeenCalledWith(3, 12, expect.objectContaining({ text: 'Laundry' }))
|
||||
})
|
||||
|
||||
it('FE-MOB-NOTESH-008: does not save without any day to attach the note to', () => {
|
||||
const planner = buildPlanner({ tripId: 3, selectedDayId: null })
|
||||
renderSheet({ planner, payload: undefined })
|
||||
typeTitle('Orphan note')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
|
||||
expect(planner.tripActions.addDayNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-NOTESH-009: prefills icon, title and detail when editing an existing note', () => {
|
||||
renderSheet({ payload: { dayId: 7, note: NOTE } })
|
||||
expect(screen.getByRole('dialog', { name: 'dayplan.noteEdit' })).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('dayplan.noteTitle *')).toHaveValue('Buy museum tickets')
|
||||
expect(screen.getByPlaceholderText('dayplan.noteSubtitle')).toHaveValue('09:30 at the kiosk')
|
||||
expect(screen.getByRole('button', { name: 'Ticket' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByRole('button', { name: 'common.save' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.delete' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-NOTESH-010: updates the existing note instead of creating a second one', async () => {
|
||||
const { planner, onClose } = renderSheet({ payload: { dayId: 7, note: NOTE } })
|
||||
typeTitle('Buy museum tickets online')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.save' }))
|
||||
|
||||
await waitFor(() => expect(planner.tripActions.updateDayNote).toHaveBeenCalledTimes(1))
|
||||
expect(planner.tripActions.updateDayNote).toHaveBeenCalledWith(3, 7, 88, {
|
||||
text: 'Buy museum tickets online',
|
||||
time: '09:30 at the kiosk',
|
||||
icon: 'Ticket',
|
||||
})
|
||||
expect(planner.tripActions.addDayNote).not.toHaveBeenCalled()
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('FE-MOB-NOTESH-011: deletes the note and closes the sheet', async () => {
|
||||
const { planner, onClose } = renderSheet({ payload: { dayId: 7, note: NOTE } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
|
||||
|
||||
await waitFor(() => expect(planner.tripActions.deleteDayNote).toHaveBeenCalledWith(3, 7, 88))
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('FE-MOB-NOTESH-012: surfaces the save error and keeps the sheet open', async () => {
|
||||
const planner = buildPlanner({ tripId: 3, selectedDayId: 7 })
|
||||
vi.mocked(planner.tripActions.addDayNote).mockRejectedValueOnce(new Error('day is locked'))
|
||||
const { onClose } = renderSheet({ planner })
|
||||
typeTitle('Sunrise walk')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
|
||||
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('day is locked'))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
// isSaving is released again, so a retry is possible.
|
||||
expect(screen.getByRole('button', { name: 'common.add' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-NOTESH-013: falls back to the generic message when the rejection is not an Error', async () => {
|
||||
const planner = buildPlanner({ tripId: 3, selectedDayId: 7 })
|
||||
vi.mocked(planner.tripActions.addDayNote).mockRejectedValueOnce('nope')
|
||||
renderSheet({ planner })
|
||||
typeTitle('Sunrise walk')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
|
||||
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.unknownError'))
|
||||
})
|
||||
|
||||
it('FE-MOB-NOTESH-014: surfaces a failing delete without closing', async () => {
|
||||
const planner = buildPlanner({ tripId: 3, selectedDayId: 7 })
|
||||
vi.mocked(planner.tripActions.deleteDayNote).mockRejectedValueOnce(new Error('gone already'))
|
||||
const { onClose } = renderSheet({ planner, payload: { dayId: 7, note: NOTE } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
|
||||
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('gone already'))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-NOTESH-015: counts the detail characters and warns near the limit', () => {
|
||||
renderSheet()
|
||||
const detail = screen.getByPlaceholderText('dayplan.noteSubtitle')
|
||||
expect(screen.getByText('0/250')).toBeInTheDocument()
|
||||
fireEvent.change(detail, { target: { value: 'a'.repeat(100) } })
|
||||
const relaxed = screen.getByText('100/250')
|
||||
expect(relaxed.className).toContain('text-m-faint')
|
||||
|
||||
fireEvent.change(detail, { target: { value: 'a'.repeat(241) } })
|
||||
const warned = screen.getByText('241/250')
|
||||
expect(warned.className).toContain('--m-st-pending')
|
||||
})
|
||||
|
||||
it('FE-MOB-NOTESH-016: cancel and the header close both report the close', () => {
|
||||
const { onClose } = renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
|
||||
expect(onClose).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-NOTESH-017: keeps the opened payload while the sheet animates out', () => {
|
||||
const planner = buildPlanner({ tripId: 3, selectedDayId: 7 })
|
||||
const onClose = vi.fn()
|
||||
const payload: MNoteSheetPayload = { dayId: 7, note: NOTE }
|
||||
const { rerender } = render(
|
||||
<MNoteSheet planner={planner} open payload={payload} onClose={onClose} />,
|
||||
)
|
||||
rerender(<MNoteSheet planner={planner} open={false} payload={undefined} onClose={onClose} />)
|
||||
// The parent drops the payload with the sheet id, but the exit animation
|
||||
// still shows the edit chrome.
|
||||
expect(screen.getByRole('button', { name: 'common.delete' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('dialog', { name: 'dayplan.noteEdit' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-NOTESH-018: reopening on another note replaces the form state', () => {
|
||||
const planner = buildPlanner({ tripId: 3, selectedDayId: 7 })
|
||||
const onClose = vi.fn()
|
||||
const first: MNoteSheetPayload = { dayId: 7, note: NOTE }
|
||||
const second: MNoteSheetPayload = {
|
||||
dayId: 9,
|
||||
note: { id: 90, day_id: 9, text: 'Return the car', time: '', icon: 'Car' } as unknown as DayNote,
|
||||
}
|
||||
const { rerender } = render(
|
||||
<MNoteSheet planner={planner} open payload={first} onClose={onClose} />,
|
||||
)
|
||||
rerender(<MNoteSheet planner={planner} open payload={second} onClose={onClose} />)
|
||||
|
||||
expect(screen.getByPlaceholderText('dayplan.noteTitle *')).toHaveValue('Return the car')
|
||||
expect(screen.getByPlaceholderText('dayplan.noteSubtitle')).toHaveValue('')
|
||||
expect(screen.getByRole('button', { name: 'Car' })).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,239 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ComponentProps } from 'react'
|
||||
import MPackItemSheet from '../../../../src/mobile/screens/trip/tabs/MPackItemSheet'
|
||||
import type { PackingItem, TripMember } from '../../../../src/types'
|
||||
import { buildPlanner } from '../../../helpers/mobileTrip'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-PACKITEM-001 to FE-MOB-PACKITEM-020
|
||||
|
||||
const ME = 7
|
||||
const ANNA = { id: 11, username: 'anna', avatar: 'anna.png', avatar_url: null } as unknown as TripMember
|
||||
const BEN = { id: 12, username: 'ben', avatar: null, avatar_url: 'https://cdn.example/ben.png' } as unknown as TripMember
|
||||
const OWNER = { id: 21, username: 'owner', avatar: null, avatar_url: null } as unknown as TripMember
|
||||
|
||||
function packItem(overrides: Partial<PackingItem> = {}): PackingItem {
|
||||
return {
|
||||
id: 1, trip_id: 3, name: 'Rain jacket', checked: 0, category: 'Clothing', sort_order: 0,
|
||||
quantity: 2, weight_grams: 450, is_private: 0, owner_id: ME,
|
||||
...overrides,
|
||||
} as PackingItem
|
||||
}
|
||||
|
||||
function setup(overrides: Partial<ComponentProps<typeof MPackItemSheet>> = {}, items: PackingItem[] = [packItem()]) {
|
||||
const planner = buildPlanner({ tripId: 3, packingItems: items })
|
||||
const props: ComponentProps<typeof MPackItemSheet> = {
|
||||
planner,
|
||||
open: true,
|
||||
itemId: 1,
|
||||
bagTrackingEnabled: true,
|
||||
tripMembers: [ANNA, BEN],
|
||||
currentUserId: ME,
|
||||
onClose: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
const view = render(<MPackItemSheet {...props} />)
|
||||
return { ...props, planner, view }
|
||||
}
|
||||
|
||||
describe('MPackItemSheet', () => {
|
||||
it('FE-MOB-PACKITEM-001: renders nothing without a resolvable item', () => {
|
||||
setup({ itemId: null })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKITEM-002: renders nothing when the id is unknown', () => {
|
||||
setup({ itemId: 99 })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKITEM-003: prefills name, quantity and weight', () => {
|
||||
setup()
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute('aria-label', 'packing.editItem')
|
||||
expect(screen.getByText('Clothing')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Rain jacket')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('2')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('450')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKITEM-004: falls back to the default category in the subtitle', () => {
|
||||
setup({}, [packItem({ category: null })])
|
||||
expect(screen.getByText('packing.defaultCategory')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKITEM-005: hides the weight field when bag tracking is off', () => {
|
||||
setup({ bagTrackingEnabled: false })
|
||||
expect(screen.queryByText('packing.itemWeight')).toBeNull()
|
||||
expect(screen.queryByDisplayValue('450')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKITEM-006: starts a placeholder row with an empty name and no sharing block', () => {
|
||||
setup({}, [packItem({ name: '...', quantity: 1, weight_grams: null })])
|
||||
const name = screen.getByPlaceholderText('packing.addItemPlaceholder')
|
||||
expect(name).toHaveValue('')
|
||||
expect(screen.getByRole('button', { name: 'common.save' })).toBeDisabled()
|
||||
expect(screen.queryByText('packing.share')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKITEM-007: strips non-digits from quantity and weight', () => {
|
||||
setup()
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '1a2' } })
|
||||
fireEvent.change(screen.getByDisplayValue('450'), { target: { value: '4x5' } })
|
||||
|
||||
expect(screen.getByDisplayValue('12')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('45')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKITEM-008: saves name, clamped quantity and weight', async () => {
|
||||
const { planner, onClose } = setup()
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('Rain jacket'), { target: { value: ' Poncho ' } })
|
||||
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '1500' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.save' }))
|
||||
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled())
|
||||
expect(planner.tripActions.updatePackingItem).toHaveBeenCalledWith(3, 1, {
|
||||
name: 'Poncho', quantity: 999, weight_grams: 450,
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKITEM-009: treats an empty quantity as 1 and an empty weight as null', async () => {
|
||||
const { planner } = setup()
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '' } })
|
||||
fireEvent.change(screen.getByDisplayValue('450'), { target: { value: '' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.save' }))
|
||||
|
||||
await waitFor(() => expect(planner.tripActions.updatePackingItem).toHaveBeenCalledWith(3, 1, {
|
||||
name: 'Rain jacket', quantity: 1, weight_grams: null,
|
||||
}))
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKITEM-010: omits weight entirely without bag tracking', async () => {
|
||||
const { planner } = setup({ bagTrackingEnabled: false })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.save' }))
|
||||
|
||||
await waitFor(() => expect(planner.tripActions.updatePackingItem).toHaveBeenCalledWith(3, 1, {
|
||||
name: 'Rain jacket', quantity: 2,
|
||||
}))
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKITEM-011: reports a failed save and keeps the sheet open', async () => {
|
||||
const { planner, onClose } = setup()
|
||||
const update = planner.tripActions.updatePackingItem as unknown as ReturnType<typeof vi.fn>
|
||||
update.mockRejectedValueOnce(new Error('nope'))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.save' }))
|
||||
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('packing.toast.saveError'))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKITEM-012: disables save for a blank name', () => {
|
||||
setup()
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('Rain jacket'), { target: { value: ' ' } })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'common.save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKITEM-013: keeps rendering the last item after it disappears from the store', () => {
|
||||
const { view, planner, ...props } = setup()
|
||||
const drained = buildPlanner({ tripId: 3, packingItems: [], t: planner.t })
|
||||
|
||||
view.rerender(<MPackItemSheet {...props} planner={drained} />)
|
||||
|
||||
expect(screen.getByDisplayValue('Rain jacket')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKITEM-014: owner switches the item to personal and back to common', () => {
|
||||
const { planner } = setup()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.tierPersonal' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.viewCommon' }))
|
||||
|
||||
expect(planner.tripActions.setPackingItemSharing).toHaveBeenNthCalledWith(1, 3, 1, 'personal', [])
|
||||
expect(planner.tripActions.setPackingItemSharing).toHaveBeenNthCalledWith(2, 3, 1, 'common', [])
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKITEM-015: owner adds and removes a share recipient', () => {
|
||||
const item = packItem({ is_private: 1, recipients: [{ user_id: 11, username: 'anna' }] })
|
||||
const { planner } = setup({}, [item])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'ben' }))
|
||||
expect(planner.tripActions.setPackingItemSharing).toHaveBeenCalledWith(3, 1, 'shared', [11, 12])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'anna' }))
|
||||
expect(planner.tripActions.setPackingItemSharing).toHaveBeenCalledWith(3, 1, 'shared', [])
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKITEM-016: renders member avatars and initials in the share picker', () => {
|
||||
const { view } = setup()
|
||||
const srcs = Array.from(view.baseElement.querySelectorAll('img')).map(i => i.getAttribute('src'))
|
||||
expect(srcs).toContain('/uploads/avatars/anna.png')
|
||||
expect(srcs).toContain('https://cdn.example/ben.png')
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKITEM-017: tells the owner when there is nobody to share with', () => {
|
||||
setup({ tripMembers: [] })
|
||||
expect(screen.getByText('packing.noOneToShare')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKITEM-018: a non-owner can pledge to bring a common item too', () => {
|
||||
const item = packItem({ owner_id: 21, owner_username: 'owner' })
|
||||
const { planner } = setup({ tripMembers: [ANNA, BEN, OWNER] }, [item])
|
||||
|
||||
expect(screen.getByText('packing.broughtBy:owner')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.alsoBring' }))
|
||||
|
||||
expect(planner.tripActions.addPackingContributor).toHaveBeenCalledWith(3, 1)
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKITEM-019: a pledged contributor can withdraw and can clone the item', () => {
|
||||
const item = packItem({
|
||||
owner_id: 21, owner_username: 'owner',
|
||||
contributors: [{ user_id: ME, username: 'me', status: 'pledged' }],
|
||||
})
|
||||
const { planner, onClose } = setup({}, [item])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.alsoBringingStop' }))
|
||||
expect(planner.tripActions.removePackingContributor).toHaveBeenCalledWith(3, 1, ME)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.cloneToMine' }))
|
||||
expect(planner.tripActions.clonePackingItem).toHaveBeenCalledWith(3, 1)
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKITEM-020: a recipient sees a read-only note instead of the tier pills', () => {
|
||||
const item = packItem({ is_private: 1, owner_id: 21, owner_username: 'owner' })
|
||||
setup({}, [item])
|
||||
|
||||
expect(screen.getByText('packing.takenCareOf:owner')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'packing.tierPersonal' })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKITEM-021: falls back to the generic hint when the owner is unknown', () => {
|
||||
const item = packItem({ is_private: 1, owner_id: 21, owner_username: null })
|
||||
setup({}, [item])
|
||||
|
||||
expect(screen.getByText('packing.tierPersonalHint')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKITEM-022: an unowned item is treated as the viewer\'s own', () => {
|
||||
const item = packItem({ owner_id: null })
|
||||
setup({}, [item])
|
||||
|
||||
expect(screen.getByRole('button', { name: 'packing.tierPersonal' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKITEM-023: closes from the header and the cancel button', () => {
|
||||
const { onClose } = setup()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { packingApi } from '../../../../src/api/client'
|
||||
import MPackingImportSheet from '../../../../src/mobile/screens/trip/tabs/MPackingImportSheet'
|
||||
import { useTripStore } from '../../../../src/store/tripStore'
|
||||
import type { PackingItem } from '../../../../src/types'
|
||||
import { buildPlanner } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-PACKIMP-001 to FE-MOB-PACKIMP-012
|
||||
|
||||
function packItem(overrides: Partial<PackingItem> = {}): PackingItem {
|
||||
return {
|
||||
id: 1, trip_id: 1, name: 'Passport', checked: 0, category: 'Documents', sort_order: 0,
|
||||
...overrides,
|
||||
} as PackingItem
|
||||
}
|
||||
|
||||
function setup(text?: string) {
|
||||
const onClose = vi.fn()
|
||||
const planner = buildPlanner({ tripId: 7 })
|
||||
const view = render(<MPackingImportSheet planner={planner} open onClose={onClose} />)
|
||||
if (text != null) {
|
||||
fireEvent.change(screen.getByPlaceholderText('packing.importPlaceholder'), { target: { value: text } })
|
||||
}
|
||||
return { onClose, planner, view }
|
||||
}
|
||||
|
||||
describe('MPackingImportSheet', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
seedStore(useTripStore, { packingItems: [packItem()] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKIMP-001: stays unmounted while closed', () => {
|
||||
const planner = buildPlanner()
|
||||
render(<MPackingImportSheet planner={planner} open={false} onClose={vi.fn()} />)
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKIMP-002: renders the hint, textarea and a disabled zero-count submit', () => {
|
||||
setup()
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute('aria-label', 'packing.importTitle')
|
||||
expect(screen.getByText('packing.importHint')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('packing.importPlaceholder')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'packing.importAction:0' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKIMP-003: counts the parsed lines in the submit label', () => {
|
||||
setup('Documents, Passport, 30\nClothing, Socks\n\n')
|
||||
expect(screen.getByRole('button', { name: 'packing.importAction:2' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKIMP-004: warns when text parses to nothing', () => {
|
||||
setup(',')
|
||||
expect(screen.getByText('packing.importEmpty')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'packing.importAction:0' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKIMP-005: keeps the warning hidden for blank input', () => {
|
||||
setup(' ')
|
||||
expect(screen.queryByText('packing.importEmpty')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKIMP-006: posts the parsed items and appends the response to the store', async () => {
|
||||
const imported = [packItem({ id: 50, name: 'Socks', category: 'Clothing' })]
|
||||
const spy = vi.spyOn(packingApi, 'bulkImport').mockResolvedValue({ items: imported, count: 1 })
|
||||
const { onClose, planner } = setup('Clothing, Socks, 120, Backpack, checked')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.importAction:1' }))
|
||||
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled())
|
||||
expect(spy).toHaveBeenCalledWith(7, [
|
||||
{ name: 'Socks', category: 'Clothing', weight_grams: '120', bag: 'Backpack', checked: true },
|
||||
])
|
||||
expect(planner.toast.success).toHaveBeenCalledWith('packing.importSuccess:1')
|
||||
expect(useTripStore.getState().packingItems.map(i => i.id)).toEqual([1, 50])
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKIMP-007: clears the textarea after a successful import', async () => {
|
||||
vi.spyOn(packingApi, 'bulkImport').mockResolvedValue({ items: [], count: 0 })
|
||||
setup('Socks')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.importAction:1' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('packing.importPlaceholder')).toHaveValue(''))
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKIMP-008: reports a failed import and keeps the sheet open', async () => {
|
||||
vi.spyOn(packingApi, 'bulkImport').mockRejectedValue(new Error('boom'))
|
||||
const { onClose, planner } = setup('Socks')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.importAction:1' }))
|
||||
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('packing.importError'))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(screen.getByPlaceholderText('packing.importPlaceholder')).toHaveValue('Socks')
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKIMP-009: opens the file picker from the CSV button', () => {
|
||||
const click = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {})
|
||||
setup()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.importCsv' }))
|
||||
|
||||
expect(click).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKIMP-010: fills the textarea from a picked file', async () => {
|
||||
const { view } = setup()
|
||||
const input = view.container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const file = new File(['Clothing, Socks\nClothing, Shirt'], 'list.csv', { type: 'text/csv' })
|
||||
|
||||
fireEvent.change(input, { target: { files: [file] } })
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByPlaceholderText('packing.importPlaceholder')).toHaveValue('Clothing, Socks\nClothing, Shirt'),
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'packing.importAction:2' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKIMP-011: ignores a change event without a file', () => {
|
||||
const { view } = setup()
|
||||
const input = view.container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement
|
||||
|
||||
fireEvent.change(input, { target: { files: [] } })
|
||||
|
||||
expect(screen.getByPlaceholderText('packing.importPlaceholder')).toHaveValue('')
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKIMP-012: closes on cancel without importing', () => {
|
||||
const spy = vi.spyOn(packingApi, 'bulkImport')
|
||||
const { onClose } = setup('Socks')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
|
||||
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,891 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RenderResult } from '@testing-library/react'
|
||||
import { packingApi } from '../../../../src/api/client'
|
||||
import MPackingListTab from '../../../../src/mobile/screens/trip/tabs/MPackingListTab'
|
||||
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { useAddonStore } from '../../../../src/store/addonStore'
|
||||
import { useAuthStore } from '../../../../src/store/authStore'
|
||||
import { useTripStore } from '../../../../src/store/tripStore'
|
||||
import type { PackingBag, PackingItem, TripMember } from '../../../../src/types'
|
||||
import { buildUser } from '../../../helpers/factories'
|
||||
import { buildPlanner } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-PACKTAB-001 to FE-MOB-PACKTAB-046
|
||||
|
||||
const ME = 7
|
||||
const ANNA = { id: 11, username: 'anna', avatar: null, avatar_url: 'https://cdn.example/anna.png' } as unknown as TripMember
|
||||
const BEN = { id: 12, username: 'ben', avatar: null, avatar_url: null } as unknown as TripMember
|
||||
|
||||
function packItem(overrides: Partial<PackingItem> = {}): PackingItem {
|
||||
return {
|
||||
id: 1, trip_id: 3, name: 'Passport', checked: 0, category: 'Documents', sort_order: 0,
|
||||
is_private: 0, owner_id: null,
|
||||
...overrides,
|
||||
} as PackingItem
|
||||
}
|
||||
|
||||
const ITEMS: PackingItem[] = [
|
||||
packItem({ id: 1, name: 'Passport', category: 'Documents', checked: 1 }),
|
||||
packItem({ id: 2, name: 'Visa', category: 'Documents' }),
|
||||
packItem({ id: 3, name: 'Socks', category: 'Clothing', quantity: 3, weight_grams: 120 }),
|
||||
packItem({ id: 4, name: 'Shirt', category: 'Clothing', checked: 1 }),
|
||||
packItem({ id: 5, name: 'Diary', category: 'Private', is_private: 1, owner_id: ME }),
|
||||
]
|
||||
|
||||
const BAGS: PackingBag[] = [
|
||||
{ id: 71, trip_id: 3, name: 'Backpack', color: '#6366f1', sort_order: 0, members: [] } as PackingBag,
|
||||
]
|
||||
|
||||
const TEMPLATES = [{ id: 91, name: 'Beach trip', item_count: 12 }]
|
||||
|
||||
/** The tab appends this zero-width space to keep a duplicate category name distinguishable. */
|
||||
const ZERO_WIDTH = ''
|
||||
|
||||
interface SetupOptions {
|
||||
items?: PackingItem[]
|
||||
planner?: Partial<TripPlanner>
|
||||
admin?: boolean
|
||||
bagTracking?: boolean
|
||||
}
|
||||
|
||||
async function setup(opts: SetupOptions = {}) {
|
||||
seedStore(useAuthStore, { user: buildUser({ id: ME, role: opts.admin ? 'admin' : 'user' }) })
|
||||
if (opts.bagTracking) seedStore(useAddonStore, { bagTracking: true })
|
||||
const planner = buildPlanner({ tripId: 3, packingItems: opts.items ?? ITEMS, ...opts.planner })
|
||||
let view!: RenderResult
|
||||
await act(async () => { view = render(<MPackingListTab planner={planner} />) })
|
||||
return { planner, view }
|
||||
}
|
||||
|
||||
/** The category card that owns the given title. */
|
||||
function card(category: string): HTMLElement {
|
||||
return screen.getByText(category).closest('div.rounded-2xl') as HTMLElement
|
||||
}
|
||||
|
||||
/** The row of an item, found via its checkbox (aria-label = item name). */
|
||||
function itemRow(name: string): HTMLElement {
|
||||
return screen.getByRole('button', { name }).parentElement as HTMLElement
|
||||
}
|
||||
|
||||
function openActions() {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.actions' }))
|
||||
}
|
||||
|
||||
function enterEditMode() {
|
||||
fireEvent.click(screen.getByRole('button', { name: /common.edit/ }))
|
||||
}
|
||||
|
||||
function openCategoryMenu(category: string) {
|
||||
fireEvent.click(within(card(category)).getByRole('button', { name: 'packing.categoryOptions' }))
|
||||
}
|
||||
|
||||
function asMock(fn: unknown): ReturnType<typeof vi.fn> {
|
||||
return fn as ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
describe('MPackingListTab', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
seedStore(useTripStore, { packingItems: [] })
|
||||
vi.spyOn(packingApi, 'listBags').mockResolvedValue({ bags: BAGS })
|
||||
vi.spyOn(packingApi, 'listTemplates').mockResolvedValue({ templates: TEMPLATES })
|
||||
vi.spyOn(packingApi, 'getCategoryAssignees').mockResolvedValue({ assignees: {} })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// ── Progress card + chrome ────────────────────────────────────────────
|
||||
|
||||
it('FE-MOB-PACKTAB-001: shows checked/total and the percentage of the common list', async () => {
|
||||
await setup()
|
||||
expect(screen.getByText('2/4')).toBeInTheDocument()
|
||||
expect(screen.getByText('50%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-002: hides the bags button while bag tracking is off', async () => {
|
||||
await setup()
|
||||
expect(screen.queryByRole('button', { name: 'packing.bags' })).toBeNull()
|
||||
expect(packingApi.listBags).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-003: loads the bags and opens the bags sheet', async () => {
|
||||
await setup({ bagTracking: true })
|
||||
expect(packingApi.listBags).toHaveBeenCalledWith(3)
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'packing.bags' })[0])
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(dialog).toHaveAttribute('aria-label', 'packing.bags')
|
||||
expect(within(dialog).getByText('Backpack')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-004: toggles edit mode', async () => {
|
||||
await setup()
|
||||
const toggle = screen.getByRole('button', { name: 'common.edit' })
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
||||
|
||||
fireEvent.click(toggle)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'packing.editDone' })).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-005: read-only members get neither edit nor the action menu', async () => {
|
||||
await setup({ planner: { can: vi.fn(() => false) } as Partial<TripPlanner> })
|
||||
expect(screen.queryByRole('button', { name: 'common.edit' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'packing.actions' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'packing.addCategory' })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-006: survives failing mount requests', async () => {
|
||||
vi.mocked(packingApi.listBags).mockRejectedValue(new Error('x'))
|
||||
vi.mocked(packingApi.listTemplates).mockRejectedValue(new Error('x'))
|
||||
vi.mocked(packingApi.getCategoryAssignees).mockRejectedValue(new Error('x'))
|
||||
|
||||
await setup({ bagTracking: true })
|
||||
openActions()
|
||||
|
||||
expect(screen.getByText('2/4')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'packing.import' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'packing.applyTemplate' })).toBeNull()
|
||||
})
|
||||
|
||||
// ── Action menu ───────────────────────────────────────────────────────
|
||||
|
||||
it('FE-MOB-PACKTAB-007: the action menu offers clear/apply/import to a non-admin', async () => {
|
||||
await setup()
|
||||
openActions()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'packing.clearChecked:2' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'packing.applyTemplate' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'packing.import' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'packing.saveAsTemplate' })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-008: admins additionally get save-as-template', async () => {
|
||||
await setup({ admin: true })
|
||||
openActions()
|
||||
expect(screen.getByRole('button', { name: 'packing.saveAsTemplate' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-009: hides clear + apply when nothing is checked and no template exists', async () => {
|
||||
vi.mocked(packingApi.listTemplates).mockResolvedValue({ templates: [] })
|
||||
await setup({ items: [packItem({ id: 1, name: 'Visa' })] })
|
||||
openActions()
|
||||
|
||||
expect(screen.queryByRole('button', { name: /packing.clearChecked/ })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'packing.applyTemplate' })).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'packing.import' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-010: clearing checked items deletes each of them after confirmation', async () => {
|
||||
const { planner } = await setup()
|
||||
openActions()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.clearChecked:2' }))
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(within(dialog).getByText('packing.confirm.clearChecked:2')).toBeInTheDocument()
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'common.delete' }))
|
||||
|
||||
await waitFor(() => expect(planner.tripActions.deletePackingItem).toHaveBeenCalledTimes(2))
|
||||
expect(planner.tripActions.deletePackingItem).toHaveBeenCalledWith(3, 1)
|
||||
expect(planner.tripActions.deletePackingItem).toHaveBeenCalledWith(3, 4)
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-011: a failed clear surfaces the delete error', async () => {
|
||||
const { planner } = await setup()
|
||||
asMock(planner.tripActions.deletePackingItem).mockRejectedValueOnce(new Error('x'))
|
||||
openActions()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.clearChecked:2' }))
|
||||
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'common.delete' }))
|
||||
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('packing.toast.deleteError'))
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-012: cancelling the clear dialog deletes nothing', async () => {
|
||||
const { planner } = await setup()
|
||||
openActions()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.clearChecked:2' }))
|
||||
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'common.cancel' }))
|
||||
|
||||
expect(planner.tripActions.deletePackingItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-013: applies a template and appends its items to the store', async () => {
|
||||
const applied = [packItem({ id: 30, name: 'Towel', category: 'Beach' })]
|
||||
vi.spyOn(packingApi, 'applyTemplate').mockResolvedValue({ items: applied, count: 1 })
|
||||
const { planner } = await setup()
|
||||
|
||||
openActions()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.applyTemplate' }))
|
||||
expect(screen.getByText('12 admin.packingTemplates.items')).toBeInTheDocument()
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Beach trip/ })) })
|
||||
|
||||
expect(packingApi.applyTemplate).toHaveBeenCalledWith(3, 91)
|
||||
expect(planner.toast.success).toHaveBeenCalledWith('packing.templateApplied:1')
|
||||
expect(useTripStore.getState().packingItems.map(i => i.id)).toEqual([30])
|
||||
expect(screen.queryByRole('button', { name: /Beach trip/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-014: reports a failing template', async () => {
|
||||
vi.spyOn(packingApi, 'applyTemplate').mockRejectedValue(new Error('x'))
|
||||
const { planner } = await setup()
|
||||
|
||||
openActions()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.applyTemplate' }))
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Beach trip/ })) })
|
||||
|
||||
expect(planner.toast.error).toHaveBeenCalledWith('packing.templateError')
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-015: saves the list as a template and reloads the template list', async () => {
|
||||
vi.spyOn(packingApi, 'saveAsTemplate').mockResolvedValue({ ok: true })
|
||||
const { planner } = await setup({ admin: true })
|
||||
|
||||
openActions()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.saveAsTemplate' }))
|
||||
expect(screen.getByRole('button', { name: 'common.save' })).toBeDisabled()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('packing.templateName'), { target: { value: ' Summer ' } })
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'common.save' })) })
|
||||
|
||||
expect(packingApi.saveAsTemplate).toHaveBeenCalledWith(3, 'Summer')
|
||||
expect(planner.toast.success).toHaveBeenCalledWith('packing.templateSaved')
|
||||
expect(packingApi.listTemplates).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-016: Enter submits the template name, blank input does nothing', async () => {
|
||||
vi.spyOn(packingApi, 'saveAsTemplate').mockResolvedValue({ ok: true })
|
||||
await setup({ admin: true })
|
||||
|
||||
openActions()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.saveAsTemplate' }))
|
||||
const input = screen.getByPlaceholderText('packing.templateName')
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(packingApi.saveAsTemplate).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'Winter' } })
|
||||
await act(async () => { fireEvent.keyDown(input, { key: 'Enter' }) })
|
||||
|
||||
expect(packingApi.saveAsTemplate).toHaveBeenCalledWith(3, 'Winter')
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-017: reports a failing template save', async () => {
|
||||
vi.spyOn(packingApi, 'saveAsTemplate').mockRejectedValue(new Error('x'))
|
||||
const { planner } = await setup({ admin: true })
|
||||
|
||||
openActions()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.saveAsTemplate' }))
|
||||
fireEvent.change(screen.getByPlaceholderText('packing.templateName'), { target: { value: 'Summer' } })
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'common.save' })) })
|
||||
|
||||
expect(planner.toast.error).toHaveBeenCalledWith('common.error')
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-018: the import row opens the import sheet', async () => {
|
||||
await setup()
|
||||
openActions()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.import' }))
|
||||
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute('aria-label', 'packing.importTitle')
|
||||
})
|
||||
|
||||
// ── Filters + empty states ────────────────────────────────────────────
|
||||
|
||||
it('FE-MOB-PACKTAB-019: the personal view shows only private items', async () => {
|
||||
await setup()
|
||||
expect(screen.queryByText('Diary')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.viewPersonal' }))
|
||||
|
||||
expect(screen.getByText('Diary')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Passport')).toBeNull()
|
||||
expect(screen.getByText('0%')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.viewCommon' }))
|
||||
|
||||
expect(screen.getByText('Passport')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Diary')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-020: an empty personal list shows its own hint', async () => {
|
||||
await setup({ items: ITEMS.filter(i => !i.is_private) })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.viewPersonal' }))
|
||||
|
||||
expect(screen.getByText('packing.personalEmptyHint')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-021: the open/done filters narrow the categories', async () => {
|
||||
await setup()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.filterDone' }))
|
||||
expect(screen.getByText('Passport')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Visa')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.filterOpen' }))
|
||||
expect(screen.getByText('Visa')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Passport')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.filterAll' }))
|
||||
expect(screen.getByText('Passport')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-022: a filter that matches nothing shows the filtered-empty hint', async () => {
|
||||
await setup({ items: [packItem({ id: 1, name: 'Visa' })] })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.filterDone' }))
|
||||
|
||||
expect(screen.getByText('packing.emptyFiltered')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-023: an empty list shows the mascot empty state and no filters', async () => {
|
||||
await setup({ items: [] })
|
||||
|
||||
expect(screen.getByText('packing.emptyTitle')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'packing.filterAll' })).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'packing.addCategory' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Categories ────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-MOB-PACKTAB-024: adds a category as a placeholder item', async () => {
|
||||
const { planner } = await setup({ items: [] })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.addCategory' }))
|
||||
expect(screen.getByRole('button', { name: 'common.add' })).toBeDisabled()
|
||||
fireEvent.change(screen.getByPlaceholderText('packing.newCategoryPlaceholder'), { target: { value: ' Gear ' } })
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'common.add' })) })
|
||||
|
||||
expect(planner.tripActions.addPackingItem).toHaveBeenCalledWith(3, {
|
||||
name: '...', category: 'Gear', visibility: 'common',
|
||||
})
|
||||
expect(screen.getByRole('button', { name: 'packing.addCategory' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-025: a duplicate category name is disambiguated', async () => {
|
||||
const { planner } = await setup()
|
||||
enterEditMode()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.addCategory' }))
|
||||
const input = screen.getByPlaceholderText('packing.newCategoryPlaceholder')
|
||||
fireEvent.change(input, { target: { value: 'Documents' } })
|
||||
await act(async () => { fireEvent.keyDown(input, { key: 'Enter' }) })
|
||||
|
||||
expect(planner.tripActions.addPackingItem).toHaveBeenCalledWith(3, {
|
||||
name: '...', category: `Documents${ZERO_WIDTH}`, visibility: 'common',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-026: a category added from the personal view is private', async () => {
|
||||
const { planner } = await setup()
|
||||
enterEditMode()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.viewPersonal' }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.addCategory' }))
|
||||
fireEvent.change(screen.getByPlaceholderText('packing.newCategoryPlaceholder'), { target: { value: 'Books' } })
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'common.add' })) })
|
||||
|
||||
expect(planner.tripActions.addPackingItem).toHaveBeenCalledWith(3, {
|
||||
name: '...', category: 'Books', visibility: 'personal',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-027: Escape aborts the new-category field and errors are reported', async () => {
|
||||
const { planner } = await setup({ items: [] })
|
||||
asMock(planner.tripActions.addPackingItem).mockRejectedValueOnce(new Error('x'))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.addCategory' }))
|
||||
fireEvent.change(screen.getByPlaceholderText('packing.newCategoryPlaceholder'), { target: { value: 'Gear' } })
|
||||
await act(async () => { fireEvent.keyDown(screen.getByPlaceholderText('packing.newCategoryPlaceholder'), { key: 'Enter' }) })
|
||||
expect(planner.toast.error).toHaveBeenCalledWith('packing.toast.addError')
|
||||
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('packing.newCategoryPlaceholder'), { key: 'Escape' })
|
||||
expect(screen.getByRole('button', { name: 'packing.addCategory' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-028: collapses a category by click and by keyboard', async () => {
|
||||
await setup()
|
||||
const header = within(card('Documents')).getByRole('button', { expanded: true })
|
||||
|
||||
fireEvent.click(header)
|
||||
expect(screen.queryByText('Passport')).toBeNull()
|
||||
|
||||
fireEvent.keyDown(header, { key: 'Enter' })
|
||||
expect(screen.getByText('Passport')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(header, { key: ' ' })
|
||||
expect(screen.queryByText('Passport')).toBeNull()
|
||||
|
||||
fireEvent.keyDown(header, { key: 'a' })
|
||||
expect(screen.queryByText('Passport')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-029: renames a category across all its items', async () => {
|
||||
const { planner } = await setup()
|
||||
enterEditMode()
|
||||
openCategoryMenu('Documents')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.menuRename' }))
|
||||
const input = screen.getByDisplayValue('Documents')
|
||||
fireEvent.click(input)
|
||||
fireEvent.change(input, { target: { value: 'Papers' } })
|
||||
await act(async () => { fireEvent.keyDown(input, { key: 'Enter' }) })
|
||||
|
||||
expect(planner.tripActions.updatePackingItem).toHaveBeenCalledWith(3, 1, { category: 'Papers' })
|
||||
expect(planner.tripActions.updatePackingItem).toHaveBeenCalledWith(3, 2, { category: 'Papers' })
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-030: an unchanged or blank rename is dropped, Escape reverts', async () => {
|
||||
const { planner } = await setup()
|
||||
enterEditMode()
|
||||
openCategoryMenu('Documents')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.menuRename' }))
|
||||
fireEvent.blur(screen.getByDisplayValue('Documents'))
|
||||
expect(planner.tripActions.updatePackingItem).not.toHaveBeenCalled()
|
||||
|
||||
openCategoryMenu('Documents')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.menuRename' }))
|
||||
const input = screen.getByDisplayValue('Documents')
|
||||
fireEvent.change(input, { target: { value: 'Nope' } })
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
|
||||
expect(planner.tripActions.updatePackingItem).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Documents')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-031: a failing rename surfaces the rename error', async () => {
|
||||
const { planner } = await setup()
|
||||
asMock(planner.tripActions.updatePackingItem).mockRejectedValueOnce(new Error('x'))
|
||||
enterEditMode()
|
||||
openCategoryMenu('Documents')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.menuRename' }))
|
||||
const input = screen.getByDisplayValue('Documents')
|
||||
fireEvent.change(input, { target: { value: 'Papers' } })
|
||||
await act(async () => { fireEvent.keyDown(input, { key: 'Enter' }) })
|
||||
|
||||
expect(planner.toast.error).toHaveBeenCalledWith('packing.toast.renameError')
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-032: checks and unchecks a whole category', async () => {
|
||||
const { planner } = await setup()
|
||||
enterEditMode()
|
||||
|
||||
openCategoryMenu('Documents')
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'packing.menuCheckAll' })) })
|
||||
expect(planner.tripActions.togglePackingItem).toHaveBeenCalledTimes(1)
|
||||
expect(planner.tripActions.togglePackingItem).toHaveBeenCalledWith(3, 2, true)
|
||||
|
||||
openCategoryMenu('Documents')
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'packing.menuUncheckAll' })) })
|
||||
expect(planner.tripActions.togglePackingItem).toHaveBeenCalledWith(3, 1, false)
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-033: a failing bulk check surfaces the save error', async () => {
|
||||
const { planner } = await setup()
|
||||
asMock(planner.tripActions.togglePackingItem).mockRejectedValueOnce(new Error('x'))
|
||||
enterEditMode()
|
||||
|
||||
openCategoryMenu('Documents')
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'packing.menuCheckAll' })) })
|
||||
|
||||
expect(planner.toast.error).toHaveBeenCalledWith('packing.toast.saveError')
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-034: a failing bulk uncheck surfaces the save error', async () => {
|
||||
const { planner } = await setup()
|
||||
asMock(planner.tripActions.togglePackingItem).mockRejectedValueOnce(new Error('x'))
|
||||
enterEditMode()
|
||||
|
||||
openCategoryMenu('Documents')
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'packing.menuUncheckAll' })) })
|
||||
|
||||
expect(planner.toast.error).toHaveBeenCalledWith('packing.toast.saveError')
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-035: deleting a category removes every item after confirmation', async () => {
|
||||
const { planner } = await setup()
|
||||
asMock(planner.tripActions.deletePackingItem).mockRejectedValueOnce(new Error('x'))
|
||||
enterEditMode()
|
||||
|
||||
openCategoryMenu('Documents')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.menuDeleteCat' }))
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(within(dialog).getByText('packing.confirm.deleteCat:Documents,2')).toBeInTheDocument()
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'common.delete' }))
|
||||
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('packing.toast.deleteError'))
|
||||
expect(planner.tripActions.deletePackingItem).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-036: cancelling the category delete keeps the items', async () => {
|
||||
const { planner } = await setup()
|
||||
enterEditMode()
|
||||
|
||||
openCategoryMenu('Documents')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.menuDeleteCat' }))
|
||||
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'common.cancel' }))
|
||||
|
||||
expect(planner.tripActions.deletePackingItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// ── Category assignees ────────────────────────────────────────────────
|
||||
|
||||
it('FE-MOB-PACKTAB-037: assigns and unassigns members of a category', async () => {
|
||||
const both = [{ user_id: 11, username: 'anna' }, { user_id: 12, username: 'ben' }]
|
||||
vi.mocked(packingApi.getCategoryAssignees).mockResolvedValue({ assignees: { Documents: [both[0]] } })
|
||||
const setAssignees = vi.spyOn(packingApi, 'setCategoryAssignees').mockResolvedValue({ assignees: both })
|
||||
await setup({ planner: { tripMembers: [ANNA, BEN] } as Partial<TripPlanner> })
|
||||
enterEditMode()
|
||||
|
||||
fireEvent.click(within(card('Documents')).getByRole('button', { name: 'packing.assignMembers' }))
|
||||
await act(async () => { fireEvent.click(within(card('Documents')).getByRole('button', { name: 'ben' })) })
|
||||
expect(setAssignees).toHaveBeenCalledWith(3, 'Documents', [11, 12])
|
||||
|
||||
// The response made both members assigned — tapping anna now removes her.
|
||||
await act(async () => { fireEvent.click(within(card('Documents')).getByRole('button', { name: 'anna' })) })
|
||||
expect(setAssignees).toHaveBeenLastCalledWith(3, 'Documents', [12])
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-038: a failing assignee update surfaces the save error', async () => {
|
||||
vi.spyOn(packingApi, 'setCategoryAssignees').mockRejectedValue(new Error('x'))
|
||||
const { planner } = await setup({ planner: { tripMembers: [ANNA] } as Partial<TripPlanner> })
|
||||
enterEditMode()
|
||||
|
||||
fireEvent.click(within(card('Documents')).getByRole('button', { name: 'packing.assignMembers' }))
|
||||
await act(async () => { fireEvent.click(within(card('Documents')).getByRole('button', { name: 'anna' })) })
|
||||
|
||||
expect(planner.toast.error).toHaveBeenCalledWith('packing.toast.saveError')
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-039: a trip without members shows the assign hint', async () => {
|
||||
await setup()
|
||||
enterEditMode()
|
||||
|
||||
fireEvent.click(within(card('Documents')).getByRole('button', { name: 'packing.assignMembers' }))
|
||||
|
||||
expect(screen.getByText('packing.noMembers')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Items ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-MOB-PACKTAB-040: adds an item to a category', async () => {
|
||||
const { planner } = await setup()
|
||||
enterEditMode()
|
||||
|
||||
fireEvent.click(within(card('Clothing')).getByRole('button', { name: 'packing.addItem' }))
|
||||
const input = within(card('Clothing')).getByPlaceholderText('packing.addItemPlaceholder')
|
||||
expect(within(card('Clothing')).getByRole('button', { name: 'common.add' })).toBeDisabled()
|
||||
fireEvent.change(input, { target: { value: ' Hat ' } })
|
||||
await act(async () => { fireEvent.click(within(card('Clothing')).getByRole('button', { name: 'common.add' })) })
|
||||
|
||||
expect(planner.tripActions.addPackingItem).toHaveBeenCalledWith(3, {
|
||||
name: 'Hat', category: 'Clothing', visibility: 'common',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-041: a category placeholder is renamed instead of adding a second row', async () => {
|
||||
const items = [packItem({ id: 1, name: 'Passport', category: 'Documents' }), packItem({ id: 9, name: '...', category: 'Gear' })]
|
||||
const { planner } = await setup({ items })
|
||||
enterEditMode()
|
||||
|
||||
fireEvent.click(within(card('Gear')).getByRole('button', { name: 'packing.addItem' }))
|
||||
const input = within(card('Gear')).getByPlaceholderText('packing.addItemPlaceholder')
|
||||
fireEvent.change(input, { target: { value: 'Rope' } })
|
||||
await act(async () => { fireEvent.keyDown(input, { key: 'Enter' }) })
|
||||
|
||||
expect(planner.tripActions.updatePackingItem).toHaveBeenCalledWith(3, 9, { name: 'Rope' })
|
||||
expect(planner.tripActions.addPackingItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-042: Escape aborts the add-item field and failures are reported', async () => {
|
||||
const { planner } = await setup()
|
||||
asMock(planner.tripActions.addPackingItem).mockRejectedValueOnce(new Error('x'))
|
||||
enterEditMode()
|
||||
|
||||
fireEvent.click(within(card('Clothing')).getByRole('button', { name: 'packing.addItem' }))
|
||||
const input = within(card('Clothing')).getByPlaceholderText('packing.addItemPlaceholder')
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(planner.tripActions.addPackingItem).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'Hat' } })
|
||||
await act(async () => { fireEvent.keyDown(input, { key: 'Enter' }) })
|
||||
expect(planner.toast.error).toHaveBeenCalledWith('packing.toast.addError')
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
expect(within(card('Clothing')).getByRole('button', { name: 'packing.addItem' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-043: toggles an item and shows its quantity badge', async () => {
|
||||
const { planner } = await setup()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Socks' }))
|
||||
expect(planner.tripActions.togglePackingItem).toHaveBeenCalledWith(3, 3, true)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Passport' }))
|
||||
expect(planner.tripActions.togglePackingItem).toHaveBeenCalledWith(3, 1, false)
|
||||
|
||||
expect(within(itemRow('Socks')).getByText('3×')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Passport' })).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-044: shows the placeholder label instead of the sentinel name', async () => {
|
||||
await setup({ items: [packItem({ id: 9, name: '...', category: 'Gear' })] })
|
||||
expect(screen.getByText('packing.addItemPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-045: shows weights in edit mode while bag tracking is on', async () => {
|
||||
await setup({ bagTracking: true })
|
||||
enterEditMode()
|
||||
|
||||
// The row shows the unit weight, not quantity x weight.
|
||||
expect(within(itemRow('Socks')).getByText('120 g')).toBeInTheDocument()
|
||||
expect(within(itemRow('Shirt')).getByText('— g')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-046: opens the item editor from the row', async () => {
|
||||
await setup()
|
||||
enterEditMode()
|
||||
|
||||
fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'common.edit' }))
|
||||
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute('aria-label', 'packing.editItem')
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-047: deletes a normal item outright', async () => {
|
||||
const { planner } = await setup()
|
||||
enterEditMode()
|
||||
|
||||
await act(async () => { fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'common.delete' })) })
|
||||
|
||||
expect(planner.tripActions.deletePackingItem).toHaveBeenCalledWith(3, 3)
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-048: the last item of a custom category is reset to a placeholder', async () => {
|
||||
const items = [
|
||||
packItem({ id: 1, name: 'Passport', category: 'Documents' }),
|
||||
packItem({ id: 8, name: 'Rope', category: 'Gear', checked: 1, quantity: 2, weight_grams: 90 }),
|
||||
]
|
||||
const { planner } = await setup({ items })
|
||||
enterEditMode()
|
||||
|
||||
await act(async () => { fireEvent.click(within(itemRow('Rope')).getByRole('button', { name: 'common.delete' })) })
|
||||
|
||||
expect(planner.tripActions.togglePackingItem).toHaveBeenCalledWith(3, 8, false)
|
||||
expect(planner.tripActions.updatePackingItem).toHaveBeenCalledWith(3, 8, {
|
||||
name: '...', weight_grams: null, bag_id: null, quantity: 1,
|
||||
})
|
||||
expect(planner.tripActions.deletePackingItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-049: a failing item delete surfaces the delete error', async () => {
|
||||
const { planner } = await setup()
|
||||
asMock(planner.tripActions.deletePackingItem).mockRejectedValueOnce(new Error('x'))
|
||||
enterEditMode()
|
||||
|
||||
await act(async () => { fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'common.delete' })) })
|
||||
|
||||
expect(planner.toast.error).toHaveBeenCalledWith('packing.toast.deleteError')
|
||||
})
|
||||
|
||||
// ── Sharing badges ────────────────────────────────────────────────────
|
||||
|
||||
it('FE-MOB-PACKTAB-050: badges an item somebody else takes care of', async () => {
|
||||
const items = [packItem({ id: 1, name: 'Tent', is_private: 1, owner_id: 21, owner_username: 'owner' })]
|
||||
await setup({ items })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.viewPersonal' }))
|
||||
|
||||
expect(screen.getByText('packing.takenCareOf:owner')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-051: badges how many people my shared item covers', async () => {
|
||||
const items = [packItem({
|
||||
id: 1, name: 'Tent', is_private: 1, owner_id: ME, owner_username: 'me',
|
||||
recipients: [{ user_id: 11, username: 'anna' }, { user_id: 12, username: 'ben' }],
|
||||
})]
|
||||
await setup({ items })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.viewPersonal' }))
|
||||
|
||||
expect(screen.getByText('packing.sharedWithCount:2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-052: shows the owner avatar and the contributor count on a common item', async () => {
|
||||
const items = [packItem({
|
||||
id: 1, name: 'Tent', owner_id: 11, owner_username: 'anna',
|
||||
contributors: [{ user_id: 12, username: 'ben', status: 'pledged' }],
|
||||
})]
|
||||
const member = { ...ANNA, avatar_url: 'https://cdn.example/anna.png' } as unknown as TripMember
|
||||
await setup({ items, planner: { tripMembers: [member] } as Partial<TripPlanner> })
|
||||
|
||||
expect(within(itemRow('Tent')).getByAltText('anna')).toHaveAttribute('src', 'https://cdn.example/anna.png')
|
||||
expect(within(itemRow('Tent')).getByText('+1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-053: falls back to the owner initial when there is no avatar', async () => {
|
||||
const items = [packItem({ id: 1, name: 'Tent', owner_id: 99, owner_username: 'zoe' })]
|
||||
await setup({ items })
|
||||
|
||||
expect(within(itemRow('Tent')).getByText('Z')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Bag picker on the row ─────────────────────────────────────────────
|
||||
|
||||
it('FE-MOB-PACKTAB-054: assigns and clears the bag of an item', async () => {
|
||||
const { planner } = await setup({ bagTracking: true })
|
||||
|
||||
fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'packing.bags' }))
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Backpack' })) })
|
||||
expect(planner.tripActions.updatePackingItem).toHaveBeenCalledWith(3, 3, { bag_id: 71 })
|
||||
|
||||
fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'packing.bags' }))
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'packing.noBag' })) })
|
||||
expect(planner.tripActions.updatePackingItem).toHaveBeenCalledWith(3, 3, { bag_id: null })
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-055: a failing bag assignment surfaces the save error', async () => {
|
||||
const { planner } = await setup({ bagTracking: true })
|
||||
asMock(planner.tripActions.updatePackingItem).mockRejectedValueOnce(new Error('x'))
|
||||
|
||||
fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'packing.bags' }))
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Backpack' })) })
|
||||
|
||||
expect(planner.toast.error).toHaveBeenCalledWith('packing.toast.saveError')
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-056: creates a bag from the row picker and assigns it right away', async () => {
|
||||
const created = { id: 72, trip_id: 3, name: 'Duffel', color: '#ec4899', sort_order: 1 } as PackingBag
|
||||
vi.spyOn(packingApi, 'createBag').mockResolvedValue({ bag: created })
|
||||
const { planner } = await setup({ bagTracking: true })
|
||||
|
||||
fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'packing.bags' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.addBag' }))
|
||||
const input = screen.getByPlaceholderText('packing.bagName')
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(packingApi.createBag).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'Duffel' } })
|
||||
await act(async () => { fireEvent.keyDown(input, { key: 'Enter' }) })
|
||||
|
||||
expect(packingApi.createBag).toHaveBeenCalledWith(3, { name: 'Duffel', color: '#ec4899' })
|
||||
expect(planner.tripActions.updatePackingItem).toHaveBeenCalledWith(3, 3, { bag_id: 72 })
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-057: a failing bag creation is reported and assigns nothing', async () => {
|
||||
vi.spyOn(packingApi, 'createBag').mockRejectedValue(new Error('x'))
|
||||
const { planner } = await setup({ bagTracking: true })
|
||||
|
||||
fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'packing.bags' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.addBag' }))
|
||||
fireEvent.change(screen.getByPlaceholderText('packing.bagName'), { target: { value: 'Duffel' } })
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'common.add' })) })
|
||||
|
||||
expect(planner.toast.error).toHaveBeenCalledWith('packing.toast.saveError')
|
||||
expect(planner.tripActions.updatePackingItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-058: Escape closes the inline bag field', async () => {
|
||||
await setup({ bagTracking: true })
|
||||
|
||||
fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'packing.bags' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.addBag' }))
|
||||
fireEvent.change(screen.getByPlaceholderText('packing.bagName'), { target: { value: 'x' } })
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('packing.bagName'), { key: 'Escape' })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'packing.addBag' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Bags sheet plumbing ───────────────────────────────────────────────
|
||||
|
||||
it('FE-MOB-PACKTAB-059: renames, deletes and re-crews a bag through the sheet', async () => {
|
||||
vi.spyOn(packingApi, 'updateBag').mockResolvedValue({ bag: { ...BAGS[0], name: 'Daypack' } })
|
||||
vi.spyOn(packingApi, 'setBagMembers').mockResolvedValue({ members: [{ user_id: 11, username: 'anna', avatar: null }] })
|
||||
vi.spyOn(packingApi, 'deleteBag').mockResolvedValue({ ok: true })
|
||||
await setup({ bagTracking: true, planner: { tripMembers: [ANNA] } as Partial<TripPlanner> })
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'packing.bags' })[0])
|
||||
const dialog = screen.getByRole('dialog')
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Backpack' }))
|
||||
const nameInput = within(dialog).getByDisplayValue('Backpack')
|
||||
fireEvent.change(nameInput, { target: { value: 'Daypack' } })
|
||||
await act(async () => { fireEvent.keyDown(nameInput, { key: 'Enter' }) })
|
||||
expect(packingApi.updateBag).toHaveBeenCalledWith(3, 71, { name: 'Daypack' })
|
||||
expect(within(dialog).getByRole('button', { name: 'Daypack' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'common.add' }))
|
||||
await act(async () => { fireEvent.click(within(dialog).getByRole('button', { name: 'anna' })) })
|
||||
expect(packingApi.setBagMembers).toHaveBeenCalledWith(3, 71, [11])
|
||||
|
||||
await act(async () => { fireEvent.click(within(dialog).getByRole('button', { name: 'common.delete' })) })
|
||||
expect(packingApi.deleteBag).toHaveBeenCalledWith(3, 71)
|
||||
expect(within(dialog).queryByRole('button', { name: 'Daypack' })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-060: reports failures of the bag sheet mutations', async () => {
|
||||
vi.spyOn(packingApi, 'updateBag').mockRejectedValue(new Error('x'))
|
||||
vi.spyOn(packingApi, 'setBagMembers').mockRejectedValue(new Error('x'))
|
||||
vi.spyOn(packingApi, 'deleteBag').mockRejectedValue(new Error('x'))
|
||||
const { planner } = await setup({ bagTracking: true, planner: { tripMembers: [ANNA] } as Partial<TripPlanner> })
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'packing.bags' })[0])
|
||||
const dialog = screen.getByRole('dialog')
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Backpack' }))
|
||||
const nameInput = within(dialog).getByDisplayValue('Backpack')
|
||||
fireEvent.change(nameInput, { target: { value: 'Daypack' } })
|
||||
await act(async () => { fireEvent.keyDown(nameInput, { key: 'Enter' }) })
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'common.add' }))
|
||||
await act(async () => { fireEvent.click(within(dialog).getByRole('button', { name: 'anna' })) })
|
||||
|
||||
await act(async () => { fireEvent.click(within(dialog).getByRole('button', { name: 'common.delete' })) })
|
||||
|
||||
expect(planner.toast.error).toHaveBeenCalledWith('common.error')
|
||||
expect(planner.toast.error).toHaveBeenCalledWith('packing.toast.deleteError')
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-061: creates a bag from the sheet with the next palette colour', async () => {
|
||||
const created = { id: 72, trip_id: 3, name: 'Duffel', color: '#ec4899', sort_order: 1 } as PackingBag
|
||||
vi.spyOn(packingApi, 'createBag').mockResolvedValue({ bag: created })
|
||||
await setup({ bagTracking: true })
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'packing.bags' })[0])
|
||||
const dialog = screen.getByRole('dialog')
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'packing.addBag' }))
|
||||
const field = within(dialog).getByPlaceholderText('packing.bagName')
|
||||
fireEvent.change(field, { target: { value: 'Duffel' } })
|
||||
const addRow = field.parentElement as HTMLElement
|
||||
await act(async () => { fireEvent.click(within(addRow).getByRole('button', { name: 'common.add' })) })
|
||||
|
||||
expect(packingApi.createBag).toHaveBeenCalledWith(3, { name: 'Duffel', color: '#ec4899' })
|
||||
expect(within(dialog).getByRole('button', { name: 'Duffel' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PACKTAB-062: every sheet the tab owns can be dismissed again', async () => {
|
||||
await setup({ bagTracking: true })
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'packing.bags' })[0])
|
||||
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'common.close' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
|
||||
openActions()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'packing.import' }))
|
||||
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'common.cancel' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
|
||||
enterEditMode()
|
||||
fireEvent.click(within(itemRow('Socks')).getByRole('button', { name: 'common.edit' }))
|
||||
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'common.close' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,393 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { HttpResponse, http } from 'msw'
|
||||
import MPlaceEditSheet from '../../../../src/mobile/screens/trip/sheets/MPlaceEditSheet'
|
||||
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import type { Assignment, Category, Place } from '../../../../src/types'
|
||||
import { buildPlanner } from '../../../helpers/mobileTrip'
|
||||
import { server } from '../../../helpers/msw/server'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-PLEDIT-001 to FE-MOB-PLEDIT-024
|
||||
// planner.t echoes the key, so every label/placeholder is asserted as its key.
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 3, name: 'Food', color: '#ef4444', icon: 'Coffee', user_id: 1 },
|
||||
] as unknown as Category[]
|
||||
|
||||
const EXISTING = {
|
||||
id: 41, trip_id: 1, name: 'Ueno Park', address: 'Taito City', lat: 35.715, lng: 139.773,
|
||||
google_place_id: 'ChIJ_ueno',
|
||||
} as unknown as Place
|
||||
|
||||
const EDITED = {
|
||||
id: 42, trip_id: 1, name: 'Senso-ji', description: 'Oldest temple', address: '2-3-1 Asakusa',
|
||||
lat: 35.7148, lng: 139.7967, category_id: 3, notes: 'Go early', website: 'https://senso-ji.jp',
|
||||
place_time: '08:00', end_time: '09:30', transport_mode: 'transit',
|
||||
} as unknown as Place
|
||||
|
||||
function setup(overrides: Partial<TripPlanner> = {}) {
|
||||
const planner = buildPlanner({ showPlaceForm: true, categories: CATEGORIES, ...overrides })
|
||||
const view = render(<MPlaceEditSheet planner={planner} />)
|
||||
return { ...view, planner }
|
||||
}
|
||||
|
||||
const nameField = () => screen.getByPlaceholderText('places.formNamePlaceholder')
|
||||
const submit = () => screen.getByRole('button', { name: /^(common\.add|common\.save|common\.saving|places\.addAnyway)$/ })
|
||||
|
||||
describe('MPlaceEditSheet', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
http.post('/api/maps/search', () => HttpResponse.json({
|
||||
source: 'osm',
|
||||
places: [{ name: 'Ueno Koen', address: 'Taito', lat: 35.7, lng: 139.7, google_place_id: 'ChIJ_ueno' }],
|
||||
})),
|
||||
http.post('/api/maps/autocomplete', () => HttpResponse.json({ source: 'osm', suggestions: [] })),
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-001: stays unmounted while the planner has no editor open', () => {
|
||||
setup({ showPlaceForm: false })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-002: opens blank in add mode', () => {
|
||||
setup()
|
||||
expect(screen.getByRole('dialog', { name: 'places.addPlace' })).toBeInTheDocument()
|
||||
expect(screen.getAllByText('places.addPlace').length).toBeGreaterThan(0)
|
||||
expect(nameField()).toHaveValue('')
|
||||
expect(submit()).toHaveTextContent('common.add')
|
||||
// No place in context — no delete affordance and no time fields.
|
||||
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('places.startTime')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-003: prefills every field from the place under edit', () => {
|
||||
setup({ editingPlace: EDITED })
|
||||
expect(screen.getByRole('dialog', { name: 'places.editPlace' })).toBeInTheDocument()
|
||||
expect(nameField()).toHaveValue('Senso-ji')
|
||||
expect(screen.getByPlaceholderText('places.formDescriptionPlaceholder')).toHaveValue('Oldest temple')
|
||||
expect(screen.getByPlaceholderText('places.formNotesPlaceholder')).toHaveValue('Go early')
|
||||
expect(screen.getByPlaceholderText('places.formAddressPlaceholder')).toHaveValue('2-3-1 Asakusa')
|
||||
expect(screen.getByPlaceholderText('places.formLat')).toHaveValue('35.7148')
|
||||
expect(screen.getByPlaceholderText('places.formLng')).toHaveValue('139.7967')
|
||||
expect(screen.getByPlaceholderText('https://')).toHaveValue('https://senso-ji.jp')
|
||||
expect(screen.getByRole('button', { name: /Food/ }).className).toContain('bg-m-act')
|
||||
expect(submit()).toHaveTextContent('common.save')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-004: leaves optional fields blank when the place has none', () => {
|
||||
const bare = { id: 43, trip_id: 1, name: 'Nameless' } as unknown as Place
|
||||
setup({ editingPlace: bare })
|
||||
expect(nameField()).toHaveValue('Nameless')
|
||||
expect(screen.getByPlaceholderText('places.formLat')).toHaveValue('')
|
||||
expect(screen.getByPlaceholderText('places.formNotesPlaceholder')).toHaveValue('')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-005: reads the times off the in-context assignment, not off the place', () => {
|
||||
const assignment = {
|
||||
id: 7, day_id: 2, place_id: 42, order_index: 0,
|
||||
place: { ...EDITED, place_time: '14:00', end_time: '15:00' },
|
||||
} as unknown as Assignment
|
||||
setup({ editingPlace: EDITED, editingAssignmentId: 7, assignments: { '2': [assignment] } })
|
||||
expect(screen.getByText('places.startTime')).toBeInTheDocument()
|
||||
const times = screen.getAllByDisplayValue(/^1[45]:00$/)
|
||||
expect(times.map(el => (el as HTMLInputElement).value)).toEqual(['14:00', '15:00'])
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-006: prefills from a map long-press / POI pick', () => {
|
||||
setup({
|
||||
prefillCoords: {
|
||||
lat: 35.6586, lng: 139.7454, name: 'Tokyo Tower', address: '4-2-8 Shibakoen',
|
||||
website: 'https://tokyotower.co.jp', phone: '+81 3', osm_id: 'W123',
|
||||
},
|
||||
})
|
||||
expect(nameField()).toHaveValue('Tokyo Tower')
|
||||
expect(screen.getByPlaceholderText('places.formLat')).toHaveValue('35.6586')
|
||||
expect(screen.getByPlaceholderText('places.formLng')).toHaveValue('139.7454')
|
||||
expect(screen.getByPlaceholderText('places.formAddressPlaceholder')).toHaveValue('4-2-8 Shibakoen')
|
||||
expect(screen.getByPlaceholderText('https://')).toHaveValue('https://tokyotower.co.jp')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-007: falls back to bare coordinates when the pick carries no metadata', () => {
|
||||
setup({ prefillCoords: { lat: 1.5, lng: 2.5 } })
|
||||
expect(nameField()).toHaveValue('')
|
||||
expect(screen.getByPlaceholderText('places.formLat')).toHaveValue('1.5')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-008: refuses to save without a name', () => {
|
||||
const { planner } = setup()
|
||||
fireEvent.change(nameField(), { target: { value: ' ' } })
|
||||
fireEvent.click(submit())
|
||||
expect(planner.toast.error).toHaveBeenCalledWith('places.nameRequired')
|
||||
expect(planner.handleSavePlace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-009: saves the parsed form and clears every editor flag', async () => {
|
||||
const { planner } = setup()
|
||||
fireEvent.change(nameField(), { target: { value: 'Nakamise' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('places.formLat'), { target: { value: '35.71' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('places.formLng'), { target: { value: '139.79' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Food/ }))
|
||||
fireEvent.click(submit())
|
||||
|
||||
await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1))
|
||||
expect(planner.handleSavePlace).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: 'Nakamise', lat: 35.71, lng: 139.79, category_id: '3', _pendingFiles: undefined,
|
||||
}))
|
||||
expect(planner.setShowPlaceForm).toHaveBeenCalledWith(false)
|
||||
expect(planner.setEditingPlace).toHaveBeenCalledWith(null)
|
||||
expect(planner.setEditingAssignmentId).toHaveBeenCalledWith(null)
|
||||
expect(planner.setPrefillCoords).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-009b: carries the free-text fields into the save payload', async () => {
|
||||
const { planner } = setup()
|
||||
fireEvent.change(nameField(), { target: { value: 'Nakamise' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('places.formDescriptionPlaceholder'), { target: { value: 'Shopping street' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('places.formNotesPlaceholder'), { target: { value: 'Cash only' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('places.formAddressPlaceholder'), { target: { value: '1-36-3 Asakusa' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('https://'), { target: { value: 'https://asakusa.example' } })
|
||||
fireEvent.click(submit())
|
||||
|
||||
await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1))
|
||||
expect(planner.handleSavePlace).toHaveBeenCalledWith(expect.objectContaining({
|
||||
description: 'Shopping street',
|
||||
notes: 'Cash only',
|
||||
address: '1-36-3 Asakusa',
|
||||
website: 'https://asakusa.example',
|
||||
}))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-010: sends null coordinates and no category when the fields stay empty', async () => {
|
||||
const { planner } = setup()
|
||||
fireEvent.change(nameField(), { target: { value: 'Somewhere' } })
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1))
|
||||
expect(planner.handleSavePlace).toHaveBeenCalledWith(expect.objectContaining({ lat: null, lng: null, category_id: null }))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-011: strips non-numeric characters from the coordinate fields', () => {
|
||||
setup()
|
||||
const lat = screen.getByPlaceholderText('places.formLat')
|
||||
fireEvent.change(lat, { target: { value: '3a5.7N' } })
|
||||
expect(lat).toHaveValue('35.7')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-012: a pasted "lat, lng" pair fills both coordinate fields', () => {
|
||||
setup()
|
||||
const lat = screen.getByPlaceholderText('places.formLat')
|
||||
fireEvent.paste(lat, { clipboardData: { getData: () => ' 35.6895, 139.6917 ' } })
|
||||
expect(lat).toHaveValue('35.6895')
|
||||
expect(screen.getByPlaceholderText('places.formLng')).toHaveValue('139.6917')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-013: an unparseable paste is left to the browser', () => {
|
||||
setup()
|
||||
const lat = screen.getByPlaceholderText('places.formLat')
|
||||
fireEvent.paste(lat, { clipboardData: { getData: () => 'Tokyo Tower' } })
|
||||
expect(lat).toHaveValue('')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-014: warns once about a duplicate name and saves on the second tap', async () => {
|
||||
const { planner } = setup({ places: [EXISTING] })
|
||||
fireEvent.change(nameField(), { target: { value: ' ueno park ' } })
|
||||
fireEvent.click(submit())
|
||||
|
||||
expect(planner.toast.warning).toHaveBeenCalledWith('places.duplicateExists:Ueno Park')
|
||||
expect(planner.handleSavePlace).not.toHaveBeenCalled()
|
||||
expect(submit()).toHaveTextContent('places.addAnyway')
|
||||
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-015: treats near-identical coordinates as a duplicate', () => {
|
||||
const { planner } = setup({ places: [EXISTING], prefillCoords: { lat: 35.71505, lng: 139.77305 } })
|
||||
fireEvent.change(nameField(), { target: { value: 'Park entrance' } })
|
||||
fireEvent.click(submit())
|
||||
expect(planner.toast.warning).toHaveBeenCalledWith('places.duplicateExists:Ueno Park')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-016: a maps pick that shares the Google Place ID counts as a duplicate', async () => {
|
||||
const { planner } = setup({ places: [EXISTING] })
|
||||
fireEvent.change(screen.getByPlaceholderText('places.mapsSearchPlaceholder'), { target: { value: 'ueno koen' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.search' }))
|
||||
fireEvent.click(await screen.findByText('Ueno Koen'))
|
||||
|
||||
expect(nameField()).toHaveValue('Ueno Koen')
|
||||
fireEvent.click(submit())
|
||||
expect(planner.toast.warning).toHaveBeenCalledWith('places.duplicateExists:Ueno Park')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-017: an existing place never triggers the duplicate guard', async () => {
|
||||
const { planner } = setup({ places: [EXISTING], editingPlace: { ...EXISTING, id: 99 } as unknown as Place })
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1))
|
||||
expect(planner.toast.warning).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-018: surfaces the server message and keeps the sheet open when the save fails', async () => {
|
||||
const handleSavePlace = vi.fn().mockRejectedValue(new Error('Trip is locked'))
|
||||
const { planner } = setup({ handleSavePlace })
|
||||
fireEvent.change(nameField(), { target: { value: 'Nakamise' } })
|
||||
fireEvent.click(submit())
|
||||
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Trip is locked'))
|
||||
expect(planner.setShowPlaceForm).not.toHaveBeenCalled()
|
||||
expect(submit()).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-019: falls back to the generic message for a non-Error rejection', async () => {
|
||||
const handleSavePlace = vi.fn().mockRejectedValue('nope')
|
||||
const { planner } = setup({ handleSavePlace })
|
||||
fireEvent.change(nameField(), { target: { value: 'Nakamise' } })
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('places.saveError'))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-020: shows the saving label and blocks the button while the save runs', async () => {
|
||||
let release: () => void = () => {}
|
||||
const handleSavePlace = vi.fn(() => new Promise<void>(resolve => { release = resolve }))
|
||||
setup({ handleSavePlace })
|
||||
fireEvent.change(nameField(), { target: { value: 'Nakamise' } })
|
||||
fireEvent.click(submit())
|
||||
|
||||
await waitFor(() => expect(submit()).toHaveTextContent('common.saving'))
|
||||
expect(submit()).toBeDisabled()
|
||||
release()
|
||||
await waitFor(() => expect(handleSavePlace).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-021: deletes on the second tap and stages the id on the first', async () => {
|
||||
const { planner } = setup({ editingPlace: EDITED })
|
||||
const del = screen.getByRole('button', { name: 'common.delete' })
|
||||
fireEvent.click(del)
|
||||
expect(planner.setDeletePlaceId).toHaveBeenCalledWith(42)
|
||||
expect(planner.toast.warning).toHaveBeenCalledWith('mobileTrip.tapAgainToDelete')
|
||||
expect(planner.confirmDeletePlace).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
|
||||
await waitFor(() => expect(planner.confirmDeletePlace).toHaveBeenCalledTimes(1))
|
||||
expect(planner.setShowPlaceForm).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-022: cancelling an armed delete un-stages the id', () => {
|
||||
const { planner } = setup({ editingPlace: EDITED })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
|
||||
expect(planner.setDeletePlaceId).toHaveBeenLastCalledWith(null)
|
||||
expect(planner.setShowPlaceForm).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-023: the header close leaves the staged id alone when nothing was armed', () => {
|
||||
const { planner } = setup({ editingPlace: EDITED })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
|
||||
expect(planner.setShowPlaceForm).toHaveBeenCalledWith(false)
|
||||
expect(planner.setDeletePlaceId).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-024: blocks the save while the end time is not after the start', () => {
|
||||
const assignment = { id: 7, day_id: 2, place_id: 42, order_index: 0, place: EDITED } as unknown as Assignment
|
||||
setup({ editingPlace: EDITED, editingAssignmentId: 7, assignments: { '2': [assignment] } })
|
||||
const [start, end] = screen.getAllByDisplayValue(/^0[89]:[0-3]0$/) as HTMLInputElement[]
|
||||
expect(submit()).not.toBeDisabled()
|
||||
fireEvent.change(end, { target: { value: '07:00' } })
|
||||
expect(start).toHaveValue('08:00')
|
||||
expect(screen.getByText('places.endTimeBeforeStart')).toBeInTheDocument()
|
||||
expect(submit()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-025: attaches pasted images and sends them with the save', async () => {
|
||||
const { planner } = setup()
|
||||
const file = new File(['x'], 'ticket.png', { type: 'image/png' })
|
||||
fireEvent.paste(screen.getByPlaceholderText('places.formAddressPlaceholder'), {
|
||||
clipboardData: { items: [{ type: 'text/plain', getAsFile: () => null }, { type: 'image/png', getAsFile: () => file }] },
|
||||
})
|
||||
expect(await screen.findByText('ticket.png')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(nameField(), { target: { value: 'Nakamise' } })
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledTimes(1))
|
||||
expect(planner.handleSavePlace).toHaveBeenCalledWith(expect.objectContaining({ _pendingFiles: [file] }))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-025b: attaches files chosen through the picker', async () => {
|
||||
const { planner } = setup()
|
||||
const file = new File(['x'], 'map.pdf', { type: 'application/pdf' })
|
||||
const picker = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
fireEvent.change(picker, { target: { files: [file] } })
|
||||
expect(await screen.findByText('map.pdf')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(nameField(), { target: { value: 'Nakamise' } })
|
||||
fireEvent.click(submit())
|
||||
await waitFor(() => expect(planner.handleSavePlace).toHaveBeenCalledWith(expect.objectContaining({ _pendingFiles: [file] })))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-026: a clipboard item without a file is ignored', () => {
|
||||
setup()
|
||||
fireEvent.paste(screen.getByPlaceholderText('places.formAddressPlaceholder'), {
|
||||
clipboardData: { items: [{ type: 'application/pdf', getAsFile: () => null }] },
|
||||
})
|
||||
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-027: drops a pending attachment again', async () => {
|
||||
setup()
|
||||
const file = new File(['x'], 'voucher.pdf', { type: 'application/pdf' })
|
||||
fireEvent.paste(screen.getByPlaceholderText('places.formAddressPlaceholder'), {
|
||||
clipboardData: { items: [{ type: 'application/pdf', getAsFile: () => file }] },
|
||||
})
|
||||
expect(await screen.findByText('voucher.pdf')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
|
||||
expect(screen.queryByText('voucher.pdf')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-028: hides the file row and ignores pastes without the upload permission', () => {
|
||||
setup({ canUploadFiles: false })
|
||||
expect(screen.queryByText('files.title')).not.toBeInTheDocument()
|
||||
const file = new File(['x'], 'ticket.png', { type: 'image/png' })
|
||||
fireEvent.paste(screen.getByPlaceholderText('places.formAddressPlaceholder'), {
|
||||
clipboardData: { items: [{ type: 'image/png', getAsFile: () => file }] },
|
||||
})
|
||||
expect(screen.queryByText('ticket.png')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
/** Types into the search row and returns the bias the autocomplete request carried. */
|
||||
async function capturedBias(places: Place[]) {
|
||||
const bodies: Record<string, unknown>[] = []
|
||||
server.use(http.post('/api/maps/autocomplete', async ({ request }) => {
|
||||
bodies.push(await request.json() as Record<string, unknown>)
|
||||
return HttpResponse.json({ source: 'osm', suggestions: [] })
|
||||
}))
|
||||
setup({ places })
|
||||
fireEvent.change(screen.getByPlaceholderText('places.mapsSearchPlaceholder'), { target: { value: 'ueno' } })
|
||||
await waitFor(() => expect(bodies).toHaveLength(1))
|
||||
return bodies[0].locationBias
|
||||
}
|
||||
|
||||
it('FE-MOB-PLEDIT-029: biases the maps search on the trip bounding box', async () => {
|
||||
const places = [
|
||||
{ id: 1, name: 'A', lat: 35.6, lng: 139.7 },
|
||||
{ id: 2, name: 'B', lat: 35.8, lng: 139.9 },
|
||||
{ id: 3, name: 'C', lat: null, lng: null },
|
||||
] as unknown as Place[]
|
||||
expect(await capturedBias(places)).toEqual({ low: { lat: 35.6, lng: 139.7 }, high: { lat: 35.8, lng: 139.9 } })
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-030: drops the bias for a trip that spans more than 500 km', async () => {
|
||||
const places = [
|
||||
{ id: 1, name: 'Tokyo', lat: 35.68, lng: 139.76 },
|
||||
{ id: 2, name: 'Sapporo', lat: 43.06, lng: 141.35 },
|
||||
] as unknown as Place[]
|
||||
expect(await capturedBias(places)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-031: sends no bias when no place has usable coordinates', async () => {
|
||||
const places = [{ id: 1, name: 'Broken', lat: 'north', lng: 'east' }] as unknown as Place[]
|
||||
expect(await capturedBias(places)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLEDIT-032: sends no bias for a trip without any located place', async () => {
|
||||
expect(await capturedBias([])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,410 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { assignmentsApi } from '../../../../src/api/client'
|
||||
import MPlaceSheet from '../../../../src/mobile/screens/trip/sheets/MPlaceSheet'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { useAddonStore } from '../../../../src/store/addonStore'
|
||||
import { useSaveToCollectionStore } from '../../../../src/store/saveToCollectionStore'
|
||||
import { useTripStore } from '../../../../src/store/tripStore'
|
||||
import { openFile } from '../../../../src/utils/fileDownload'
|
||||
import type { Assignment, Category, Day, Place, TripFile, TripMember } from '../../../../src/types'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-PLSH-001 to FE-MOB-PLSH-025
|
||||
|
||||
vi.mock('../../../../src/utils/fileDownload', () => ({ openFile: vi.fn() }))
|
||||
|
||||
const DAYS = [
|
||||
{ id: 1, trip_id: 5, day_number: 1, date: '2026-05-01', title: null },
|
||||
{ id: 2, trip_id: 5, day_number: 2, date: '2026-05-02', title: 'Old Town' },
|
||||
{ id: 3, trip_id: 5, day_number: 3, date: '2026-05-03', title: null },
|
||||
] as unknown as Day[]
|
||||
|
||||
const CATEGORIES = [{ id: 9, name: 'Museum', icon: 'Landmark', color: '#ff0000' }] as unknown as Category[]
|
||||
|
||||
const PLACE = {
|
||||
id: 101,
|
||||
trip_id: 5,
|
||||
name: 'Kunsthistorisches Museum',
|
||||
address: 'Maria-Theresien-Platz',
|
||||
phone: '+43 1 525240',
|
||||
website: 'https://khm.at',
|
||||
description: 'Habsburg collections',
|
||||
notes: 'Book the skip-the-line ticket',
|
||||
category_id: 9,
|
||||
lat: 48.2038,
|
||||
lng: 16.3616,
|
||||
image_url: '/uploads/places/khm.jpg',
|
||||
ratings: [],
|
||||
rating_avg: null,
|
||||
} as unknown as Place
|
||||
|
||||
const MEMBERS = [
|
||||
{ id: 1, username: 'ann', avatar: null, avatar_url: null },
|
||||
{ id: 2, username: 'bob', avatar: null, avatar_url: null },
|
||||
{ id: 3, username: 'cara', avatar: null, avatar_url: null },
|
||||
] as unknown as TripMember[]
|
||||
|
||||
function dayAssignment(id: number, dayId: number, participants: { user_id: number; username: string }[] = []) {
|
||||
return {
|
||||
id,
|
||||
day_id: dayId,
|
||||
place_id: PLACE.id,
|
||||
order_index: 0,
|
||||
participants,
|
||||
place: { id: PLACE.id, name: PLACE.name, lat: PLACE.lat, lng: PLACE.lng },
|
||||
} as unknown as Assignment
|
||||
}
|
||||
|
||||
const FILES = [
|
||||
{ id: 71, trip_id: 5, place_id: 101, original_name: 'ticket.pdf', url: '/uploads/files/ticket.pdf', mime_type: 'application/pdf', filename: 'a', created_at: '' },
|
||||
{ id: 72, trip_id: 5, place_id: null, linked_place_ids: [101], original_name: 'map.png', url: '/uploads/files/map.png', mime_type: 'image/png', filename: 'b', created_at: '' },
|
||||
{ id: 73, trip_id: 5, place_id: 101, original_name: 'deleted.pdf', url: '/x', mime_type: 'application/pdf', filename: 'c', created_at: '', deleted_at: '2026-01-01' },
|
||||
{ id: 74, trip_id: 5, place_id: 999, original_name: 'other.pdf', url: '/y', mime_type: 'application/pdf', filename: 'd', created_at: '' },
|
||||
] as unknown as TripFile[]
|
||||
|
||||
function makeShell(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
view: 'plan',
|
||||
mode: 'go',
|
||||
trTab: 'plan',
|
||||
sheet: null,
|
||||
openSheet: vi.fn(),
|
||||
closeSheet: vi.fn(),
|
||||
setTrTab: vi.fn(),
|
||||
toggleView: vi.fn(),
|
||||
setTravelMode: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as MTripShellApi
|
||||
}
|
||||
|
||||
function makePlanner(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
tripId: 5,
|
||||
trip: { id: 5, title: 'Vienna' },
|
||||
language: 'en',
|
||||
days: DAYS,
|
||||
categories: CATEGORIES,
|
||||
assignments: { '2': [dayAssignment(11, 2)] },
|
||||
files: FILES,
|
||||
canUploadFiles: true,
|
||||
selectedPlace: PLACE,
|
||||
selectedDayId: 2,
|
||||
selectedAssignmentId: null,
|
||||
setSelectedPlaceId: vi.fn(),
|
||||
tripMembers: MEMBERS,
|
||||
setFitKey: vi.fn(),
|
||||
openPlaceEditor: vi.fn(),
|
||||
handleDeletePlace: vi.fn(),
|
||||
handleAssignToDay: vi.fn(),
|
||||
handleRemoveAssignment: vi.fn(),
|
||||
tripActions: {
|
||||
uploadPlaceImage: vi.fn().mockResolvedValue(undefined),
|
||||
updatePlace: vi.fn().mockResolvedValue(undefined),
|
||||
addFile: vi.fn().mockResolvedValue(undefined),
|
||||
ratePlace: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
can: vi.fn(() => true),
|
||||
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
...overrides,
|
||||
} as unknown as TripPlanner
|
||||
}
|
||||
|
||||
function renderSheet(planner: TripPlanner = makePlanner(), shell: MTripShellApi = makeShell()) {
|
||||
const view = render(<MPlaceSheet planner={planner} shell={shell} />)
|
||||
return { ...view, planner, shell }
|
||||
}
|
||||
|
||||
/** jsdom keeps input.files read-only, so the selection has to be planted. */
|
||||
function selectFiles(input: HTMLInputElement, files: File[]) {
|
||||
Object.defineProperty(input, 'files', { value: files, configurable: true, writable: true })
|
||||
fireEvent.change(input)
|
||||
}
|
||||
|
||||
describe('MPlaceSheet', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
seedStore(useAddonStore, { addons: [{ id: 'collections', name: 'Collections', type: 'feature', icon: '', enabled: true }] })
|
||||
vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-001: stays closed without a selected place', () => {
|
||||
renderSheet(makePlanner({ selectedPlace: null }))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-002: renders name, address, phone link, category and image', () => {
|
||||
renderSheet()
|
||||
expect(screen.getByRole('dialog', { name: PLACE.name })).toBeInTheDocument()
|
||||
expect(screen.getByText(PLACE.name)).toBeInTheDocument()
|
||||
expect(screen.getByText('Maria-Theresien-Platz')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: '+43 1 525240' })).toHaveAttribute('href', 'tel:+43 1 525240')
|
||||
expect(screen.getByText('Museum')).toBeInTheDocument()
|
||||
expect(screen.getByText('Habsburg collections')).toBeInTheDocument()
|
||||
expect(screen.getByText('Book the skip-the-line ticket')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Change image' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-003: clears the place selection when closed', () => {
|
||||
const { planner } = renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(planner.setSelectedPlaceId).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-004: casts a star rating through the trip actions', async () => {
|
||||
const { planner } = renderSheet()
|
||||
expect(screen.getByText('Not rated yet')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('radio', { name: '4' }))
|
||||
await waitFor(() => expect(planner.tripActions.ratePlace).toHaveBeenCalledWith(5, 101, 4))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-005: reports a failing rating as a toast', async () => {
|
||||
const planner = makePlanner()
|
||||
vi.mocked(planner.tripActions.ratePlace).mockRejectedValue(new Error('rate limited'))
|
||||
renderSheet(planner)
|
||||
fireEvent.click(screen.getByRole('radio', { name: '2' }))
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('rate limited'))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-006: hides the track colour section without a route geometry', () => {
|
||||
renderSheet()
|
||||
expect(screen.queryByText('Track color')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-007: picks a track colour for a place that carries a track', async () => {
|
||||
const place = { ...PLACE, route_geometry: '{"type":"LineString"}', route_color: null } as unknown as Place
|
||||
const planner = makePlanner({ selectedPlace: place })
|
||||
renderSheet(planner)
|
||||
expect(screen.getByText('Track color')).toBeInTheDocument()
|
||||
|
||||
const toggle = screen.getByRole('button', { name: /Automatic color/ })
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '#ea580c' }))
|
||||
await waitFor(() => expect(planner.tripActions.updatePlace).toHaveBeenCalledWith(5, 101, { route_color: '#ea580c' }))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-008: reports a failing track colour update', async () => {
|
||||
const place = { ...PLACE, route_geometry: 'x', route_color: '#1d4ed8' } as unknown as Place
|
||||
const planner = makePlanner({ selectedPlace: place })
|
||||
vi.mocked(planner.tripActions.updatePlace).mockRejectedValue(new Error('conflict'))
|
||||
renderSheet(planner)
|
||||
fireEvent.click(screen.getByRole('button', { name: /#1d4ed8/ }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Automatic color' })[0])
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Unknown error'))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-009: lists the assigned days and removes an assignment', () => {
|
||||
const { planner } = renderSheet()
|
||||
expect(screen.getByText('Old Town')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove from Day' }))
|
||||
expect(planner.handleRemoveAssignment).toHaveBeenCalledWith(2, 11)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-010: assigns the place to a day from the picker', () => {
|
||||
const { planner } = renderSheet()
|
||||
const add = screen.getByRole('button', { name: 'Add to Day' })
|
||||
expect(add).toHaveAttribute('aria-expanded', 'false')
|
||||
fireEvent.click(add)
|
||||
expect(screen.getByText('May 1')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('Day 3'))
|
||||
expect(planner.handleAssignToDay).toHaveBeenCalledWith(101, 3)
|
||||
expect(screen.queryByText('May 1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-011: treats an assignment without participants as everyone joined', async () => {
|
||||
vi.spyOn(assignmentsApi, 'setParticipants').mockResolvedValue({ participants: [{ user_id: 2, username: 'bob' }] })
|
||||
renderSheet()
|
||||
expect(screen.getByText('Participants')).toBeInTheDocument()
|
||||
expect(screen.getByText('ann')).toBeInTheDocument()
|
||||
expect(screen.getByText('cara')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('ann'))
|
||||
await waitFor(() => expect(assignmentsApi.setParticipants).toHaveBeenCalledWith(5, 11, [2, 3]))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-012: writes the new participant list back into the trip store', async () => {
|
||||
vi.spyOn(assignmentsApi, 'setParticipants').mockResolvedValue({ participants: [{ user_id: 1, username: 'ann' }, { user_id: 2, username: 'bob' }] })
|
||||
seedStore(useTripStore, { assignments: { '2': [dayAssignment(11, 2, [{ user_id: 1, username: 'ann' }])] } })
|
||||
const planner = makePlanner({ assignments: { '2': [dayAssignment(11, 2, [{ user_id: 1, username: 'ann' }])] } })
|
||||
renderSheet(planner)
|
||||
|
||||
expect(screen.getByText('ann')).toBeInTheDocument()
|
||||
expect(screen.queryByText('bob')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
fireEvent.click(screen.getByText('bob'))
|
||||
await waitFor(() => expect(assignmentsApi.setParticipants).toHaveBeenCalledWith(5, 11, [1, 2]))
|
||||
await waitFor(() =>
|
||||
expect(useTripStore.getState().assignments['2'][0].participants).toEqual([
|
||||
{ user_id: 1, username: 'ann' },
|
||||
{ user_id: 2, username: 'bob' },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-013: reports a failing participant update', async () => {
|
||||
vi.spyOn(assignmentsApi, 'setParticipants').mockRejectedValue(new Error('forbidden'))
|
||||
const { planner } = renderSheet()
|
||||
fireEvent.click(screen.getByText('bob'))
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('forbidden'))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-014: prefers the explicitly selected assignment for the participant row', async () => {
|
||||
vi.spyOn(assignmentsApi, 'setParticipants').mockResolvedValue({ participants: [] })
|
||||
const planner = makePlanner({
|
||||
selectedAssignmentId: 12,
|
||||
assignments: { '2': [dayAssignment(11, 2), dayAssignment(12, 2)] },
|
||||
})
|
||||
renderSheet(planner)
|
||||
fireEvent.click(screen.getByText('ann'))
|
||||
await waitFor(() => expect(assignmentsApi.setParticipants).toHaveBeenCalledWith(5, 12, [2, 3]))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-015: hides participants when the trip has a single member', () => {
|
||||
renderSheet(makePlanner({ tripMembers: [MEMBERS[0]] }))
|
||||
expect(screen.queryByText('Participants')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-016: counts only this place\'s live files and opens one', () => {
|
||||
renderSheet()
|
||||
expect(screen.getByText('2 files')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('2 files'))
|
||||
expect(screen.getByText('ticket.pdf')).toBeInTheDocument()
|
||||
expect(screen.getByText('map.png')).toBeInTheDocument()
|
||||
expect(screen.queryByText('deleted.pdf')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('other.pdf')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('ticket.pdf'))
|
||||
expect(openFile).toHaveBeenCalledWith('/uploads/files/ticket.pdf', 'ticket.pdf')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-017: uploads every picked file against the place and expands the list', async () => {
|
||||
const planner = makePlanner({ files: [] })
|
||||
renderSheet(planner)
|
||||
expect(screen.getByText('Files')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /Upload/ }))
|
||||
|
||||
const input = document.querySelector('input[type="file"][multiple]') as HTMLInputElement
|
||||
selectFiles(input, [new File(['a'], 'a.pdf'), new File(['b'], 'b.pdf')])
|
||||
|
||||
await waitFor(() => expect(planner.tripActions.addFile).toHaveBeenCalledTimes(2))
|
||||
const fd = vi.mocked(planner.tripActions.addFile).mock.calls[0][1] as FormData
|
||||
expect(fd.get('place_id')).toBe('101')
|
||||
expect((fd.get('file') as File).name).toBe('a.pdf')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-018: reports a failing file upload', async () => {
|
||||
const planner = makePlanner()
|
||||
vi.mocked(planner.tripActions.addFile).mockRejectedValue(new Error('too large'))
|
||||
renderSheet(planner)
|
||||
const input = document.querySelector('input[type="file"][multiple]') as HTMLInputElement
|
||||
selectFiles(input, [new File(['a'], 'a.pdf')])
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Upload failed'))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-019: uploads and removes the custom place image', async () => {
|
||||
const { planner } = renderSheet()
|
||||
const input = document.querySelector('input[type="file"][accept*="image"]') as HTMLInputElement
|
||||
const openPicker = vi.spyOn(input, 'click')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Change image' }))
|
||||
expect(openPicker).toHaveBeenCalledTimes(1)
|
||||
|
||||
const picture = new File(['x'], 'front.png', { type: 'image/png' })
|
||||
selectFiles(input, [picture])
|
||||
await waitFor(() => expect(planner.tripActions.uploadPlaceImage).toHaveBeenCalledWith(5, 101, picture))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove image' }))
|
||||
await waitFor(() => expect(planner.tripActions.updatePlace).toHaveBeenCalledWith(5, 101, { image_url: null }))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-020: reports a failing image upload and offers upload on a place without one', async () => {
|
||||
const planner = makePlanner({ selectedPlace: { ...PLACE, image_url: null } })
|
||||
vi.mocked(planner.tripActions.uploadPlaceImage).mockRejectedValue(new Error('unsupported'))
|
||||
renderSheet(planner)
|
||||
expect(screen.getByRole('button', { name: 'Upload image' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Remove image' })).not.toBeInTheDocument()
|
||||
|
||||
const input = document.querySelector('input[type="file"][accept*="image"]') as HTMLInputElement
|
||||
selectFiles(input, [new File(['x'], 'front.png', { type: 'image/png' })])
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Could not upload image'))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-021: blocks the picker while an image request runs and reports its failure', async () => {
|
||||
const planner = makePlanner()
|
||||
let rejectUpdate: (err: Error) => void = () => {}
|
||||
vi.mocked(planner.tripActions.updatePlace).mockReturnValue(new Promise((_res, rej) => { rejectUpdate = rej }))
|
||||
renderSheet(planner)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove image' }))
|
||||
const input = document.querySelector('input[type="file"][accept*="image"]') as HTMLInputElement
|
||||
const openPicker = vi.spyOn(input, 'click')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Change image' }))
|
||||
expect(openPicker).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => { rejectUpdate(new Error('nope')) })
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Could not upload image'))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-022: hands the place to the save-to-collection picker', () => {
|
||||
renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save to Collection' }))
|
||||
expect(useSaveToCollectionStore.getState().target).toMatchObject({
|
||||
name: PLACE.name,
|
||||
source_trip_id: 5,
|
||||
source_place_id: 101,
|
||||
lat: 48.2038,
|
||||
lng: 16.3616,
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-023: opens directions, the website and the map view', async () => {
|
||||
const shell = makeShell({ trTab: 'dateien', view: 'plan' })
|
||||
const { planner } = renderSheet(makePlanner(), shell)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Google Maps' }))
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://www.google.com/maps/search/?api=1&query=48.2038,16.3616', '_blank', 'noopener,noreferrer',
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open Website' }))
|
||||
expect(window.open).toHaveBeenCalledWith('https://khm.at', '_blank', 'noopener,noreferrer')
|
||||
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Show on map' })) })
|
||||
expect(planner.setSelectedPlaceId).toHaveBeenCalledWith(null)
|
||||
expect(shell.setTrTab).toHaveBeenCalledWith('plan')
|
||||
expect(shell.toggleView).toHaveBeenCalledTimes(1)
|
||||
expect(planner.setFitKey).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-024: edits and deletes the place, both closing the sheet', () => {
|
||||
const { planner } = renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
expect(planner.openPlaceEditor).toHaveBeenCalledWith(PLACE, 11)
|
||||
expect(planner.setSelectedPlaceId).toHaveBeenCalledWith(null)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
expect(planner.handleDeletePlace).toHaveBeenCalledWith(101)
|
||||
expect(planner.setSelectedPlaceId).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSH-025: strips every editing affordance for a read-only member', () => {
|
||||
seedStore(useAddonStore, { addons: [] })
|
||||
renderSheet(makePlanner({ can: () => false, canUploadFiles: false }))
|
||||
expect(screen.queryByRole('button', { name: 'Change image' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Remove from Day' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Add to Day' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Upload/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Save to Collection' })).not.toBeInTheDocument()
|
||||
// Read-only members still get the navigation actions.
|
||||
expect(screen.getByRole('button', { name: 'Google Maps' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,360 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import MPlacesBrowser from '../../../../src/mobile/screens/trip/places/MPlacesBrowser'
|
||||
import { collectionsApi } from '../../../../src/api/collections'
|
||||
import type { CollectionListResponse } from '@trek/shared'
|
||||
import { useAddonStore } from '../../../../src/store/addonStore'
|
||||
import { useTripStore } from '../../../../src/store/tripStore'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import type { AssignmentsMap, Category, Day, Place } from '../../../../src/types'
|
||||
|
||||
// FE-MOB-PBROW-001 to FE-MOB-PBROW-029
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 1, name: 'Sights', color: '#123456', icon: 'landmark' },
|
||||
// No colour — the panel and the row fall back to the muted token.
|
||||
{ id: 2, name: 'Food', color: '', icon: 'utensils' },
|
||||
] as unknown as Category[]
|
||||
|
||||
const DAYS = [
|
||||
{ id: 21, trip_id: 1, day_number: 1, date: '2026-05-01' },
|
||||
{ id: 22, trip_id: 1, day_number: 2, date: '2026-05-02' },
|
||||
] as unknown as Day[]
|
||||
|
||||
function place(over: Partial<Place>): Place {
|
||||
return {
|
||||
id: 0, trip_id: 1, name: '', address: null, description: null, category_id: null,
|
||||
lat: 48.2, lng: 16.3, image_url: null, google_place_id: null, osm_id: null,
|
||||
route_geometry: null, route_color: null,
|
||||
...over,
|
||||
} as unknown as Place
|
||||
}
|
||||
|
||||
const LOUVRE = place({ id: 1, name: 'Louvre', address: 'Rue de Rivoli', category_id: 1 })
|
||||
const EIFFEL = place({ id: 2, name: 'Eiffel Tower', description: 'Iron lady' })
|
||||
const SEINE = place({ id: 3, name: 'Seine Track', category_id: 2, route_geometry: 'abc', route_color: '#ff0000' })
|
||||
|
||||
const PLACES = [LOUVRE, EIFFEL, SEINE]
|
||||
|
||||
// Louvre is planned on day 22 (day_number 2), the rest is still in the pool.
|
||||
const ASSIGNMENTS: AssignmentsMap = {
|
||||
'22': [{ id: 90, day_id: 22, place_id: 1, order_index: 0, place: { id: 1, name: 'Louvre' } }],
|
||||
} as unknown as AssignmentsMap
|
||||
|
||||
function makePlanner(overrides: Partial<TripPlanner> = {}): TripPlanner {
|
||||
return buildPlanner({
|
||||
places: PLACES,
|
||||
categories: CATEGORIES,
|
||||
assignments: ASSIGNMENTS,
|
||||
days: DAYS,
|
||||
...overrides,
|
||||
} as Partial<TripPlanner>)
|
||||
}
|
||||
|
||||
function renderBrowser(planner: TripPlanner = makePlanner(), shell: MTripShellApi = buildShell()) {
|
||||
const view = render(<MPlacesBrowser planner={planner} shell={shell} />)
|
||||
return { ...view, planner, shell }
|
||||
}
|
||||
|
||||
/** Row button of a place — the whole left side of the row is one button. */
|
||||
const row = (name: string) => screen.getByRole('button', { name: new RegExp(name) })
|
||||
|
||||
describe('MPlacesBrowser', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
seedStore(useAddonStore, { addons: [{ id: 'collections', enabled: true }] })
|
||||
vi.spyOn(collectionsApi, 'list').mockResolvedValue({ collections: [], incomingInvites: [] } as CollectionListResponse)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-001: lists every place with its count divider', () => {
|
||||
renderBrowser()
|
||||
expect(screen.getByText('places.count:3')).toBeInTheDocument()
|
||||
expect(screen.getByText('Louvre')).toBeInTheDocument()
|
||||
expect(screen.getByText('Rue de Rivoli')).toBeInTheDocument()
|
||||
// No address → the description takes the sub line.
|
||||
expect(screen.getByText('Iron lady')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-002: badges a planned place with its first day and offers no quick-add', () => {
|
||||
renderBrowser()
|
||||
expect(screen.getByText('planner.dayN:2')).toBeInTheDocument()
|
||||
// Two unplanned places → two quick-add buttons.
|
||||
expect(screen.getAllByRole('button', { name: 'places.assignToDay' })).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-003: quick-add opens the place-actions sheet with the day picker', () => {
|
||||
const { shell } = renderBrowser()
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'places.assignToDay' })[0])
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('bract', { placeId: 2, dayPicker: true })
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-004: a row tap opens the place-actions sheet without the day picker', () => {
|
||||
const { shell } = renderBrowser()
|
||||
fireEvent.click(row('Louvre'))
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('bract', { placeId: 1, dayPicker: false })
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-005: the filter chips write the pool filter to the trip store', () => {
|
||||
renderBrowser()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'places.unplanned' }))
|
||||
expect(useTripStore.getState().placesFilter).toBe('unplanned')
|
||||
expect(screen.getByText('places.count:2')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Louvre')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'places.planned' }))
|
||||
expect(useTripStore.getState().placesFilter).toBe('planned')
|
||||
expect(screen.getByText('places.countSingular')).toBeInTheDocument()
|
||||
expect(screen.getByText('Louvre')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'places.all' }))
|
||||
expect(useTripStore.getState().placesFilter).toBe('all')
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-006: the tracks chip only appears with a track and filters to it', () => {
|
||||
renderBrowser()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'places.filterTracks' }))
|
||||
expect(screen.getByText('places.countSingular')).toBeInTheDocument()
|
||||
expect(screen.getByText('Seine Track')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Louvre')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-007: hides the tracks chip and falls back to all when the last track is gone', () => {
|
||||
seedStore(useTripStore, { placesFilter: 'tracks' })
|
||||
renderBrowser(makePlanner({ places: [LOUVRE, EIFFEL] } as Partial<TripPlanner>))
|
||||
expect(screen.queryByRole('button', { name: 'places.filterTracks' })).not.toBeInTheDocument()
|
||||
expect(useTripStore.getState().placesFilter).toBe('all')
|
||||
expect(screen.getByText('places.count:2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-008: entering the browser from the edit segment seeds the unplanned pool', () => {
|
||||
renderBrowser(makePlanner(), buildShell({ browseFromEdit: true }))
|
||||
expect(useTripStore.getState().placesFilter).toBe('unplanned')
|
||||
expect(screen.queryByText('Louvre')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-009: the search box matches name and address', () => {
|
||||
renderBrowser()
|
||||
const input = screen.getByPlaceholderText('places.search')
|
||||
fireEvent.change(input, { target: { value: 'rivoli' } })
|
||||
expect(screen.getByText('Louvre')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Seine Track')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'seine' } })
|
||||
expect(screen.getByText('Seine Track')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Louvre')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-010: the category panel toggles the shared filter set and badges its size', () => {
|
||||
renderBrowser()
|
||||
const catBtn = screen.getByRole('button', { name: 'places.allCategories' })
|
||||
expect(catBtn).toHaveAttribute('aria-expanded', 'false')
|
||||
fireEvent.click(catBtn)
|
||||
expect(catBtn).toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Sights' }))
|
||||
expect([...useTripStore.getState().placesCategoryFilter]).toEqual(['1'])
|
||||
expect(screen.getByRole('checkbox', { name: 'Sights' })).toHaveAttribute('aria-checked', 'true')
|
||||
expect(screen.getByText('Louvre')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Eiffel Tower')).not.toBeInTheDocument()
|
||||
expect(catBtn).toHaveTextContent('1')
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Sights' }))
|
||||
expect(useTripStore.getState().placesCategoryFilter.size).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-011: the uncategorized row filters the places without a category', () => {
|
||||
renderBrowser()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'places.allCategories' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'places.noCategory' }))
|
||||
expect([...useTripStore.getState().placesCategoryFilter]).toEqual(['uncategorized'])
|
||||
expect(screen.getByText('Eiffel Tower')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Louvre')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-012: the uncategorized row is dropped when every place has a category', () => {
|
||||
const planner = makePlanner({ places: [LOUVRE, SEINE] } as Partial<TripPlanner>)
|
||||
renderBrowser(planner)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'places.allCategories' }))
|
||||
expect(screen.queryByRole('checkbox', { name: 'places.noCategory' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('checkbox', { name: 'Food' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-013: select mode picks rows and reports the count', () => {
|
||||
renderBrowser()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
|
||||
expect(screen.getByText('places.selectionCount:0')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(row('Louvre'))
|
||||
fireEvent.click(row('Eiffel Tower'))
|
||||
expect(screen.getByText('places.selectionCount:2')).toBeInTheDocument()
|
||||
fireEvent.click(row('Louvre'))
|
||||
expect(screen.getByText('places.selectionCount:1')).toBeInTheDocument()
|
||||
// Row taps no longer open the actions sheet while selecting.
|
||||
expect(screen.queryByRole('button', { name: 'places.assignToDay' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-014: select all / deselect all covers exactly the visible pool', () => {
|
||||
renderBrowser()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.selectAll' }))
|
||||
expect(screen.getByText('places.selectionCount:3')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.deselectAll' }))
|
||||
expect(screen.getByText('places.selectionCount:0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-015: switching the pool filter or searching clears the selection', () => {
|
||||
renderBrowser()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.selectAll' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'places.unplanned' }))
|
||||
expect(screen.getByText('places.selectionCount:0')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(row('Eiffel Tower'))
|
||||
expect(screen.getByText('places.selectionCount:1')).toBeInTheDocument()
|
||||
fireEvent.change(screen.getByPlaceholderText('places.search'), { target: { value: 'ei' } })
|
||||
expect(screen.getByText('places.selectionCount:0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-016: leaving select mode drops the selection', () => {
|
||||
renderBrowser()
|
||||
const toggle = screen.getByRole('button', { name: 'common.select' })
|
||||
fireEvent.click(toggle)
|
||||
fireEvent.click(row('Louvre'))
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(screen.queryByText(/places.selectionCount/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-017: the bulk actions stay disabled until something is selected', () => {
|
||||
renderBrowser()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
|
||||
expect(screen.getByRole('button', { name: 'places.changeCategory' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'inspector.saveToCollection' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'places.deleteSelected' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'common.selectAll' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-018: the bulk category sheet applies the pick and leaves select mode', () => {
|
||||
const { planner } = renderBrowser()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
|
||||
fireEvent.click(row('Louvre'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'places.changeCategory' }))
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Change category' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Food' }))
|
||||
expect(planner.confirmChangeCategory).toHaveBeenCalledWith([1], 2)
|
||||
expect(screen.queryByText(/places.selectionCount/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-019: dismissing the category sheet keeps the selection untouched', async () => {
|
||||
const { planner } = renderBrowser()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
|
||||
fireEvent.click(row('Louvre'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'places.changeCategory' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Change category' })).not.toBeInTheDocument())
|
||||
expect(planner.confirmChangeCategory).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('places.selectionCount:1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-020: the delete confirmation removes the selection in one call', () => {
|
||||
const { planner } = renderBrowser()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
|
||||
fireEvent.click(row('Louvre'))
|
||||
fireEvent.click(row('Seine Track'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'places.deleteSelected' }))
|
||||
|
||||
expect(screen.getByText('trip.confirm.deletePlaces:2')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
|
||||
expect(planner.confirmDeletePlaces).toHaveBeenCalledWith([1, 3])
|
||||
expect(screen.queryByText(/places.selectionCount/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-021: cancelling the delete keeps the selection intact', () => {
|
||||
const { planner } = renderBrowser()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
|
||||
fireEvent.click(row('Louvre'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'places.deleteSelected' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
|
||||
expect(planner.confirmDeletePlaces).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('places.selectionCount:1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-022: the save-to-collection action opens the list picker', async () => {
|
||||
renderBrowser()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
|
||||
fireEvent.click(row('Louvre'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'inspector.saveToCollection' }))
|
||||
await waitFor(() => expect(collectionsApi.list).toHaveBeenCalled())
|
||||
expect(screen.getByRole('dialog', { name: 'Save 1 to a list' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Save 1 to a list' })).not.toBeInTheDocument())
|
||||
expect(screen.getByText('places.selectionCount:1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-023: without the collections addon neither the action nor the sheet exists', () => {
|
||||
seedStore(useAddonStore, { addons: [] })
|
||||
renderBrowser()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
|
||||
expect(screen.queryByRole('button', { name: 'inspector.saveToCollection' })).not.toBeInTheDocument()
|
||||
expect(collectionsApi.list).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-024: the add button opens a blank place form', () => {
|
||||
const { planner } = renderBrowser()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
|
||||
expect(planner.setEditingPlace).toHaveBeenCalledWith(null)
|
||||
expect(planner.setEditingAssignmentId).toHaveBeenCalledWith(null)
|
||||
expect(planner.setPrefillCoords).toHaveBeenCalledWith(null)
|
||||
expect(planner.setShowPlaceForm).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-025: the ellipsis opens the import sheet', () => {
|
||||
const { shell } = renderBrowser()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.importPlaces' }))
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('import')
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-026: a read-only member keeps the filters but loses every mutation', () => {
|
||||
renderBrowser(makePlanner({ can: vi.fn(() => false) } as Partial<TripPlanner>))
|
||||
expect(screen.getByRole('button', { name: 'places.all' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'mobileTrip.importPlaces' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.select' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.add' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-027: the empty unplanned pool celebrates instead of reporting no matches', () => {
|
||||
renderBrowser(makePlanner({ places: [LOUVRE] } as Partial<TripPlanner>), buildShell({ browseFromEdit: true }))
|
||||
expect(screen.getByText('places.allPlanned')).toBeInTheDocument()
|
||||
expect(screen.queryByText('places.noneFound')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-028: a search without hits reports no matches', () => {
|
||||
renderBrowser()
|
||||
fireEvent.change(screen.getByPlaceholderText('places.search'), { target: { value: 'zzz' } })
|
||||
expect(screen.getByText('places.noneFound')).toBeInTheDocument()
|
||||
expect(screen.getByText('places.count:0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PBROW-029: a deleted place drops out of the selection count', async () => {
|
||||
const shell = buildShell()
|
||||
const { rerender } = renderBrowser(makePlanner(), shell)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.selectAll' }))
|
||||
expect(screen.getByText('places.selectionCount:3')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
rerender(<MPlacesBrowser planner={makePlanner({ places: [LOUVRE, EIFFEL] } as Partial<TripPlanner>)} shell={shell} />)
|
||||
})
|
||||
expect(screen.getByText('places.selectionCount:2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '../../../helpers/render'
|
||||
import MPlacesBulkCategorySheet from '../../../../src/mobile/screens/trip/places/MPlacesBulkCategorySheet'
|
||||
import type { Category } from '../../../../src/types'
|
||||
|
||||
// FE-MOB-PBULK-001 to FE-MOB-PBULK-006
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 1, name: 'Sights', color: '#123456', icon: 'landmark' },
|
||||
{ id: 2, name: 'Food', color: '', icon: 'unknown-icon' },
|
||||
] as unknown as Category[]
|
||||
|
||||
function renderSheet(over: { open?: boolean; count?: number; categories?: Category[] } = {}) {
|
||||
const onPick = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
const view = render(
|
||||
<MPlacesBulkCategorySheet
|
||||
open={over.open ?? true}
|
||||
count={over.count ?? 3}
|
||||
categories={over.categories ?? CATEGORIES}
|
||||
onPick={onPick}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
return { ...view, onPick, onClose }
|
||||
}
|
||||
|
||||
describe('MPlacesBulkCategorySheet', () => {
|
||||
it('FE-MOB-PBULK-001: titles the sheet and reports how many places the pick applies to', () => {
|
||||
renderSheet()
|
||||
expect(screen.getByRole('dialog', { name: 'Change category' })).toBeInTheDocument()
|
||||
expect(screen.getByText('3 selected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PBULK-002: lists every category plus the clear-category row', () => {
|
||||
renderSheet()
|
||||
expect(screen.getByRole('button', { name: 'Sights' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Food' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'No Category' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PBULK-003: hands the picked category id back', () => {
|
||||
const { onPick } = renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sights' }))
|
||||
expect(onPick).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-PBULK-004: the clear row picks null', () => {
|
||||
const { onPick } = renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'No Category' }))
|
||||
expect(onPick).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-MOB-PBULK-005: the close button dismisses without picking', () => {
|
||||
const { onPick, onClose } = renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
expect(onPick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PBULK-006: without categories only the clear row remains and it loses its divider', () => {
|
||||
renderSheet({ categories: [], count: 1 })
|
||||
expect(screen.getByText('1 selected')).toBeInTheDocument()
|
||||
const clear = screen.getByRole('button', { name: 'No Category' })
|
||||
expect(clear.className).not.toContain('border-t')
|
||||
expect(screen.queryByRole('button', { name: 'Sights' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PBULK-007: stays unmounted while closed', () => {
|
||||
renderSheet({ open: false })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Collection, CollectionListResponse } from '@trek/shared'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
import MPlacesSaveToCollectionSheet from '../../../../src/mobile/screens/trip/places/MPlacesSaveToCollectionSheet'
|
||||
import { collectionsApi } from '../../../../src/api/collections'
|
||||
|
||||
// FE-MOB-PSAVE-001 to FE-MOB-PSAVE-015
|
||||
|
||||
const listResponse = (collections: Collection[]): CollectionListResponse => ({ collections, incomingInvites: [] })
|
||||
|
||||
function list(over: Partial<Collection>): Collection {
|
||||
return { id: 1, owner_id: 7, name: 'List', color: null, place_count: 0, ...over } as Collection
|
||||
}
|
||||
|
||||
const FAVORITES = list({ id: 1, name: 'Favorites', color: '#ff0000', place_count: 4 })
|
||||
const WISHLIST = list({ id: 2, name: 'Wishlist' })
|
||||
|
||||
let addToast: ReturnType<typeof vi.fn>
|
||||
|
||||
function renderSheet(props: Partial<Parameters<typeof MPlacesSaveToCollectionSheet>[0]> = {}) {
|
||||
const onClose = vi.fn()
|
||||
const onDone = vi.fn()
|
||||
const view = render(
|
||||
<MPlacesSaveToCollectionSheet
|
||||
open
|
||||
tripId={5}
|
||||
placeIds={[11, 12]}
|
||||
onClose={onClose}
|
||||
onDone={onDone}
|
||||
{...props}
|
||||
/>,
|
||||
)
|
||||
return { ...view, onClose, onDone }
|
||||
}
|
||||
|
||||
describe('MPlacesSaveToCollectionSheet', () => {
|
||||
beforeEach(() => {
|
||||
addToast = vi.fn()
|
||||
window.__addToast = addToast as unknown as typeof window.__addToast
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
delete window.__addToast
|
||||
})
|
||||
|
||||
it('FE-MOB-PSAVE-001: lists the collections the user owns with their place counts', async () => {
|
||||
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES, WISHLIST]))
|
||||
renderSheet()
|
||||
expect(await screen.findByText('Favorites')).toBeInTheDocument()
|
||||
expect(screen.getByRole('dialog', { name: 'Save 2 to a list' })).toBeInTheDocument()
|
||||
expect(screen.getByText('4 places')).toBeInTheDocument()
|
||||
expect(screen.getByText('0 places')).toBeInTheDocument()
|
||||
expect(screen.getByText('Duplicates are skipped automatically')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PSAVE-002: drops lists shared with the user — they cannot be written to', async () => {
|
||||
// A list the server sent without a count must still render a countable row.
|
||||
const untracked = { id: 4, owner_id: 7, name: 'Untracked' } as Collection
|
||||
vi.spyOn(collectionsApi, 'list').mockResolvedValue(
|
||||
listResponse([FAVORITES, list({ id: 3, name: 'Shared trip ideas', is_owner: false }), untracked]),
|
||||
)
|
||||
renderSheet()
|
||||
expect(await screen.findByText('Favorites')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Shared trip ideas')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Untracked')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('0 places')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-PSAVE-003: shows the spinner until the lists arrive', async () => {
|
||||
let resolve!: (v: CollectionListResponse) => void
|
||||
vi.spyOn(collectionsApi, 'list').mockReturnValue(new Promise(r => { resolve = r }))
|
||||
renderSheet()
|
||||
expect(document.querySelector('.animate-spin')).not.toBeNull()
|
||||
expect(screen.queryByText('You have no lists yet')).not.toBeInTheDocument()
|
||||
resolve(listResponse([FAVORITES]))
|
||||
expect(await screen.findByText('Favorites')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PSAVE-004: reports the empty state when the response carries no collections', async () => {
|
||||
vi.spyOn(collectionsApi, 'list').mockResolvedValue({ incomingInvites: [] } as unknown as CollectionListResponse)
|
||||
renderSheet()
|
||||
expect(await screen.findByText('You have no lists yet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PSAVE-005: a failing request falls back to the empty state instead of breaking', async () => {
|
||||
vi.spyOn(collectionsApi, 'list').mockRejectedValue(new Error('offline'))
|
||||
renderSheet()
|
||||
expect(await screen.findByText('You have no lists yet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PSAVE-006: requests nothing while the sheet is closed', () => {
|
||||
const spy = vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([]))
|
||||
renderSheet({ open: false })
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PSAVE-007: the search box appears above five lists and filters by name', async () => {
|
||||
const many = [1, 2, 3, 4, 5, 6].map(id => list({ id, name: `List ${id}` }))
|
||||
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse(many))
|
||||
renderSheet()
|
||||
const search = await screen.findByPlaceholderText('Search')
|
||||
fireEvent.change(search, { target: { value: 'list 4' } })
|
||||
expect(screen.getByText('List 4')).toBeInTheDocument()
|
||||
expect(screen.queryByText('List 5')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.change(search, { target: { value: 'nope' } })
|
||||
expect(screen.getByText('You have no lists yet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PSAVE-008: five lists or fewer need no search box', async () => {
|
||||
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES, WISHLIST]))
|
||||
renderSheet()
|
||||
await screen.findByText('Favorites')
|
||||
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PSAVE-009: picking a list copies every selected place and reports the count', async () => {
|
||||
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES]))
|
||||
const save = vi.spyOn(collectionsApi, 'saveFromTripMany').mockResolvedValue({ copied: 2, skipped: [] })
|
||||
const { onClose, onDone } = renderSheet()
|
||||
fireEvent.click(await screen.findByText('Favorites'))
|
||||
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled())
|
||||
expect(save).toHaveBeenCalledWith(1, 5, [11, 12])
|
||||
expect(addToast).toHaveBeenCalledWith('Saved 2 to Favorites', 'success', undefined)
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PSAVE-010: duplicates the server skipped are reported separately', async () => {
|
||||
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES]))
|
||||
vi.spyOn(collectionsApi, 'saveFromTripMany').mockResolvedValue({
|
||||
copied: 1, skipped: [{ id: 12, name: 'Louvre' }],
|
||||
})
|
||||
renderSheet()
|
||||
fireEvent.click(await screen.findByText('Favorites'))
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Skipped 1 duplicates', 'info', undefined))
|
||||
expect(addToast).toHaveBeenCalledWith('Saved 1 to Favorites', 'success', undefined)
|
||||
})
|
||||
|
||||
it('FE-MOB-PSAVE-011: a no-op save says so instead of staying silent', async () => {
|
||||
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES]))
|
||||
vi.spyOn(collectionsApi, 'saveFromTripMany').mockResolvedValue({ copied: 0, skipped: [] })
|
||||
renderSheet()
|
||||
fireEvent.click(await screen.findByText('Favorites'))
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Nothing to copy', 'info', undefined))
|
||||
})
|
||||
|
||||
it('FE-MOB-PSAVE-012: a failed save surfaces the server message and keeps the sheet open', async () => {
|
||||
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES]))
|
||||
vi.spyOn(collectionsApi, 'saveFromTripMany').mockRejectedValue({
|
||||
response: { data: { error: 'List is full' } },
|
||||
})
|
||||
const { onClose, onDone } = renderSheet()
|
||||
fireEvent.click(await screen.findByText('Favorites'))
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('List is full', 'error', undefined))
|
||||
expect(onDone).not.toHaveBeenCalled()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PSAVE-013: a second tap is ignored while a save is still running', async () => {
|
||||
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES, WISHLIST]))
|
||||
let resolve!: (v: { copied: number; skipped: { id: number; name: string }[] }) => void
|
||||
const save = vi.spyOn(collectionsApi, 'saveFromTripMany')
|
||||
.mockReturnValue(new Promise(r => { resolve = r }))
|
||||
const { onDone } = renderSheet()
|
||||
fireEvent.click(await screen.findByText('Favorites'))
|
||||
fireEvent.click(screen.getByText('Wishlist'))
|
||||
expect(save).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolve({ copied: 2, skipped: [] })
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('FE-MOB-PSAVE-014: a response that lands after the sheet is gone is dropped', async () => {
|
||||
let resolve!: (v: CollectionListResponse) => void
|
||||
const spy = vi.spyOn(collectionsApi, 'list').mockReturnValue(new Promise(r => { resolve = r }))
|
||||
const first = renderSheet()
|
||||
first.unmount()
|
||||
resolve(listResponse([FAVORITES]))
|
||||
await Promise.resolve()
|
||||
expect(screen.queryByText('Favorites')).not.toBeInTheDocument()
|
||||
|
||||
let reject!: (e: Error) => void
|
||||
spy.mockReturnValue(new Promise((_, r) => { reject = r }))
|
||||
const second = renderSheet()
|
||||
second.unmount()
|
||||
reject(new Error('offline'))
|
||||
await Promise.resolve()
|
||||
expect(screen.queryByText('You have no lists yet')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PSAVE-015: an empty selection never reaches the server, and close hands back', async () => {
|
||||
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES]))
|
||||
const save = vi.spyOn(collectionsApi, 'saveFromTripMany')
|
||||
const { onClose } = renderSheet({ placeIds: [] })
|
||||
fireEvent.click(await screen.findByText('Favorites'))
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,560 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, within } from '../../../helpers/render'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import type { PluginDaySchedule } from '../../../../src/components/Plugins/PluginDaySchedule'
|
||||
import type { MPlanTimelineController } from '../../../../src/mobile/screens/trip/plan/useMPlanTimeline'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import type { PlanRow, TransitMeta, TransportEntry } from '../../../../src/mobile/screens/trip/plan/planTimelineModel'
|
||||
import type { MergedItem } from '../../../../src/utils/dayMerge'
|
||||
import type { Assignment, Day, DayNote, Place, RouteSegment } from '../../../../src/types'
|
||||
import MPlanTimeline from '../../../../src/mobile/screens/trip/plan/MPlanTimeline'
|
||||
|
||||
// FE-MOB-PLTL-001 to FE-MOB-PLTL-038
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
tl: {} as Record<string, unknown>,
|
||||
schedule: {} as PluginDaySchedule,
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/mobile/screens/trip/plan/useMPlanTimeline', () => ({
|
||||
useMPlanTimeline: () => mocks.tl,
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/components/Plugins/PluginDaySchedule', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('../../../../src/components/Plugins/PluginDaySchedule')>()
|
||||
return { ...actual, usePluginDaySchedule: () => mocks.schedule }
|
||||
})
|
||||
|
||||
const DAY = { id: 2, trip_id: 1, day_number: 2, date: '2026-05-02', title: 'Tokyo → Kyoto' } as unknown as Day
|
||||
|
||||
const MUSEUM = {
|
||||
id: 11, day_id: 2, place_id: 101, order_index: 0,
|
||||
place: { id: 101, name: 'Museum', place_time: '09:30', address: 'Museum Rd 1', lat: 35.71, lng: 139.79 },
|
||||
} as unknown as Assignment
|
||||
|
||||
const PARK = {
|
||||
id: 12, day_id: 2, place_id: 102, order_index: 1,
|
||||
place: { id: 102, name: 'Ueno Park', lat: 35.72, lng: 139.77 },
|
||||
} as unknown as Assignment
|
||||
|
||||
const NOTE = {
|
||||
id: 41, day_id: 2, text: 'Buy museum tickets', time: '08:00', icon: 'Ticket', sort_order: 0,
|
||||
} as unknown as DayNote
|
||||
|
||||
const FLIGHT = {
|
||||
id: 21, type: 'flight', title: 'LH 714 to Tokyo', day_id: 2,
|
||||
reservation_time: '2026-05-02T08:15',
|
||||
} as unknown as TransportEntry
|
||||
|
||||
const TRANSIT_RES = {
|
||||
id: 22, type: 'transit', title: 'Shibuya → Asakusa', day_id: 2,
|
||||
reservation_time: '2026-05-02T10:05',
|
||||
} as unknown as TransportEntry
|
||||
|
||||
const TRANSIT: TransitMeta = {
|
||||
legs: [{
|
||||
mode: 'SUBWAY', line: 'G', line_color: '#FF9500', duration: 900,
|
||||
from: { name: 'Shibuya Sta.' }, to: { name: 'Asakusa Sta.' },
|
||||
}],
|
||||
}
|
||||
|
||||
const SEG: RouteSegment = {
|
||||
mid: [35.7, 139.7], from: [35.71, 139.79], to: [35.72, 139.77],
|
||||
distance: 2100, duration: 600,
|
||||
walkingText: '26 min', drivingText: '7 min', distanceText: '2.1 km',
|
||||
}
|
||||
|
||||
const M_PLACE: MergedItem = { type: 'place', sortKey: 0, data: MUSEUM }
|
||||
const M_FLIGHT: MergedItem = { type: 'transport', sortKey: 1, data: FLIGHT }
|
||||
const M_TRANSIT: MergedItem = { type: 'transport', sortKey: 2, data: TRANSIT_RES }
|
||||
const M_NOTE: MergedItem = { type: 'note', sortKey: 3, data: NOTE }
|
||||
const M_PLACE2: MergedItem = { type: 'place', sortKey: 4, data: PARK }
|
||||
|
||||
const MERGED = [M_PLACE, M_FLIGHT, M_TRANSIT, M_NOTE, M_PLACE2]
|
||||
|
||||
const ROWS: PlanRow[] = [
|
||||
{ key: 'pl-11', kind: 'place', item: M_PLACE, assignment: MUSEUM, linkedRes: null },
|
||||
{ key: 'conn-pl-11', kind: 'conn', seg: SEG, assignmentId: 11 },
|
||||
{ key: 'tr-21', kind: 'transport', item: M_FLIGHT, res: FLIGHT },
|
||||
{ key: 'tr-22', kind: 'transit', item: M_TRANSIT, res: TRANSIT_RES, transit: TRANSIT },
|
||||
{ key: 'note-41', kind: 'note', item: M_NOTE, note: NOTE },
|
||||
{ key: 'pl-12', kind: 'place', item: M_PLACE2, assignment: PARK, linkedRes: null },
|
||||
{ key: 'conn-orphan', kind: 'conn', seg: { ...SEG, mode: 'walking' } },
|
||||
]
|
||||
|
||||
const EMPTY_SCHEDULE: PluginDaySchedule = {
|
||||
byAssignment: {}, byReservation: {}, byPosition: {}, minutesByDay: {},
|
||||
}
|
||||
|
||||
function buildTl(over: Record<string, unknown> = {}): MPlanTimelineController {
|
||||
return {
|
||||
day: DAY,
|
||||
rows: ROWS,
|
||||
merged: MERGED,
|
||||
hotelLegs: { top: null, bottom: null },
|
||||
hotelChips: [],
|
||||
weather: null,
|
||||
weatherTemp: null,
|
||||
upNext: { assignment: MUSEUM, minutesUntil: 45 },
|
||||
language: 'en',
|
||||
timeFormat: '24h',
|
||||
openTransitKeys: new Set<string>(),
|
||||
toggleTransit: vi.fn(),
|
||||
moveRow: vi.fn(),
|
||||
removeAssignment: vi.fn(),
|
||||
editAssignment: vi.fn(),
|
||||
editTransport: vi.fn(),
|
||||
openTransitJourney: vi.fn(),
|
||||
addPlace: vi.fn(),
|
||||
addBooking: vi.fn(),
|
||||
addTransport: vi.fn(),
|
||||
optimize: vi.fn(async () => undefined),
|
||||
exportGoogleMaps: vi.fn(),
|
||||
renameDay: vi.fn(),
|
||||
fullPlaceOf: vi.fn(() => undefined as Place | undefined),
|
||||
routeModeOptions: [
|
||||
{ key: 'driving', label: 'Driving' },
|
||||
{ key: 'walking', label: 'Walking' },
|
||||
{ key: 'plugin:ev/fastest', label: 'EV fastest' },
|
||||
],
|
||||
setLegMode: vi.fn(),
|
||||
...over,
|
||||
} as unknown as MPlanTimelineController
|
||||
}
|
||||
|
||||
/** The scrollable timeline card — its inline top offset encodes the layout mode. */
|
||||
const card = (container: HTMLElement) => container.querySelector('.overflow-y-auto') as HTMLElement
|
||||
|
||||
function renderTimeline(
|
||||
tlOver: Record<string, unknown> = {},
|
||||
plannerOver: Partial<TripPlanner> = {},
|
||||
shellOver: Partial<MTripShellApi> = {},
|
||||
) {
|
||||
mocks.tl = buildTl(tlOver) as unknown as Record<string, unknown>
|
||||
const planner = buildPlanner(plannerOver)
|
||||
const shell = buildShell(shellOver)
|
||||
return { planner, shell, ...render(<MPlanTimeline planner={planner} shell={shell} />) }
|
||||
}
|
||||
|
||||
describe('MPlanTimeline', () => {
|
||||
beforeEach(() => {
|
||||
mocks.schedule = EMPTY_SCHEDULE
|
||||
})
|
||||
|
||||
describe('go mode — up next card', () => {
|
||||
it('FE-MOB-PLTL-001: shows the next stop with its time, subtitle and countdown', () => {
|
||||
renderTimeline()
|
||||
|
||||
expect(screen.getByText('mobileTrip.upNext')).toBeInTheDocument()
|
||||
expect(screen.getByText('mobileTrip.inCountdown:transit.min:45')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Museum').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('09:30').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Museum Rd 1').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-002: tapping the card selects the place and its assignment', () => {
|
||||
const { planner } = renderTimeline()
|
||||
|
||||
fireEvent.click(screen.getByText('mobileTrip.upNext'))
|
||||
|
||||
expect(planner.handlePlaceClick).toHaveBeenCalledWith(101, 11)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-003: hides the countdown, time chip and subtitle when the stop has none', () => {
|
||||
const bare = { ...MUSEUM, place: { id: 101, name: 'Museum' } } as unknown as Assignment
|
||||
renderTimeline({ upNext: { assignment: bare, minutesUntil: null }, rows: [], merged: [] })
|
||||
|
||||
expect(screen.queryByText(/mobileTrip.inCountdown/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('09:30')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Museum Rd 1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-004: collapses the reserved space when the day has no up next', () => {
|
||||
const { container, unmount } = renderTimeline()
|
||||
expect(card(container).style.top).toContain('216px')
|
||||
unmount()
|
||||
|
||||
const empty = renderTimeline({ upNext: null, rows: [], merged: [] })
|
||||
expect(screen.queryByText('mobileTrip.upNext')).not.toBeInTheDocument()
|
||||
expect(card(empty.container).style.top).toContain('102px')
|
||||
})
|
||||
})
|
||||
|
||||
describe('timeline header', () => {
|
||||
it('FE-MOB-PLTL-005: renders the accommodation chips and the weather chip', () => {
|
||||
renderTimeline({
|
||||
hotelChips: [
|
||||
{ key: 'out-1', variant: 'checkout', name: 'Hotel Sacher', time: '11:00:00' },
|
||||
{ key: 'in-2', variant: 'checkin', name: 'Ryokan Kyoto', time: '15:00:00' },
|
||||
{ key: 'stay-3', variant: 'stay', name: 'Capsule Tokyo', time: null },
|
||||
],
|
||||
weather: { main: 'Rain', temp: 17 },
|
||||
weatherTemp: 17,
|
||||
})
|
||||
|
||||
expect(screen.getByText(/Hotel Sacher/)).toHaveTextContent('Hotel Sacher · 11:00')
|
||||
expect(screen.getByText(/Ryokan Kyoto/)).toHaveTextContent('Ryokan Kyoto · 15:00')
|
||||
expect(screen.getByText('Capsule Tokyo')).toBeInTheDocument()
|
||||
expect(screen.getByText('17°')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-006: a chip opens the day sheet', () => {
|
||||
const { shell } = renderTimeline({
|
||||
hotelChips: [{ key: 'stay-3', variant: 'stay', name: 'Capsule Tokyo', time: null }],
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Capsule Tokyo'))
|
||||
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('day', { dayId: 2 })
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-007: is left out entirely without chips and without weather', () => {
|
||||
const { container } = renderTimeline()
|
||||
|
||||
expect(container.querySelector('.border-b')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-008: draws the hotel bookend legs above and below the rows', () => {
|
||||
renderTimeline({
|
||||
hotelLegs: {
|
||||
top: { seg: SEG, name: 'Hotel Sacher' },
|
||||
bottom: { seg: SEG, name: 'Ryokan Kyoto' },
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('Hotel Sacher')).toBeInTheDocument()
|
||||
expect(screen.getByText('Ryokan Kyoto')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rows', () => {
|
||||
it('FE-MOB-PLTL-009: renders one row per kind', () => {
|
||||
renderTimeline()
|
||||
|
||||
expect(screen.getAllByText('Museum').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Ueno Park')).toBeInTheDocument()
|
||||
expect(screen.getByText('LH 714 to Tokyo')).toBeInTheDocument()
|
||||
expect(screen.getByText('Shibuya → Asakusa')).toBeInTheDocument()
|
||||
expect(screen.getByText('Buy museum tickets')).toBeInTheDocument()
|
||||
expect(screen.getByText('7 min')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-010: a place row selects its place', () => {
|
||||
const { planner } = renderTimeline()
|
||||
|
||||
fireEvent.click(screen.getByText('Ueno Park'))
|
||||
|
||||
expect(planner.handlePlaceClick).toHaveBeenCalledWith(102, 12)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-011: a place row without a place still reports the assignment', () => {
|
||||
const orphan = { id: 13, day_id: 2, place_id: 103, order_index: 2, place: null } as unknown as Assignment
|
||||
const rows: PlanRow[] = [{ key: 'pl-13', kind: 'place', item: M_PLACE, assignment: orphan, linkedRes: null }]
|
||||
const { planner, container } = renderTimeline({ rows })
|
||||
|
||||
fireEvent.click(container.querySelector('.cursor-pointer.items-center') as HTMLElement)
|
||||
|
||||
expect(planner.handlePlaceClick).toHaveBeenCalledWith(null, 13)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-012: a transport row opens the transport sheet in go mode', () => {
|
||||
const { shell } = renderTimeline()
|
||||
|
||||
fireEvent.click(screen.getByText('LH 714 to Tokyo'))
|
||||
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('transport', { reservationId: 21 })
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-013: a transport row opens the editor in edit mode', () => {
|
||||
const { shell } = renderTimeline({}, {}, { mode: 'edit' })
|
||||
|
||||
fireEvent.click(screen.getByText('LH 714 to Tokyo'))
|
||||
|
||||
expect(mocks.tl.editTransport).toHaveBeenCalledWith(FLIGHT)
|
||||
expect(shell.openSheet).not.toHaveBeenCalledWith('transport', { reservationId: 21 })
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-014: a transit row toggles by its row key and renders its legs when open', () => {
|
||||
const { unmount } = renderTimeline()
|
||||
fireEvent.click(screen.getByText('Shibuya → Asakusa'))
|
||||
expect(mocks.tl.toggleTransit).toHaveBeenCalledWith('tr-22')
|
||||
expect(screen.queryByText('Shibuya Sta. → Asakusa Sta.')).not.toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
renderTimeline({ openTransitKeys: new Set(['tr-22']) })
|
||||
|
||||
expect(screen.getByText('Shibuya Sta. → Asakusa Sta.')).toBeInTheDocument()
|
||||
expect(screen.getByText('transit.min:15')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-015: a note row opens the note sheet with its note in edit mode', () => {
|
||||
const { shell } = renderTimeline({}, {}, { mode: 'edit' })
|
||||
|
||||
fireEvent.click(screen.getByText('Buy museum tickets'))
|
||||
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('note', { dayId: 2, note: NOTE })
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-016: the reorder arrows move the merged item they belong to', () => {
|
||||
renderTimeline({}, {}, { mode: 'edit' })
|
||||
|
||||
const ups = screen.getAllByLabelText('dayplan.moveUp')
|
||||
const downs = screen.getAllByLabelText('dayplan.moveDown')
|
||||
|
||||
// one stack per reorderable row: place, transport, transit, note, place
|
||||
expect(ups).toHaveLength(5)
|
||||
expect(ups[0]).toBeDisabled()
|
||||
expect(downs[4]).toBeDisabled()
|
||||
|
||||
fireEvent.click(downs[0])
|
||||
fireEvent.click(ups[4])
|
||||
|
||||
expect(mocks.tl.moveRow).toHaveBeenNthCalledWith(1, M_PLACE, 'down')
|
||||
expect(mocks.tl.moveRow).toHaveBeenNthCalledWith(2, M_PLACE2, 'up')
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-segment travel mode', () => {
|
||||
it('FE-MOB-PLTL-017: a connector with an origin assignment opens the mode menu', () => {
|
||||
renderTimeline()
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(screen.getByText('Driving')).toBeInTheDocument()
|
||||
expect(screen.getByText('Walking')).toBeInTheDocument()
|
||||
expect(screen.getByText('EV fastest')).toBeInTheDocument()
|
||||
expect(screen.getByText('dayplan.transportMode.useDefault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-018: picking a mode sets it on the origin assignment', () => {
|
||||
renderTimeline()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
fireEvent.click(screen.getByText('Walking'))
|
||||
|
||||
expect(mocks.tl.setLegMode).toHaveBeenCalledWith(11, 'walking')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-019: the default entry clears the override', () => {
|
||||
renderTimeline()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
fireEvent.click(screen.getByText('dayplan.transportMode.useDefault'))
|
||||
|
||||
expect(mocks.tl.setLegMode).toHaveBeenCalledWith(11, null)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-020: read-only members get no tappable connectors', () => {
|
||||
const { container } = renderTimeline({}, { can: vi.fn(() => false) })
|
||||
|
||||
expect(container.querySelector('button')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin day schedule', () => {
|
||||
it('FE-MOB-PLTL-021: slots contributions under their anchors and at the day edges', () => {
|
||||
mocks.schedule = {
|
||||
byAssignment: { 2: { 11: [{ pluginId: 'ev', id: 'a1', dayId: 2, assignmentId: 11, minutes: 35, label: 'Charging stop', tone: 'success' }] } },
|
||||
byReservation: {
|
||||
2: {
|
||||
21: [{ pluginId: 'ev', id: 'r1', dayId: 2, reservationId: 21, minutes: 45, label: 'Security', tone: 'warn' }],
|
||||
22: [{ pluginId: 'ev', id: 'r2', dayId: 2, reservationId: 22, label: 'Ticket check', tone: 'default' }],
|
||||
},
|
||||
},
|
||||
byPosition: {
|
||||
2: {
|
||||
start: [{ pluginId: 'ev', id: 's1', dayId: 2, position: 'start', minutes: 10, label: 'Morning prep', tone: 'default' }],
|
||||
end: [{ pluginId: 'ev', id: 'e1', dayId: 2, position: 'end', minutes: 20, label: 'Evening wrap', tone: 'danger' }],
|
||||
},
|
||||
},
|
||||
minutesByDay: { 2: 110 },
|
||||
}
|
||||
renderTimeline()
|
||||
|
||||
expect(screen.getByText('Charging stop')).toBeInTheDocument()
|
||||
expect(screen.getByText('Security')).toBeInTheDocument()
|
||||
expect(screen.getByText('Ticket check')).toBeInTheDocument()
|
||||
expect(screen.getByText('Morning prep')).toBeInTheDocument()
|
||||
expect(screen.getByText('Evening wrap')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-022: contributes nothing while no day is selected', () => {
|
||||
mocks.schedule = {
|
||||
byAssignment: { 2: { 11: [{ pluginId: 'ev', id: 'a1', dayId: 2, assignmentId: 11, minutes: 35, label: 'Charging stop', tone: 'success' }] } },
|
||||
byReservation: {},
|
||||
byPosition: { 2: { start: [{ pluginId: 'ev', id: 's1', dayId: 2, position: 'start', label: 'Morning prep', tone: 'default' }], end: [] } },
|
||||
minutesByDay: { 2: 35 },
|
||||
}
|
||||
const rows: PlanRow[] = [{ key: 'pl-11', kind: 'place', item: M_PLACE, assignment: MUSEUM, linkedRes: null }]
|
||||
renderTimeline({ day: undefined, rows })
|
||||
|
||||
expect(screen.queryByText('Charging stop')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Morning prep')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty day', () => {
|
||||
it('FE-MOB-PLTL-023: shows the mascot empty state in go mode', () => {
|
||||
const { container } = renderTimeline({ rows: [], merged: [], upNext: null })
|
||||
|
||||
expect(screen.getByText('dayplan.emptyDay')).toBeInTheDocument()
|
||||
expect(container.querySelector('.trek--guide')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-024: replaces it with the action grid in edit mode', () => {
|
||||
renderTimeline({ rows: [], merged: [], upNext: null }, {}, { mode: 'edit' })
|
||||
|
||||
expect(screen.queryByText('dayplan.emptyDay')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('mobileTrip.addPlaceShort')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('edit mode', () => {
|
||||
it('FE-MOB-PLTL-025: reserves the header space and hides the up-next card', () => {
|
||||
const { container } = renderTimeline({}, {}, { mode: 'edit' })
|
||||
|
||||
expect(screen.queryByText('mobileTrip.upNext')).not.toBeInTheDocument()
|
||||
expect(card(container).style.top).toContain('140px')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-026: splits the day title into city pills', () => {
|
||||
renderTimeline({}, {}, { mode: 'edit' })
|
||||
|
||||
expect(screen.getByText('Tokyo')).toBeInTheDocument()
|
||||
expect(screen.getByText('Kyoto')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-027: falls back to the day number when the day has no title', () => {
|
||||
renderTimeline({ day: { ...DAY, title: null } }, {}, { mode: 'edit' })
|
||||
|
||||
expect(screen.getByText('planner.dayN:2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-028: the pencil renames the day and Enter commits the change', () => {
|
||||
renderTimeline({}, {}, { mode: 'edit' })
|
||||
|
||||
fireEvent.click(screen.getByLabelText('mobileTrip.renameDay'))
|
||||
const input = screen.getByPlaceholderText('mobileTrip.dayTitlePlaceholder') as HTMLInputElement
|
||||
expect(input.value).toBe('Tokyo → Kyoto')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'Osaka' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(mocks.tl.renameDay).toHaveBeenCalledWith('Osaka')
|
||||
// committing closes the field again — the pills come back from the store
|
||||
expect(screen.queryByPlaceholderText('mobileTrip.dayTitlePlaceholder')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Tokyo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-029: Escape leaves the title untouched', () => {
|
||||
renderTimeline({}, {}, { mode: 'edit' })
|
||||
|
||||
fireEvent.click(screen.getByLabelText('mobileTrip.renameDay'))
|
||||
const input = screen.getByPlaceholderText('mobileTrip.dayTitlePlaceholder')
|
||||
fireEvent.change(input, { target: { value: 'Osaka' } })
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
|
||||
expect(mocks.tl.renameDay).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Tokyo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-030: blurring an unchanged draft does not rename', () => {
|
||||
renderTimeline({}, {}, { mode: 'edit' })
|
||||
|
||||
fireEvent.click(screen.getByLabelText('mobileTrip.renameDay'))
|
||||
fireEvent.blur(screen.getByPlaceholderText('mobileTrip.dayTitlePlaceholder'))
|
||||
|
||||
expect(mocks.tl.renameDay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-031: the calendar button opens the day reorder sheet', () => {
|
||||
const { shell } = renderTimeline({}, {}, { mode: 'edit' })
|
||||
|
||||
fireEvent.click(screen.getByLabelText('dayplan.reorderDays'))
|
||||
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('days')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-032: undo is disabled until there is something to undo', () => {
|
||||
const { unmount } = renderTimeline({}, {}, { mode: 'edit' })
|
||||
expect(screen.getByText('undo.button').closest('button')).toBeDisabled()
|
||||
unmount()
|
||||
|
||||
const { planner } = renderTimeline(
|
||||
{}, { canUndo: true, lastActionLabel: 'undo.reorder' }, { mode: 'edit' },
|
||||
)
|
||||
const button = screen.getByText('undo.button').closest('button') as HTMLButtonElement
|
||||
expect(button).toHaveAttribute('title', 'undo.tooltip:undo.reorder')
|
||||
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(planner.handleUndo).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-033: every action tile fires its planner action', () => {
|
||||
const { shell } = renderTimeline({}, {}, { mode: 'edit' })
|
||||
|
||||
fireEvent.click(screen.getByText('mobileTrip.addPlaceShort'))
|
||||
fireEvent.click(screen.getByText('mobileTrip.addNoteShort'))
|
||||
fireEvent.click(screen.getByText('mobileTrip.addBookingShort'))
|
||||
fireEvent.click(screen.getByText('mobileTrip.addTransportShort'))
|
||||
fireEvent.click(screen.getByText('dayplan.optimize'))
|
||||
fireEvent.click(screen.getByText('mobileTrip.googleMaps'))
|
||||
|
||||
expect(mocks.tl.addPlace).toHaveBeenCalledTimes(1)
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('note', { dayId: 2 })
|
||||
expect(mocks.tl.addBooking).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.tl.addTransport).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.tl.optimize).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.tl.exportGoogleMaps).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-034: the note tile is inert while no day is selected', () => {
|
||||
const { shell } = renderTimeline({ day: undefined, rows: [], merged: [] }, {}, { mode: 'edit' })
|
||||
|
||||
fireEvent.click(screen.getByText('mobileTrip.addNoteShort'))
|
||||
|
||||
expect(shell.openSheet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-038: renaming starts from an empty draft while no day is loaded', () => {
|
||||
renderTimeline({ day: undefined, rows: [], merged: [] }, {}, { mode: 'edit' })
|
||||
|
||||
fireEvent.click(screen.getByLabelText('mobileTrip.renameDay'))
|
||||
const input = screen.getByPlaceholderText('mobileTrip.dayTitlePlaceholder') as HTMLInputElement
|
||||
expect(input.value).toBe('')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'Nara' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(mocks.tl.renameDay).toHaveBeenCalledWith('Nara')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-035: read-only members never enter edit mode', () => {
|
||||
renderTimeline({}, { can: vi.fn(() => false) }, { mode: 'edit' })
|
||||
|
||||
expect(screen.queryByText('mobileTrip.addPlaceShort')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('mobileTrip.upNext')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-036: place rows expose edit and remove in edit mode', () => {
|
||||
renderTimeline({}, {}, { mode: 'edit' })
|
||||
|
||||
const edits = screen.getAllByLabelText('common.edit')
|
||||
fireEvent.click(edits[0])
|
||||
fireEvent.click(screen.getAllByLabelText('planner.removeFromDay')[0])
|
||||
|
||||
expect(mocks.tl.editAssignment).toHaveBeenCalledWith(MUSEUM)
|
||||
expect(mocks.tl.removeAssignment).toHaveBeenCalledWith(MUSEUM)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-037: the transit row opens the journey view from its edit circle', () => {
|
||||
renderTimeline({}, {}, { mode: 'edit' })
|
||||
|
||||
const transitCard = screen.getByText('Shibuya → Asakusa').closest('.mt-1\\.5') as HTMLElement
|
||||
fireEvent.click(within(transitCard).getByLabelText('common.edit'))
|
||||
|
||||
expect(mocks.tl.openTransitJourney).toHaveBeenCalledWith(TRANSIT_RES)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,481 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '../../../helpers/render'
|
||||
import {
|
||||
ConnRow, HotelConnRow, NoteRow, PlaceRow, PlanScheduleRow, ReorderStack, TransitRow, TransportRow,
|
||||
} from '../../../../src/mobile/screens/trip/plan/MPlanTimelineRows'
|
||||
import type { TransitMeta, TransportEntry } from '../../../../src/mobile/screens/trip/plan/planTimelineModel'
|
||||
import type { PluginDayScheduleItem } from '../../../../src/api/client'
|
||||
import type { Assignment, DayNote, Place, Reservation, RouteSegment, TranslationFn } from '../../../../src/types'
|
||||
|
||||
// FE-MOB-PLROW-001 to FE-MOB-PLROW-038
|
||||
|
||||
// Same echo strategy as tests/helpers/mobileTrip: assertions stay on keys, not copy.
|
||||
const t: TranslationFn = (key, params) =>
|
||||
params ? `${key}:${Object.values(params).join(',')}` : key
|
||||
|
||||
const chrome = (editing = false) => ({ editing, t, language: 'en', timeFormat: '24h' })
|
||||
|
||||
const REORDER = <span data-testid="reorder">reorder</span>
|
||||
|
||||
function assignment(over: Record<string, unknown> = {}): Assignment {
|
||||
return {
|
||||
id: 11,
|
||||
day_id: 2,
|
||||
place_id: 101,
|
||||
order_index: 0,
|
||||
place: {
|
||||
id: 101,
|
||||
name: 'Museum',
|
||||
place_time: '09:30',
|
||||
address: 'Museum Rd 1',
|
||||
lat: 35.71,
|
||||
lng: 139.79,
|
||||
category: { icon: 'Landmark', color: '#a855f7' },
|
||||
},
|
||||
...over,
|
||||
} as unknown as Assignment
|
||||
}
|
||||
|
||||
const SEG: RouteSegment = {
|
||||
mid: [35.7, 139.7], from: [35.71, 139.79], to: [35.65, 139.7],
|
||||
distance: 5400, duration: 900,
|
||||
walkingText: '1 h 5 min', drivingText: '14 min', distanceText: '5.4 km',
|
||||
}
|
||||
|
||||
describe('ReorderStack', () => {
|
||||
it('FE-MOB-PLROW-001: renders both arrows with their labels and fires them', () => {
|
||||
const onUp = vi.fn()
|
||||
const onDown = vi.fn()
|
||||
render(<ReorderStack onUp={onUp} onDown={onDown} canUp canDown t={t} />)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('dayplan.moveUp'))
|
||||
fireEvent.click(screen.getByLabelText('dayplan.moveDown'))
|
||||
|
||||
expect(onUp).toHaveBeenCalledTimes(1)
|
||||
expect(onDown).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-002: disables the arrow that would fall off the list', () => {
|
||||
render(<ReorderStack onUp={vi.fn()} onDown={vi.fn()} canUp={false} canDown={false} t={t} />)
|
||||
|
||||
expect(screen.getByLabelText('dayplan.moveUp')).toBeDisabled()
|
||||
expect(screen.getByLabelText('dayplan.moveDown')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-003: stops the click from opening the row it sits in', () => {
|
||||
const rowClick = vi.fn()
|
||||
const onUp = vi.fn()
|
||||
render(
|
||||
<div onClick={rowClick}>
|
||||
<ReorderStack onUp={onUp} onDown={vi.fn()} canUp canDown t={t} />
|
||||
</div>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('dayplan.moveUp'))
|
||||
|
||||
expect(onUp).toHaveBeenCalled()
|
||||
expect(rowClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PlaceRow', () => {
|
||||
const props = {
|
||||
assignment: assignment(),
|
||||
fullPlace: undefined,
|
||||
linkedRes: null,
|
||||
chrome: chrome(),
|
||||
reorder: REORDER,
|
||||
onOpen: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onRemove: vi.fn(),
|
||||
}
|
||||
|
||||
it('FE-MOB-PLROW-004: shows name, formatted time and the address subtitle', () => {
|
||||
render(<PlaceRow {...props} onOpen={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('Museum')).toBeInTheDocument()
|
||||
expect(screen.getByText('09:30')).toBeInTheDocument()
|
||||
expect(screen.getByText('Museum Rd 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-005: falls back to the description when there is no address', () => {
|
||||
const a = assignment({
|
||||
place: { id: 101, name: 'Museum', address: null, description: 'Ukiyo-e wing' },
|
||||
})
|
||||
render(<PlaceRow {...props} assignment={a} />)
|
||||
|
||||
expect(screen.getByText('Ukiyo-e wing')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-006: drops the meta line entirely when there is neither time nor subtitle', () => {
|
||||
const a = assignment({ place: { id: 101, name: 'Museum' } })
|
||||
const { container } = render(<PlaceRow {...props} assignment={a} />)
|
||||
|
||||
expect(container.textContent).toBe('Museum')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-007: a linked booking replaces the subtitle and adds the booking badge', () => {
|
||||
const linkedRes = { id: 31, status: 'confirmed', confirmation_number: 'X9K' } as unknown as Reservation
|
||||
render(<PlaceRow {...props} linkedRes={linkedRes} />)
|
||||
|
||||
expect(screen.getByText('dayplan.confirmed · #X9K')).toBeInTheDocument()
|
||||
expect(screen.getByText('mobileTrip.resBadge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-008: a pending booking without a number shows just the status', () => {
|
||||
const linkedRes = { id: 31, status: 'pending', confirmation_number: null } as unknown as Reservation
|
||||
render(<PlaceRow {...props} linkedRes={linkedRes} />)
|
||||
|
||||
expect(screen.getByText('dayplan.pendingRes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-009: renders the pool photo of the place in go mode', () => {
|
||||
const fullPlace = {
|
||||
id: 101, name: 'Museum', image_url: '/uploads/places/museum.jpg',
|
||||
google_place_id: null, osm_id: null, lat: 35.71, lng: 139.79,
|
||||
} as unknown as Place
|
||||
render(<PlaceRow {...props} fullPlace={fullPlace} />)
|
||||
|
||||
expect(screen.getByAltText('Museum')).toHaveAttribute('src', '/uploads/places/museum.jpg')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-010: falls back to the assignment projection when the pool entry is missing', () => {
|
||||
const a = assignment({
|
||||
place: { id: 101, name: 'Museum', image_url: '/uploads/places/thumb.jpg', category: null },
|
||||
})
|
||||
render(<PlaceRow {...props} assignment={a} fullPlace={undefined} />)
|
||||
|
||||
expect(screen.getByAltText('Museum')).toHaveAttribute('src', '/uploads/places/thumb.jpg')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-011: tapping the row opens the place', () => {
|
||||
const onOpen = vi.fn()
|
||||
render(<PlaceRow {...props} onOpen={onOpen} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Museum'))
|
||||
|
||||
expect(onOpen).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-012: edit mode swaps the avatar for edit/remove circles plus the reorder slot', () => {
|
||||
const onEdit = vi.fn()
|
||||
const onRemove = vi.fn()
|
||||
const onOpen = vi.fn()
|
||||
render(
|
||||
<PlaceRow {...props} chrome={chrome(true)} onEdit={onEdit} onRemove={onRemove} onOpen={onOpen} />,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('reorder')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByLabelText('common.edit'))
|
||||
fireEvent.click(screen.getByLabelText('planner.removeFromDay'))
|
||||
|
||||
expect(onEdit).toHaveBeenCalledTimes(1)
|
||||
expect(onRemove).toHaveBeenCalledTimes(1)
|
||||
// the circles stop propagation so the row does not also open
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TransportRow', () => {
|
||||
const FLIGHT = {
|
||||
id: 21, type: 'flight', title: 'LH 714 to Tokyo', day_id: 2,
|
||||
reservation_time: '2026-05-02T08:15', reservation_end_time: '2026-05-02T18:40',
|
||||
metadata: { airline: 'Lufthansa', flight_number: 'LH714', departure_airport: 'MUC', arrival_airport: 'HND' },
|
||||
} as unknown as TransportEntry
|
||||
|
||||
const base = { dayId: 2, chrome: chrome(), reorder: REORDER, onOpen: vi.fn() }
|
||||
|
||||
it('FE-MOB-PLROW-013: shows a single-day booking as a start–end range with the flight subtitle', () => {
|
||||
const { container } = render(<TransportRow {...base} res={FLIGHT} />)
|
||||
|
||||
expect(screen.getByText('LH 714 to Tokyo')).toBeInTheDocument()
|
||||
expect(container.textContent).toContain('08:15 – 18:40')
|
||||
expect(screen.getByText('Lufthansa · LH714 · MUC → HND')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-014: a multi-day span shows only the day-relevant time', () => {
|
||||
const car = {
|
||||
id: 22, type: 'car', title: 'Rental car', day_id: 1, end_day_id: 3,
|
||||
reservation_time: '2026-05-01T09:00', reservation_end_time: '2026-05-03T17:00',
|
||||
location: 'Hertz Shinjuku',
|
||||
} as unknown as TransportEntry
|
||||
|
||||
const { container, unmount } = render(<TransportRow {...base} dayId={1} res={car} />)
|
||||
expect(container.textContent).toContain('09:00')
|
||||
expect(container.textContent).not.toContain('–')
|
||||
expect(screen.getByText('Hertz Shinjuku')).toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
const dropOff = render(<TransportRow {...base} dayId={3} res={car} />)
|
||||
expect(dropOff.container.textContent).toContain('17:00')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-015: a per-leg row is subtitled with its own from → to', () => {
|
||||
const leg = { ...FLIGHT, __leg: { index: 1, total: 2, from: 'FRA', to: 'HND' } } as TransportEntry
|
||||
render(<TransportRow {...base} res={leg} />)
|
||||
|
||||
expect(screen.getByText('FRA → HND')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-016: a per-leg row without endpoints has no subtitle', () => {
|
||||
const leg = { ...FLIGHT, __leg: { index: 0, total: 2, from: null, to: null } } as TransportEntry
|
||||
const { container } = render(<TransportRow {...base} res={leg} />)
|
||||
|
||||
expect(container.textContent).not.toContain('Lufthansa')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-017: a train subtitle carries number, platform and seat', () => {
|
||||
const train = {
|
||||
id: 23, type: 'train', title: 'ICE to Hamburg', day_id: 2,
|
||||
metadata: JSON.stringify({ train_number: 'ICE 599', platform: '7', seat: '21A' }),
|
||||
} as unknown as TransportEntry
|
||||
render(<TransportRow {...base} res={train} />)
|
||||
|
||||
expect(screen.getByText('ICE 599 · Gl. 7 · Sitz 21A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-018: an untimed booking of an unknown type still renders its title', () => {
|
||||
const other = { id: 24, type: 'yoga_retreat', title: 'Sunrise yoga', day_id: 2 } as unknown as TransportEntry
|
||||
const { container } = render(<TransportRow {...base} res={other} />)
|
||||
|
||||
expect(screen.getByText('Sunrise yoga')).toBeInTheDocument()
|
||||
expect(container.textContent).toBe('Sunrise yoga')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-019: tapping the row opens it', () => {
|
||||
const onOpen = vi.fn()
|
||||
render(<TransportRow {...base} res={FLIGHT} onOpen={onOpen} />)
|
||||
|
||||
fireEvent.click(screen.getByText('LH 714 to Tokyo'))
|
||||
|
||||
expect(onOpen).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-020: edit mode shows the reorder slot instead of the avatar', () => {
|
||||
render(<TransportRow {...base} res={FLIGHT} chrome={chrome(true)} />)
|
||||
|
||||
expect(screen.getByTestId('reorder')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TransitRow', () => {
|
||||
const TRANSIT_RES = {
|
||||
id: 25, type: 'transit', title: 'Shibuya → Asakusa', day_id: 2,
|
||||
reservation_time: '2026-05-02T10:05', reservation_end_time: '2026-05-02T10:47',
|
||||
} as unknown as TransportEntry
|
||||
|
||||
const TRANSIT: TransitMeta = {
|
||||
transfers: 1,
|
||||
duration: 2520,
|
||||
legs: [
|
||||
{ mode: 'WALK', duration: 240, to: { name: 'Shibuya Sta.' } },
|
||||
{
|
||||
mode: 'SUBWAY', line: 'G', line_color: '#FF9500', line_text_color: '#ffffff',
|
||||
duration: 1500, stops: 9,
|
||||
from: { name: 'Shibuya Sta.', time: '10:05', track: '3' },
|
||||
to: { name: 'Asakusa Sta.', time: '10:30' },
|
||||
},
|
||||
{ mode: 'WALK', duration: 30, to: { name: 'Hotel' } },
|
||||
],
|
||||
}
|
||||
|
||||
const base = {
|
||||
res: TRANSIT_RES, transit: TRANSIT, dayId: 2, chrome: chrome(), reorder: REORDER,
|
||||
onToggle: vi.fn(), onOpenJourney: vi.fn(),
|
||||
}
|
||||
|
||||
it('FE-MOB-PLROW-021: collapsed row shows the from → to headline and the time range', () => {
|
||||
const { container } = render(<TransitRow {...base} open={false} />)
|
||||
|
||||
expect(screen.getByText('Shibuya → Asakusa')).toBeInTheDocument()
|
||||
expect(container.textContent).toContain('10:05 – 10:47')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-022: the strip drops sub-minute walks and keeps the line badge', () => {
|
||||
render(<TransitRow {...base} open={false} />)
|
||||
|
||||
// one badge only — the collapsed strip; the 30s walk is filtered out
|
||||
expect(screen.getAllByText('G')).toHaveLength(1)
|
||||
expect(screen.queryByText('transit.min:1 · transit.walkTo:Hotel')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-023: expanding renders every leg with duration, stops and platform', () => {
|
||||
render(<TransitRow {...base} open />)
|
||||
|
||||
expect(screen.getAllByText('G')).toHaveLength(2)
|
||||
expect(screen.getByText('transit.min:4 · transit.walkTo:Shibuya Sta.')).toBeInTheDocument()
|
||||
expect(screen.getByText('Shibuya Sta. → Asakusa Sta.')).toBeInTheDocument()
|
||||
expect(screen.getByText('transit.min:25 · transit.stops:9 · transit.platform:3')).toBeInTheDocument()
|
||||
expect(screen.getByText('transit.min:1 · transit.walkTo:Hotel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-024: tapping the header toggles the row', () => {
|
||||
const onToggle = vi.fn()
|
||||
render(<TransitRow {...base} open={false} onToggle={onToggle} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Shibuya → Asakusa'))
|
||||
|
||||
expect(onToggle).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-025: an untimed single-leg journey falls back to the raw title and the mode badge', () => {
|
||||
const res = { id: 26, type: 'transit', title: 'Airport shuttle', day_id: 2 } as unknown as TransportEntry
|
||||
const transit: TransitMeta = { legs: [{ mode: 'BUS', line: null, duration: 0, from: {}, to: {} }] }
|
||||
const { container } = render(<TransitRow {...base} res={res} transit={transit} open />)
|
||||
|
||||
expect(screen.getByText('Airport shuttle')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('BUS')).toHaveLength(2)
|
||||
expect(container.textContent).not.toContain('10:05')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-038: survives uncoloured lines, missing durations and unnamed stops', () => {
|
||||
const res = {
|
||||
id: 27, type: 'transit', title: 'Depot → Bridge', day_id: 2, reservation_time: '2026-05-02T06:00',
|
||||
} as unknown as TransportEntry
|
||||
const transit: TransitMeta = {
|
||||
legs: [
|
||||
{ mode: 'TRAM', line: '17', from: { name: 'Depot' }, to: { name: 'Bridge' } },
|
||||
{ mode: 'WALK', duration: 600 },
|
||||
],
|
||||
}
|
||||
const { container } = render(<TransitRow {...base} res={res} transit={transit} open />)
|
||||
|
||||
expect(container.textContent).toContain('06:00')
|
||||
// no end time — the chip stays a single value
|
||||
expect(container.textContent).not.toContain('–')
|
||||
// once as the row headline, once as the expanded leg
|
||||
expect(screen.getAllByText('Depot → Bridge')).toHaveLength(2)
|
||||
expect(screen.getByText('transit.min:10 · transit.walkTo:')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-026: edit mode opens the journey view and keeps the reorder slot', () => {
|
||||
const onOpenJourney = vi.fn()
|
||||
render(<TransitRow {...base} open={false} chrome={chrome(true)} onOpenJourney={onOpenJourney} />)
|
||||
|
||||
expect(screen.getByTestId('reorder')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByLabelText('common.edit'))
|
||||
|
||||
expect(onOpenJourney).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConnRow', () => {
|
||||
it('FE-MOB-PLROW-027: defaults to the driving duration and the distance', () => {
|
||||
const { container } = render(<ConnRow seg={SEG} />)
|
||||
|
||||
expect(screen.getByText('14 min')).toBeInTheDocument()
|
||||
expect(screen.getByText('· 5.4 km')).toBeInTheDocument()
|
||||
expect(container.querySelector('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-028: a walking leg shows the walking duration', () => {
|
||||
render(<ConnRow seg={{ ...SEG, mode: 'walking' }} />)
|
||||
|
||||
expect(screen.getByText('1 h 5 min')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-029: a plugin leg uses its own duration text and note', () => {
|
||||
render(<ConnRow seg={{ ...SEG, mode: 'plugin:ev/fastest', durationText: '22 min', noteText: '25 min charge' }} />)
|
||||
|
||||
expect(screen.getByText('22 min')).toBeInTheDocument()
|
||||
expect(screen.getByText('25 min charge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-030: becomes a button when the leg mode can be changed', () => {
|
||||
const onTap = vi.fn()
|
||||
render(<ConnRow seg={SEG} onTap={onTap} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(onTap).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PlanScheduleRow', () => {
|
||||
const item = (over: Partial<PluginDayScheduleItem> = {}): PluginDayScheduleItem => ({
|
||||
pluginId: 'ev', id: 'charge-1', dayId: 2, minutes: 95, label: 'Charging stop', tone: 'success',
|
||||
...over,
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-031: renders the label with formatted minutes and the tone colour', () => {
|
||||
const { container } = render(<PlanScheduleRow item={item()} />)
|
||||
|
||||
expect(screen.getByText('Charging stop')).toBeInTheDocument()
|
||||
expect(container.textContent).toContain('1 h 35 min')
|
||||
expect(container.querySelector('svg')).toHaveStyle({ color: '#10b981' })
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-032: an unknown tone falls back to the default colour and minutes stay optional', () => {
|
||||
const { container } = render(
|
||||
<PlanScheduleRow item={item({ minutes: undefined, tone: 'chartreuse' as unknown as PluginDayScheduleItem['tone'] })} />,
|
||||
)
|
||||
|
||||
expect(container.textContent).toBe('Charging stop')
|
||||
expect(container.querySelector('svg')).toHaveStyle({ color: '#4F46E5' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('HotelConnRow', () => {
|
||||
it('FE-MOB-PLROW-033: puts the hotel above the leg at the top of the day and below it at the bottom', () => {
|
||||
const { container, unmount } = render(<HotelConnRow seg={SEG} name="Hotel Sacher" placement="top" />)
|
||||
const top = container.firstElementChild as HTMLElement
|
||||
|
||||
expect(top.children[0].textContent).toBe('Hotel Sacher')
|
||||
expect(top.children[1].textContent).toContain('1 h 5 min')
|
||||
expect(top.children[1].textContent).toContain('· 5.4 km ·')
|
||||
expect(top.children[1].textContent).toContain('14 min')
|
||||
unmount()
|
||||
|
||||
const bottom = render(<HotelConnRow seg={SEG} name="Hotel Sacher" placement="bottom" />)
|
||||
.container.firstElementChild as HTMLElement
|
||||
|
||||
expect(bottom.children[0].textContent).toContain('1 h 5 min')
|
||||
expect(bottom.children[1].textContent).toBe('Hotel Sacher')
|
||||
})
|
||||
})
|
||||
|
||||
describe('NoteRow', () => {
|
||||
const note = (over: Record<string, unknown> = {}): DayNote => ({
|
||||
id: 41, day_id: 2, text: 'Buy museum tickets\nCash only', time: '09:30 at the kiosk',
|
||||
icon: 'Ticket', sort_order: 0,
|
||||
...over,
|
||||
} as unknown as DayNote)
|
||||
|
||||
const base = { chrome: chrome(), reorder: REORDER, onEdit: vi.fn() }
|
||||
|
||||
it('FE-MOB-PLROW-034: splits the leading time off and joins the rest into the subtitle', () => {
|
||||
render(<NoteRow {...base} note={note()} />)
|
||||
|
||||
expect(screen.getByText('09:30')).toBeInTheDocument()
|
||||
expect(screen.getByText('Buy museum tickets')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cash only · at the kiosk')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-035: a free-text time stays in the subtitle and shows no time chip', () => {
|
||||
render(<NoteRow {...base} note={note({ text: 'Pack the day bag', time: 'before leaving' })} />)
|
||||
|
||||
expect(screen.getByText('before leaving')).toBeInTheDocument()
|
||||
expect(screen.queryByText('09:30')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-036: a bare one-line note renders title only', () => {
|
||||
const { container } = render(<NoteRow {...base} note={note({ text: 'Pack the day bag', time: null })} />)
|
||||
|
||||
expect(container.textContent).toBe('Pack the day bag')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-037: is inert in go mode and editable in edit mode', () => {
|
||||
const onEdit = vi.fn()
|
||||
const { unmount } = render(<NoteRow {...base} note={note()} onEdit={onEdit} />)
|
||||
fireEvent.click(screen.getByText('Buy museum tickets'))
|
||||
expect(onEdit).not.toHaveBeenCalled()
|
||||
unmount()
|
||||
|
||||
render(<NoteRow {...base} note={note()} chrome={chrome(true)} onEdit={onEdit} />)
|
||||
fireEvent.click(screen.getByText('Buy museum tickets'))
|
||||
|
||||
expect(onEdit).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByTestId('reorder')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,714 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MReservationSheet from '../../../../src/mobile/screens/trip/sheets/MReservationSheet'
|
||||
import { useAddonStore } from '../../../../src/store/addonStore'
|
||||
import { useTripStore } from '../../../../src/store/tripStore'
|
||||
import type { Accommodation, Day, Place, Reservation, TripMember } from '../../../../src/types'
|
||||
import { buildPlanner } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-RESSH-001 to FE-MOB-RESSH-051
|
||||
|
||||
// Date/time/select pickers have their own suites — here they only have to be
|
||||
// addressable, so they render as plain controls.
|
||||
vi.mock('../../../../src/components/shared/CustomSelect', () => ({
|
||||
default: ({ value, onChange, options = [], placeholder }: {
|
||||
value: string | number
|
||||
onChange: (v: string | number) => void
|
||||
options?: { value: string | number; label: string }[]
|
||||
placeholder?: string
|
||||
}) => (
|
||||
<select
|
||||
data-testid="cselect"
|
||||
data-value={String(value ?? '')}
|
||||
aria-label={placeholder}
|
||||
value={options.some(o => String(o.value) === String(value)) ? String(value) : ''}
|
||||
onChange={e => {
|
||||
const hit = options.find(o => String(o.value) === e.target.value)
|
||||
onChange(hit ? hit.value : e.target.value)
|
||||
}}
|
||||
>
|
||||
<option value="" />
|
||||
{options.map(o => <option key={String(o.value)} value={String(o.value)}>{o.label}</option>)}
|
||||
</select>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/components/shared/CustomTimePicker', () => ({
|
||||
default: ({ value, onChange, placeholder = '00:00' }: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
placeholder?: string
|
||||
}) => (
|
||||
<input data-testid="ctime" placeholder={placeholder} value={value} onChange={e => onChange(e.target.value)} />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/components/shared/CustomDateTimePicker', () => ({
|
||||
CustomDatePicker: ({ value, onChange, min, max }: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
min?: string
|
||||
max?: string
|
||||
}) => (
|
||||
<input
|
||||
data-testid="cdate"
|
||||
data-min={min ?? ''}
|
||||
data-max={max ?? ''}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
const DAYS = [
|
||||
{ id: 11, trip_id: 5, day_number: 1, date: '2026-05-01', title: null },
|
||||
{ id: 12, trip_id: 5, day_number: 2, date: '2026-05-02', title: null },
|
||||
{ id: 13, trip_id: 5, day_number: 3, date: '2026-05-03', title: 'Fly home' },
|
||||
] as unknown as Day[]
|
||||
|
||||
const PLACES = [
|
||||
{ id: 101, name: 'Hotel Sacher', address: 'Philharmonikerstrasse 4' },
|
||||
{ id: 102, name: 'Cafe Central', address: null },
|
||||
] as unknown as Place[]
|
||||
|
||||
const ACCOMMODATIONS = [
|
||||
{ id: 77, trip_id: 5, place_id: 101, start_day_id: 12, end_day_id: 13 },
|
||||
] as unknown as Accommodation[]
|
||||
|
||||
const MEMBERS = [
|
||||
{ id: 7, username: 'Ana', avatar_url: '/uploads/ana.png' },
|
||||
{ id: 8, username: 'bob', is_guest: true },
|
||||
] as unknown as TripMember[]
|
||||
|
||||
const DINNER = {
|
||||
id: 55, trip_id: 5, title: 'Dinner', type: 'restaurant', status: 'confirmed',
|
||||
reservation_time: '2026-05-02T19:30:00', reservation_end_time: '2026-05-02T21:00',
|
||||
location: 'Old Town', confirmation_number: 'C1', notes: 'window seat', url: 'https://example.test',
|
||||
place_id: 102, metadata: '{"check_in_time":"15:00"}',
|
||||
travelers: [{ user_id: 7 }],
|
||||
} as unknown as Reservation
|
||||
|
||||
const HOTEL = {
|
||||
id: 56, trip_id: 5, title: 'Hotel Sacher', type: 'hotel', status: 'pending',
|
||||
accommodation_id: 77, location: 'fallback address',
|
||||
metadata: { check_in_time: '15:00', check_out_time: '11:00' },
|
||||
} as unknown as Reservation
|
||||
|
||||
let setTravelers: ReturnType<typeof vi.fn>
|
||||
|
||||
function makePlanner(overrides: Record<string, unknown> = {}) {
|
||||
return buildPlanner({
|
||||
tripId: 5,
|
||||
days: DAYS,
|
||||
places: PLACES,
|
||||
tripAccommodations: ACCOMMODATIONS,
|
||||
tripMembers: [],
|
||||
selectedDayId: 12,
|
||||
showReservationModal: true,
|
||||
canUploadFiles: true,
|
||||
handleSaveReservation: vi.fn(async () => ({ id: 99 })),
|
||||
...overrides,
|
||||
} as never)
|
||||
}
|
||||
|
||||
function setup(planner = makePlanner()) {
|
||||
const onOpenExpense = vi.fn()
|
||||
const view = render(<MReservationSheet planner={planner} onOpenExpense={onOpenExpense} />)
|
||||
return { ...view, planner, onOpenExpense }
|
||||
}
|
||||
|
||||
const times = () => screen.getAllByTestId('ctime') as HTMLInputElement[]
|
||||
const dates = () => screen.getAllByTestId('cdate') as HTMLInputElement[]
|
||||
const selects = () => screen.getAllByTestId('cselect') as HTMLSelectElement[]
|
||||
|
||||
const titleField = () => screen.getByPlaceholderText('reservations.titlePlaceholder')
|
||||
const submitBtn = () => screen.getByRole('button', { name: /^common\.(add|update|saving)$/ })
|
||||
|
||||
function type(el: HTMLElement, value: string) {
|
||||
fireEvent.change(el, { target: { value } })
|
||||
}
|
||||
|
||||
function pick(select: HTMLSelectElement, value: string | number) {
|
||||
fireEvent.change(select, { target: { value: String(value) } })
|
||||
}
|
||||
|
||||
/** The payload the sheet handed to planner.handleSaveReservation. */
|
||||
function savedPayload(planner: ReturnType<typeof makePlanner>) {
|
||||
const fn = planner.handleSaveReservation as unknown as ReturnType<typeof vi.fn>
|
||||
return fn.mock.calls[0][0] as Record<string, unknown>
|
||||
}
|
||||
|
||||
describe('MReservationSheet', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
setTravelers = vi.fn(async () => undefined)
|
||||
useTripStore.setState({ setReservationTravelers: setTravelers as never })
|
||||
useAddonStore.setState({
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'trip', icon: 'wallet', enabled: true }],
|
||||
loaded: true,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// ── Open / close ───────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-MOB-RESSH-001: renders nothing while the planner has the editor closed', () => {
|
||||
setup(makePlanner({ showReservationModal: false }))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-002: opens the empty create form with a disabled submit', () => {
|
||||
setup()
|
||||
expect(screen.getByRole('dialog', { name: 'reservations.newTitle' })).toBeInTheDocument()
|
||||
expect(titleField()).toHaveValue('')
|
||||
expect(screen.getByRole('button', { name: 'reservations.type.other' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(submitBtn()).toHaveTextContent('common.add')
|
||||
expect(submitBtn()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-003: cancel clears every editor flag of the planner', () => {
|
||||
const { planner } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
|
||||
expect(planner.setShowReservationModal).toHaveBeenCalledWith(false)
|
||||
expect(planner.setEditingReservation).toHaveBeenCalledWith(null)
|
||||
expect(planner.setBookingForAssignmentId).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-004: the header X closes the same way', () => {
|
||||
const { planner } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
|
||||
expect(planner.setShowReservationModal).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-005: during an import review closing advances the queue instead', () => {
|
||||
const { planner } = setup(makePlanner({ importReviewActive: true }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
|
||||
expect(planner.advanceImportReview).toHaveBeenCalledTimes(1)
|
||||
expect(planner.setShowReservationModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// ── Form basics ────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-MOB-RESSH-006: a title unlocks the submit and the default booking is saved as-is', async () => {
|
||||
const { planner } = setup()
|
||||
type(titleField(), 'Museum ticket')
|
||||
expect(submitBtn()).toBeEnabled()
|
||||
fireEvent.click(submitBtn())
|
||||
|
||||
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
|
||||
expect(savedPayload(planner)).toEqual({
|
||||
title: 'Museum ticket', type: 'other', status: 'pending',
|
||||
reservation_time: null, reservation_end_time: null,
|
||||
location: '', confirmation_number: '', notes: '', url: '',
|
||||
assignment_id: null, accommodation_id: null, place_id: null,
|
||||
metadata: null, endpoints: [], needs_review: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-007: the type chips switch the form between event and hotel layout', () => {
|
||||
setup()
|
||||
expect(screen.getByLabelText('reservations.meta.pickPlace')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.hotel' }))
|
||||
expect(screen.getByRole('button', { name: 'reservations.type.hotel' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByLabelText('reservations.meta.pickHotel')).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('reservations.meta.pickPlace')).not.toBeInTheDocument()
|
||||
expect(screen.queryAllByTestId('cdate')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-008: status, code, link and notes all reach the payload', async () => {
|
||||
const { planner } = setup()
|
||||
type(titleField(), 'Opera')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.confirmed' }))
|
||||
type(screen.getByPlaceholderText('reservations.confirmationPlaceholder'), 'OP-7')
|
||||
type(screen.getByPlaceholderText('reservations.urlPlaceholder'), 'https://opera.test')
|
||||
type(screen.getByPlaceholderText('reservations.notesPlaceholder'), 'balcony')
|
||||
type(screen.getByPlaceholderText('reservations.locationPlaceholder'), 'Ringstrasse 2')
|
||||
fireEvent.click(submitBtn())
|
||||
|
||||
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
|
||||
expect(savedPayload(planner)).toMatchObject({
|
||||
status: 'confirmed', confirmation_number: 'OP-7',
|
||||
url: 'https://opera.test', notes: 'balcony', location: 'Ringstrasse 2',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-009: the date pickers are pinned to the trip span', () => {
|
||||
setup()
|
||||
for (const el of dates()) {
|
||||
expect(el).toHaveAttribute('data-min', '2026-05-01')
|
||||
expect(el).toHaveAttribute('data-max', '2026-05-03')
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-010: linking a place fills the still-empty title and address', () => {
|
||||
setup()
|
||||
pick(screen.getByLabelText('reservations.meta.pickPlace') as HTMLSelectElement, 101)
|
||||
expect(titleField()).toHaveValue('Hotel Sacher')
|
||||
expect(screen.getByPlaceholderText('reservations.locationPlaceholder')).toHaveValue('Philharmonikerstrasse 4')
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-011: linking a place never overwrites what the user already typed', () => {
|
||||
setup()
|
||||
type(titleField(), 'My dinner')
|
||||
type(screen.getByPlaceholderText('reservations.locationPlaceholder'), 'My address')
|
||||
pick(screen.getByLabelText('reservations.meta.pickPlace') as HTMLSelectElement, 101)
|
||||
expect(titleField()).toHaveValue('My dinner')
|
||||
expect(screen.getByPlaceholderText('reservations.locationPlaceholder')).toHaveValue('My address')
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-012: unlinking the place leaves the typed fields alone', async () => {
|
||||
const { planner } = setup()
|
||||
type(titleField(), 'Walk')
|
||||
const placeSelect = screen.getByLabelText('reservations.meta.pickPlace') as HTMLSelectElement
|
||||
pick(placeSelect, 102)
|
||||
pick(placeSelect, '')
|
||||
fireEvent.click(submitBtn())
|
||||
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
|
||||
expect(savedPayload(planner)).toMatchObject({ place_id: null, title: 'Walk' })
|
||||
})
|
||||
|
||||
// ── Times ──────────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-MOB-RESSH-013: date plus start and end time are combined into one timestamp pair', async () => {
|
||||
const { planner } = setup()
|
||||
type(titleField(), 'Dinner')
|
||||
type(dates()[0], '2026-05-02')
|
||||
type(times()[0], '19:30')
|
||||
type(times()[1], '21:00')
|
||||
fireEvent.click(submitBtn())
|
||||
|
||||
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
|
||||
expect(savedPayload(planner)).toMatchObject({
|
||||
reservation_time: '2026-05-02T19:30',
|
||||
reservation_end_time: '2026-05-02T21:00',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-014: a start time without a date falls back to the selected day', async () => {
|
||||
const { planner } = setup()
|
||||
type(titleField(), 'Dinner')
|
||||
type(times()[0], '09:00')
|
||||
fireEvent.click(submitBtn())
|
||||
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
|
||||
expect(savedPayload(planner)).toMatchObject({ reservation_time: '2026-05-02T09:00' })
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-015: an end date without an end time stays a plain date', async () => {
|
||||
const { planner } = setup()
|
||||
type(titleField(), 'Festival')
|
||||
type(dates()[0], '2026-05-01')
|
||||
type(dates()[1], '2026-05-03')
|
||||
fireEvent.click(submitBtn())
|
||||
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
|
||||
expect(savedPayload(planner)).toMatchObject({
|
||||
reservation_time: '2026-05-01',
|
||||
reservation_end_time: '2026-05-03',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-016: clearing the start date drops the timestamp again', async () => {
|
||||
const { planner } = setup()
|
||||
type(titleField(), 'Festival')
|
||||
type(dates()[0], '2026-05-01')
|
||||
type(dates()[0], '')
|
||||
fireEvent.click(submitBtn())
|
||||
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
|
||||
expect(savedPayload(planner)).toMatchObject({ reservation_time: null })
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-017: an end before the start blocks the submit and warns inline', () => {
|
||||
setup()
|
||||
type(titleField(), 'Dinner')
|
||||
type(dates()[0], '2026-05-02')
|
||||
type(times()[0], '19:30')
|
||||
type(dates()[1], '2026-05-01')
|
||||
expect(screen.getByText('reservations.validation.endBeforeStart')).toBeInTheDocument()
|
||||
expect(submitBtn()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-018: submitting an inverted range through the costs shortcut only toasts', async () => {
|
||||
const { planner, onOpenExpense } = setup()
|
||||
type(titleField(), 'Dinner')
|
||||
type(dates()[0], '2026-05-02')
|
||||
type(times()[0], '19:30')
|
||||
type(dates()[1], '2026-05-01')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.createExpense' }))
|
||||
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('reservations.validation.endBeforeStart'))
|
||||
expect(planner.handleSaveReservation).not.toHaveBeenCalled()
|
||||
expect(onOpenExpense).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// ── Editing an existing booking ────────────────────────────────────────────
|
||||
|
||||
it('FE-MOB-RESSH-019: editing seeds every field and splits the end timestamp', () => {
|
||||
setup(makePlanner({ editingReservation: DINNER }))
|
||||
expect(screen.getByRole('dialog', { name: 'reservations.editTitle' })).toBeInTheDocument()
|
||||
expect(submitBtn()).toHaveTextContent('common.update')
|
||||
expect(titleField()).toHaveValue('Dinner')
|
||||
expect(screen.getByRole('button', { name: 'reservations.type.restaurant' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(dates()[0]).toHaveValue('2026-05-02')
|
||||
expect(times()[0]).toHaveValue('19:30')
|
||||
expect(dates()[1]).toHaveValue('2026-05-02')
|
||||
expect(times()[1]).toHaveValue('21:00')
|
||||
expect(screen.getByPlaceholderText('reservations.locationPlaceholder')).toHaveValue('Old Town')
|
||||
expect(screen.getByPlaceholderText('reservations.confirmationPlaceholder')).toHaveValue('C1')
|
||||
expect(screen.getByPlaceholderText('reservations.notesPlaceholder')).toHaveValue('window seat')
|
||||
expect(screen.getByPlaceholderText('reservations.urlPlaceholder')).toHaveValue('https://example.test')
|
||||
expect(screen.getByLabelText('reservations.meta.pickPlace')).toHaveAttribute('data-value', '102')
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-020: a date-only end is read back into the end-date field', () => {
|
||||
setup(makePlanner({ editingReservation: { ...DINNER, reservation_end_time: '2026-05-03' } }))
|
||||
expect(dates()[1]).toHaveValue('2026-05-03')
|
||||
expect(times()[1]).toHaveValue('')
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-021: a booking with no times at all seeds empty fields', () => {
|
||||
setup(makePlanner({
|
||||
editingReservation: {
|
||||
id: 57, title: 'Museum', type: undefined, status: undefined,
|
||||
reservation_time: null, reservation_end_time: null, metadata: null,
|
||||
} as unknown as Reservation,
|
||||
}))
|
||||
expect(titleField()).toHaveValue('Museum')
|
||||
expect(screen.getByRole('button', { name: 'reservations.type.other' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(dates()[0]).toHaveValue('')
|
||||
expect(times()[1]).toHaveValue('')
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-022: a hotel booking seeds its stay from the linked accommodation', () => {
|
||||
setup(makePlanner({ editingReservation: HOTEL }))
|
||||
expect(screen.getByLabelText('reservations.meta.pickHotel')).toHaveAttribute('data-value', '101')
|
||||
const [from, to] = selects().slice(1)
|
||||
expect(from).toHaveAttribute('data-value', '12')
|
||||
expect(to).toHaveAttribute('data-value', '13')
|
||||
expect(screen.getByPlaceholderText('15:00')).toHaveValue('15:00')
|
||||
expect(screen.getByPlaceholderText('11:00')).toHaveValue('11:00')
|
||||
expect(screen.getByPlaceholderText('reservations.locationPlaceholder')).toHaveValue('Philharmonikerstrasse 4')
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-023: a hotel without an accommodation falls back to the booking location', () => {
|
||||
setup(makePlanner({ editingReservation: { ...HOTEL, accommodation_id: null } }))
|
||||
expect(screen.getByPlaceholderText('reservations.locationPlaceholder')).toHaveValue('fallback address')
|
||||
expect(screen.getByLabelText('reservations.meta.pickHotel')).toHaveAttribute('data-value', '')
|
||||
})
|
||||
|
||||
// ── Hotels ─────────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-MOB-RESSH-024: a hotel with a linked place and a day range creates the accommodation', async () => {
|
||||
const { planner } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.hotel' }))
|
||||
type(titleField(), 'Sacher')
|
||||
pick(screen.getByLabelText('reservations.meta.pickHotel') as HTMLSelectElement, 101)
|
||||
pick(selects()[1], 12)
|
||||
pick(selects()[2], 13)
|
||||
type(screen.getByPlaceholderText('15:00'), '15:00')
|
||||
type(screen.getByPlaceholderText('11:00'), '11:00')
|
||||
fireEvent.click(submitBtn())
|
||||
|
||||
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
|
||||
expect(savedPayload(planner)).toMatchObject({
|
||||
type: 'hotel',
|
||||
reservation_time: null,
|
||||
reservation_end_time: null,
|
||||
location: 'Philharmonikerstrasse 4',
|
||||
accommodation_id: null,
|
||||
place_id: null,
|
||||
metadata: { check_in_time: '15:00', check_out_time: '11:00' },
|
||||
create_accommodation: {
|
||||
place_id: 101, venue: null, address: 'Philharmonikerstrasse 4',
|
||||
start_day_id: 12, end_day_id: 13, check_in: '15:00', check_out: '11:00', confirmation: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-025: a hotel without a linked place proposes a venue from title and address', async () => {
|
||||
const { planner } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.hotel' }))
|
||||
type(titleField(), 'Ibis')
|
||||
type(screen.getByPlaceholderText('reservations.locationPlaceholder'), 'Somewhere 5')
|
||||
pick(selects()[1], 12)
|
||||
fireEvent.click(submitBtn())
|
||||
|
||||
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
|
||||
expect(savedPayload(planner)).toMatchObject({
|
||||
create_accommodation: {
|
||||
place_id: null,
|
||||
venue: { name: 'Ibis', address: 'Somewhere 5' },
|
||||
start_day_id: 12, end_day_id: 12,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-026: a hotel without any day range creates no accommodation', async () => {
|
||||
const { planner } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.hotel' }))
|
||||
type(titleField(), 'Ibis')
|
||||
fireEvent.click(submitBtn())
|
||||
|
||||
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
|
||||
expect(savedPayload(planner)).not.toHaveProperty('create_accommodation')
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-027: picking a hotel place adopts its name and address', () => {
|
||||
setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.hotel' }))
|
||||
pick(screen.getByLabelText('reservations.meta.pickHotel') as HTMLSelectElement, 101)
|
||||
expect(titleField()).toHaveValue('Hotel Sacher')
|
||||
expect(screen.getByPlaceholderText('reservations.locationPlaceholder')).toHaveValue('Philharmonikerstrasse 4')
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-028: an earlier end day pulls the start day back with it', () => {
|
||||
setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.hotel' }))
|
||||
pick(selects()[1], 13)
|
||||
expect(selects()[2]).toHaveAttribute('data-value', '13')
|
||||
pick(selects()[2], 11)
|
||||
expect(selects()[1]).toHaveAttribute('data-value', '11')
|
||||
})
|
||||
|
||||
// ── Prefill from the booking import ────────────────────────────────────────
|
||||
|
||||
it('FE-MOB-RESSH-029: an import prefill seeds the hotel form, its day range and the source file', () => {
|
||||
setup(makePlanner({
|
||||
reservationPrefill: {
|
||||
title: 'Hotel Ibis', type: 'hotel', status: 'confirmed',
|
||||
reservation_time: '2026-05-02T14:00:00', reservation_end_time: '2026-05-03T10:00',
|
||||
location: 'Vienna', confirmation_number: 'PF1', notes: 'late arrival', url: 'https://ibis.test',
|
||||
metadata: { check_in_time: '14:00', check_out_time: '10:00' },
|
||||
_accommodation: { check_in: '2026-05-02', check_out: '2026-05-03' },
|
||||
_venue: { address: 'Mariahilfer 1' },
|
||||
_sourceFiles: [new File(['x'], 'voucher.pdf', { type: 'application/pdf' })],
|
||||
},
|
||||
}))
|
||||
expect(titleField()).toHaveValue('Hotel Ibis')
|
||||
expect(screen.getByPlaceholderText('reservations.confirmationPlaceholder')).toHaveValue('PF1')
|
||||
expect(screen.getByPlaceholderText('reservations.urlPlaceholder')).toHaveValue('https://ibis.test')
|
||||
expect(screen.getByPlaceholderText('15:00')).toHaveValue('14:00')
|
||||
expect(selects()[1]).toHaveAttribute('data-value', '12')
|
||||
expect(selects()[2]).toHaveAttribute('data-value', '13')
|
||||
expect(screen.getByPlaceholderText('reservations.locationPlaceholder')).toHaveValue('Mariahilfer 1')
|
||||
expect(screen.getByText('voucher.pdf')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-030: a prefill with a date-only end fills the end-date field', () => {
|
||||
setup(makePlanner({
|
||||
reservationPrefill: { title: 'Tour', type: 'tour', reservation_end_time: '2026-05-03', metadata: null },
|
||||
}))
|
||||
expect(dates()[1]).toHaveValue('2026-05-03')
|
||||
expect(times()[1]).toHaveValue('')
|
||||
})
|
||||
|
||||
// ── Travelers ──────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-MOB-RESSH-031: without trip members the traveler list shows its empty note', () => {
|
||||
setup()
|
||||
expect(screen.getByText('reservations.travelers.none')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-032: members render with avatar, initial and the guest badge', () => {
|
||||
setup(makePlanner({ tripMembers: MEMBERS }))
|
||||
const ana = screen.getByText('Ana').closest('button') as HTMLButtonElement
|
||||
expect(ana.querySelector('img')).toHaveAttribute('src', '/uploads/ana.png')
|
||||
const bob = screen.getByText('bob').closest('button') as HTMLButtonElement
|
||||
expect(bob.querySelector('img')).toBeNull()
|
||||
expect(bob).toHaveTextContent('B')
|
||||
expect(bob).toHaveTextContent('Guest')
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-033: assigning travelers persists after the booking was created', async () => {
|
||||
setup(makePlanner({ tripMembers: MEMBERS }))
|
||||
type(titleField(), 'Dinner')
|
||||
fireEvent.click(screen.getByText('Ana'))
|
||||
fireEvent.click(screen.getByText('bob'))
|
||||
fireEvent.click(screen.getByText('Ana'))
|
||||
fireEvent.click(submitBtn())
|
||||
await waitFor(() => expect(setTravelers).toHaveBeenCalledWith(5, 99, [8]))
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-034: an unchanged traveler set is not written again', async () => {
|
||||
const { planner } = setup(makePlanner({ tripMembers: MEMBERS, editingReservation: DINNER }))
|
||||
fireEvent.click(submitBtn())
|
||||
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
|
||||
expect(setTravelers).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-035: removing a traveler falls back to the edited booking id', async () => {
|
||||
const planner = makePlanner({
|
||||
tripMembers: MEMBERS,
|
||||
editingReservation: DINNER,
|
||||
handleSaveReservation: vi.fn(async () => undefined),
|
||||
})
|
||||
setup(planner)
|
||||
fireEvent.click(screen.getByText('Ana'))
|
||||
fireEvent.click(submitBtn())
|
||||
await waitFor(() => expect(setTravelers).toHaveBeenCalledWith(5, 55, []))
|
||||
})
|
||||
|
||||
// ── Files, costs, errors ───────────────────────────────────────────────────
|
||||
|
||||
it('FE-MOB-RESSH-036: attached files are uploaded against the new booking', async () => {
|
||||
const { planner } = setup()
|
||||
type(titleField(), 'Flight docs')
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { files: [new File(['a'], 'a.pdf'), new File(['b'], 'b.pdf')] } })
|
||||
expect(screen.getByText('a.pdf')).toBeInTheDocument()
|
||||
fireEvent.click(submitBtn())
|
||||
|
||||
await waitFor(() => expect(planner.tripActions.addFile).toHaveBeenCalledTimes(2))
|
||||
const [tripId, fd] = vi.mocked(planner.tripActions.addFile).mock.calls[0] as [number, FormData]
|
||||
expect(tripId).toBe(5)
|
||||
expect(fd.get('reservation_id')).toBe('99')
|
||||
expect(fd.get('description')).toBe('Flight docs')
|
||||
expect((fd.get('file') as File).name).toBe('a.pdf')
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-037: files are not re-uploaded when an existing booking is edited', async () => {
|
||||
const { planner } = setup(makePlanner({ editingReservation: DINNER }))
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { files: [new File(['a'], 'a.pdf')] } })
|
||||
fireEvent.click(submitBtn())
|
||||
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
|
||||
expect(planner.tripActions.addFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-038: without upload permission the attachment block is gone', () => {
|
||||
setup(makePlanner({ canUploadFiles: false }))
|
||||
expect(screen.queryByRole('button', { name: 'files.attach' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-039: the costs shortcut saves first and then opens the expense form', async () => {
|
||||
const { planner, onOpenExpense } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.restaurant' }))
|
||||
type(titleField(), 'Dinner')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.createExpense' }))
|
||||
|
||||
await waitFor(() => expect(onOpenExpense).toHaveBeenCalled())
|
||||
expect(planner.handleSaveReservation).toHaveBeenCalledTimes(1)
|
||||
expect(onOpenExpense).toHaveBeenCalledWith({
|
||||
prefill: { reservationId: 99, name: 'Dinner', category: 'food' },
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-040: a plain submit never opens the expense form', async () => {
|
||||
const { planner, onOpenExpense } = setup()
|
||||
type(titleField(), 'Dinner')
|
||||
fireEvent.click(submitBtn())
|
||||
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
|
||||
expect(onOpenExpense).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-041: without the budget addon there is no costs section', () => {
|
||||
useAddonStore.setState({ addons: [], loaded: true })
|
||||
setup()
|
||||
expect(screen.queryByRole('button', { name: 'reservations.createExpense' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-042: a failing save surfaces the error message and unlocks the form', async () => {
|
||||
const planner = makePlanner({ handleSaveReservation: vi.fn(async () => { throw new Error('server said no') }) })
|
||||
setup(planner)
|
||||
type(titleField(), 'Dinner')
|
||||
fireEvent.click(submitBtn())
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('server said no'))
|
||||
expect(submitBtn()).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-043: a non-Error rejection falls back to the generic message', async () => {
|
||||
const planner = makePlanner({ handleSaveReservation: vi.fn(async () => { throw 'nope' }) })
|
||||
setup(planner)
|
||||
type(titleField(), 'Dinner')
|
||||
fireEvent.click(submitBtn())
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.unknownError'))
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-044: a booking opened for a transport segment keeps that assignment', async () => {
|
||||
const { planner } = setup(makePlanner({ bookingForAssignmentId: 42 }))
|
||||
type(titleField(), 'Seat reservation')
|
||||
fireEvent.click(submitBtn())
|
||||
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
|
||||
expect(savedPayload(planner)).toMatchObject({ assignment_id: 42 })
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-045: a saved import review advances to the next parsed booking', async () => {
|
||||
const { planner } = setup(makePlanner({ importReviewActive: true }))
|
||||
type(titleField(), 'Imported hotel')
|
||||
fireEvent.click(submitBtn())
|
||||
await waitFor(() => expect(planner.advanceImportReview).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-046: a pending attachment can be dropped again before saving', async () => {
|
||||
const { planner } = setup()
|
||||
type(titleField(), 'Flight docs')
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { files: [new File(['a'], 'a.pdf'), new File(['b'], 'b.pdf')] } })
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'common.delete' })[0])
|
||||
expect(screen.queryByText('a.pdf')).not.toBeInTheDocument()
|
||||
fireEvent.click(submitBtn())
|
||||
|
||||
await waitFor(() => expect(planner.tripActions.addFile).toHaveBeenCalledTimes(1))
|
||||
const [, fd] = vi.mocked(planner.tripActions.addFile).mock.calls[0] as [number, FormData]
|
||||
expect((fd.get('file') as File).name).toBe('b.pdf')
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-047: picking the date after the time keeps the time', async () => {
|
||||
const { planner } = setup()
|
||||
type(titleField(), 'Dinner')
|
||||
type(times()[0], '19:30')
|
||||
type(dates()[0], '2026-05-01')
|
||||
fireEvent.click(submitBtn())
|
||||
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
|
||||
expect(savedPayload(planner)).toMatchObject({ reservation_time: '2026-05-01T19:30' })
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-048: clearing the end date drops the end timestamp', async () => {
|
||||
const { planner } = setup()
|
||||
type(titleField(), 'Festival')
|
||||
type(dates()[1], '2026-05-03')
|
||||
type(dates()[1], '')
|
||||
fireEvent.click(submitBtn())
|
||||
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
|
||||
expect(savedPayload(planner)).toMatchObject({ reservation_end_time: null })
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-049: a hotel with only an end day and no address stays a name-only venue', async () => {
|
||||
const { planner } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.hotel' }))
|
||||
type(titleField(), 'Ibis')
|
||||
pick(selects()[2], 13)
|
||||
fireEvent.click(submitBtn())
|
||||
|
||||
await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled())
|
||||
expect(savedPayload(planner)).toMatchObject({
|
||||
location: '',
|
||||
create_accommodation: {
|
||||
place_id: null,
|
||||
venue: { name: 'Ibis', address: null },
|
||||
address: null,
|
||||
start_day_id: 13, end_day_id: 13,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-050: a later start day inside the range leaves the end day alone', () => {
|
||||
setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.hotel' }))
|
||||
pick(selects()[2], 13)
|
||||
pick(selects()[1], 12)
|
||||
expect(selects()[1]).toHaveAttribute('data-value', '12')
|
||||
expect(selects()[2]).toHaveAttribute('data-value', '13')
|
||||
})
|
||||
|
||||
it('FE-MOB-RESSH-051: a hotel place without an address keeps the typed one', () => {
|
||||
setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.hotel' }))
|
||||
type(screen.getByPlaceholderText('reservations.locationPlaceholder'), 'Manual 3')
|
||||
pick(screen.getByLabelText('reservations.meta.pickHotel') as HTMLSelectElement, 102)
|
||||
expect(titleField()).toHaveValue('Cafe Central')
|
||||
expect(screen.getByPlaceholderText('reservations.locationPlaceholder')).toHaveValue('Manual 3')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,363 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MTaskSheet from '../../../../src/mobile/screens/trip/tabs/MTaskSheet'
|
||||
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import type { TodoItem, TripMember } from '../../../../src/types'
|
||||
import { buildPlanner, buildToast, buildTripActions } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-TASK-001 to FE-MOB-TASK-020
|
||||
|
||||
const MEMBERS = [
|
||||
{ id: 7, username: 'maurice', avatar: null, avatar_url: null },
|
||||
{ id: 8, username: 'lea', avatar: 'lea.png', avatar_url: null },
|
||||
] as unknown as TripMember[]
|
||||
|
||||
function todo(overrides: Partial<TodoItem> = {}): TodoItem {
|
||||
return {
|
||||
id: 42,
|
||||
trip_id: 1,
|
||||
name: 'Book flights',
|
||||
category: 'Admin',
|
||||
checked: 0,
|
||||
sort_order: 0,
|
||||
due_date: '2026-08-01',
|
||||
description: 'Direct if possible',
|
||||
assigned_user_id: 8,
|
||||
priority: 2,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
interface SetupOptions {
|
||||
items?: TodoItem[]
|
||||
itemId?: number | null
|
||||
categories?: string[]
|
||||
defaultCategory?: string | null
|
||||
open?: boolean
|
||||
}
|
||||
|
||||
function setup({ items = [], itemId = null, categories = ['Admin', 'Gear'], defaultCategory = null, open = true }: SetupOptions = {}) {
|
||||
const actions = buildTripActions()
|
||||
const toast = buildToast()
|
||||
const onClose = vi.fn()
|
||||
const planner = buildPlanner({
|
||||
todoItems: items,
|
||||
tripActions: actions as unknown as TripPlanner['tripActions'],
|
||||
toast: toast as unknown as TripPlanner['toast'],
|
||||
})
|
||||
const view = render(
|
||||
<MTaskSheet
|
||||
planner={planner}
|
||||
open={open}
|
||||
itemId={itemId}
|
||||
categories={categories}
|
||||
members={MEMBERS}
|
||||
defaultCategory={defaultCategory}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
return { ...view, actions, toast, onClose, planner }
|
||||
}
|
||||
|
||||
describe('MTaskSheet', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
})
|
||||
|
||||
it('FE-MOB-TASK-001: stays unmounted while closed', () => {
|
||||
setup({ open: false })
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TASK-002: opens in create mode with empty fields and no delete action', () => {
|
||||
setup()
|
||||
|
||||
expect(screen.getAllByText('todo.newItem').length).toBeGreaterThan(0)
|
||||
expect(screen.getByPlaceholderText('todo.namePlaceholder')).toHaveValue('')
|
||||
expect(screen.getByPlaceholderText('todo.descriptionPlaceholder')).toHaveValue('')
|
||||
expect(screen.getByRole('button', { name: 'todo.detail.create' })).toBeDisabled()
|
||||
expect(screen.queryByRole('button', { name: 'todo.detail.delete' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TASK-003: enables the submit only once a non-blank name is typed', () => {
|
||||
setup()
|
||||
const submit = screen.getByRole('button', { name: 'todo.detail.create' })
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('todo.namePlaceholder'), { target: { value: ' ' } })
|
||||
expect(submit).toBeDisabled()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('todo.namePlaceholder'), { target: { value: 'Visa' } })
|
||||
expect(submit).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-TASK-004: creates a task with the trimmed name and undefined optionals', async () => {
|
||||
const { actions, onClose } = setup()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('todo.namePlaceholder'), { target: { value: ' Visa ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'todo.detail.create' }))
|
||||
|
||||
await waitFor(() => expect(actions.addTodoItem).toHaveBeenCalledWith(1, {
|
||||
name: 'Visa',
|
||||
description: undefined,
|
||||
due_date: undefined,
|
||||
category: undefined,
|
||||
assigned_user_id: undefined,
|
||||
priority: 0,
|
||||
}))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-TASK-005: seeds the category from defaultCategory and sends it on create', async () => {
|
||||
const { actions } = setup({ defaultCategory: 'Gear' })
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('todo.namePlaceholder'), { target: { value: 'Boots' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'todo.detail.create' }))
|
||||
|
||||
await waitFor(() => expect(actions.addTodoItem).toHaveBeenCalledWith(1, expect.objectContaining({ category: 'Gear' })))
|
||||
})
|
||||
|
||||
it('FE-MOB-TASK-006: sends description, priority and assignee picked in the form', async () => {
|
||||
const { actions } = setup()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('todo.namePlaceholder'), { target: { value: 'Visa' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('todo.descriptionPlaceholder'), { target: { value: 'Embassy' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /^P1$/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /lea/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'todo.detail.create' }))
|
||||
|
||||
await waitFor(() => expect(actions.addTodoItem).toHaveBeenCalledWith(1, expect.objectContaining({
|
||||
description: 'Embassy',
|
||||
priority: 1,
|
||||
assigned_user_id: 8,
|
||||
})))
|
||||
})
|
||||
|
||||
it('FE-MOB-TASK-007: only submits once while a save is in flight', async () => {
|
||||
const { actions } = setup()
|
||||
let release = () => {}
|
||||
actions.addTodoItem.mockImplementation(() => new Promise<void>(resolve => { release = () => resolve() }))
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('todo.namePlaceholder'), { target: { value: 'Visa' } })
|
||||
const submit = screen.getByRole('button', { name: 'todo.detail.create' })
|
||||
fireEvent.click(submit)
|
||||
fireEvent.click(submit)
|
||||
|
||||
expect(actions.addTodoItem).toHaveBeenCalledTimes(1)
|
||||
release()
|
||||
await waitFor(() => expect(submit).toBeEnabled())
|
||||
})
|
||||
|
||||
it('FE-MOB-TASK-008: toasts and keeps the sheet open when creating fails', async () => {
|
||||
const { actions, toast, onClose } = setup()
|
||||
actions.addTodoItem.mockRejectedValue(new Error('boom'))
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('todo.namePlaceholder'), { target: { value: 'Visa' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'todo.detail.create' }))
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('common.error'))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-TASK-009: prefills every field in edit mode', () => {
|
||||
setup({ items: [todo()], itemId: 42 })
|
||||
|
||||
expect(screen.getAllByText('todo.detail.title').length).toBeGreaterThan(0)
|
||||
expect(screen.getByPlaceholderText('todo.namePlaceholder')).toHaveValue('Book flights')
|
||||
expect(screen.getByPlaceholderText('todo.descriptionPlaceholder')).toHaveValue('Direct if possible')
|
||||
expect(screen.getByRole('button', { name: 'todo.detail.save' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'todo.detail.delete' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TASK-010: updates a task and nulls out the cleared optionals', async () => {
|
||||
const { actions, onClose } = setup({ items: [todo({ description: null, due_date: null, category: null, assigned_user_id: null, priority: 0 })], itemId: 42 })
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('todo.namePlaceholder'), { target: { value: 'Book trains' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'todo.detail.save' }))
|
||||
|
||||
await waitFor(() => expect(actions.updateTodoItem).toHaveBeenCalledWith(1, 42, {
|
||||
name: 'Book trains',
|
||||
description: null,
|
||||
due_date: null,
|
||||
category: null,
|
||||
assigned_user_id: null,
|
||||
priority: 0,
|
||||
}))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-TASK-011: keeps the stored due date and category when only the name changes', async () => {
|
||||
const { actions } = setup({ items: [todo()], itemId: 42 })
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('todo.namePlaceholder'), { target: { value: 'Book trains' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'todo.detail.save' }))
|
||||
|
||||
await waitFor(() => expect(actions.updateTodoItem).toHaveBeenCalledWith(1, 42, expect.objectContaining({
|
||||
due_date: '2026-08-01',
|
||||
category: 'Admin',
|
||||
assigned_user_id: 8,
|
||||
priority: 2,
|
||||
})))
|
||||
})
|
||||
|
||||
it('FE-MOB-TASK-012: toasts when updating fails', async () => {
|
||||
const { actions, toast, onClose } = setup({ items: [todo()], itemId: 42 })
|
||||
actions.updateTodoItem.mockRejectedValue(new Error('boom'))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'todo.detail.save' }))
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('common.error'))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-TASK-013: deletes the task and closes', async () => {
|
||||
const { actions, onClose } = setup({ items: [todo()], itemId: 42 })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'todo.detail.delete' }))
|
||||
|
||||
await waitFor(() => expect(actions.deleteTodoItem).toHaveBeenCalledWith(1, 42))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-TASK-014: toasts when deleting fails', async () => {
|
||||
const { actions, toast, onClose } = setup({ items: [todo()], itemId: 42 })
|
||||
actions.deleteTodoItem.mockRejectedValue(new Error('boom'))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'todo.detail.delete' }))
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('common.error'))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-TASK-015: holds on to the last known row when it vanishes from the store', () => {
|
||||
const { rerender, planner } = setup({ items: [todo()], itemId: 42 })
|
||||
expect(screen.getByPlaceholderText('todo.namePlaceholder')).toHaveValue('Book flights')
|
||||
|
||||
const emptied = { ...planner, todoItems: [] } as unknown as TripPlanner
|
||||
rerender(
|
||||
<MTaskSheet
|
||||
planner={emptied}
|
||||
open
|
||||
itemId={42}
|
||||
categories={['Admin', 'Gear']}
|
||||
members={MEMBERS}
|
||||
defaultCategory={null}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'todo.detail.save' })).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('todo.namePlaceholder')).toHaveValue('Book flights')
|
||||
})
|
||||
|
||||
it('FE-MOB-TASK-016: switches the category between the offered pills', async () => {
|
||||
const { actions } = setup({ items: [todo()], itemId: 42 })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Gear' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'todo.detail.save' }))
|
||||
|
||||
await waitFor(() => expect(actions.updateTodoItem).toHaveBeenCalledWith(1, 42, expect.objectContaining({ category: 'Gear' })))
|
||||
})
|
||||
|
||||
it('FE-MOB-TASK-017: clears the category via the "no category" pill', async () => {
|
||||
const { actions } = setup({ items: [todo()], itemId: 42 })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'todo.noCategory' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'todo.detail.save' }))
|
||||
|
||||
await waitFor(() => expect(actions.updateTodoItem).toHaveBeenCalledWith(1, 42, expect.objectContaining({ category: null })))
|
||||
})
|
||||
|
||||
it('FE-MOB-TASK-018: keeps a stored category that is not in the offered list', async () => {
|
||||
const { actions } = setup({ items: [todo({ category: 'Legacy' })], itemId: 42, categories: ['Admin'] })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Legacy' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'todo.detail.save' }))
|
||||
|
||||
await waitFor(() => expect(actions.updateTodoItem).toHaveBeenCalledWith(1, 42, expect.objectContaining({ category: 'Legacy' })))
|
||||
})
|
||||
|
||||
it('FE-MOB-TASK-019: adds a new category with the confirm button', async () => {
|
||||
const { actions } = setup()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('todo.namePlaceholder'), { target: { value: 'Visa' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /todo.addCategory/ }))
|
||||
expect(screen.getByRole('button', { name: 'common.add' })).toBeDisabled()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('todo.newCategory'), { target: { value: ' Docs ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Docs' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'todo.detail.create' }))
|
||||
await waitFor(() => expect(actions.addTodoItem).toHaveBeenCalledWith(1, expect.objectContaining({ category: 'Docs' })))
|
||||
})
|
||||
|
||||
it('FE-MOB-TASK-020: commits a new category on Enter and abandons it on Escape', () => {
|
||||
setup()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /todo.addCategory/ }))
|
||||
fireEvent.change(screen.getByPlaceholderText('todo.newCategory'), { target: { value: 'Docs' } })
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('todo.newCategory'), { key: 'Enter' })
|
||||
expect(screen.getByRole('button', { name: 'Docs' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /todo.addCategory/ }))
|
||||
fireEvent.change(screen.getByPlaceholderText('todo.newCategory'), { target: { value: 'Ignored' } })
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('todo.newCategory'), { key: 'Escape' })
|
||||
|
||||
expect(screen.queryByPlaceholderText('todo.newCategory')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Ignored' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Docs' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TASK-021: keeps the current category when the draft is blank', () => {
|
||||
setup({ items: [todo()], itemId: 42 })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /todo.addCategory/ }))
|
||||
fireEvent.change(screen.getByPlaceholderText('todo.newCategory'), { target: { value: ' ' } })
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('todo.newCategory'), { key: 'Enter' })
|
||||
|
||||
expect(screen.queryByPlaceholderText('todo.newCategory')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Admin' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TASK-022: unassigns a task again', async () => {
|
||||
const { actions } = setup({ items: [todo()], itemId: 42 })
|
||||
|
||||
expect(document.querySelector('img[src="/uploads/avatars/lea.png"]')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /todo.unassigned/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'todo.detail.save' }))
|
||||
|
||||
await waitFor(() => expect(actions.updateTodoItem).toHaveBeenCalledWith(1, 42, expect.objectContaining({ assigned_user_id: null })))
|
||||
})
|
||||
|
||||
it('FE-MOB-TASK-023: falls back to the initial for a member without an avatar', () => {
|
||||
setup()
|
||||
|
||||
const chip = screen.getByRole('button', { name: /maurice/ })
|
||||
expect(chip).toHaveTextContent('M')
|
||||
expect(chip.querySelector('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-TASK-024: resets the form when the same sheet is reused for another task', () => {
|
||||
const items = [todo(), todo({ id: 43, name: 'Renew passport', description: null, category: null, assigned_user_id: null, priority: 0 })]
|
||||
const { rerender, planner } = setup({ items, itemId: 42 })
|
||||
|
||||
expect(screen.getByPlaceholderText('todo.namePlaceholder')).toHaveValue('Book flights')
|
||||
|
||||
rerender(
|
||||
<MTaskSheet
|
||||
planner={planner}
|
||||
open
|
||||
itemId={43}
|
||||
categories={['Admin', 'Gear']}
|
||||
members={MEMBERS}
|
||||
defaultCategory={null}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByPlaceholderText('todo.namePlaceholder')).toHaveValue('Renew passport')
|
||||
expect(screen.getByPlaceholderText('todo.descriptionPlaceholder')).toHaveValue('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,296 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MTodoListTab from '../../../../src/mobile/screens/trip/tabs/MTodoListTab'
|
||||
import type { MTaskSheetProps } from '../../../../src/mobile/screens/trip/tabs/MTaskSheet'
|
||||
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { useAuthStore } from '../../../../src/store/authStore'
|
||||
import type { TodoItem, TripMember } from '../../../../src/types'
|
||||
import { buildUser } from '../../../helpers/factories'
|
||||
import { buildPlanner, buildTripActions } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-TODO-001 to FE-MOB-TODO-016
|
||||
|
||||
// The sheet has its own suite; here it only has to report the props the tab feeds it.
|
||||
vi.mock('../../../../src/mobile/screens/trip/tabs/MTaskSheet', () => ({
|
||||
default: (props: MTaskSheetProps) => (
|
||||
<div
|
||||
data-testid="task-sheet"
|
||||
data-open={String(props.open)}
|
||||
data-item-id={String(props.itemId)}
|
||||
data-default-category={String(props.defaultCategory)}
|
||||
data-categories={props.categories.join('|')}
|
||||
data-members={props.members.map(m => m.username).join('|')}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
const ME = 7
|
||||
const PAST = '2020-01-05'
|
||||
const FUTURE = '2999-12-31'
|
||||
|
||||
const MEMBERS = [
|
||||
{ id: ME, username: 'maurice', avatar: null, avatar_url: null },
|
||||
{ id: 8, username: 'lea', avatar: null, avatar_url: '/uploads/avatars/lea.png' },
|
||||
] as unknown as TripMember[]
|
||||
|
||||
function todo(overrides: Partial<TodoItem> = {}): TodoItem {
|
||||
return {
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
name: 'Book flights',
|
||||
category: null,
|
||||
checked: 0,
|
||||
sort_order: 0,
|
||||
due_date: null,
|
||||
description: null,
|
||||
assigned_user_id: null,
|
||||
priority: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function setup(items: TodoItem[], plannerOverrides: Partial<TripPlanner> = {}) {
|
||||
const actions = buildTripActions()
|
||||
const planner = buildPlanner({
|
||||
todoItems: items,
|
||||
tripMembers: MEMBERS,
|
||||
tripActions: actions as unknown as TripPlanner['tripActions'],
|
||||
...plannerOverrides,
|
||||
})
|
||||
const view = render(<MTodoListTab planner={planner} />)
|
||||
return { ...view, actions, planner }
|
||||
}
|
||||
|
||||
function sheet() {
|
||||
return screen.getByTestId('task-sheet')
|
||||
}
|
||||
|
||||
/** Row order, read off the check buttons — they are the only ones carrying the task name as aria-label. */
|
||||
function rowOrder(names: string[]) {
|
||||
return screen
|
||||
.getAllByRole('button')
|
||||
.map(b => b.getAttribute('aria-label'))
|
||||
.filter((label): label is string => !!label && names.includes(label))
|
||||
}
|
||||
|
||||
describe('MTodoListTab', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
seedStore(useAuthStore, { user: buildUser({ id: ME, username: 'maurice' }) })
|
||||
})
|
||||
|
||||
it('FE-MOB-TODO-001: renders the empty state and no filter row without items', () => {
|
||||
setup([])
|
||||
|
||||
expect(screen.getByText('todo.empty')).toBeInTheDocument()
|
||||
expect(screen.getByText('0/0')).toBeInTheDocument()
|
||||
expect(screen.getByText('0% · todo.completed')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /todo.filter.all/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TODO-002: reports done/total and the completion percentage', () => {
|
||||
setup([todo({ id: 1 }), todo({ id: 2, checked: 1 }), todo({ id: 3, checked: 1 }), todo({ id: 4 })])
|
||||
|
||||
expect(screen.getByText('2/4')).toBeInTheDocument()
|
||||
expect(screen.getByText('50% · todo.completed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TODO-003: hides "new task" without packing_edit', () => {
|
||||
setup([todo()], { can: vi.fn(() => false) as unknown as TripPlanner['can'] })
|
||||
|
||||
expect(screen.queryByRole('button', { name: /todo.newItem/ })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Book flights')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TODO-004: shows the four built-in filters with their counts', () => {
|
||||
setup([
|
||||
todo({ id: 1, name: 'Open' }),
|
||||
todo({ id: 2, name: 'Mine', assigned_user_id: ME }),
|
||||
todo({ id: 3, name: 'Late', due_date: PAST }),
|
||||
todo({ id: 4, name: 'Done', checked: 1 }),
|
||||
])
|
||||
|
||||
expect(screen.getByRole('button', { name: 'todo.filter.all3' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'todo.filter.my1' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'todo.filter.overdue1' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'todo.filter.done1' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TODO-005: defaults to the open items and hides done ones', () => {
|
||||
setup([todo({ id: 1, name: 'Open' }), todo({ id: 2, name: 'Packed', checked: 1 })])
|
||||
|
||||
expect(screen.getByText('Open')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Packed')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TODO-006: switches to the my / overdue / done buckets', () => {
|
||||
setup([
|
||||
todo({ id: 1, name: 'Open' }),
|
||||
todo({ id: 2, name: 'Mine', assigned_user_id: ME }),
|
||||
todo({ id: 3, name: 'Late', due_date: PAST }),
|
||||
todo({ id: 4, name: 'Packed', checked: 1 }),
|
||||
])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /todo.filter.my/ }))
|
||||
expect(screen.getByText('Mine')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Open')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /todo.filter.overdue/ }))
|
||||
expect(screen.getByText('Late')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Mine')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /todo.filter.done/ }))
|
||||
expect(screen.getByText('Packed')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Late')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TODO-006b: leaves the "my" bucket empty when nobody is signed in', () => {
|
||||
seedStore(useAuthStore, { user: null })
|
||||
setup([todo({ id: 1, name: 'Mine', assigned_user_id: ME })])
|
||||
|
||||
expect(screen.getByRole('button', { name: 'todo.filter.my0' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /todo.filter.my/ }))
|
||||
|
||||
expect(screen.getByText('todo.emptyFiltered')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TODO-007: shows the filtered-empty hint when a bucket is empty', () => {
|
||||
setup([todo({ id: 1, name: 'Open' })])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /todo.filter.done/ }))
|
||||
|
||||
expect(screen.getByText('todo.emptyFiltered')).toBeInTheDocument()
|
||||
expect(screen.queryByText('todo.empty')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TODO-008: toggles the priority sort and reorders the rows', () => {
|
||||
setup([
|
||||
todo({ id: 1, name: 'Low', priority: 3 }),
|
||||
todo({ id: 2, name: 'High', priority: 1 }),
|
||||
])
|
||||
|
||||
expect(rowOrder(['Low', 'High'])).toEqual(['Low', 'High'])
|
||||
|
||||
const sortButton = screen.getByRole('button', { name: /todo.priority/ })
|
||||
expect(sortButton).toHaveAttribute('aria-pressed', 'false')
|
||||
fireEvent.click(sortButton)
|
||||
|
||||
expect(sortButton).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(rowOrder(['Low', 'High'])).toEqual(['High', 'Low'])
|
||||
})
|
||||
|
||||
it('FE-MOB-TODO-009: floats overdue rows above the other open ones', () => {
|
||||
setup([
|
||||
todo({ id: 1, name: 'Open' }),
|
||||
todo({ id: 2, name: 'Packed', checked: 1 }),
|
||||
todo({ id: 3, name: 'Late', due_date: PAST }),
|
||||
])
|
||||
|
||||
expect(rowOrder(['Open', 'Packed', 'Late'])).toEqual(['Late', 'Open'])
|
||||
})
|
||||
|
||||
it('FE-MOB-TODO-009b: sinks done rows to the end of a category bucket', () => {
|
||||
setup([
|
||||
todo({ id: 1, name: 'Packed', category: 'Gear', checked: 1 }),
|
||||
todo({ id: 2, name: 'Boots', category: 'Gear' }),
|
||||
])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Gear1' }))
|
||||
|
||||
expect(rowOrder(['Packed', 'Boots'])).toEqual(['Boots', 'Packed'])
|
||||
})
|
||||
|
||||
it('FE-MOB-TODO-010: renders a category rail with open counts and filters by it', () => {
|
||||
setup([
|
||||
todo({ id: 1, name: 'Visa', category: 'Admin' }),
|
||||
todo({ id: 2, name: 'Insurance', category: 'Admin', checked: 1 }),
|
||||
todo({ id: 3, name: 'Boots', category: 'Gear' }),
|
||||
])
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Admin1' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Gear1' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Admin1' }))
|
||||
|
||||
// the category bucket keeps done items visible
|
||||
expect(screen.getByText('Visa')).toBeInTheDocument()
|
||||
expect(screen.getByText('Insurance')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Boots')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TODO-011: toggles a task through the store action', () => {
|
||||
const { actions } = setup([todo({ id: 12, name: 'Book flights' })])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Book flights', pressed: false }))
|
||||
|
||||
expect(actions.toggleTodoItem).toHaveBeenCalledWith(1, 12, true)
|
||||
})
|
||||
|
||||
it('FE-MOB-TODO-012: unchecks an already done task', () => {
|
||||
const { actions } = setup([todo({ id: 12, name: 'Book flights', checked: 1 })])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /todo.filter.done/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Book flights', pressed: true }))
|
||||
|
||||
expect(actions.toggleTodoItem).toHaveBeenCalledWith(1, 12, false)
|
||||
})
|
||||
|
||||
it('FE-MOB-TODO-013: renders description, priority, due date and assignee on a card', () => {
|
||||
setup([todo({
|
||||
id: 3,
|
||||
name: 'Book flights',
|
||||
description: 'Direct if possible',
|
||||
priority: 1,
|
||||
due_date: PAST,
|
||||
assigned_user_id: 8,
|
||||
})])
|
||||
|
||||
expect(screen.getByText('Direct if possible')).toBeInTheDocument()
|
||||
expect(screen.getByText('P1')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Jan/)).toBeInTheDocument()
|
||||
expect(screen.getByText('lea')).toBeInTheDocument()
|
||||
expect(document.querySelector('img[src="/uploads/avatars/lea.png"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TODO-014: falls back to the username initial when a member has no avatar', () => {
|
||||
setup([todo({ id: 3, due_date: FUTURE, assigned_user_id: ME })])
|
||||
|
||||
expect(screen.getByText('maurice')).toBeInTheDocument()
|
||||
expect(screen.getByText('M')).toBeInTheDocument()
|
||||
expect(document.querySelector('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-TODO-015: opens the sheet in create mode and closes it again', () => {
|
||||
setup([todo({ id: 3, category: 'Admin' })])
|
||||
|
||||
expect(sheet()).toHaveAttribute('data-open', 'false')
|
||||
expect(sheet()).toHaveAttribute('data-categories', 'Admin')
|
||||
expect(sheet()).toHaveAttribute('data-members', 'maurice|lea')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /todo.newItem/ }))
|
||||
|
||||
expect(sheet()).toHaveAttribute('data-open', 'true')
|
||||
expect(sheet()).toHaveAttribute('data-item-id', 'null')
|
||||
expect(sheet()).toHaveAttribute('data-default-category', 'null')
|
||||
})
|
||||
|
||||
it('FE-MOB-TODO-016: seeds the new task with the active category filter', () => {
|
||||
setup([todo({ id: 3, name: 'Visa', category: 'Admin' })])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Admin1' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /todo.newItem/ }))
|
||||
|
||||
expect(sheet()).toHaveAttribute('data-default-category', 'Admin')
|
||||
})
|
||||
|
||||
it('FE-MOB-TODO-017: opens the sheet in edit mode for the tapped row', () => {
|
||||
setup([todo({ id: 42, name: 'Book flights' })])
|
||||
|
||||
fireEvent.click(screen.getByText('Book flights'))
|
||||
|
||||
expect(sheet()).toHaveAttribute('data-open', 'true')
|
||||
expect(sheet()).toHaveAttribute('data-item-id', '42')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,877 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MTransportFormSheet from '../../../../src/mobile/screens/trip/sheets/MTransportFormSheet'
|
||||
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { useAddonStore } from '../../../../src/store/addonStore'
|
||||
import { useTripStore } from '../../../../src/store/tripStore'
|
||||
import type { Day, Reservation, TripMember } from '../../../../src/types'
|
||||
import { buildPlanner } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { act, fireEvent, render, screen } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-TRFRM-001 to FE-MOB-TRFRM-045
|
||||
//
|
||||
// The sheet's own pickers (airport/location search, day select, time picker) and
|
||||
// the embedded transit panel are replaced by minimal controlled stand-ins so the
|
||||
// form's own state machine and payload assembly are what the tests exercise.
|
||||
|
||||
const { AIRPORTS, LOCATIONS } = vi.hoisted(() => ({
|
||||
AIRPORTS: {
|
||||
FRA: { iata: 'FRA', icao: null, name: 'Frankfurt Airport', city: 'Frankfurt', country: 'DE', lat: 50.03, lng: 8.57, tz: 'Europe/Berlin' },
|
||||
IST: { iata: 'IST', icao: null, name: 'Istanbul Airport', city: 'Istanbul', country: 'TR', lat: 41.27, lng: 28.75, tz: 'Europe/Istanbul' },
|
||||
HND: { iata: 'HND', icao: null, name: 'Haneda Airport', city: 'Tokyo', country: 'JP', lat: 35.55, lng: 139.78, tz: 'Asia/Tokyo' },
|
||||
} as Record<string, { iata: string; icao: null; name: string; city: string; country: string; lat: number; lng: number; tz: string }>,
|
||||
LOCATIONS: {
|
||||
osaka: { name: 'Osaka Station', lat: 34.7, lng: 135.5, address: null },
|
||||
kyoto: { name: 'Kyoto Station', lat: 34.98, lng: 135.75, address: null },
|
||||
nara: { name: 'Nara Station', lat: 34.68, lng: 135.82, address: null },
|
||||
} as Record<string, { name: string; lat: number; lng: number; address: null }>,
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/components/shared/CustomSelect', () => ({
|
||||
default: ({ value, onChange, options = [] }: {
|
||||
value: string | number
|
||||
onChange: (v: string | number) => void
|
||||
options?: { value: string | number; label: string }[]
|
||||
}) => (
|
||||
<select
|
||||
aria-label="day-select"
|
||||
value={String(value)}
|
||||
onChange={e => {
|
||||
const opt = options.find(o => String(o.value) === e.target.value)
|
||||
onChange(opt ? opt.value : e.target.value)
|
||||
}}
|
||||
>
|
||||
{options.map(o => <option key={String(o.value)} value={String(o.value)}>{o.label}</option>)}
|
||||
</select>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/components/shared/CustomTimePicker', () => ({
|
||||
default: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
|
||||
<input aria-label="time-input" value={value} onChange={e => onChange(e.target.value)} />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/components/Planner/AirportSelect', () => ({
|
||||
default: ({ value, onChange }: { value: { iata: string } | null; onChange: (a: unknown) => void }) => (
|
||||
<input
|
||||
aria-label="airport-select"
|
||||
value={value?.iata ?? ''}
|
||||
onChange={e => onChange(AIRPORTS[e.target.value] ?? null)}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/components/Planner/LocationSelect', () => ({
|
||||
default: ({ value, onChange }: { value: { name: string } | null; onChange: (l: unknown) => void }) => (
|
||||
<input
|
||||
aria-label="location-select"
|
||||
value={value?.name ?? ''}
|
||||
onChange={e => onChange(LOCATIONS[e.target.value] ?? null)}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/components/Planner/TransitSearchPanel', () => ({
|
||||
default: ({ day, places, onAdd, initialFrom }: {
|
||||
day: { id: number }
|
||||
places: { name: string }[]
|
||||
onAdd: (p: Record<string, unknown>) => void
|
||||
initialFrom: { name: string } | null
|
||||
}) => (
|
||||
<div>
|
||||
<span data-testid="transit-day">{day.id}</span>
|
||||
<span data-testid="transit-places">{places.map(p => p.name).join(',')}</span>
|
||||
<span data-testid="transit-from">{initialFrom?.name ?? ''}</span>
|
||||
<button type="button" onClick={() => onAdd({ title: 'U4 to Prater', type: 'transit' })}>add-itinerary</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/mobile/screens/trip/sheets/PlFileAttach', () => ({
|
||||
default: ({ files, onAdd, onRemove }: { files: File[]; onAdd: (f: File[]) => void; onRemove: (i: number) => void }) => (
|
||||
<div>
|
||||
<button type="button" onClick={() => onAdd([new File(['x'], 'extra.pdf')])}>attach-file</button>
|
||||
{files.map((f, i) => (
|
||||
<button key={`${f.name}-${i}`} type="button" onClick={() => onRemove(i)}>{`drop-${f.name}`}</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const DAYS = [
|
||||
{ id: 11, trip_id: 1, day_number: 1, date: '2026-05-01', title: null },
|
||||
{ id: 12, trip_id: 1, day_number: 2, date: '2026-05-02', title: 'Kyoto' },
|
||||
{ id: 13, trip_id: 1, day_number: 3, date: '2026-05-03', title: null },
|
||||
] as unknown as Day[]
|
||||
|
||||
const MEMBERS = [
|
||||
{ id: 4, username: 'maurice', avatar_url: null },
|
||||
{ id: 5, username: 'julien', avatar_url: '/uploads/avatars/j.png', is_guest: true },
|
||||
] as unknown as TripMember[]
|
||||
|
||||
type SavePayload = Record<string, unknown> & { title: string }
|
||||
|
||||
function makeSave(result: unknown = { id: 99 } as unknown) {
|
||||
return vi.fn(async (_payload: SavePayload) => result)
|
||||
}
|
||||
|
||||
function makePlanner(overrides: Record<string, unknown> = {}): TripPlanner {
|
||||
return buildPlanner({
|
||||
days: DAYS,
|
||||
showTransportModal: true,
|
||||
trip: { id: 1, title: 'Japan 2026', start_date: '2026-05-01', end_date: '2026-05-03', currency: 'EUR' },
|
||||
...overrides,
|
||||
} as unknown as Parameters<typeof buildPlanner>[0])
|
||||
}
|
||||
|
||||
function renderSheet(planner: TripPlanner, onOpenExpense = vi.fn()) {
|
||||
const view = render(<MTransportFormSheet planner={planner} onOpenExpense={onOpenExpense} />)
|
||||
return { ...view, planner, onOpenExpense }
|
||||
}
|
||||
|
||||
const daySelects = () => screen.getAllByLabelText('day-select') as HTMLSelectElement[]
|
||||
const timeInputs = () => screen.getAllByLabelText('time-input') as HTMLInputElement[]
|
||||
const airportInputs = () => screen.getAllByLabelText('airport-select') as HTMLInputElement[]
|
||||
const locationInputs = () => screen.getAllByLabelText('location-select') as HTMLInputElement[]
|
||||
|
||||
const setValue = (el: HTMLElement, value: string) => fireEvent.change(el, { target: { value } })
|
||||
|
||||
const typeTitle = (value: string) =>
|
||||
setValue(screen.getByPlaceholderText('reservations.titlePlaceholder'), value)
|
||||
|
||||
async function submit(label = 'common.add') {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: label }))
|
||||
})
|
||||
}
|
||||
|
||||
describe('MTransportFormSheet', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
seedStore(useAddonStore, { addons: [{ id: 'budget', name: 'Budget', type: 'addon', icon: '', enabled: true }] })
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-001: renders nothing while the planner has the editor closed', () => {
|
||||
renderSheet(makePlanner({ showTransportModal: false }))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-002: opens in create mode with a disabled save and the manual/automated switch', () => {
|
||||
renderSheet(makePlanner())
|
||||
expect(screen.getByRole('dialog', { name: 'transport.modalTitle.create' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.add' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'transport.modeManual' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'transport.modeAutomated' })).toBeInTheDocument()
|
||||
// Nothing to delete on a fresh booking.
|
||||
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-003: hides the automated switch when the trip has no dates', () => {
|
||||
renderSheet(makePlanner({ trip: { id: 1, title: 'Someday', start_date: null, end_date: null } }))
|
||||
expect(screen.queryByRole('button', { name: 'transport.modeAutomated' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-004: offers every transport type and marks the active one', () => {
|
||||
renderSheet(makePlanner())
|
||||
for (const type of ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other']) {
|
||||
expect(screen.getByRole('button', { name: `reservations.type.${type}` })).toBeInTheDocument()
|
||||
}
|
||||
expect(screen.getByRole('button', { name: 'reservations.type.flight' })).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.bus' }))
|
||||
expect(screen.getByRole('button', { name: 'reservations.type.bus' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByRole('button', { name: 'reservations.type.flight' })).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-005: a car booking relabels the date rows as pick-up and return', () => {
|
||||
renderSheet(makePlanner())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.bus' }))
|
||||
expect(screen.getByText('reservations.date')).toBeInTheDocument()
|
||||
expect(screen.getByText('reservations.startTime')).toBeInTheDocument()
|
||||
expect(screen.getByText('reservations.endDate')).toBeInTheDocument()
|
||||
expect(screen.getByText('reservations.endTime')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.car' }))
|
||||
expect(screen.getByText('reservations.pickupDate')).toBeInTheDocument()
|
||||
expect(screen.getByText('reservations.pickupTime')).toBeInTheDocument()
|
||||
expect(screen.getByText('reservations.returnDate')).toBeInTheDocument()
|
||||
expect(screen.getByText('reservations.returnTime')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-006: saves a simple two-endpoint booking with the day-anchored times', async () => {
|
||||
const handleSaveTransport = makeSave()
|
||||
renderSheet(makePlanner({ handleSaveTransport }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.bus' }))
|
||||
typeTitle('Bus to Kyoto')
|
||||
setValue(locationInputs()[0], 'osaka')
|
||||
setValue(locationInputs()[1], 'kyoto')
|
||||
setValue(daySelects()[0], '11')
|
||||
setValue(timeInputs()[0], '08:15')
|
||||
setValue(daySelects()[1], '12')
|
||||
setValue(timeInputs()[1], '11:00')
|
||||
setValue(screen.getByPlaceholderText('reservations.confirmationPlaceholder'), 'BUS-4711')
|
||||
setValue(screen.getByPlaceholderText('reservations.notesPlaceholder'), 'Seat 4 reserved')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.confirmed' }))
|
||||
|
||||
await submit()
|
||||
|
||||
expect(handleSaveTransport).toHaveBeenCalledTimes(1)
|
||||
expect(handleSaveTransport.mock.calls[0][0]).toEqual({
|
||||
title: 'Bus to Kyoto',
|
||||
type: 'bus',
|
||||
status: 'confirmed',
|
||||
day_id: 11,
|
||||
end_day_id: 12,
|
||||
reservation_time: '2026-05-01T08:15',
|
||||
reservation_end_time: '2026-05-02T11:00',
|
||||
location: null,
|
||||
confirmation_number: 'BUS-4711',
|
||||
notes: 'Seat 4 reserved',
|
||||
metadata: null,
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'Osaka Station', code: null, lat: 34.7, lng: 135.5, timezone: null, local_date: '2026-05-01', local_time: '08:15' },
|
||||
{ role: 'to', sequence: 1, name: 'Kyoto Station', code: null, lat: 34.98, lng: 135.75, timezone: null, local_date: '2026-05-02', local_time: '11:00' },
|
||||
],
|
||||
needs_review: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-007: falls back to the start day for the end date and keeps a bare time', async () => {
|
||||
const handleSaveTransport = makeSave()
|
||||
renderSheet(makePlanner({ handleSaveTransport, days: [] }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.taxi' }))
|
||||
typeTitle('Airport transfer')
|
||||
setValue(timeInputs()[0], '05:40')
|
||||
|
||||
await submit()
|
||||
|
||||
const payload = handleSaveTransport.mock.calls[0][0]
|
||||
expect(payload.day_id).toBeNull()
|
||||
expect(payload.end_day_id).toBeNull()
|
||||
// No day means no date prefix — the bare clock time is stored.
|
||||
expect(payload.reservation_time).toBe('05:40')
|
||||
expect(payload.reservation_end_time).toBeNull()
|
||||
expect(payload.endpoints).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-008: saves a single-leg flight with the flat airline metadata', async () => {
|
||||
const handleSaveTransport = makeSave()
|
||||
renderSheet(makePlanner({ handleSaveTransport }))
|
||||
typeTitle('LH 716')
|
||||
setValue(airportInputs()[0], 'FRA')
|
||||
setValue(airportInputs()[1], 'HND')
|
||||
setValue(daySelects()[0], '11')
|
||||
setValue(timeInputs()[0], '10:20')
|
||||
setValue(daySelects()[1], '12')
|
||||
setValue(timeInputs()[1], '06:45')
|
||||
setValue(screen.getByPlaceholderText('Lufthansa'), 'Lufthansa')
|
||||
setValue(screen.getByPlaceholderText('LH 123'), 'LH 716')
|
||||
setValue(screen.getByPlaceholderText('12A'), '31A')
|
||||
|
||||
await submit()
|
||||
|
||||
expect(handleSaveTransport.mock.calls[0][0]).toEqual({
|
||||
title: 'LH 716',
|
||||
type: 'flight',
|
||||
status: 'pending',
|
||||
day_id: 11,
|
||||
end_day_id: 12,
|
||||
reservation_time: '2026-05-01T10:20',
|
||||
reservation_end_time: '2026-05-02T06:45',
|
||||
location: null,
|
||||
confirmation_number: null,
|
||||
notes: null,
|
||||
metadata: {
|
||||
airline: 'Lufthansa',
|
||||
flight_number: 'LH 716',
|
||||
seat: '31A',
|
||||
departure_airport: 'FRA',
|
||||
departure_timezone: 'Europe/Berlin',
|
||||
arrival_airport: 'HND',
|
||||
arrival_timezone: 'Asia/Tokyo',
|
||||
},
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'Frankfurt (FRA)', code: 'FRA', lat: 50.03, lng: 8.57, timezone: 'Europe/Berlin', local_date: '2026-05-01', local_time: '10:20' },
|
||||
{ role: 'to', sequence: 1, name: 'Tokyo (HND)', code: 'HND', lat: 35.55, lng: 139.78, timezone: 'Asia/Tokyo', local_date: '2026-05-02', local_time: '06:45' },
|
||||
],
|
||||
needs_review: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-009: adds a layover and writes one metadata leg per segment', async () => {
|
||||
const handleSaveTransport = makeSave()
|
||||
renderSheet(makePlanner({ handleSaveTransport }))
|
||||
typeTitle('FRA-IST-HND')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.layover.addStop' }))
|
||||
expect(screen.getAllByLabelText('airport-select')).toHaveLength(3)
|
||||
expect(screen.getByText('reservations.layover.stop')).toBeInTheDocument()
|
||||
|
||||
setValue(airportInputs()[0], 'FRA')
|
||||
setValue(airportInputs()[1], 'IST')
|
||||
setValue(airportInputs()[2], 'HND')
|
||||
// selects: dep(0) | arr(1) dep(1) | arr(2)
|
||||
setValue(daySelects()[0], '11')
|
||||
setValue(timeInputs()[0], '10:20')
|
||||
setValue(daySelects()[1], '11')
|
||||
setValue(timeInputs()[1], '14:05')
|
||||
setValue(daySelects()[2], '11')
|
||||
setValue(timeInputs()[2], '16:30')
|
||||
setValue(daySelects()[3], '12')
|
||||
setValue(timeInputs()[3], '09:00')
|
||||
const airlines = screen.getAllByPlaceholderText('Lufthansa')
|
||||
setValue(airlines[0], 'Lufthansa')
|
||||
setValue(airlines[1], 'Turkish Airlines')
|
||||
const flightNumbers = screen.getAllByPlaceholderText('LH 123')
|
||||
setValue(flightNumbers[0], 'LH 1300')
|
||||
setValue(flightNumbers[1], 'TK 46')
|
||||
setValue(screen.getAllByPlaceholderText('12A')[0], '2A')
|
||||
|
||||
await submit()
|
||||
|
||||
const payload = handleSaveTransport.mock.calls[0][0]
|
||||
expect(payload.metadata).toEqual({
|
||||
airline: 'Lufthansa',
|
||||
flight_number: 'LH 1300',
|
||||
seat: '2A',
|
||||
departure_airport: 'FRA',
|
||||
departure_timezone: 'Europe/Berlin',
|
||||
arrival_airport: 'HND',
|
||||
arrival_timezone: 'Asia/Tokyo',
|
||||
legs: [
|
||||
{ from: 'FRA', to: 'IST', airline: 'Lufthansa', flight_number: 'LH 1300', seat: '2A', dep_day_id: 11, dep_time: '10:20', arr_day_id: 11, arr_time: '14:05' },
|
||||
{ from: 'IST', to: 'HND', airline: 'Turkish Airlines', flight_number: 'TK 46', dep_day_id: 11, dep_time: '16:30', arr_day_id: 12, arr_time: '09:00' },
|
||||
],
|
||||
})
|
||||
expect(payload.endpoints).toEqual([
|
||||
expect.objectContaining({ role: 'from', sequence: 0, code: 'FRA', local_time: '10:20' }),
|
||||
expect.objectContaining({ role: 'stop', sequence: 1, code: 'IST', local_time: '16:30' }),
|
||||
expect.objectContaining({ role: 'to', sequence: 2, code: 'HND', local_time: '09:00' }),
|
||||
])
|
||||
expect(payload.day_id).toBe(11)
|
||||
expect(payload.end_day_id).toBe(12)
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-010: removing a layover falls back to the flat single-leg shape', async () => {
|
||||
const handleSaveTransport = makeSave()
|
||||
renderSheet(makePlanner({ handleSaveTransport }))
|
||||
typeTitle('FRA-HND')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.layover.addStop' }))
|
||||
expect(airportInputs()).toHaveLength(3)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
|
||||
expect(airportInputs()).toHaveLength(2)
|
||||
|
||||
setValue(airportInputs()[0], 'FRA')
|
||||
setValue(airportInputs()[1], 'HND')
|
||||
await submit()
|
||||
|
||||
expect(handleSaveTransport.mock.calls[0][0].metadata).not.toHaveProperty('legs')
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-011: a flight without any picked airport saves no metadata and no endpoints', async () => {
|
||||
const handleSaveTransport = makeSave()
|
||||
renderSheet(makePlanner({ handleSaveTransport }))
|
||||
typeTitle('Placeholder flight')
|
||||
await submit()
|
||||
|
||||
const payload = handleSaveTransport.mock.calls[0][0]
|
||||
expect(payload.metadata).toBeNull()
|
||||
expect(payload.endpoints).toEqual([])
|
||||
expect(payload.day_id).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-012: saves a single-leg train with train number, platform and seat', async () => {
|
||||
const handleSaveTransport = makeSave()
|
||||
renderSheet(makePlanner({ handleSaveTransport }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.train' }))
|
||||
typeTitle('ICE 599')
|
||||
setValue(locationInputs()[0], 'osaka')
|
||||
setValue(locationInputs()[1], 'kyoto')
|
||||
setValue(daySelects()[0], '12')
|
||||
setValue(timeInputs()[0], '07:45')
|
||||
setValue(daySelects()[1], '12')
|
||||
setValue(timeInputs()[1], '09:12')
|
||||
setValue(screen.getByPlaceholderText('ICE 123'), 'ICE 599')
|
||||
setValue(screen.getByPlaceholderText('12'), '7')
|
||||
setValue(screen.getByPlaceholderText('42A'), '12C')
|
||||
|
||||
await submit()
|
||||
|
||||
const payload = handleSaveTransport.mock.calls[0][0]
|
||||
expect(payload.metadata).toEqual({ train_number: 'ICE 599', platform: '7', seat: '12C' })
|
||||
expect(payload.day_id).toBe(12)
|
||||
expect(payload.end_day_id).toBe(12)
|
||||
expect(payload.reservation_time).toBe('2026-05-02T07:45')
|
||||
expect(payload.reservation_end_time).toBe('2026-05-02T09:12')
|
||||
expect(payload.endpoints).toEqual([
|
||||
{ role: 'from', sequence: 0, name: 'Osaka Station', code: null, lat: 34.7, lng: 135.5, timezone: null, local_date: '2026-05-02', local_time: '07:45' },
|
||||
{ role: 'to', sequence: 1, name: 'Kyoto Station', code: null, lat: 34.98, lng: 135.75, timezone: null, local_date: '2026-05-02', local_time: '09:12' },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-013: a multi-stop train writes per-leg train numbers and stop endpoints', async () => {
|
||||
const handleSaveTransport = makeSave()
|
||||
renderSheet(makePlanner({ handleSaveTransport }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.train' }))
|
||||
typeTitle('Osaka via Nara')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.layover.addStop' }))
|
||||
setValue(locationInputs()[0], 'osaka')
|
||||
setValue(locationInputs()[1], 'nara')
|
||||
setValue(locationInputs()[2], 'kyoto')
|
||||
setValue(daySelects()[0], '12')
|
||||
setValue(timeInputs()[0], '08:00')
|
||||
setValue(daySelects()[1], '12')
|
||||
setValue(timeInputs()[1], '08:40')
|
||||
setValue(daySelects()[2], '12')
|
||||
setValue(timeInputs()[2], '09:00')
|
||||
setValue(daySelects()[3], '12')
|
||||
setValue(timeInputs()[3], '09:35')
|
||||
const trainNumbers = screen.getAllByPlaceholderText('ICE 123')
|
||||
setValue(trainNumbers[0], 'JR 12')
|
||||
setValue(trainNumbers[1], 'JR 88')
|
||||
setValue(screen.getAllByPlaceholderText('12')[0], '3')
|
||||
|
||||
await submit()
|
||||
|
||||
const payload = handleSaveTransport.mock.calls[0][0]
|
||||
expect(payload.metadata).toEqual({
|
||||
train_number: 'JR 12',
|
||||
platform: '3',
|
||||
legs: [
|
||||
{ from: 'Osaka Station', to: 'Nara Station', train_number: 'JR 12', platform: '3', dep_day_id: 12, dep_time: '08:00', arr_day_id: 12, arr_time: '08:40' },
|
||||
{ from: 'Nara Station', to: 'Kyoto Station', train_number: 'JR 88', dep_day_id: 12, dep_time: '09:00', arr_day_id: 12, arr_time: '09:35' },
|
||||
],
|
||||
})
|
||||
expect(payload.endpoints).toEqual([
|
||||
expect.objectContaining({ role: 'from', sequence: 0, name: 'Osaka Station' }),
|
||||
expect.objectContaining({ role: 'stop', sequence: 1, name: 'Nara Station' }),
|
||||
expect.objectContaining({ role: 'to', sequence: 2, name: 'Kyoto Station' }),
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-043: removing a train stop restores the two-station route', () => {
|
||||
renderSheet(makePlanner())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.train' }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'reservations.layover.addStop' })[0])
|
||||
expect(locationInputs()).toHaveLength(3)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
|
||||
expect(locationInputs()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-044: seeds an edited train route from its ordered station endpoints', () => {
|
||||
const editingTransport = {
|
||||
id: 51, trip_id: 1, type: 'train', title: 'Osaka via Nara', status: 'pending',
|
||||
day_id: 12, end_day_id: 13, metadata: { train_number: 'JR 12', platform: '3', seat: '5B' },
|
||||
endpoints: [
|
||||
{ id: 3, reservation_id: 51, role: 'to', sequence: 2, name: 'Kyoto Station', code: null, lat: 34.98, lng: 135.75, timezone: null, local_date: '2026-05-03', local_time: '09:35' },
|
||||
{ id: 1, reservation_id: 51, role: 'from', sequence: 0, name: 'Osaka Station', code: null, lat: 34.7, lng: 135.5, timezone: null, local_date: '2026-05-02', local_time: '08:00' },
|
||||
{ id: 2, reservation_id: 51, role: 'stop', sequence: 1, name: 'Nara Station', code: null, lat: 34.68, lng: 135.82, timezone: null, local_date: '2026-05-02', local_time: '08:40' },
|
||||
],
|
||||
} as unknown as Reservation
|
||||
renderSheet(makePlanner({ editingTransport }))
|
||||
|
||||
expect(locationInputs().map(i => i.value)).toEqual(['Osaka Station', 'Nara Station', 'Kyoto Station'])
|
||||
// The stop's own local_time seeds both its arrival and its departure.
|
||||
expect(timeInputs().map(i => i.value)).toEqual(['08:00', '08:40', '08:40', '09:35'])
|
||||
expect(daySelects().map(s => s.value)).toEqual(['12', '', '', '13'])
|
||||
expect(screen.getAllByPlaceholderText('ICE 123')[0]).toHaveValue('JR 12')
|
||||
expect(screen.getAllByPlaceholderText('42A')[0]).toHaveValue('5B')
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-045: picking a day in the automated tab reveals the transit search', () => {
|
||||
renderSheet(makePlanner({ transportModalAutomated: true }))
|
||||
expect(screen.getByText('transit.pickDay')).toBeInTheDocument()
|
||||
setValue(daySelects()[0], '13')
|
||||
expect(screen.queryByText('transit.pickDay')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('transit-day')).toHaveTextContent('13')
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-014: reports a failing save through the toast', async () => {
|
||||
const planner = makePlanner({ handleSaveTransport: vi.fn(async () => { throw new Error('server said no') }) })
|
||||
renderSheet(planner)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.bus' }))
|
||||
typeTitle('Broken bus')
|
||||
await submit()
|
||||
expect(planner.toast.error).toHaveBeenCalledWith('server said no')
|
||||
expect(screen.getByRole('button', { name: 'common.add' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-015: falls back to the generic error text for a non-Error rejection', async () => {
|
||||
const planner = makePlanner({ handleSaveTransport: vi.fn(async () => { throw 'nope' }) })
|
||||
renderSheet(planner)
|
||||
typeTitle('Broken flight')
|
||||
await submit()
|
||||
expect(planner.toast.error).toHaveBeenCalledWith('common.unknownError')
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-016: disables and relabels the save button while the request is in flight', async () => {
|
||||
let release: (v: { id: number }) => void = () => {}
|
||||
const handleSaveTransport = vi.fn(() => new Promise<{ id: number }>(resolve => { release = resolve }))
|
||||
renderSheet(makePlanner({ handleSaveTransport }))
|
||||
typeTitle('Slow flight')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
|
||||
|
||||
const saving = await screen.findByRole('button', { name: 'common.saving' })
|
||||
expect(saving).toBeDisabled()
|
||||
await act(async () => { release({ id: 99 }) })
|
||||
expect(screen.getByRole('button', { name: 'common.add' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-017: closing resets every editor flag on the planner', () => {
|
||||
const planner = makePlanner()
|
||||
renderSheet(planner)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
|
||||
expect(planner.setShowTransportModal).toHaveBeenCalledWith(false)
|
||||
expect(planner.setEditingTransport).toHaveBeenCalledWith(null)
|
||||
expect(planner.setTransportModalDayId).toHaveBeenCalledWith(null)
|
||||
expect(planner.setTransportModalAutomated).toHaveBeenCalledWith(false)
|
||||
expect(planner.setTransitPrefill).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-018: during an import review the close button advances instead of resetting', () => {
|
||||
const planner = makePlanner({ importReviewActive: true })
|
||||
renderSheet(planner)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
|
||||
expect(planner.advanceImportReview).toHaveBeenCalledTimes(1)
|
||||
expect(planner.setShowTransportModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-019: a successful save during an import review advances to the next item', async () => {
|
||||
const planner = makePlanner({ importReviewActive: true, handleSaveTransport: makeSave() })
|
||||
renderSheet(planner)
|
||||
typeTitle('Imported flight')
|
||||
await submit()
|
||||
expect(planner.advanceImportReview).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-020: a save that returns nothing does not advance the import review', async () => {
|
||||
const planner = makePlanner({ importReviewActive: true, handleSaveTransport: makeSave(null) })
|
||||
renderSheet(planner)
|
||||
typeTitle('Imported flight')
|
||||
await submit()
|
||||
expect(planner.advanceImportReview).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-021: seeds the form from the edited flight including the day selects', () => {
|
||||
const editingTransport = {
|
||||
id: 42, trip_id: 1, type: 'flight', title: 'LH 716', status: 'confirmed',
|
||||
day_id: 11, end_day_id: 12,
|
||||
reservation_time: '2026-05-01T10:20', reservation_end_time: '2026-05-02T06:45',
|
||||
confirmation_number: 'ABC123', notes: 'Exit row',
|
||||
metadata: JSON.stringify({ airline: 'Lufthansa', flight_number: 'LH 716', seat: '31A' }),
|
||||
endpoints: [
|
||||
{ id: 1, reservation_id: 42, role: 'to', sequence: 1, name: 'Tokyo (HND)', code: 'HND', lat: 35.55, lng: 139.78, timezone: 'Asia/Tokyo', local_date: '2026-05-02', local_time: '06:45' },
|
||||
{ id: 2, reservation_id: 42, role: 'from', sequence: 0, name: 'Frankfurt (FRA)', code: 'FRA', lat: 50.03, lng: 8.57, timezone: 'Europe/Berlin', local_date: '2026-05-01', local_time: '10:20' },
|
||||
],
|
||||
} as unknown as Reservation
|
||||
renderSheet(makePlanner({ editingTransport }))
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'transport.modalTitle.edit' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.update' })).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('reservations.titlePlaceholder')).toHaveValue('LH 716')
|
||||
expect(screen.getByPlaceholderText('reservations.confirmationPlaceholder')).toHaveValue('ABC123')
|
||||
expect(screen.getByPlaceholderText('reservations.notesPlaceholder')).toHaveValue('Exit row')
|
||||
expect(screen.getByRole('button', { name: 'reservations.confirmed' }).className).toContain('bg-m-act')
|
||||
// Endpoints are read in sequence order, not array order.
|
||||
expect(airportInputs().map(i => i.value)).toEqual(['FRA', 'HND'])
|
||||
expect(daySelects().map(s => s.value)).toEqual(['11', '12'])
|
||||
expect(timeInputs().map(i => i.value)).toEqual(['10:20', '06:45'])
|
||||
expect(screen.getByPlaceholderText('Lufthansa')).toHaveValue('Lufthansa')
|
||||
expect(screen.getByPlaceholderText('12A')).toHaveValue('31A')
|
||||
// No mode switch while editing.
|
||||
expect(screen.queryByRole('button', { name: 'transport.modeAutomated' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-022: seeds a multi-leg flight from metadata.legs and keeps the day positions on re-save', async () => {
|
||||
const handleSaveTransport = makeSave()
|
||||
const editingTransport = {
|
||||
id: 43, trip_id: 1, type: 'flight', title: 'FRA-IST-HND', status: 'pending',
|
||||
day_id: 11, end_day_id: 12,
|
||||
metadata: {
|
||||
legs: [
|
||||
{ from: 'FRA', to: 'IST', airline: 'Lufthansa', flight_number: 'LH 1300', seat: '2A', dep_day_id: 11, dep_time: '10:20', arr_day_id: 11, arr_time: '14:05', day_positions: { '11': 3 } },
|
||||
{ from: 'IST', to: 'HND', airline: 'Turkish Airlines', flight_number: 'TK 46', dep_day_id: 11, dep_time: '16:30', arr_day_id: 12, arr_time: '09:00' },
|
||||
],
|
||||
},
|
||||
endpoints: [
|
||||
{ id: 1, reservation_id: 43, role: 'from', sequence: 0, name: 'Frankfurt (FRA)', code: 'FRA', lat: 50.03, lng: 8.57, timezone: 'Europe/Berlin', local_date: null, local_time: null },
|
||||
{ id: 2, reservation_id: 43, role: 'stop', sequence: 1, name: 'Istanbul (IST)', code: 'IST', lat: 41.27, lng: 28.75, timezone: 'Europe/Istanbul', local_date: null, local_time: null },
|
||||
{ id: 3, reservation_id: 43, role: 'to', sequence: 2, name: 'Tokyo (HND)', code: 'HND', lat: 35.55, lng: 139.78, timezone: 'Asia/Tokyo', local_date: null, local_time: null },
|
||||
],
|
||||
} as unknown as Reservation
|
||||
renderSheet(makePlanner({ editingTransport, handleSaveTransport }))
|
||||
|
||||
expect(airportInputs().map(i => i.value)).toEqual(['FRA', 'IST', 'HND'])
|
||||
expect(timeInputs().map(i => i.value)).toEqual(['10:20', '14:05', '16:30', '09:00'])
|
||||
|
||||
await submit('common.update')
|
||||
const legs = (handleSaveTransport.mock.calls[0][0].metadata as { legs: Record<string, unknown>[] }).legs
|
||||
expect(legs[0]).toMatchObject({ from: 'FRA', to: 'IST', day_positions: { '11': 3 } })
|
||||
expect(legs[1]).not.toHaveProperty('day_positions')
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-023: an edited flight without endpoints falls back to the reservation times', () => {
|
||||
const editingTransport = {
|
||||
id: 44, trip_id: 1, type: 'flight', title: 'Legacy flight', status: 'pending',
|
||||
day_id: 11, end_day_id: 13,
|
||||
reservation_time: '2026-05-01T06:00', reservation_end_time: '2026-05-03T21:30',
|
||||
metadata: { airline: 'Air Legacy', flight_number: 'AL 1', seat: '9F' },
|
||||
endpoints: [],
|
||||
} as unknown as Reservation
|
||||
renderSheet(makePlanner({ editingTransport }))
|
||||
expect(airportInputs().map(i => i.value)).toEqual(['', ''])
|
||||
expect(daySelects().map(s => s.value)).toEqual(['11', '13'])
|
||||
expect(timeInputs().map(i => i.value)).toEqual(['06:00', '21:30'])
|
||||
expect(screen.getByPlaceholderText('Lufthansa')).toHaveValue('Air Legacy')
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-024: an edited train without endpoints falls back to the flat metadata', () => {
|
||||
const editingTransport = {
|
||||
id: 45, trip_id: 1, type: 'train', title: 'Legacy train', status: 'pending',
|
||||
day_id: 12, reservation_time: '2026-05-02T07:45', reservation_end_time: '2026-05-02T09:12',
|
||||
metadata: { train_number: 'ICE 599', platform: '7', seat: '12C' },
|
||||
endpoints: [],
|
||||
} as unknown as Reservation
|
||||
renderSheet(makePlanner({ editingTransport }))
|
||||
expect(screen.getByPlaceholderText('ICE 123')).toHaveValue('ICE 599')
|
||||
expect(screen.getByPlaceholderText('12')).toHaveValue('7')
|
||||
expect(screen.getByPlaceholderText('42A')).toHaveValue('12C')
|
||||
expect(timeInputs().map(i => i.value)).toEqual(['07:45', '09:12'])
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-025: an unknown booking type is edited as a flight', () => {
|
||||
const editingTransport = {
|
||||
id: 46, trip_id: 1, type: 'hotel', title: 'Wrong type', status: 'pending', endpoints: [], metadata: null,
|
||||
} as unknown as Reservation
|
||||
renderSheet(makePlanner({ editingTransport }))
|
||||
expect(screen.getByRole('button', { name: 'reservations.type.flight' })).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-026: keeps a transit itinerary and its stops while the endpoints stay put', async () => {
|
||||
const handleSaveTransport = makeSave()
|
||||
const editingTransport = {
|
||||
id: 47, trip_id: 1, type: 'transit', title: 'U4 to Prater', status: 'pending', day_id: 12,
|
||||
reservation_time: '2026-05-02T09:05', reservation_end_time: '2026-05-02T09:30',
|
||||
metadata: { transit: { legs: [{ mode: 'SUBWAY', line: 'U4' }] }, airtrail_ids: [7, 8] },
|
||||
endpoints: [
|
||||
{ id: 1, reservation_id: 47, role: 'from', sequence: 0, name: 'Osaka Station', code: null, lat: 34.7, lng: 135.5, timezone: null, local_date: '2026-05-02', local_time: '09:05' },
|
||||
{ id: 2, reservation_id: 47, role: 'stop', sequence: 1, name: 'Change', code: null, lat: 34.8, lng: 135.6, timezone: null, local_date: '2026-05-02', local_time: '09:15' },
|
||||
{ id: 3, reservation_id: 47, role: 'to', sequence: 2, name: 'Kyoto Station', code: null, lat: 34.98, lng: 135.75, timezone: null, local_date: '2026-05-02', local_time: '09:30' },
|
||||
],
|
||||
} as unknown as Reservation
|
||||
renderSheet(makePlanner({ editingTransport, handleSaveTransport }))
|
||||
|
||||
expect(locationInputs().map(i => i.value)).toEqual(['Osaka Station', 'Kyoto Station'])
|
||||
await submit('common.update')
|
||||
|
||||
const payload = handleSaveTransport.mock.calls[0][0]
|
||||
expect(payload.metadata).toEqual({
|
||||
transit: { legs: [{ mode: 'SUBWAY', line: 'U4' }] },
|
||||
airtrail_ids: [7, 8],
|
||||
})
|
||||
expect(payload.endpoints).toEqual([
|
||||
expect.objectContaining({ role: 'from', sequence: 0, name: 'Osaka Station' }),
|
||||
expect.objectContaining({ role: 'stop', sequence: 1, name: 'Change', local_time: '09:15' }),
|
||||
expect.objectContaining({ role: 'to', sequence: 2, name: 'Kyoto Station' }),
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-027: changing an endpoint drops the stale transit itinerary', async () => {
|
||||
const handleSaveTransport = makeSave()
|
||||
const editingTransport = {
|
||||
id: 48, trip_id: 1, type: 'transit', title: 'U4 to Prater', status: 'pending', day_id: 12,
|
||||
metadata: { transit: { legs: [{ mode: 'SUBWAY', line: 'U4' }] } },
|
||||
endpoints: [
|
||||
{ id: 1, reservation_id: 48, role: 'from', sequence: 0, name: 'Osaka Station', code: null, lat: 34.7, lng: 135.5, timezone: null, local_date: null, local_time: null },
|
||||
{ id: 2, reservation_id: 48, role: 'stop', sequence: 1, name: 'Change', code: null, lat: 34.8, lng: 135.6, timezone: null, local_date: null, local_time: null },
|
||||
{ id: 3, reservation_id: 48, role: 'to', sequence: 2, name: 'Kyoto Station', code: null, lat: 34.98, lng: 135.75, timezone: null, local_date: null, local_time: null },
|
||||
],
|
||||
} as unknown as Reservation
|
||||
renderSheet(makePlanner({ editingTransport, handleSaveTransport }))
|
||||
setValue(locationInputs()[1], 'nara')
|
||||
|
||||
await submit('common.update')
|
||||
const payload = handleSaveTransport.mock.calls[0][0]
|
||||
expect(payload.metadata).toBeNull()
|
||||
expect(payload.endpoints).toEqual([
|
||||
expect.objectContaining({ role: 'from', name: 'Osaka Station' }),
|
||||
expect.objectContaining({ role: 'to', sequence: 1, name: 'Nara Station' }),
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-028: deleting asks for a second tap before it removes the booking', async () => {
|
||||
const editingTransport = {
|
||||
id: 49, trip_id: 1, type: 'bus', title: 'Bus', status: 'pending', endpoints: [], metadata: null,
|
||||
} as unknown as Reservation
|
||||
const planner = makePlanner({ editingTransport })
|
||||
renderSheet(planner)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
|
||||
expect(planner.toast.warning).toHaveBeenCalledWith('mobileTrip.tapAgainToDelete')
|
||||
expect(planner.handleDeleteReservation).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'common.delete' })) })
|
||||
expect(planner.handleDeleteReservation).toHaveBeenCalledWith(49)
|
||||
expect(planner.setShowTransportModal).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-029: imports a parsed booking with its day, source file and parsed price', async () => {
|
||||
const handleSaveTransport = makeSave()
|
||||
const addFile = vi.fn(async (_tripId: number, _form: FormData) => undefined)
|
||||
const sourceFile = new File(['pdf'], 'voucher.pdf')
|
||||
const transportPrefill = {
|
||||
type: 'car', title: 'Rental car', status: 'pending',
|
||||
reservation_time: '2026-05-02T09:00', reservation_end_time: '2026-05-03T18:00',
|
||||
metadata: { price: 120 }, endpoints: [], _sourceFiles: [sourceFile],
|
||||
}
|
||||
const planner = makePlanner({
|
||||
transportPrefill, handleSaveTransport, tripActions: { addFile },
|
||||
})
|
||||
renderSheet(planner)
|
||||
|
||||
expect(screen.getByPlaceholderText('reservations.titlePlaceholder')).toHaveValue('Rental car')
|
||||
expect(screen.getByRole('button', { name: 'reservations.type.car' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(daySelects().map(s => s.value)).toEqual(['12', '13'])
|
||||
expect(timeInputs().map(i => i.value)).toEqual(['09:00', '18:00'])
|
||||
expect(screen.getByRole('button', { name: 'drop-voucher.pdf' })).toBeInTheDocument()
|
||||
|
||||
await submit()
|
||||
const payload = handleSaveTransport.mock.calls[0][0]
|
||||
expect(payload.create_budget_entry).toEqual({ total_price: 120, category: 'transport' })
|
||||
expect(addFile).toHaveBeenCalledTimes(1)
|
||||
expect(addFile.mock.calls[0][0]).toBe(1)
|
||||
expect((addFile.mock.calls[0][1] as FormData).get('description')).toBe('Rental car')
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-030: a prefill without a price and with the budget addon off skips the cost entry', async () => {
|
||||
seedStore(useAddonStore, { addons: [] })
|
||||
const handleSaveTransport = makeSave()
|
||||
const planner = makePlanner({
|
||||
transportPrefill: { type: 'bus', title: 'Shuttle', metadata: { price: 0 }, endpoints: [] },
|
||||
handleSaveTransport,
|
||||
})
|
||||
renderSheet(planner)
|
||||
expect(screen.queryByRole('button', { name: 'reservations.createExpense' })).not.toBeInTheDocument()
|
||||
await submit()
|
||||
expect(handleSaveTransport.mock.calls[0][0]).not.toHaveProperty('create_budget_entry')
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-031: attaching and dropping files only uploads what is left', async () => {
|
||||
const handleSaveTransport = makeSave()
|
||||
const addFile = vi.fn(async (_tripId: number, _form: FormData) => undefined)
|
||||
renderSheet(makePlanner({ handleSaveTransport, tripActions: { addFile } }))
|
||||
typeTitle('Flight with docs')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'attach-file' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'attach-file' }))
|
||||
expect(screen.getAllByRole('button', { name: 'drop-extra.pdf' })).toHaveLength(2)
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'drop-extra.pdf' })[0])
|
||||
|
||||
await submit()
|
||||
expect(addFile).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-032: hides the file row without upload permission', () => {
|
||||
renderSheet(makePlanner({ canUploadFiles: false }))
|
||||
expect(screen.queryByRole('button', { name: 'attach-file' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-033: assigns travelers and persists them once the booking has an id', async () => {
|
||||
const setReservationTravelers = vi.fn(async (_tripId: number, _resId: number, _ids: number[]) => undefined)
|
||||
seedStore(useTripStore, { setReservationTravelers })
|
||||
const handleSaveTransport = makeSave()
|
||||
renderSheet(makePlanner({ handleSaveTransport, tripMembers: MEMBERS }))
|
||||
typeTitle('Shared flight')
|
||||
fireEvent.click(screen.getByRole('button', { name: /maurice/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /julien/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /maurice/ }))
|
||||
|
||||
await submit()
|
||||
expect(setReservationTravelers).toHaveBeenCalledWith(1, 99, [5])
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-034: leaves the traveler list alone when nothing changed', async () => {
|
||||
const setReservationTravelers = vi.fn(async (_tripId: number, _resId: number, _ids: number[]) => undefined)
|
||||
seedStore(useTripStore, { setReservationTravelers })
|
||||
const editingTransport = {
|
||||
id: 50, trip_id: 1, type: 'bus', title: 'Bus', status: 'pending', endpoints: [], metadata: null,
|
||||
travelers: [{ user_id: 4, username: 'maurice' }],
|
||||
} as unknown as Reservation
|
||||
renderSheet(makePlanner({ editingTransport, tripMembers: MEMBERS, handleSaveTransport: makeSave(null) }))
|
||||
expect(screen.getByRole('button', { name: /maurice/ }).className).not.toContain('opacity-60')
|
||||
|
||||
await submit('common.update')
|
||||
expect(setReservationTravelers).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-035: shows the empty hint when the trip has no members', () => {
|
||||
renderSheet(makePlanner({ tripMembers: [] }))
|
||||
expect(screen.getByText('reservations.travelers.none')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-036: creating an expense saves first and hands the new id to the cost sheet', async () => {
|
||||
const handleSaveTransport = makeSave()
|
||||
const onOpenExpense = vi.fn()
|
||||
renderSheet(makePlanner({ handleSaveTransport }), onOpenExpense)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.ferry' }))
|
||||
typeTitle('Ferry ticket')
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.createExpense' }))
|
||||
})
|
||||
expect(handleSaveTransport).toHaveBeenCalledTimes(1)
|
||||
expect(onOpenExpense).toHaveBeenCalledWith({
|
||||
prefill: { reservationId: 99, name: 'Ferry ticket', category: 'transport' },
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-037: a plain save does not open the cost sheet', async () => {
|
||||
const onOpenExpense = vi.fn()
|
||||
renderSheet(makePlanner({ handleSaveTransport: makeSave() }), onOpenExpense)
|
||||
typeTitle('No costs please')
|
||||
await submit()
|
||||
expect(onOpenExpense).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-038: the automated tab asks for a day before it searches', () => {
|
||||
renderSheet(makePlanner({ transportModalAutomated: true }))
|
||||
expect(screen.getByRole('dialog', { name: 'transit.title' })).toBeInTheDocument()
|
||||
expect(screen.getByText('transit.pickDay')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.add' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.cancel' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-039: the transit panel gets the chosen day, its ordered places and the prefill', () => {
|
||||
const planner = makePlanner({
|
||||
transportModalAutomated: true,
|
||||
transportModalDayId: 12,
|
||||
transitPrefill: { from: { name: 'Hotel', lat: 34.7, lng: 135.5 }, to: null },
|
||||
places: [{ id: 1, name: 'Fushimi Inari' }, { id: 2, name: 'Nishiki Market' }],
|
||||
assignments: { '12': [{ id: 9, place_id: 2, order_index: 1 }, { id: 8, place_id: 1, order_index: 0 }] },
|
||||
})
|
||||
renderSheet(planner)
|
||||
expect(screen.getByTestId('transit-day')).toHaveTextContent('12')
|
||||
expect(screen.getByTestId('transit-places')).toHaveTextContent('Fushimi Inari,Nishiki Market')
|
||||
expect(screen.getByTestId('transit-from')).toHaveTextContent('Hotel')
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-040: the transit panel saves its itinerary through the shared save path', async () => {
|
||||
const handleSaveTransport = makeSave()
|
||||
renderSheet(makePlanner({ transportModalAutomated: true, transportModalDayId: 12, handleSaveTransport }))
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'add-itinerary' })) })
|
||||
expect(handleSaveTransport).toHaveBeenCalledWith({ title: 'U4 to Prater', type: 'transit' })
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-041: switching between manual and automated swaps the form', () => {
|
||||
renderSheet(makePlanner())
|
||||
expect(screen.getByText('reservations.bookingType')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'transport.modeAutomated' }))
|
||||
expect(screen.queryByText('reservations.bookingType')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('transit.searchHint')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'transport.modeManual' }))
|
||||
expect(screen.getByText('reservations.bookingType')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRFRM-042: a new booking opened from a day preselects that day', () => {
|
||||
renderSheet(makePlanner({ transportModalDayId: 13 }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.bus' }))
|
||||
expect(daySelects().map(s => s.value)).toEqual(['13', '13'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,276 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MTransportSheet from '../../../../src/mobile/screens/trip/sheets/MTransportSheet'
|
||||
import { openFile } from '../../../../src/utils/fileDownload'
|
||||
import { useSettingsStore } from '../../../../src/store/settingsStore'
|
||||
import type { Reservation, TripFile } from '../../../../src/types'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-TRSH-001 to FE-MOB-TRSH-020
|
||||
|
||||
vi.mock('../../../../src/utils/fileDownload', () => ({ openFile: vi.fn() }))
|
||||
|
||||
const FLIGHT = {
|
||||
id: 7,
|
||||
trip_id: 1,
|
||||
type: 'flight',
|
||||
title: 'FRA → HND',
|
||||
status: 'pending',
|
||||
day_id: 3,
|
||||
reservation_time: '2026-05-01T10:20',
|
||||
reservation_end_time: '2026-05-02T06:45',
|
||||
confirmation_number: 'QW7788',
|
||||
notes: 'Window seat requested',
|
||||
metadata: JSON.stringify({ airline: 'Lufthansa', flight_number: 'LH 716', seat: '31A' }),
|
||||
endpoints: [
|
||||
{ id: 1, reservation_id: 7, role: 'from', sequence: 0, name: 'Frankfurt (FRA)', code: 'FRA', lat: 50.03, lng: 8.57, timezone: 'Europe/Berlin', local_date: '2026-05-01', local_time: '10:20' },
|
||||
{ id: 2, reservation_id: 7, role: 'to', sequence: 1, name: 'Tokyo (HND)', code: 'HND', lat: 35.55, lng: 139.78, timezone: 'Asia/Tokyo', local_date: '2026-05-02', local_time: '06:45' },
|
||||
],
|
||||
} as unknown as Reservation
|
||||
|
||||
function makePlanner(overrides: Record<string, unknown> = {}) {
|
||||
return buildPlanner({
|
||||
reservations: [FLIGHT],
|
||||
files: [],
|
||||
// The real planner exposes the visible connection ids as an array.
|
||||
visibleConnections: [],
|
||||
...overrides,
|
||||
} as unknown as Parameters<typeof buildPlanner>[0])
|
||||
}
|
||||
|
||||
function makeShell(overrides: Record<string, unknown> = {}) {
|
||||
return buildShell({ sheet: { id: 'transport', payload: { reservationId: 7 } }, ...overrides } as unknown as Parameters<typeof buildShell>[0])
|
||||
}
|
||||
|
||||
describe('MTransportSheet', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRSH-001: renders nothing when the payload points at an unknown reservation', () => {
|
||||
render(<MTransportSheet planner={makePlanner({ reservations: [] })} shell={makeShell()} />)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRSH-002: stays closed while another sheet id is active', () => {
|
||||
render(<MTransportSheet planner={makePlanner()} shell={makeShell({ sheet: { id: 'mehr', payload: { reservationId: 7 } } })} />)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRSH-003: shows title, airline meta and the endpoint route in the subline', () => {
|
||||
render(<MTransportSheet planner={makePlanner()} shell={makeShell()} />)
|
||||
expect(screen.getByRole('dialog', { name: 'FRA → HND' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Lufthansa · LH 716 · Frankfurt (FRA) → Tokyo (HND)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRSH-004: falls back to the formatted date when the booking has no endpoints', () => {
|
||||
const res = { ...FLIGHT, endpoints: [], metadata: null } as unknown as Reservation
|
||||
render(<MTransportSheet planner={makePlanner({ reservations: [res] })} shell={makeShell()} />)
|
||||
expect(screen.getByText('Fri, May 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRSH-005: shows departure and arrival stat boxes labelled with the endpoint names', () => {
|
||||
render(<MTransportSheet planner={makePlanner()} shell={makeShell()} />)
|
||||
expect(screen.getByText('10:20')).toBeInTheDocument()
|
||||
expect(screen.getByText('06:45')).toBeInTheDocument()
|
||||
expect(screen.getByText('Frankfurt (FRA)')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tokyo (HND)')).toBeInTheDocument()
|
||||
expect(screen.getByText('31A')).toBeInTheDocument()
|
||||
expect(screen.getByText('Seat')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRSH-006: renders the ISO reservation time in the 12h format and the generic time label', () => {
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, time_format: '12h' } })
|
||||
const res = {
|
||||
...FLIGHT,
|
||||
endpoints: [],
|
||||
reservation_time: '2026-05-01T15:30:00Z',
|
||||
reservation_end_time: null,
|
||||
metadata: null,
|
||||
} as unknown as Reservation
|
||||
render(<MTransportSheet planner={makePlanner({ reservations: [res] })} shell={makeShell()} />)
|
||||
expect(screen.getByText(/PM$/)).toBeInTheDocument()
|
||||
expect(screen.getByText('Time')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRSH-007: shows the train number and platform of a train booking', () => {
|
||||
const res = {
|
||||
...FLIGHT, type: 'train', title: 'ICE to Berlin',
|
||||
metadata: { train_number: 'ICE 599', platform: '7', seat: '12C' },
|
||||
} as unknown as Reservation
|
||||
render(<MTransportSheet planner={makePlanner({ reservations: [res] })} shell={makeShell()} />)
|
||||
expect(screen.getByText(/ICE 599/)).toBeInTheDocument()
|
||||
expect(screen.getByText('7')).toBeInTheDocument()
|
||||
expect(screen.getByText('Platform')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRSH-008: survives unparsable metadata without meta rows', () => {
|
||||
const res = { ...FLIGHT, metadata: '{not json', endpoints: [] } as unknown as Reservation
|
||||
render(<MTransportSheet planner={makePlanner({ reservations: [res] })} shell={makeShell()} />)
|
||||
expect(screen.getByRole('dialog', { name: 'FRA → HND' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Seat')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRSH-009: renders the transit itinerary with line, walk, duration and stop counts', () => {
|
||||
const res = {
|
||||
...FLIGHT, type: 'transit', title: 'To the museum', endpoints: [], metadata: {
|
||||
transit: {
|
||||
legs: [
|
||||
{ mode: 'WALK', duration: 240, to: { name: 'Karlsplatz' } },
|
||||
{
|
||||
mode: 'SUBWAY', line: 'U4', line_color: '#00a94f', line_text_color: '#fff', duration: 600, stops: 4,
|
||||
from: { name: 'Karlsplatz', time: '09:10' }, to: { name: 'Schwedenplatz', time: '09:20' },
|
||||
},
|
||||
{ mode: 'BUS', line: null, duration: 0, from: { name: 'Schwedenplatz', time: null }, to: { name: 'Prater' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as Reservation
|
||||
render(<MTransportSheet planner={makePlanner({ reservations: [res] })} shell={makeShell()} />)
|
||||
expect(screen.getByText('Walk to Karlsplatz')).toBeInTheDocument()
|
||||
expect(screen.getByText('4 min')).toBeInTheDocument()
|
||||
expect(screen.getByText('U4')).toBeInTheDocument()
|
||||
expect(screen.getByText('09:10 – 09:20 · 10 min · 4 stops')).toBeInTheDocument()
|
||||
// A leg without a line falls back to its mode and renders no meta line.
|
||||
expect(screen.getByText('BUS')).toBeInTheDocument()
|
||||
expect(screen.getByText('Prater')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRSH-010: shows the pending status and the booking code unblurred by default', () => {
|
||||
render(<MTransportSheet planner={makePlanner()} shell={makeShell()} />)
|
||||
expect(screen.getByText('Pending')).toBeInTheDocument()
|
||||
const code = screen.getByText('#QW7788')
|
||||
expect(code.className).not.toContain('blur')
|
||||
})
|
||||
|
||||
it('FE-MOB-TRSH-011: blurs the booking code until it is tapped', () => {
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, blur_booking_codes: true } })
|
||||
const res = { ...FLIGHT, status: 'confirmed' } as unknown as Reservation
|
||||
render(<MTransportSheet planner={makePlanner({ reservations: [res] })} shell={makeShell()} />)
|
||||
expect(screen.getByText('Confirmed')).toBeInTheDocument()
|
||||
const code = screen.getByText('#QW7788')
|
||||
expect(code.className).toContain('blur-[4px]')
|
||||
fireEvent.click(code)
|
||||
expect(screen.getByText('#QW7788').className).not.toContain('blur-[4px]')
|
||||
})
|
||||
|
||||
it('FE-MOB-TRSH-012: re-blurs the code once the sheet is closed again', () => {
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, blur_booking_codes: true } })
|
||||
const planner = makePlanner()
|
||||
const { rerender } = render(<MTransportSheet planner={planner} shell={makeShell()} />)
|
||||
fireEvent.click(screen.getByText('#QW7788'))
|
||||
expect(screen.getByText('#QW7788').className).not.toContain('blur-[4px]')
|
||||
rerender(<MTransportSheet planner={planner} shell={makeShell({ sheet: null })} />)
|
||||
rerender(<MTransportSheet planner={planner} shell={makeShell()} />)
|
||||
expect(screen.getByText('#QW7788').className).toContain('blur-[4px]')
|
||||
})
|
||||
|
||||
it('FE-MOB-TRSH-013: renders the notes block only when the booking has notes', () => {
|
||||
render(<MTransportSheet planner={makePlanner()} shell={makeShell()} />)
|
||||
expect(screen.getByText('Window seat requested')).toBeInTheDocument()
|
||||
|
||||
const res = { ...FLIGHT, notes: null } as unknown as Reservation
|
||||
render(<MTransportSheet planner={makePlanner({ reservations: [res] })} shell={makeShell()} />)
|
||||
expect(screen.getAllByText('Notes')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-TRSH-014: drawing the route closes the sheet and jumps to the plan map', () => {
|
||||
const planner = makePlanner()
|
||||
const shell = makeShell({ trTab: 'transports', view: 'plan' })
|
||||
render(<MTransportSheet planner={planner} shell={shell} />)
|
||||
const btn = screen.getByRole('button', { name: 'On map' })
|
||||
expect(btn).toHaveAttribute('aria-pressed', 'false')
|
||||
fireEvent.click(btn)
|
||||
expect(planner.toggleConnection).toHaveBeenCalledWith(7)
|
||||
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
|
||||
expect(shell.setTrTab).toHaveBeenCalledWith('plan')
|
||||
expect(shell.toggleView).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-TRSH-015: hides an already drawn route without navigating away', () => {
|
||||
const planner = makePlanner({ visibleConnections: [7] })
|
||||
const shell = makeShell({ trTab: 'plan', view: 'map' })
|
||||
render(<MTransportSheet planner={planner} shell={shell} />)
|
||||
const btn = screen.getByRole('button', { name: 'On map' })
|
||||
expect(btn).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(btn)
|
||||
expect(planner.toggleConnection).toHaveBeenCalledWith(7)
|
||||
expect(shell.closeSheet).not.toHaveBeenCalled()
|
||||
expect(shell.setTrTab).not.toHaveBeenCalled()
|
||||
expect(shell.toggleView).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRSH-016: lists the attached files and opens one on tap', () => {
|
||||
const files = [
|
||||
{ id: 1, reservation_id: 7, original_name: 'boardingpass.pdf', url: '/api/trips/1/files/1/download' },
|
||||
{ id: 2, reservation_id: null, linked_reservation_ids: [7], original_name: 'invoice.pdf', url: '/api/trips/1/files/2/download' },
|
||||
{ id: 3, reservation_id: 7, deleted_at: '2026-04-01', original_name: 'trashed.pdf', url: '/x' },
|
||||
{ id: 4, reservation_id: 99, original_name: 'other.pdf', url: '/y' },
|
||||
] as unknown as TripFile[]
|
||||
render(<MTransportSheet planner={makePlanner({ files })} shell={makeShell()} />)
|
||||
expect(screen.getByText('boardingpass.pdf')).toBeInTheDocument()
|
||||
expect(screen.getByText('invoice.pdf')).toBeInTheDocument()
|
||||
expect(screen.queryByText('trashed.pdf')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('other.pdf')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('boardingpass.pdf'))
|
||||
expect(openFile).toHaveBeenCalledWith('/api/trips/1/files/1/download', 'boardingpass.pdf')
|
||||
})
|
||||
|
||||
it('FE-MOB-TRSH-017: opens the transport editor for the booking', () => {
|
||||
const planner = makePlanner()
|
||||
const shell = makeShell()
|
||||
render(<MTransportSheet planner={planner} shell={shell} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
expect(planner.setEditingTransport).toHaveBeenCalledWith(expect.objectContaining({ id: 7 }))
|
||||
expect(planner.setTransportModalDayId).toHaveBeenCalledWith(3)
|
||||
expect(planner.setShowTransportModal).toHaveBeenCalledWith(true)
|
||||
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-TRSH-018: passes a null day id to the editor when the booking has none', () => {
|
||||
const res = { ...FLIGHT, day_id: null } as unknown as Reservation
|
||||
const planner = makePlanner({ reservations: [res] })
|
||||
render(<MTransportSheet planner={planner} shell={makeShell()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
expect(planner.setTransportModalDayId).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-MOB-TRSH-019: deletes the booking and closes the sheet', () => {
|
||||
const planner = makePlanner()
|
||||
const shell = makeShell()
|
||||
render(<MTransportSheet planner={planner} shell={shell} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
expect(planner.handleDeleteReservation).toHaveBeenCalledWith(7)
|
||||
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-TRSH-020: hides edit and delete without the day_edit permission but keeps the map toggle', () => {
|
||||
const planner = makePlanner({ can: vi.fn(() => false) })
|
||||
render(<MTransportSheet planner={planner} shell={makeShell()} />)
|
||||
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'On map' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRSH-021: keeps the last booking on screen while the sheet animates out', () => {
|
||||
const planner = makePlanner()
|
||||
const shell = makeShell()
|
||||
const { rerender } = render(<MTransportSheet planner={planner} shell={shell} />)
|
||||
expect(screen.getByText('FRA → HND')).toBeInTheDocument()
|
||||
rerender(<MTransportSheet planner={makePlanner({ reservations: [] })} shell={shell} />)
|
||||
expect(screen.getByText('FRA → HND')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRSH-022: closes from the header button', () => {
|
||||
const shell = makeShell()
|
||||
render(<MTransportSheet planner={makePlanner()} shell={shell} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,323 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MTransportsTab from '../../../../src/mobile/screens/trip/tabs/MTransportsTab'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { openFile } from '../../../../src/utils/fileDownload'
|
||||
import type { Day, Reservation, TripFile } from '../../../../src/types'
|
||||
import { buildSettings } from '../../../helpers/factories'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-TRTAB-001 to FE-MOB-TRTAB-022
|
||||
|
||||
vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()),
|
||||
openFile: vi.fn(),
|
||||
}))
|
||||
|
||||
const DAYS = [
|
||||
{ id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null },
|
||||
{ id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: 'Kyoto' },
|
||||
] as unknown as Day[]
|
||||
|
||||
const MEMBERS = [
|
||||
{ id: 1, username: 'Me', avatar_url: null },
|
||||
{ id: 2, username: 'Ada', avatar_url: '/uploads/avatars/ada.png' },
|
||||
]
|
||||
|
||||
/** Endpoints deliberately out of sequence — orderedEndpoints has to sort them. */
|
||||
const FLIGHT = {
|
||||
id: 101, trip_id: 7, type: 'flight', status: 'confirmed', title: 'HND to ITM',
|
||||
day_id: 1, reservation_time: '2026-05-01T08:30', reservation_end_time: '2026-05-01T11:45',
|
||||
confirmation_number: 'ABC123',
|
||||
metadata: JSON.stringify({ airline: 'ANA', flight_number: 'NH203', departure_airport: 'HND', arrival_airport: 'ITM' }),
|
||||
endpoints: [
|
||||
{ role: 'to', sequence: 1, name: 'Osaka Itami' },
|
||||
{ role: 'from', sequence: 0, name: 'Tokyo Haneda' },
|
||||
],
|
||||
travelers: [{ user_id: 2, username: 'Ada', avatar: null }],
|
||||
} as unknown as Reservation
|
||||
|
||||
/** metadata as an already-parsed object, spans two days, flagged for review. */
|
||||
const TRAIN = {
|
||||
id: 102, trip_id: 7, type: 'train', status: 'pending', title: 'Shinkansen Nozomi',
|
||||
day_id: 1, end_day_id: 2, reservation_time: '2026-05-01T14:00', needs_review: 1,
|
||||
metadata: { train_number: 'ICE 599', platform: '7', seat: '12A', class: 'Business', price: 49.9, priceCurrency: 'EUR' },
|
||||
} as unknown as Reservation
|
||||
|
||||
const BUS = {
|
||||
id: 103, trip_id: 7, type: 'bus', status: 'pending', title: 'Airport shuttle',
|
||||
metadata: JSON.stringify({ price: '25', priceCurrency: 'CHF' }),
|
||||
} as unknown as Reservation
|
||||
|
||||
const TRANSIT = {
|
||||
id: 104, trip_id: 7, type: 'transit', status: 'confirmed', title: 'Metro to hotel',
|
||||
day_id: 2, reservation_time: '2026-05-02T09:10',
|
||||
metadata: JSON.stringify({ departure_airport: 'Shibuya' }),
|
||||
} as unknown as Reservation
|
||||
|
||||
const CAR = {
|
||||
id: 105, trip_id: 7, type: 'car', status: 'confirmed', title: 'Rental pickup',
|
||||
reservation_time: '2026-06-15T09:00',
|
||||
metadata: JSON.stringify({ arrival_airport: 'Nagoya' }),
|
||||
} as unknown as Reservation
|
||||
|
||||
const DINNER = {
|
||||
id: 106, trip_id: 7, type: 'restaurant', status: 'confirmed', title: 'Sushi Saito',
|
||||
} as unknown as Reservation
|
||||
|
||||
const FILES = [
|
||||
{ id: 201, trip_id: 7, reservation_id: 101, original_name: 'boarding-pass.pdf', url: '/uploads/f/201' },
|
||||
{ id: 202, trip_id: 7, linked_reservation_ids: [101], original_name: 'seat-map.pdf', url: '/uploads/f/202' },
|
||||
{ id: 203, trip_id: 7, reservation_id: 101, original_name: 'stale.pdf', url: '/uploads/f/203', deleted_at: '2026-01-01' },
|
||||
] as unknown as TripFile[]
|
||||
|
||||
const ALL = [FLIGHT, TRAIN, BUS, TRANSIT, CAR, DINNER]
|
||||
|
||||
function planner(overrides: Partial<TripPlanner> = {}) {
|
||||
return buildPlanner({
|
||||
tripId: 7,
|
||||
days: DAYS,
|
||||
reservations: ALL,
|
||||
files: FILES,
|
||||
tripMembers: MEMBERS as unknown as TripPlanner['tripMembers'],
|
||||
settings: buildSettings({ time_format: '24h' }),
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
function renderTab(p: TripPlanner = planner(), shell: MTripShellApi = buildShell()) {
|
||||
const view = render(<MTransportsTab planner={p} shell={shell} />)
|
||||
return { ...view, planner: p, shell }
|
||||
}
|
||||
|
||||
/** The card wrapper of a transport row — walks up from its title. */
|
||||
function cardOf(title: string): HTMLElement {
|
||||
let el = screen.getByText(title).parentElement
|
||||
while (el && !el.className.includes('rounded-2xl')) el = el.parentElement
|
||||
if (!el) throw new Error(`no card for ${title}`)
|
||||
return el
|
||||
}
|
||||
|
||||
describe('MTransportsTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(openFile).mockClear()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-001: groups the transport reservations and leaves bookings out', () => {
|
||||
renderTab()
|
||||
expect(screen.getByRole('button', { name: 'reservations.confirmed2' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'reservations.pending2' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'reservations.type.transit1' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Sushi Saito')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-002: orders each group chronologically with undated rows last', () => {
|
||||
renderTab()
|
||||
const titles = screen.getAllByText(/HND to ITM|Rental pickup|Shinkansen Nozomi|Airport shuttle/)
|
||||
.map(n => n.textContent)
|
||||
expect(titles).toEqual(['HND to ITM', 'Rental pickup', 'Shinkansen Nozomi', 'Airport shuttle'])
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-003: collapses a section and keeps the others open', () => {
|
||||
renderTab()
|
||||
const header = screen.getByRole('button', { name: 'reservations.confirmed2' })
|
||||
fireEvent.click(header)
|
||||
expect(header).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.queryByText('HND to ITM')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Shinkansen Nozomi')).toBeInTheDocument()
|
||||
fireEvent.click(header)
|
||||
expect(screen.getByText('HND to ITM')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-004: shows the mascot empty state without transports', () => {
|
||||
renderTab(planner({ reservations: [DINNER] }))
|
||||
expect(screen.getByText('mobileTrip.transportsEmpty')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /reservations.confirmed/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-005: renders day range, times and the confirmation code of a flight', () => {
|
||||
renderTab()
|
||||
const card = cardOf('HND to ITM')
|
||||
expect(within(card).getByText('dayplan.dayN:1')).toBeInTheDocument()
|
||||
expect(within(card).getByText('08:30 – 11:45')).toBeInTheDocument()
|
||||
expect(within(card).getByText('ABC123')).toBeInTheDocument()
|
||||
expect(within(card).getByText('ABC123')).not.toHaveClass('blur-[4px]')
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-006: spans start and end day when they differ', () => {
|
||||
renderTab()
|
||||
expect(within(cardOf('Shinkansen Nozomi')).getByText('dayplan.dayN:1 – Kyoto')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-007: falls back to the raw reservation date without a linked day', () => {
|
||||
renderTab()
|
||||
expect(within(cardOf('Rental pickup')).getByText('Jun 15')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-008: shows em dashes when neither day nor time is known', () => {
|
||||
renderTab()
|
||||
const card = cardOf('Airport shuttle')
|
||||
expect(within(card).getAllByText('—')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-009: renders the ordered endpoints and drops the airport meta cells', () => {
|
||||
renderTab()
|
||||
const card = cardOf('HND to ITM')
|
||||
expect(within(card).getByText('Tokyo Haneda')).toBeInTheDocument()
|
||||
expect(within(card).getByText('Osaka Itami')).toBeInTheDocument()
|
||||
expect(within(card).getByText('reservations.meta.airline')).toBeInTheDocument()
|
||||
expect(within(card).getByText('ANA')).toBeInTheDocument()
|
||||
expect(within(card).getByText('NH203')).toBeInTheDocument()
|
||||
expect(within(card).queryByText('reservations.meta.from')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-010: reads object metadata and caps the meta row at three cells', () => {
|
||||
renderTab()
|
||||
const card = cardOf('Shinkansen Nozomi')
|
||||
expect(within(card).getByText('ICE 599')).toBeInTheDocument()
|
||||
expect(within(card).getByText('7')).toBeInTheDocument()
|
||||
expect(within(card).getByText('12A · Business')).toBeInTheDocument()
|
||||
expect(within(card).queryByText('reservations.price')).not.toBeInTheDocument()
|
||||
expect(within(card).getByText('reservations.needsReview')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-011: appends the currency to the price cell', () => {
|
||||
renderTab()
|
||||
const card = cardOf('Airport shuttle')
|
||||
expect(within(card).getByText('reservations.price')).toBeInTheDocument()
|
||||
expect(within(card).getByText('25 CHF')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-012: uses the airport meta cells when no endpoints are stored', () => {
|
||||
renderTab()
|
||||
expect(within(cardOf('Metro to hotel')).getByText('Shibuya')).toBeInTheDocument()
|
||||
expect(within(cardOf('Rental pickup')).getByText('Nagoya')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-013: lists the linked, non-deleted files and opens one on tap', () => {
|
||||
renderTab()
|
||||
const card = cardOf('HND to ITM')
|
||||
expect(within(card).getByText('boarding-pass.pdf')).toBeInTheDocument()
|
||||
expect(within(card).getByText('seat-map.pdf')).toBeInTheDocument()
|
||||
expect(within(card).queryByText('stale.pdf')).not.toBeInTheDocument()
|
||||
fireEvent.click(within(card).getByText('boarding-pass.pdf'))
|
||||
expect(openFile).toHaveBeenCalledWith('/uploads/f/201', 'boarding-pass.pdf')
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-014: reveals a blurred confirmation code on tap', () => {
|
||||
renderTab(planner({ settings: buildSettings({ time_format: '24h', blur_booking_codes: true }) }))
|
||||
const code = within(cardOf('HND to ITM')).getByText('ABC123')
|
||||
expect(code).toHaveClass('blur-[4px]')
|
||||
fireEvent.click(code)
|
||||
expect(code).not.toHaveClass('blur-[4px]')
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-015: opens the transport detail sheet from the row', () => {
|
||||
const { shell } = renderTab()
|
||||
fireEvent.click(screen.getByText('HND to ITM'))
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('transport', { reservationId: 101 })
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-016: toggles the reservation status from the dot', async () => {
|
||||
const p = planner()
|
||||
renderTab(p)
|
||||
fireEvent.click(within(cardOf('HND to ITM')).getByRole('button', { name: 'reservations.pending' }))
|
||||
await waitFor(() => expect(p.tripActions.toggleReservationStatus).toHaveBeenCalledWith(7, 101))
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-017: toasts when the status toggle fails', async () => {
|
||||
const p = planner()
|
||||
vi.mocked(p.tripActions.toggleReservationStatus).mockRejectedValueOnce(new Error('offline'))
|
||||
renderTab(p)
|
||||
fireEvent.click(within(cardOf('HND to ITM')).getByRole('button', { name: 'reservations.pending' }))
|
||||
await waitFor(() => expect(p.toast.error).toHaveBeenCalledWith('reservations.toast.updateError'))
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-018: opens the transport editor with the row and its day', () => {
|
||||
const p = planner()
|
||||
renderTab(p)
|
||||
fireEvent.click(within(cardOf('Shinkansen Nozomi')).getByRole('button', { name: 'common.edit' }))
|
||||
expect(p.setEditingTransport).toHaveBeenCalledWith(expect.objectContaining({ id: 102 }))
|
||||
expect(p.setTransportModalDayId).toHaveBeenCalledWith(1)
|
||||
expect(p.setShowTransportModal).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-019: deletes after confirming and toasts on failure', async () => {
|
||||
const p = planner({ handleDeleteReservation: vi.fn(() => Promise.reject(new Error('boom'))) as unknown as TripPlanner['handleDeleteReservation'] })
|
||||
renderTab(p)
|
||||
fireEvent.click(within(cardOf('HND to ITM')).getByRole('button', { name: 'common.delete' }))
|
||||
const dialog = screen.getByRole('dialog', { name: 'reservations.confirm.deleteTitle' })
|
||||
expect(within(dialog).getByText('reservations.confirm.deleteBody:HND to ITM')).toBeInTheDocument()
|
||||
fireEvent.click(within(dialog).getByText('common.delete'))
|
||||
expect(p.handleDeleteReservation).toHaveBeenCalledWith(101)
|
||||
await waitFor(() => expect(p.toast.error).toHaveBeenCalledWith('reservations.toast.deleteError'))
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-020: hides every edit affordance without the day_edit permission', () => {
|
||||
renderTab(planner({ can: vi.fn(() => false) as unknown as TripPlanner['can'] }))
|
||||
expect(screen.queryByRole('button', { name: 'common.edit' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'reservations.pending' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('HND to ITM')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-021: compact mode keeps the header and drops the card body', () => {
|
||||
renderTab(planner(), buildShell({ transportsCompact: true }))
|
||||
expect(screen.getByText('HND to ITM')).toBeInTheDocument()
|
||||
expect(screen.queryByText('reservations.date')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('ABC123')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-022: filters by assigned traveler and shows the avatars on the card', () => {
|
||||
renderTab()
|
||||
expect(within(cardOf('HND to ITM')).getByText('reservations.travelers.label')).toBeInTheDocument()
|
||||
const ada = screen.getByTitle('Ada')
|
||||
fireEvent.click(ada)
|
||||
expect(screen.getByText('HND to ITM')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Shinkansen Nozomi')).not.toBeInTheDocument()
|
||||
fireEvent.click(ada)
|
||||
expect(screen.getByText('Shinkansen Nozomi')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-023: hides the traveler filter for a solo trip', () => {
|
||||
renderTab(planner({ tripMembers: [MEMBERS[0]] as unknown as TripPlanner['tripMembers'] }))
|
||||
expect(screen.queryByTitle('Ada')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('HND to ITM')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-024: cancelling the confirm keeps the transport', () => {
|
||||
const p = planner()
|
||||
renderTab(p)
|
||||
fireEvent.click(within(cardOf('HND to ITM')).getByRole('button', { name: 'common.delete' }))
|
||||
fireEvent.click(within(screen.getByRole('dialog')).getByText('common.cancel'))
|
||||
expect(p.handleDeleteReservation).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('HND to ITM')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-025: opens the editor without a day for an unassigned transport', () => {
|
||||
const p = planner()
|
||||
renderTab(p)
|
||||
fireEvent.click(within(cardOf('Airport shuttle')).getByRole('button', { name: 'common.edit' }))
|
||||
expect(p.setTransportModalDayId).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-026: falls back to the generic icon for an unknown transport type', () => {
|
||||
const HELI = {
|
||||
id: 107, trip_id: 7, type: 'helicopter', status: 'confirmed', title: 'Heli transfer',
|
||||
metadata: JSON.stringify({ seat: '3B', price: '99' }),
|
||||
} as unknown as Reservation
|
||||
renderTab(planner({
|
||||
reservations: [HELI],
|
||||
files: undefined as unknown as TripPlanner['files'],
|
||||
settings: { ...buildSettings(), time_format: '' },
|
||||
TRANSPORT_TYPES: new Set(['helicopter']) as unknown as TripPlanner['TRANSPORT_TYPES'],
|
||||
}))
|
||||
const card = cardOf('Heli transfer')
|
||||
expect(within(card).getByText('reservations.type.helicopter')).toBeInTheDocument()
|
||||
// seat without a class and a price without a currency stay bare
|
||||
expect(within(card).getByText('3B')).toBeInTheDocument()
|
||||
expect(within(card).getByText('99')).toBeInTheDocument()
|
||||
// nobody is assigned, so the traveler filter row stays away
|
||||
expect(screen.queryByTitle('Ada')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, render, screen } from '../../../helpers/render'
|
||||
import MTripLoadingSplash from '../../../../src/mobile/screens/trip/MTripLoadingSplash'
|
||||
|
||||
// FE-MOB-SPLASH-001 to FE-MOB-SPLASH-010
|
||||
|
||||
const STEP_MS = 1400
|
||||
|
||||
const origMatchMedia = window.matchMedia
|
||||
|
||||
function stubReducedMotion(matches: boolean) {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})) as unknown as typeof window.matchMedia
|
||||
}
|
||||
|
||||
const tick = (ms: number) => act(() => { vi.advanceTimersByTime(ms) })
|
||||
|
||||
const dots = (container: HTMLElement) =>
|
||||
Array.from(container.querySelectorAll('span[class*="h-[6px]"]'))
|
||||
|
||||
const activeDotIndex = (container: HTMLElement) =>
|
||||
dots(container).findIndex(d => d.className.includes('w-5'))
|
||||
|
||||
describe('MTripLoadingSplash', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
stubReducedMotion(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
window.matchMedia = origMatchMedia
|
||||
})
|
||||
|
||||
it('FE-MOB-SPLASH-001: opens on the packing beat with the trip title', () => {
|
||||
const { container } = render(<MTripLoadingSplash title="Japan 2026" />)
|
||||
|
||||
expect(screen.getByText('Japan 2026')).toBeInTheDocument()
|
||||
expect(screen.getByText('Packing your bags...')).toBeInTheDocument()
|
||||
expect(container.querySelector('.trek--packing')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-MOB-SPLASH-002: falls back to the TREK wordmark when the trip has no title', () => {
|
||||
render(<MTripLoadingSplash title="" />)
|
||||
|
||||
expect(screen.getByText('TREK')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-SPLASH-003: walks the four beats in order, one every 1400ms', () => {
|
||||
const { container } = render(<MTripLoadingSplash title="Japan 2026" />)
|
||||
|
||||
tick(STEP_MS)
|
||||
expect(screen.getByText('Hitting the road...')).toBeInTheDocument()
|
||||
expect(container.querySelector('.trek--transport')).toBeTruthy()
|
||||
|
||||
tick(STEP_MS)
|
||||
expect(screen.getByText('Loading place photos...')).toBeInTheDocument()
|
||||
expect(container.querySelector('.trek--dashboard')).toBeTruthy()
|
||||
|
||||
tick(STEP_MS)
|
||||
expect(screen.getByText('Almost there...')).toBeInTheDocument()
|
||||
expect(container.querySelector('.trek--collections')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-MOB-SPLASH-004: wraps back to the first beat after the last one', () => {
|
||||
render(<MTripLoadingSplash title="Japan 2026" />)
|
||||
|
||||
tick(STEP_MS * 4)
|
||||
|
||||
expect(screen.getByText('Packing your bags...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-SPLASH-005: does not advance before the beat is due', () => {
|
||||
render(<MTripLoadingSplash title="Japan 2026" />)
|
||||
|
||||
tick(STEP_MS - 1)
|
||||
|
||||
expect(screen.getByText('Packing your bags...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-SPLASH-006: the active dot tracks the current beat', () => {
|
||||
const { container } = render(<MTripLoadingSplash title="Japan 2026" />)
|
||||
|
||||
expect(dots(container)).toHaveLength(4)
|
||||
expect(activeDotIndex(container)).toBe(0)
|
||||
|
||||
tick(STEP_MS * 2)
|
||||
expect(activeDotIndex(container)).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-SPLASH-007: animates the mascot stage between beats', () => {
|
||||
const { container } = render(<MTripLoadingSplash title="Japan 2026" />)
|
||||
|
||||
const stage = container.querySelector('.trek--packing')?.parentElement
|
||||
expect(stage).toHaveStyle({ animation: 'm-trek-beat 460ms cubic-bezier(.34,1.56,.64,1) both' })
|
||||
})
|
||||
|
||||
it('FE-MOB-SPLASH-008: reduced motion parks on the photo beat and never advances', () => {
|
||||
stubReducedMotion(true)
|
||||
const { container } = render(<MTripLoadingSplash title="Japan 2026" />)
|
||||
|
||||
expect(screen.getByText('Loading place photos...')).toBeInTheDocument()
|
||||
expect(activeDotIndex(container)).toBe(2)
|
||||
|
||||
tick(STEP_MS * 3)
|
||||
|
||||
expect(screen.getByText('Loading place photos...')).toBeInTheDocument()
|
||||
expect(activeDotIndex(container)).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-SPLASH-009: reduced motion drops the beat animation', () => {
|
||||
stubReducedMotion(true)
|
||||
const { container } = render(<MTripLoadingSplash title="Japan 2026" />)
|
||||
|
||||
const stage = container.querySelector('.trek--dashboard')?.parentElement
|
||||
expect(stage?.getAttribute('style')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-SPLASH-010: clears its beat interval on unmount', () => {
|
||||
const before = vi.getTimerCount()
|
||||
const { unmount } = render(<MTripLoadingSplash title="Japan 2026" />)
|
||||
|
||||
expect(vi.getTimerCount()).toBe(before + 1)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(vi.getTimerCount()).toBe(before)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Home } from 'lucide-react'
|
||||
import {
|
||||
ActionCircle,
|
||||
Eyebrow,
|
||||
INNER_CLS,
|
||||
StatBox,
|
||||
TileHeader,
|
||||
displayTime,
|
||||
} from '../../../../src/mobile/screens/trip/sheets/MTripSheetUi'
|
||||
import { fireEvent, render, screen } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-SHUI-001 to FE-MOB-SHUI-014
|
||||
|
||||
describe('MTripSheetUi', () => {
|
||||
it('FE-MOB-SHUI-001: INNER_CLS carries both inner-surface tokens', () => {
|
||||
expect(INNER_CLS).toContain('var(--m-inbr)')
|
||||
expect(INNER_CLS).toContain('var(--m-inner)')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHUI-002: Eyebrow renders its children with the faint label styling', () => {
|
||||
render(<Eyebrow>Stops</Eyebrow>)
|
||||
const el = screen.getByText('Stops')
|
||||
expect(el).toHaveClass('font-geist', 'text-m-faint')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHUI-003: Eyebrow appends the caller class', () => {
|
||||
render(<Eyebrow className="uppercase">Stops</Eyebrow>)
|
||||
expect(screen.getByText('Stops')).toHaveClass('uppercase')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHUI-004: TileHeader shows icon, title and subline', () => {
|
||||
render(
|
||||
<TileHeader
|
||||
icon={<Home data-testid="tile-icon" />}
|
||||
title="Hotel Sacher"
|
||||
sub="Philharmonikerstrasse 4"
|
||||
onClose={vi.fn()}
|
||||
closeLabel="Close"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('tile-icon')).toBeInTheDocument()
|
||||
expect(screen.getByText('Hotel Sacher')).toBeInTheDocument()
|
||||
expect(screen.getByText('Philharmonikerstrasse 4')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHUI-005: TileHeader omits the subline when no sub is given', () => {
|
||||
render(<TileHeader icon={<Home />} title="Hotel" onClose={vi.fn()} closeLabel="Close" />)
|
||||
expect(document.querySelector('.text-m-muted')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHUI-006: TileHeader close button reports the label and fires onClose', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<TileHeader icon={<Home />} title="Hotel" onClose={onClose} closeLabel="Close" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHUI-007: StatBox renders value over label', () => {
|
||||
render(<StatBox value="15:00" label="Check-in" />)
|
||||
expect(screen.getByText('15:00')).toBeInTheDocument()
|
||||
expect(screen.getByText('Check-in')).toBeInTheDocument()
|
||||
expect(screen.getByText('15:00')).not.toHaveClass('blur-[4px]')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHUI-008: StatBox blurs the value when asked', () => {
|
||||
render(<StatBox value="ABC123" label="Code" blurred />)
|
||||
expect(screen.getByText('ABC123')).toHaveClass('blur-[4px]', 'select-none')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHUI-009: StatBox forwards clicks', () => {
|
||||
const onClick = vi.fn()
|
||||
render(<StatBox value="ABC123" label="Code" onClick={onClick} />)
|
||||
fireEvent.click(screen.getByText('Code'))
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHUI-010: ActionCircle uses the accent surface when primary', () => {
|
||||
const onClick = vi.fn()
|
||||
render(<ActionCircle label="Route" primary onClick={onClick}><Home /></ActionCircle>)
|
||||
const btn = screen.getByRole('button', { name: 'Route' })
|
||||
expect(btn).toHaveClass('bg-m-act', 'text-m-actfg')
|
||||
expect(btn).toHaveAttribute('title', 'Route')
|
||||
fireEvent.click(btn)
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHUI-011: ActionCircle switches to the danger tint and keeps the caller class', () => {
|
||||
render(<ActionCircle label="Delete" danger className="ml-2"><Home /></ActionCircle>)
|
||||
const btn = screen.getByRole('button', { name: 'Delete' })
|
||||
expect(btn.className).toContain('var(--m-st-danger)')
|
||||
expect(btn).toHaveClass('ml-2')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHUI-012: ActionCircle defaults to the neutral glass surface', () => {
|
||||
render(<ActionCircle label="Share"><Home /></ActionCircle>)
|
||||
const btn = screen.getByRole('button', { name: 'Share' })
|
||||
expect(btn).toHaveClass('text-m-ink')
|
||||
expect(btn).not.toHaveClass('bg-m-act')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHUI-013: displayTime formats a plain HH:MM through formatTime', () => {
|
||||
expect(displayTime('15:00', 'en-US', '24h')).toBe('15:00')
|
||||
expect(displayTime('15:00', 'en-US', '12h')).toBe('3:00 PM')
|
||||
expect(displayTime(null, 'en-US', '24h')).toBe('')
|
||||
expect(displayTime(undefined, 'en-US', '24h')).toBe('')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHUI-014: displayTime reads an ISO timestamp and falls back when it is unparsable', () => {
|
||||
expect(displayTime('2026-05-02T19:30:00Z', 'en-US', '24h')).toMatch(/^\d{2}:\d{2}$/)
|
||||
expect(displayTime('2026-05-02T19:30:00Z', 'en-US', '12h')).toMatch(/(AM|PM)$/)
|
||||
// Not a date, still contains "T" — formatTime takes over and returns "00:00".
|
||||
expect(displayTime('TBD', 'en-US', '24h')).toBe('00:00')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,435 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { BookingExpenseRequest } from '../../../../src/components/Planner/BookingCostsSection.types'
|
||||
import type { ExpensePrefill } from '../../../../src/components/Budget/CostsPanel'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import type { BudgetItem, Reservation, Trip } from '../../../../src/types'
|
||||
import { useAuthStore } from '../../../../src/store/authStore'
|
||||
import { useSettingsStore } from '../../../../src/store/settingsStore'
|
||||
import { useTripStore } from '../../../../src/store/tripStore'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-SHOST-001 to FE-MOB-SHOST-027
|
||||
//
|
||||
// Every child sheet is stubbed: this file is about the host — which sheet is
|
||||
// mounted for which shell.sheet id, and how the host's own callbacks wire the
|
||||
// planner, the trip store and the booking-linked expense editor together.
|
||||
|
||||
interface StubProps { shell: MTripShellApi; planner: TripPlanner }
|
||||
|
||||
/** Sheets that route on shell.sheet themselves — the stub reports what it saw. */
|
||||
function selfRouted(testid: string) {
|
||||
return ({ planner, shell }: StubProps) => (
|
||||
<div data-testid={testid} data-sheet={shell.sheet?.id ?? 'none'} data-trip={planner.tripId} />
|
||||
)
|
||||
}
|
||||
|
||||
vi.mock('../../../../src/mobile/screens/trip/sheets/MPlaceSheet', () => ({ default: selfRouted('stub-place') }))
|
||||
vi.mock('../../../../src/mobile/screens/trip/sheets/MDaySheet', () => ({ default: selfRouted('stub-day') }))
|
||||
vi.mock('../../../../src/mobile/screens/trip/sheets/MDaysSheet', () => ({ default: selfRouted('stub-days') }))
|
||||
vi.mock('../../../../src/mobile/screens/trip/sheets/MAccommodationSheet', () => ({ default: selfRouted('stub-accommodation') }))
|
||||
vi.mock('../../../../src/mobile/screens/trip/sheets/MTransportSheet', () => ({ default: selfRouted('stub-transport') }))
|
||||
vi.mock('../../../../src/mobile/screens/trip/sheets/MBrowseActionsSheet', () => ({ default: selfRouted('stub-bract') }))
|
||||
vi.mock('../../../../src/mobile/screens/trip/sheets/MMehrSheet', () => ({ default: selfRouted('stub-mehr') }))
|
||||
vi.mock('../../../../src/mobile/screens/trip/sheets/MExportSheet', () => ({ default: selfRouted('stub-export') }))
|
||||
|
||||
vi.mock('../../../../src/mobile/screens/trip/sheets/MNoteSheet', () => ({
|
||||
default: ({ open, payload, onClose }: { open: boolean; payload?: { dayId?: number }; onClose: () => void }) => (
|
||||
<div data-testid="stub-note" data-open={String(open)} data-day={payload?.dayId ?? 'none'}>
|
||||
<button type="button" onClick={onClose}>close note</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/mobile/screens/trip/sheets/MImportSheet', () => ({
|
||||
default: ({ open, onClose }: { open: boolean; onClose: () => void }) => (
|
||||
<div data-testid="stub-import" data-open={String(open)}>
|
||||
<button type="button" onClick={onClose}>close import</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/mobile/screens/trip/sheets/MPlaceEditSheet', () => ({
|
||||
default: ({ planner }: StubProps) => <div data-testid="stub-placeedit" data-trip={planner.tripId} />,
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/mobile/screens/trip/sheets/MReservationSheet', () => ({
|
||||
default: ({ onOpenExpense }: { onOpenExpense: (req: BookingExpenseRequest) => void }) => (
|
||||
<div data-testid="stub-reservation">
|
||||
<button type="button" onClick={() => onOpenExpense({ editItem: { id: 99, name: 'Ticket' } as BudgetItem })}>
|
||||
expense edit
|
||||
</button>
|
||||
<button type="button" onClick={() => onOpenExpense({})}>expense noop</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/mobile/screens/trip/sheets/MTransportFormSheet', () => ({
|
||||
default: ({ onOpenExpense }: { onOpenExpense: (req: BookingExpenseRequest) => void }) => (
|
||||
<div data-testid="stub-transportform">
|
||||
<button type="button" onClick={() => onOpenExpense({ prefill: { name: 'Shinkansen', amount: 120 } })}>
|
||||
expense prefill
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/mobile/screens/trip/sheets/MCostSheet', () => ({
|
||||
default: ({ tripId, base, me, editing, prefill, onClose, onSaved }: {
|
||||
tripId: number; base: string; me: number
|
||||
editing: BudgetItem | null; prefill?: ExpensePrefill
|
||||
onClose: () => void; onSaved: () => void
|
||||
}) => (
|
||||
<div
|
||||
data-testid="stub-cost"
|
||||
data-trip={tripId}
|
||||
data-base={base}
|
||||
data-me={me}
|
||||
data-editing={editing?.id ?? 'none'}
|
||||
data-prefill={prefill?.name ?? 'none'}
|
||||
>
|
||||
<button type="button" onClick={onClose}>close cost</button>
|
||||
<button type="button" onClick={onSaved}>save cost</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/mobile/screens/settings/MConfirmSheet', () => ({
|
||||
default: ({ open, title, message, onClose, onConfirm }: {
|
||||
open: boolean; title: string; message: ReactNode; onClose: () => void; onConfirm?: () => void
|
||||
}) =>
|
||||
open ? (
|
||||
<div data-testid="stub-confirm" data-title={title}>
|
||||
<span>{message}</span>
|
||||
<button type="button" onClick={onConfirm}>confirm delete</button>
|
||||
<button type="button" onClick={onClose}>cancel delete</button>
|
||||
</div>
|
||||
) : null,
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/components/Planner/BookingImportModal', () => ({
|
||||
default: ({ isOpen, onClose, tripId }: { isOpen: boolean; onClose: () => void; tripId: number }) => (
|
||||
<div data-testid="stub-bookingimport" data-open={String(isOpen)} data-trip={tripId}>
|
||||
<button type="button" onClick={onClose}>close booking import</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/components/Planner/AirTrailImportModal', () => ({
|
||||
default: ({ isOpen, onClose, pushUndo }: { isOpen: boolean; onClose: () => void; pushUndo: unknown }) => (
|
||||
<div data-testid="stub-airtrail" data-open={String(isOpen)} data-hasundo={String(typeof pushUndo === 'function')}>
|
||||
<button type="button" onClick={onClose}>close airtrail</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/components/Trips/TripFormModal', () => ({
|
||||
default: ({ isOpen, onClose, onSave, trip, onCoverUpdate }: {
|
||||
isOpen: boolean; onClose: () => void
|
||||
onSave: (data: Record<string, unknown>) => Promise<void>
|
||||
trip?: Trip | null
|
||||
onCoverUpdate: (id: number, coverUrl: string) => void
|
||||
}) => (
|
||||
<div data-testid="stub-tripform" data-open={String(isOpen)} data-title={trip?.title ?? 'none'}>
|
||||
<button type="button" onClick={() => void onSave({ title: 'Japan 2027' })}>save trip</button>
|
||||
<button type="button" onClick={() => onCoverUpdate(1, '/uploads/covers/new.jpg')}>update cover</button>
|
||||
<button type="button" onClick={onClose}>close trip form</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/components/Trips/TripMembersModal', () => ({
|
||||
default: ({ isOpen, onClose, tripTitle, onMembersChanged }: {
|
||||
isOpen: boolean; onClose: () => void; tripTitle?: string; onMembersChanged: () => void
|
||||
}) => (
|
||||
<div data-testid="stub-members" data-open={String(isOpen)} data-title={tripTitle ?? 'none'}>
|
||||
<button type="button" onClick={onMembersChanged}>members changed</button>
|
||||
<button type="button" onClick={onClose}>close members</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/components/Planner/TransitJourneyModal', () => ({
|
||||
default: ({ reservation, canEdit, onClose, onSave, onDelete, onChangeRoute }: {
|
||||
reservation: Reservation
|
||||
canEdit: boolean
|
||||
onClose: () => void
|
||||
onSave: (fields: Record<string, unknown>) => Promise<void>
|
||||
onDelete: () => Promise<void>
|
||||
onChangeRoute: () => void
|
||||
}) => (
|
||||
<div data-testid="stub-transit" data-title={reservation.title} data-canedit={String(canEdit)}>
|
||||
<button type="button" onClick={() => void onSave({ title: 'Renamed' })}>save transit</button>
|
||||
<button type="button" onClick={() => void onDelete()}>delete transit</button>
|
||||
<button type="button" onClick={onChangeRoute}>change route</button>
|
||||
<button type="button" onClick={onClose}>close transit</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
import MTripSheets from '../../../../src/mobile/screens/trip/sheets/MTripSheets'
|
||||
|
||||
const JOURNEY = {
|
||||
id: 55, trip_id: 1, day_id: 4, type: 'transit', title: 'Tokyo → Kyoto',
|
||||
endpoints: [
|
||||
{ role: 'from', name: 'Tokyo Sta.', lat: 35.68, lng: 139.76 },
|
||||
{ role: 'to', name: 'Kyoto Sta.', lat: 34.98, lng: 135.75 },
|
||||
],
|
||||
} as unknown as Reservation
|
||||
|
||||
function renderHost(plannerOverrides: Partial<TripPlanner> = {}, shellOverrides: Partial<MTripShellApi> = {}) {
|
||||
const planner = buildPlanner(plannerOverrides)
|
||||
const shell = buildShell(shellOverrides)
|
||||
render(<MTripSheets planner={planner} shell={shell} />)
|
||||
return { planner, shell }
|
||||
}
|
||||
|
||||
describe('MTripSheets', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
seedStore(useAuthStore, { user: { id: 7, username: 'maurice', email: 'm@example.com' } })
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-001: mounts every sheet of the host with the same planner and shell', () => {
|
||||
renderHost({}, { sheet: null })
|
||||
for (const id of ['stub-place', 'stub-day', 'stub-days', 'stub-accommodation', 'stub-transport',
|
||||
'stub-bract', 'stub-mehr', 'stub-export', 'stub-note', 'stub-import', 'stub-placeedit',
|
||||
'stub-reservation', 'stub-transportform', 'stub-bookingimport', 'stub-airtrail',
|
||||
'stub-tripform', 'stub-members']) {
|
||||
expect(screen.getByTestId(id)).toBeInTheDocument()
|
||||
}
|
||||
expect(screen.getByTestId('stub-day')).toHaveAttribute('data-sheet', 'none')
|
||||
expect(screen.getByTestId('stub-placeedit')).toHaveAttribute('data-trip', '1')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['day', 'stub-day'],
|
||||
['days', 'stub-days'],
|
||||
['accommodation', 'stub-accommodation'],
|
||||
['transport', 'stub-transport'],
|
||||
['bract', 'stub-bract'],
|
||||
['mehr', 'stub-mehr'],
|
||||
['export', 'stub-export'],
|
||||
])('FE-MOB-SHOST-002: forwards the "%s" sheet id to %s', (id, testid) => {
|
||||
renderHost({}, { sheet: { id } })
|
||||
expect(screen.getByTestId(testid)).toHaveAttribute('data-sheet', id)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['note', 'stub-note'],
|
||||
['import', 'stub-import'],
|
||||
['tripedit', 'stub-tripform'],
|
||||
['members', 'stub-members'],
|
||||
])('FE-MOB-SHOST-003: opens only %s for its own id', (id, testid) => {
|
||||
renderHost({}, { sheet: { id } })
|
||||
const hostRouted = ['stub-note', 'stub-import', 'stub-tripform', 'stub-members']
|
||||
for (const other of hostRouted) {
|
||||
expect(screen.getByTestId(other)).toHaveAttribute('data-open', String(other === testid))
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-004: hands the note payload to the note sheet and only for that id', () => {
|
||||
renderHost({}, { sheet: { id: 'note', payload: { dayId: 4 } } })
|
||||
expect(screen.getByTestId('stub-note')).toHaveAttribute('data-day', '4')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-005: withholds the payload while a different sheet is open', () => {
|
||||
renderHost({}, { sheet: { id: 'day', payload: { dayId: 4 } } })
|
||||
expect(screen.getByTestId('stub-note')).toHaveAttribute('data-day', 'none')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-006: the note and import sheets close through the shell', () => {
|
||||
const { shell } = renderHost({}, { sheet: { id: 'note' } })
|
||||
fireEvent.click(screen.getByText('close note'))
|
||||
fireEvent.click(screen.getByText('close import'))
|
||||
expect(shell.closeSheet).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-007: saving the trip form updates the trip and toasts', async () => {
|
||||
const { planner } = renderHost({}, { sheet: { id: 'tripedit' } })
|
||||
fireEvent.click(screen.getByText('save trip'))
|
||||
await waitFor(() => expect(planner.tripActions.updateTrip).toHaveBeenCalledWith(1, { title: 'Japan 2027' }))
|
||||
expect(planner.toast.success).toHaveBeenCalledWith('trip.toast.tripUpdated')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-008: a new cover is patched straight into the trip store', () => {
|
||||
seedStore(useTripStore, { trip: { id: 1, title: 'Japan 2026', cover_image: '/uploads/covers/old.jpg' } })
|
||||
renderHost({}, { sheet: { id: 'tripedit' } })
|
||||
fireEvent.click(screen.getByText('update cover'))
|
||||
expect(useTripStore.getState().trip?.cover_image).toBe('/uploads/covers/new.jpg')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-009: a cover update without a loaded trip leaves the store alone', () => {
|
||||
seedStore(useTripStore, { trip: null })
|
||||
renderHost({}, { sheet: { id: 'tripedit' } })
|
||||
fireEvent.click(screen.getByText('update cover'))
|
||||
expect(useTripStore.getState().trip).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-010: the trip form and members modal close through the shell', () => {
|
||||
const { shell } = renderHost({}, { sheet: { id: 'members' } })
|
||||
fireEvent.click(screen.getByText('close trip form'))
|
||||
fireEvent.click(screen.getByText('close members'))
|
||||
expect(shell.closeSheet).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-011: the members modal gets the trip title and reports changes to the planner', () => {
|
||||
const { planner } = renderHost({}, { sheet: { id: 'members' } })
|
||||
expect(screen.getByTestId('stub-members')).toHaveAttribute('data-title', 'Japan 2026')
|
||||
fireEvent.click(screen.getByText('members changed'))
|
||||
expect(planner.refreshMembers).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-012: the booking and AirTrail importers follow their planner flags', () => {
|
||||
const { planner } = renderHost({ showBookingImport: true, showAirTrailImport: true })
|
||||
expect(screen.getByTestId('stub-bookingimport')).toHaveAttribute('data-open', 'true')
|
||||
expect(screen.getByTestId('stub-bookingimport')).toHaveAttribute('data-trip', '1')
|
||||
expect(screen.getByTestId('stub-airtrail')).toHaveAttribute('data-hasundo', 'true')
|
||||
|
||||
fireEvent.click(screen.getByText('close booking import'))
|
||||
fireEvent.click(screen.getByText('close airtrail'))
|
||||
expect(planner.setShowBookingImport).toHaveBeenCalledWith(false)
|
||||
expect(planner.setShowAirTrailImport).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-013: no transit journey modal without a selected journey', () => {
|
||||
renderHost()
|
||||
expect(screen.queryByTestId('stub-transit')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-014: prefers the stored reservation over the journey snapshot', () => {
|
||||
const stored = { ...JOURNEY, title: 'Tokyo → Kyoto (saved)' } as unknown as Reservation
|
||||
renderHost({ transitJourney: JOURNEY, reservations: [stored] })
|
||||
expect(screen.getByTestId('stub-transit')).toHaveAttribute('data-title', 'Tokyo → Kyoto (saved)')
|
||||
expect(screen.getByTestId('stub-transit')).toHaveAttribute('data-canedit', 'true')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-015: falls back to the journey itself when it is not in the list', () => {
|
||||
const planner = buildPlanner({ transitJourney: JOURNEY, reservations: [], can: vi.fn(() => false) as TripPlanner['can'] })
|
||||
render(<MTripSheets planner={planner} shell={buildShell()} />)
|
||||
expect(screen.getByTestId('stub-transit')).toHaveAttribute('data-title', 'Tokyo → Kyoto')
|
||||
expect(screen.getByTestId('stub-transit')).toHaveAttribute('data-canedit', 'false')
|
||||
expect(planner.can).toHaveBeenCalledWith('day_edit', planner.trip)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-016: saving the journey updates the reservation and clears the selection', async () => {
|
||||
const { planner } = renderHost({ transitJourney: JOURNEY })
|
||||
fireEvent.click(screen.getByText('save transit'))
|
||||
await waitFor(() =>
|
||||
expect(planner.tripActions.updateReservation).toHaveBeenCalledWith(1, 55, { title: 'Renamed' }))
|
||||
expect(planner.setTransitJourney).toHaveBeenLastCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-017: deleting the journey goes through the planner and clears the selection', async () => {
|
||||
const { planner } = renderHost({ transitJourney: JOURNEY })
|
||||
fireEvent.click(screen.getByText('delete transit'))
|
||||
await waitFor(() => expect(planner.handleDeleteReservation).toHaveBeenCalledWith(55))
|
||||
expect(planner.setTransitJourney).toHaveBeenLastCalledWith(null)
|
||||
fireEvent.click(screen.getByText('close transit'))
|
||||
expect(planner.setTransitJourney).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-018: changing the route reopens the transport search seeded with both endpoints', () => {
|
||||
const { planner } = renderHost({ transitJourney: JOURNEY })
|
||||
fireEvent.click(screen.getByText('change route'))
|
||||
expect(planner.setTransitPrefill).toHaveBeenCalledWith({
|
||||
from: { name: 'Tokyo Sta.', lat: 35.68, lng: 139.76 },
|
||||
to: { name: 'Kyoto Sta.', lat: 34.98, lng: 135.75 },
|
||||
})
|
||||
expect(planner.setEditingTransport).toHaveBeenCalledWith(JOURNEY)
|
||||
expect(planner.setTransportModalDayId).toHaveBeenCalledWith(4)
|
||||
expect(planner.setTransportModalAutomated).toHaveBeenCalledWith(true)
|
||||
expect(planner.setTransitJourney).toHaveBeenCalledWith(null)
|
||||
expect(planner.setShowTransportModal).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-019: a journey without endpoints seeds empty prefills and no day', () => {
|
||||
const bare = { id: 56, trip_id: 1, type: 'transit', title: 'Unknown leg' } as unknown as Reservation
|
||||
const { planner } = renderHost({ transitJourney: bare })
|
||||
fireEvent.click(screen.getByText('change route'))
|
||||
expect(planner.setTransitPrefill).toHaveBeenCalledWith({ from: null, to: null })
|
||||
expect(planner.setTransportModalDayId).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-020: a booking opens the expense editor for its linked item and closes again', () => {
|
||||
renderHost()
|
||||
expect(screen.queryByTestId('stub-cost')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('expense noop'))
|
||||
expect(screen.queryByTestId('stub-cost')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('expense edit'))
|
||||
const cost = screen.getByTestId('stub-cost')
|
||||
expect(cost).toHaveAttribute('data-editing', '99')
|
||||
expect(cost).toHaveAttribute('data-prefill', 'none')
|
||||
expect(cost).toHaveAttribute('data-me', '7')
|
||||
expect(cost).toHaveAttribute('data-trip', '1')
|
||||
|
||||
fireEvent.click(screen.getByText('close cost'))
|
||||
expect(screen.queryByTestId('stub-cost')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-021: a transport prefill opens a new expense and reloads the budget on save', () => {
|
||||
const loadBudgetItems = vi.fn()
|
||||
seedStore(useTripStore, { loadBudgetItems })
|
||||
renderHost()
|
||||
fireEvent.click(screen.getByText('expense prefill'))
|
||||
const cost = screen.getByTestId('stub-cost')
|
||||
expect(cost).toHaveAttribute('data-editing', 'none')
|
||||
expect(cost).toHaveAttribute('data-prefill', 'Shinkansen')
|
||||
|
||||
fireEvent.click(screen.getByText('save cost'))
|
||||
expect(loadBudgetItems).toHaveBeenCalledWith(1)
|
||||
expect(screen.queryByTestId('stub-cost')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-022: the expense base currency prefers the display setting, then the trip', () => {
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'jpy' } })
|
||||
renderHost()
|
||||
fireEvent.click(screen.getByText('expense edit'))
|
||||
expect(screen.getByTestId('stub-cost')).toHaveAttribute('data-base', 'JPY')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-023: falls back to the trip currency and finally to EUR', () => {
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: '' } })
|
||||
const { planner } = renderHost()
|
||||
fireEvent.click(screen.getByText('expense edit'))
|
||||
expect(screen.getByTestId('stub-cost')).toHaveAttribute('data-base', 'EUR')
|
||||
|
||||
const noCurrency = { ...planner.trip, currency: '' } as unknown as Trip
|
||||
render(<MTripSheets planner={buildPlanner({ trip: noCurrency })} shell={buildShell()} />)
|
||||
fireEvent.click(screen.getAllByText('expense edit')[1])
|
||||
expect(screen.getAllByTestId('stub-cost')[1]).toHaveAttribute('data-base', 'EUR')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-024: an anonymous session pays as user -1', () => {
|
||||
seedStore(useAuthStore, { user: null })
|
||||
renderHost()
|
||||
fireEvent.click(screen.getByText('expense edit'))
|
||||
expect(screen.getByTestId('stub-cost')).toHaveAttribute('data-me', '-1')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-025: the delete-place confirm runs the planner confirmation and disarms', () => {
|
||||
const { planner } = renderHost({ deletePlaceId: 101 })
|
||||
expect(screen.getByTestId('stub-confirm')).toHaveAttribute('data-title', 'common.delete')
|
||||
expect(screen.getByText('trip.confirm.deletePlace')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('confirm delete'))
|
||||
expect(planner.confirmDeletePlace).toHaveBeenCalledTimes(1)
|
||||
expect(planner.setDeletePlaceId).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-026: cancelling the confirm only disarms the flag', () => {
|
||||
const { planner } = renderHost({ deletePlaceId: 101 })
|
||||
fireEvent.click(screen.getByText('cancel delete'))
|
||||
expect(planner.confirmDeletePlace).not.toHaveBeenCalled()
|
||||
expect(planner.setDeletePlaceId).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-027: the place edit sheet owns the confirm while its own form is open', () => {
|
||||
renderHost({ deletePlaceId: 101, showPlaceForm: true })
|
||||
expect(screen.queryByTestId('stub-confirm')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,421 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, fireEvent, render, screen } from '../../../helpers/render'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import type { Day, PackingItem, TodoItem } from '../../../../src/types'
|
||||
|
||||
// FE-MOB-SHELL-001 to FE-MOB-SHELL-034
|
||||
|
||||
const mocks = vi.hoisted(() => ({ planner: {} as TripPlanner }))
|
||||
|
||||
vi.mock('../../../../src/pages/tripPlanner/useTripPlanner', () => ({
|
||||
useTripPlanner: () => mocks.planner,
|
||||
}))
|
||||
|
||||
// The real slots pull in the map engine and every tab panel; the shell only
|
||||
// ever hands them props, so they are replaced by the probes below.
|
||||
vi.mock('../../../../src/mobile/screens/trip/plan/MPlanTimeline', () => ({ default: () => null }))
|
||||
vi.mock('../../../../src/mobile/screens/trip/map/MMapArea', () => ({ default: () => null }))
|
||||
vi.mock('../../../../src/mobile/screens/trip/places/MPlacesBrowser', () => ({ default: () => null }))
|
||||
vi.mock('../../../../src/mobile/screens/trip/tabs/MTripTabPanel', () => ({ default: () => null }))
|
||||
vi.mock('../../../../src/mobile/screens/trip/sheets/MTripSheets', () => ({ default: () => null }))
|
||||
|
||||
import MTripShell from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
|
||||
/** Last shell api handed to a slot — the object every slot shares. */
|
||||
let shellApi!: MTripShellApi
|
||||
|
||||
function slot(testId: string) {
|
||||
return function Slot({ shell }: { planner: TripPlanner; shell: MTripShellApi }) {
|
||||
shellApi = shell
|
||||
return <div data-testid={testId} />
|
||||
}
|
||||
}
|
||||
|
||||
function TabSlot({ shell, tab }: { planner: TripPlanner; shell: MTripShellApi; tab: string }) {
|
||||
shellApi = shell
|
||||
return <div data-testid="tab-panel">{tab}</div>
|
||||
}
|
||||
|
||||
const TRIP_TABS = [
|
||||
{ id: 'plan', label: 'Plan' },
|
||||
{ id: 'transports', label: 'Transport' },
|
||||
{ id: 'buchungen', label: 'Bookings' },
|
||||
{ id: 'finanzplan', label: 'Budget' },
|
||||
{ id: 'listen', label: 'Lists' },
|
||||
{ id: 'dateien', label: 'Files' },
|
||||
{ id: 'collab', label: 'Collaboration' },
|
||||
]
|
||||
|
||||
const DAYS = [
|
||||
{ id: 11, trip_id: 1, day_number: 1, date: '2026-05-02', title: null },
|
||||
{ id: 12, trip_id: 1, day_number: 2, date: '2026-05-03', title: null },
|
||||
] as unknown as Day[]
|
||||
|
||||
function todayIso(): string {
|
||||
const now = new Date()
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function renderShell(overrides: Partial<TripPlanner> = {}) {
|
||||
mocks.planner = buildPlanner({
|
||||
days: DAYS,
|
||||
selectedDayId: 11,
|
||||
TRIP_TABS,
|
||||
...overrides,
|
||||
} as Partial<TripPlanner>)
|
||||
const view = render(
|
||||
<MTripShell
|
||||
PlanTimeline={slot('plan-timeline')}
|
||||
MapArea={slot('map-area')}
|
||||
PlacesBrowser={slot('places-browser')}
|
||||
TabPanel={TabSlot}
|
||||
Sheets={slot('sheets')}
|
||||
/>,
|
||||
)
|
||||
return { ...view, planner: mocks.planner }
|
||||
}
|
||||
|
||||
const spy = (planner: TripPlanner, name: keyof TripPlanner) =>
|
||||
vi.mocked(planner[name] as unknown as ReturnType<typeof vi.fn>)
|
||||
|
||||
describe('MTripShell', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-001: shows the loading splash with the trip title while the planner loads', () => {
|
||||
renderShell({ isLoading: true } as Partial<TripPlanner>)
|
||||
expect(screen.getByText('Japan 2026')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('sheets')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-002: keeps the splash up until the photo warm-up finished', () => {
|
||||
renderShell({ splashDone: false } as Partial<TripPlanner>)
|
||||
expect(screen.getByText('Japan 2026')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('map-area')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-003: renders nothing once loading finished without a trip', () => {
|
||||
const { container } = renderShell({ trip: null } as Partial<TripPlanner>)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-003b: the splash falls back to the brand name before the trip arrives', () => {
|
||||
renderShell({ isLoading: true, trip: null } as Partial<TripPlanner>)
|
||||
expect(screen.getByText('TREK')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-004: seeds the running trip on today rather than on day one', () => {
|
||||
const days = [
|
||||
{ id: 11, day_number: 1, date: '2026-05-02' },
|
||||
{ id: 12, day_number: 2, date: todayIso() },
|
||||
] as unknown as Day[]
|
||||
const { planner } = renderShell({ days, selectedDayId: null } as Partial<TripPlanner>)
|
||||
expect(planner.tripActions.setSelectedDay).toHaveBeenCalledWith(12)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-005: falls back to the first day when the trip is not running', () => {
|
||||
const { planner } = renderShell({ selectedDayId: null } as Partial<TripPlanner>)
|
||||
expect(planner.tripActions.setSelectedDay).toHaveBeenCalledWith(11)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-006: leaves an existing day selection alone', () => {
|
||||
const { planner } = renderShell()
|
||||
expect(planner.tripActions.setSelectedDay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-007: does not seed a day before the days arrive', () => {
|
||||
const { planner } = renderShell({ days: [], selectedDayId: null } as Partial<TripPlanner>)
|
||||
expect(planner.tripActions.setSelectedDay).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('button', { name: 'Sat 2' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-008: the back button leaves for the dashboard', () => {
|
||||
const { planner } = renderShell()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.back' }))
|
||||
expect(planner.navigate).toHaveBeenCalledWith('/dashboard')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-009: the plan tab mounts the map under the timeline and no browser', () => {
|
||||
renderShell()
|
||||
expect(screen.getByTestId('map-area')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('plan-timeline')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sheets')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('places-browser')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('tab-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-010: the browse segment swaps the timeline for the places browser', () => {
|
||||
renderShell()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'trip.mobilePlaces' }))
|
||||
expect(screen.getByTestId('places-browser')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('plan-timeline')).not.toBeInTheDocument()
|
||||
expect(shellApi.mode).toBe('browse')
|
||||
expect(shellApi.view).toBe('plan')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-011: browse entered from edit flags browseFromEdit for the pool filter', () => {
|
||||
renderShell()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'trip.mobilePlan' }))
|
||||
expect(shellApi.mode).toBe('edit')
|
||||
expect(shellApi.browseFromEdit).toBe(false)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'trip.mobilePlaces' }))
|
||||
expect(shellApi.browseFromEdit).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-012: browse entered from travel leaves browseFromEdit off', () => {
|
||||
renderShell()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'trip.mobilePlaces' }))
|
||||
expect(shellApi.browseFromEdit).toBe(false)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.travel' }))
|
||||
expect(shellApi.mode).toBe('go')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-013: switching the travel mode from another tab forces the plan tab', () => {
|
||||
const { planner } = renderShell({ activeTab: 'listen' } as Partial<TripPlanner>)
|
||||
act(() => { shellApi.setTravelMode('edit') })
|
||||
expect(planner.handleTabChange).toHaveBeenCalledWith('plan')
|
||||
expect(shellApi.mode).toBe('edit')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-014: the map toggle draws the route and refits the selected day', () => {
|
||||
const { planner } = renderShell()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.mapView' }))
|
||||
expect(planner.setRouteShown).toHaveBeenCalledWith(true)
|
||||
expect(planner.handleSelectDay).toHaveBeenCalledWith(11, false)
|
||||
expect(shellApi.view).toBe('map')
|
||||
// The timeline unmounts, the map layer stays warm underneath.
|
||||
expect(screen.queryByTestId('plan-timeline')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('map-area')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-015: leaving the map restores the timeline without touching the route', () => {
|
||||
const { planner } = renderShell()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.mapView' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.listView' }))
|
||||
expect(shellApi.view).toBe('plan')
|
||||
expect(screen.getByTestId('plan-timeline')).toBeInTheDocument()
|
||||
expect(spy(planner, 'setRouteShown')).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-016: the map toggle skips the refit while no day is selected', () => {
|
||||
const { planner } = renderShell({ selectedDayId: null } as Partial<TripPlanner>)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.mapView' }))
|
||||
expect(planner.setRouteShown).toHaveBeenCalledWith(true)
|
||||
expect(planner.handleSelectDay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-017: the map toggle drops out of browse mode', () => {
|
||||
renderShell()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'trip.mobilePlaces' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.mapView' }))
|
||||
expect(shellApi.mode).toBe('go')
|
||||
expect(screen.queryByTestId('places-browser')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-018: day chips render weekday + date and mark the active one', () => {
|
||||
renderShell()
|
||||
const chip = screen.getByRole('button', { name: 'Sat 2' })
|
||||
expect(chip).toHaveAttribute('aria-current', 'true')
|
||||
expect(screen.getByRole('button', { name: 'Sun 3' })).not.toHaveAttribute('aria-current')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-019: a chip without a usable date falls back to the day number', () => {
|
||||
const days = [
|
||||
{ id: 11, day_number: 1, date: null },
|
||||
{ id: 12, day_number: 2, date: 'nodatehere' },
|
||||
{ id: 13, date: null },
|
||||
] as unknown as Day[]
|
||||
renderShell({ days } as Partial<TripPlanner>)
|
||||
expect(screen.getByRole('button', { name: 'planner.dayN:1' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'planner.dayN:2' })).toBeInTheDocument()
|
||||
// A day the server sent without a number still gets a chip.
|
||||
expect(screen.getByRole('button', { name: 'planner.dayN:0' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-020: tapping another day selects it and fits the list view', () => {
|
||||
const { planner } = renderShell()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sun 3' }))
|
||||
expect(planner.handleSelectDay).toHaveBeenCalledWith(12, true)
|
||||
expect(planner.setRouteShown).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-021: tapping the active day opens the day sheet', () => {
|
||||
const { planner } = renderShell()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sat 2' }))
|
||||
expect(shellApi.sheet).toEqual({ id: 'day', payload: { dayId: 11 } })
|
||||
expect(planner.handleSelectDay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-022: a day tap in map mode fits without the list flag and draws the route', () => {
|
||||
const { planner } = renderShell()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.mapView' }))
|
||||
vi.mocked(planner.setRouteShown).mockClear()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sun 3' }))
|
||||
expect(planner.handleSelectDay).toHaveBeenLastCalledWith(12, false)
|
||||
expect(planner.setRouteShown).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-023: the dock only shows enabled tabs and marks the active one', () => {
|
||||
const { planner } = renderShell({
|
||||
TRIP_TABS: [
|
||||
{ id: 'plan', label: 'Plan' },
|
||||
{ id: 'transports', label: 'Transport' },
|
||||
// A plugin tab that arrived without a name still needs a label.
|
||||
{ id: 'buchungen' },
|
||||
],
|
||||
} as Partial<TripPlanner>)
|
||||
expect(screen.getByRole('button', { name: 'Plan' })).toHaveAttribute('aria-current', 'page')
|
||||
expect(screen.getByRole('button', { name: 'buchungen' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Lists' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Transport' }))
|
||||
expect(planner.handleTabChange).toHaveBeenCalledWith('transports')
|
||||
expect(shellApi.mode).toBe('go')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-024: a dock tap changes the tab and drops out of browse mode', () => {
|
||||
const { planner } = renderShell()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'trip.mobilePlaces' }))
|
||||
expect(shellApi.mode).toBe('browse')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Lists' }))
|
||||
expect(planner.handleTabChange).toHaveBeenCalledWith('listen')
|
||||
expect(shellApi.mode).toBe('go')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-025: the Mehr button opens the sheet and closeSheet clears it', () => {
|
||||
renderShell()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.more' }))
|
||||
expect(shellApi.sheet).toEqual({ id: 'mehr', payload: undefined })
|
||||
act(() => { shellApi.closeSheet() })
|
||||
expect(shellApi.sheet).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-026: a non-plan tab replaces the plan chrome with its panel', () => {
|
||||
renderShell({ activeTab: 'dateien' } as Partial<TripPlanner>)
|
||||
expect(screen.getByTestId('tab-panel')).toHaveTextContent('dateien')
|
||||
expect(screen.queryByTestId('map-area')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'mobileTrip.travel' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'mobileTrip.mapView' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-027: the transports header opens a blank transport modal', () => {
|
||||
const { planner } = renderShell({ activeTab: 'transports' } as Partial<TripPlanner>)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'transport.addTransport' }))
|
||||
expect(planner.setEditingTransport).toHaveBeenCalledWith(null)
|
||||
expect(planner.setTransitPrefill).toHaveBeenCalledWith(null)
|
||||
expect(planner.setTransportModalAutomated).toHaveBeenCalledWith(false)
|
||||
expect(planner.setShowTransportModal).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-028: the transports header offers both importers when they are available', () => {
|
||||
const { planner } = renderShell({ activeTab: 'transports', airTrailAvailable: true } as Partial<TripPlanner>)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.import.title' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.airtrail.title' }))
|
||||
expect(planner.setShowBookingImport).toHaveBeenCalledWith(true)
|
||||
expect(planner.setShowAirTrailImport).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-029: the transports header hides the importers that are switched off', () => {
|
||||
renderShell({
|
||||
activeTab: 'transports', bookingImportAvailable: false, airTrailAvailable: false,
|
||||
} as Partial<TripPlanner>)
|
||||
expect(screen.queryByRole('button', { name: 'reservations.import.title' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'reservations.airtrail.title' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-030: the transports compact toggle flips the shell flag', () => {
|
||||
renderShell({ activeTab: 'transports' } as Partial<TripPlanner>)
|
||||
const toggle = screen.getByRole('button', { name: 'mobileTrip.compactView' })
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(shellApi.transportsCompact).toBe(true)
|
||||
expect(shellApi.bookingsCompact).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-031: the bookings header opens a blank reservation and toggles its own density', () => {
|
||||
const { planner } = renderShell({ activeTab: 'buchungen' } as Partial<TripPlanner>)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.newReservation' }))
|
||||
expect(planner.setEditingReservation).toHaveBeenCalledWith(null)
|
||||
expect(planner.setShowReservationModal).toHaveBeenCalledWith(true)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.import.title' }))
|
||||
expect(planner.setShowBookingImport).toHaveBeenCalledWith(true)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.compactView' }))
|
||||
expect(shellApi.bookingsCompact).toBe(true)
|
||||
expect(shellApi.transportsCompact).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-032: the costs header raises the add-expense and CSV signals', () => {
|
||||
renderShell({ activeTab: 'finanzplan' } as Partial<TripPlanner>)
|
||||
expect(shellApi.addExpenseSignal).toBe(0)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'costs.addExpense' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'budget.exportCsv' }))
|
||||
expect(shellApi.addExpenseSignal).toBe(1)
|
||||
expect(shellApi.exportCostsCsvSignal).toBe(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-033: the files header raises the upload and trash signals', () => {
|
||||
renderShell({ activeTab: 'dateien' } as Partial<TripPlanner>)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.upload' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'files.trash' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.upload' }))
|
||||
expect(shellApi.uploadFilesSignal).toBe(2)
|
||||
expect(shellApi.openFilesTrashSignal).toBe(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-034: the lists header shows packed/open counts and persists the sub-tab', () => {
|
||||
const packingItems = [
|
||||
{ id: 1, checked: true }, { id: 2, checked: false }, { id: 3, checked: true },
|
||||
] as unknown as PackingItem[]
|
||||
const todoItems = [{ id: 1, checked: false }, { id: 2, checked: false }] as unknown as TodoItem[]
|
||||
renderShell({ activeTab: 'listen', packingItems, todoItems } as Partial<TripPlanner>)
|
||||
expect(screen.getByRole('button', { name: /todo\.subtab\.packing/ })).toHaveTextContent('2/3')
|
||||
const todoTab = screen.getByRole('button', { name: /todo\.subtab\.todo/ })
|
||||
expect(todoTab).toHaveTextContent('mobileTrip.todoOpenCount:2')
|
||||
|
||||
fireEvent.click(todoTab)
|
||||
expect(shellApi.listsTab).toBe('todo')
|
||||
expect(sessionStorage.getItem('trip-lists-subtab-1')).toBe('todo')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-035: the lists sub-tab is restored from the session', () => {
|
||||
sessionStorage.setItem('trip-lists-subtab-1', 'todo')
|
||||
renderShell({ activeTab: 'listen' } as Partial<TripPlanner>)
|
||||
expect(shellApi.listsTab).toBe('todo')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-036: the collab header switches the sub-tab', () => {
|
||||
renderShell({ activeTab: 'collab' } as Partial<TripPlanner>)
|
||||
expect(shellApi.collabTab).toBe('chat')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.tabs.polls' }))
|
||||
expect(shellApi.collabTab).toBe('polls')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.tabs.notes' }))
|
||||
expect(shellApi.collabTab).toBe('notes')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-037: setTrTab from a slot routes through the planner and resets browse', () => {
|
||||
const { planner } = renderShell()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'trip.mobilePlaces' }))
|
||||
act(() => { shellApi.setTrTab('collab') })
|
||||
expect(planner.handleTabChange).toHaveBeenCalledWith('collab')
|
||||
expect(shellApi.mode).toBe('go')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-038: openSheet carries the payload through to the slots', () => {
|
||||
renderShell()
|
||||
act(() => { shellApi.openSheet('note', { dayId: 12 }) })
|
||||
expect(shellApi.sheet).toEqual({ id: 'note', payload: { dayId: 12 } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildShell fixture', () => {
|
||||
it('FE-MOB-SHELL-039: defaults to the plan/go state and takes overrides', () => {
|
||||
const shell = buildShell({ mode: 'browse', browseFromEdit: true })
|
||||
expect(shell.view).toBe('plan')
|
||||
expect(shell.trTab).toBe('plan')
|
||||
expect(shell.mode).toBe('browse')
|
||||
expect(shell.browseFromEdit).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import MTripTabPanel from '../../../../src/mobile/screens/trip/tabs/MTripTabPanel'
|
||||
import type { MTabScreenProps } from '../../../../src/mobile/screens/trip/tabs/tabModel'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { render, screen } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-TABPANEL-001 to FE-MOB-TABPANEL-010
|
||||
|
||||
/** Each tab is stubbed so the routing itself is what gets asserted. */
|
||||
function stub(name: string) {
|
||||
return function Stub({ planner, shell }: MTabScreenProps) {
|
||||
return <div data-testid={name} data-trip={String(planner.tripId)} data-tab={shell.trTab} />
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('../../../../src/mobile/screens/trip/tabs/MTransportsTab', () => ({ default: stub('transports') }))
|
||||
vi.mock('../../../../src/mobile/screens/trip/tabs/MBookingsTab', () => ({ default: stub('bookings') }))
|
||||
vi.mock('../../../../src/mobile/screens/trip/tabs/MCostsTab', () => ({ default: stub('costs') }))
|
||||
vi.mock('../../../../src/mobile/screens/trip/tabs/MFilesTab', () => ({ default: stub('files') }))
|
||||
vi.mock('../../../../src/mobile/screens/trip/tabs/MCollabTab', () => ({ default: stub('collab') }))
|
||||
vi.mock('../../../../src/mobile/screens/trip/tabs/MListsTab', () => ({ default: stub('lists') }))
|
||||
vi.mock('../../../../src/components/Plugins/PluginFrame', () => ({
|
||||
default: ({ pluginId, tripId, fill }: { pluginId: string; tripId: string | null; fill?: boolean }) => (
|
||||
<div data-testid="plugin-frame" data-plugin={pluginId} data-trip={String(tripId)} data-fill={String(!!fill)} />
|
||||
),
|
||||
}))
|
||||
|
||||
function renderTab(tab: string, plannerOverrides = {}) {
|
||||
const planner = buildPlanner(plannerOverrides)
|
||||
const shell = buildShell({ trTab: tab })
|
||||
return { ...render(<MTripTabPanel planner={planner} shell={shell} tab={tab} />), planner, shell }
|
||||
}
|
||||
|
||||
describe('MTripTabPanel', () => {
|
||||
it('FE-MOB-TABPANEL-001: routes transports and hands both props down', () => {
|
||||
renderTab('transports')
|
||||
const panel = screen.getByTestId('transports')
|
||||
expect(panel).toHaveAttribute('data-trip', '1')
|
||||
expect(panel).toHaveAttribute('data-tab', 'transports')
|
||||
expect(screen.queryByTestId('bookings')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TABPANEL-002: routes buchungen to the bookings panel', () => {
|
||||
renderTab('buchungen')
|
||||
expect(screen.getByTestId('bookings')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TABPANEL-003: routes finanzplan to the costs panel', () => {
|
||||
renderTab('finanzplan')
|
||||
expect(screen.getByTestId('costs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TABPANEL-004: routes dateien to the files panel', () => {
|
||||
renderTab('dateien')
|
||||
expect(screen.getByTestId('files')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TABPANEL-005: routes collab to the collab panel', () => {
|
||||
renderTab('collab')
|
||||
expect(screen.getByTestId('collab')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TABPANEL-006: routes listen to the lists panel', () => {
|
||||
renderTab('listen')
|
||||
expect(screen.getByTestId('lists')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TABPANEL-007: mounts the sandboxed frame for a plugin tab', () => {
|
||||
renderTab('plugin:trip-todos')
|
||||
const frame = screen.getByTestId('plugin-frame')
|
||||
expect(frame).toHaveAttribute('data-plugin', 'trip-todos')
|
||||
expect(frame).toHaveAttribute('data-trip', '1')
|
||||
expect(frame).toHaveAttribute('data-fill', 'true')
|
||||
// The frame keeps the dock clearance the scroll body uses.
|
||||
expect((frame.parentElement as HTMLElement).style.paddingBottom).toContain('safe-area-inset-bottom')
|
||||
})
|
||||
|
||||
it('FE-MOB-TABPANEL-008: passes a null tripId to the plugin frame before the trip is known', () => {
|
||||
renderTab('plugin:koffi', { tripId: null })
|
||||
expect(screen.getByTestId('plugin-frame')).toHaveAttribute('data-trip', 'null')
|
||||
})
|
||||
|
||||
it('FE-MOB-TABPANEL-009: keeps a plugin id that itself contains a colon intact', () => {
|
||||
renderTab('plugin:acme:maps')
|
||||
expect(screen.getByTestId('plugin-frame')).toHaveAttribute('data-plugin', 'acme:maps')
|
||||
})
|
||||
|
||||
it('FE-MOB-TABPANEL-010: an unbuilt tab falls back to the empty scroll body', () => {
|
||||
const { container } = renderTab('plan')
|
||||
expect(container.querySelector('[data-testid]')).toBeNull()
|
||||
const body = container.querySelector('.overflow-y-auto') as HTMLElement
|
||||
expect(body).not.toBeNull()
|
||||
expect(body).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import PlCategoryPicker from '../../../../src/mobile/screens/trip/sheets/PlCategoryPicker'
|
||||
import type { Category } from '../../../../src/types'
|
||||
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { buildPlanner, buildTripActions } from '../../../helpers/mobileTrip'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-PLCAT-001 to FE-MOB-PLCAT-011
|
||||
// planner.t echoes the key, so every visible label is asserted as its key.
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 3, name: 'Food', color: '#ef4444', icon: 'Coffee', user_id: 1 },
|
||||
{ id: 4, name: 'Museums', color: '#22c55e', icon: 'Landmark', user_id: 1 },
|
||||
] as unknown as Category[]
|
||||
|
||||
function setup(plannerOverrides: Partial<TripPlanner> = {}, value = '') {
|
||||
const onChange = vi.fn()
|
||||
const planner = buildPlanner({ categories: CATEGORIES, ...plannerOverrides })
|
||||
const view = render(<PlCategoryPicker planner={planner} value={value} onChange={onChange} />)
|
||||
return { ...view, planner, onChange }
|
||||
}
|
||||
|
||||
describe('PlCategoryPicker', () => {
|
||||
it('FE-MOB-PLCAT-001: renders the no-category pill plus one pill per trip category', () => {
|
||||
setup()
|
||||
expect(screen.getByRole('button', { name: /places\.noCategory/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Food/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Museums/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /mobileTrip\.newCategory/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLCAT-002: marks the no-category pill active for the empty value', () => {
|
||||
setup()
|
||||
expect(screen.getByRole('button', { name: /places\.noCategory/ }).className).toContain('bg-m-act')
|
||||
expect(screen.getByRole('button', { name: /Food/ }).className).not.toContain('bg-m-act')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLCAT-003: marks the pill of the selected id active', () => {
|
||||
setup({}, '4')
|
||||
expect(screen.getByRole('button', { name: /Museums/ }).className).toContain('bg-m-act')
|
||||
expect(screen.getByRole('button', { name: /places\.noCategory/ }).className).not.toContain('bg-m-act')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLCAT-004: reports the picked category id as a string', () => {
|
||||
const { onChange } = setup({}, '')
|
||||
fireEvent.click(screen.getByRole('button', { name: /Food/ }))
|
||||
expect(onChange).toHaveBeenCalledWith('3')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLCAT-005: reports the empty value when the category is cleared', () => {
|
||||
const { onChange } = setup({}, '3')
|
||||
fireEvent.click(screen.getByRole('button', { name: /places\.noCategory/ }))
|
||||
expect(onChange).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLCAT-006: creates a category inline and selects it right away', async () => {
|
||||
const tripActions = buildTripActions()
|
||||
tripActions.addCategory.mockResolvedValue({ id: 9, name: 'Bars' } as unknown as Category)
|
||||
const { onChange } = setup({ tripActions } as unknown as Partial<TripPlanner>)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /mobileTrip\.newCategory/ }))
|
||||
const input = screen.getByPlaceholderText('places.categoryNamePlaceholder')
|
||||
fireEvent.change(input, { target: { value: ' Bars ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
|
||||
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalledWith('9'))
|
||||
expect(tripActions.addCategory).toHaveBeenCalledWith({ name: 'Bars', color: '#6366f1', icon: 'MapPin' })
|
||||
// The inline row collapses back to the dashed pill.
|
||||
await waitFor(() => expect(screen.queryByPlaceholderText('places.categoryNamePlaceholder')).not.toBeInTheDocument())
|
||||
expect(screen.getByRole('button', { name: /mobileTrip\.newCategory/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLCAT-007: Enter in the name field creates the category', async () => {
|
||||
const tripActions = buildTripActions()
|
||||
tripActions.addCategory.mockResolvedValue({ id: 12, name: 'Beaches' } as unknown as Category)
|
||||
const { onChange } = setup({ tripActions } as unknown as Partial<TripPlanner>)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /mobileTrip\.newCategory/ }))
|
||||
const input = screen.getByPlaceholderText('places.categoryNamePlaceholder')
|
||||
fireEvent.change(input, { target: { value: 'Beaches' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalledWith('12'))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLCAT-008: ignores Enter and keeps the confirm disabled while the name is blank', () => {
|
||||
const tripActions = buildTripActions()
|
||||
setup({ tripActions } as unknown as Partial<TripPlanner>)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /mobileTrip\.newCategory/ }))
|
||||
const input = screen.getByPlaceholderText('places.categoryNamePlaceholder')
|
||||
expect(screen.getByRole('button', { name: 'common.add' })).toBeDisabled()
|
||||
fireEvent.change(input, { target: { value: ' ' } })
|
||||
fireEvent.keyDown(input, { key: 'a' })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(tripActions.addCategory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLCAT-009: does not fire a second create while the first is in flight', async () => {
|
||||
const tripActions = buildTripActions()
|
||||
let release: (cat: Category) => void = () => {}
|
||||
tripActions.addCategory.mockReturnValue(new Promise<Category>(resolve => { release = resolve }))
|
||||
const { onChange } = setup({ tripActions } as unknown as Partial<TripPlanner>)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /mobileTrip\.newCategory/ }))
|
||||
const input = screen.getByPlaceholderText('places.categoryNamePlaceholder')
|
||||
fireEvent.change(input, { target: { value: 'Bars' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(tripActions.addCategory).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByRole('button', { name: 'common.add' })).toBeDisabled()
|
||||
|
||||
release({ id: 21 } as unknown as Category)
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalledWith('21'))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLCAT-010: toasts and keeps the draft when the create fails', async () => {
|
||||
const tripActions = buildTripActions()
|
||||
tripActions.addCategory.mockRejectedValue(new Error('409'))
|
||||
const { planner, onChange } = setup({ tripActions } as unknown as Partial<TripPlanner>)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /mobileTrip\.newCategory/ }))
|
||||
fireEvent.change(screen.getByPlaceholderText('places.categoryNamePlaceholder'), { target: { value: 'Bars' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
|
||||
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('places.categoryCreateError'))
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
expect(screen.getByPlaceholderText('places.categoryNamePlaceholder')).toHaveValue('Bars')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLCAT-011: cancelling drops the draft and restores the dashed pill', () => {
|
||||
setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: /mobileTrip\.newCategory/ }))
|
||||
fireEvent.change(screen.getByPlaceholderText('places.categoryNamePlaceholder'), { target: { value: 'Bars' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
|
||||
|
||||
expect(screen.queryByPlaceholderText('places.categoryNamePlaceholder')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /mobileTrip\.newCategory/ }))
|
||||
expect(screen.getByPlaceholderText('places.categoryNamePlaceholder')).toHaveValue('')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLCAT-012: tolerates a trip without any categories', () => {
|
||||
setup({ categories: undefined } as unknown as Partial<TripPlanner>)
|
||||
expect(screen.getByRole('button', { name: /places\.noCategory/ })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Food/ })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import PlFileAttach from '../../../../src/mobile/screens/trip/sheets/PlFileAttach'
|
||||
import { buildPlanner } from '../../../helpers/mobileTrip'
|
||||
import { fireEvent, render, screen } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-PLFILE-001 to FE-MOB-PLFILE-010
|
||||
|
||||
const planner = buildPlanner()
|
||||
|
||||
function file(name: string, type = 'application/pdf') {
|
||||
return new File(['x'], name, { type })
|
||||
}
|
||||
|
||||
function setup(props: Partial<ComponentProps<typeof PlFileAttach>> = {}) {
|
||||
const onAdd = vi.fn()
|
||||
const onRemove = vi.fn()
|
||||
const view = render(
|
||||
<PlFileAttach planner={planner} files={[]} onAdd={onAdd} onRemove={onRemove} {...props} />,
|
||||
)
|
||||
const input = view.container.querySelector('input[type="file"]') as HTMLInputElement
|
||||
return { ...view, onAdd, onRemove, input }
|
||||
}
|
||||
|
||||
describe('PlFileAttach', () => {
|
||||
it('FE-MOB-PLFILE-001: renders the files heading, the paste hint and the attach pill', () => {
|
||||
setup()
|
||||
expect(screen.getByText('files.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('files.pasteHint')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'files.attach' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLFILE-002: hideHint drops the paste subline but keeps the heading', () => {
|
||||
setup({ hideHint: true })
|
||||
expect(screen.getByText('files.title')).toBeInTheDocument()
|
||||
expect(screen.queryByText('files.pasteHint')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLFILE-003: the picker input is hidden and accepts multiple files', () => {
|
||||
const { input } = setup()
|
||||
expect(input).toHaveClass('hidden')
|
||||
expect(input).toHaveAttribute('multiple')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLFILE-004: the attach pill opens the hidden picker', () => {
|
||||
const { input } = setup()
|
||||
const click = vi.spyOn(input, 'click').mockImplementation(() => undefined)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'files.attach' }))
|
||||
expect(click).toHaveBeenCalledTimes(1)
|
||||
click.mockRestore()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLFILE-005: picking files reports them as an array and clears the input', () => {
|
||||
const { input, onAdd } = setup()
|
||||
const a = file('ticket.pdf')
|
||||
const b = file('map.png', 'image/png')
|
||||
fireEvent.change(input, { target: { files: [a, b] } })
|
||||
expect(onAdd).toHaveBeenCalledTimes(1)
|
||||
expect(onAdd.mock.calls[0][0]).toEqual([a, b])
|
||||
expect(input.value).toBe('')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLFILE-006: an empty pick still reports an empty array', () => {
|
||||
const { input, onAdd } = setup()
|
||||
fireEvent.change(input, { target: { files: [] } })
|
||||
expect(onAdd).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('FE-MOB-PLFILE-010: a cancelled pick without a file list reports an empty array', () => {
|
||||
const { input, onAdd } = setup()
|
||||
fireEvent.change(input, { target: { files: null } })
|
||||
expect(onAdd).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('FE-MOB-PLFILE-007: renders no attachment list while nothing is pending', () => {
|
||||
setup()
|
||||
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLFILE-008: lists every pending attachment by name', () => {
|
||||
setup({ files: [file('booking.pdf'), file('voucher.pdf')] })
|
||||
expect(screen.getByText('booking.pdf')).toBeInTheDocument()
|
||||
expect(screen.getByText('voucher.pdf')).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('button', { name: 'common.delete' })).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLFILE-009: the row X removes exactly its own index', () => {
|
||||
const { onRemove } = setup({ files: [file('a.pdf'), file('b.pdf'), file('c.pdf')] })
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'common.delete' })[1])
|
||||
expect(onRemove).toHaveBeenCalledWith(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,372 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { HttpResponse, delay, http } from 'msw'
|
||||
import PlPlaceSearch from '../../../../src/mobile/screens/trip/sheets/PlPlaceSearch'
|
||||
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { buildPlanner } from '../../../helpers/mobileTrip'
|
||||
import { server } from '../../../helpers/msw/server'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-PLSRCH-001 to FE-MOB-PLSRCH-018
|
||||
// planner.t echoes the key, so labels/toasts are asserted as their keys.
|
||||
|
||||
const LOUVRE = {
|
||||
name: 'Louvre Museum',
|
||||
address: 'Rue de Rivoli, Paris',
|
||||
lat: 48.8606,
|
||||
lng: 2.3376,
|
||||
google_place_id: 'ChIJ_louvre',
|
||||
google_ftid: '0x47e:0x1',
|
||||
osm_id: 'W7444,',
|
||||
website: 'https://louvre.fr',
|
||||
phone: '+33 1',
|
||||
}
|
||||
|
||||
const SUGGESTION = { placeId: 'sug-1', mainText: 'Louvre', secondaryText: 'Paris, France' }
|
||||
|
||||
/** Bodies of every autocomplete request the component fired. */
|
||||
let autocompleteBodies: Record<string, unknown>[] = []
|
||||
let searchBodies: Record<string, unknown>[] = []
|
||||
|
||||
function recordAutocomplete(suggestions: unknown[] = [SUGGESTION]) {
|
||||
return http.post('/api/maps/autocomplete', async ({ request }) => {
|
||||
autocompleteBodies.push(await request.json() as Record<string, unknown>)
|
||||
return HttpResponse.json({ suggestions, source: 'osm' })
|
||||
})
|
||||
}
|
||||
|
||||
function recordSearch(places: unknown[] = [LOUVRE]) {
|
||||
return http.post('/api/maps/search', async ({ request }) => {
|
||||
searchBodies.push(await request.json() as Record<string, unknown>)
|
||||
return HttpResponse.json({ places, source: 'osm' })
|
||||
})
|
||||
}
|
||||
|
||||
function setup(plannerOverrides: Partial<TripPlanner> = {}, locationBias?: Parameters<typeof PlPlaceSearch>[0]['locationBias']) {
|
||||
const onPick = vi.fn()
|
||||
const onResolvingChange = vi.fn()
|
||||
const planner = buildPlanner(plannerOverrides)
|
||||
const view = render(
|
||||
<PlPlaceSearch planner={planner} locationBias={locationBias} onPick={onPick} onResolvingChange={onResolvingChange} />,
|
||||
)
|
||||
const input = screen.getByPlaceholderText('places.mapsSearchPlaceholder')
|
||||
return { ...view, planner, onPick, onResolvingChange, input }
|
||||
}
|
||||
|
||||
describe('PlPlaceSearch', () => {
|
||||
beforeEach(() => {
|
||||
autocompleteBodies = []
|
||||
searchBodies = []
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSRCH-001: stays quiet below two characters', async () => {
|
||||
server.use(recordAutocomplete())
|
||||
const { input } = setup()
|
||||
fireEvent.change(input, { target: { value: 'L' } })
|
||||
await new Promise(r => setTimeout(r, 450))
|
||||
expect(autocompleteBodies).toHaveLength(0)
|
||||
expect(screen.queryByText('Louvre')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSRCH-002: debounces the autocomplete and lists both suggestion lines', async () => {
|
||||
server.use(recordAutocomplete())
|
||||
const { input } = setup()
|
||||
fireEvent.change(input, { target: { value: 'Lou' } })
|
||||
expect(autocompleteBodies).toHaveLength(0)
|
||||
expect(await screen.findByText('Louvre')).toBeInTheDocument()
|
||||
expect(screen.getByText('Paris, France')).toBeInTheDocument()
|
||||
expect(autocompleteBodies[0]).toMatchObject({ input: 'Lou', lang: 'en' })
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSRCH-003: forwards the trip-centre bias', async () => {
|
||||
server.use(recordAutocomplete())
|
||||
const bias = { low: { lat: 48.8, lng: 2.3 }, high: { lat: 48.9, lng: 2.4 } }
|
||||
const { input } = setup({}, bias)
|
||||
fireEvent.change(input, { target: { value: 'Lou' } })
|
||||
await waitFor(() => expect(autocompleteBodies).toHaveLength(1))
|
||||
expect(autocompleteBodies[0].locationBias).toEqual(bias)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSRCH-004: picking a suggestion applies its resolved details and clears the field', async () => {
|
||||
server.use(
|
||||
recordAutocomplete(),
|
||||
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: LOUVRE })),
|
||||
)
|
||||
const { input, onPick } = setup()
|
||||
fireEvent.change(input, { target: { value: 'Lou' } })
|
||||
const row = await screen.findByText('Louvre')
|
||||
// The row swallows pointerdown so the field's blur handler cannot close
|
||||
// the dropdown before the click lands.
|
||||
const pointerDown = fireEvent.pointerDown(row)
|
||||
expect(pointerDown).toBe(false)
|
||||
fireEvent.click(row)
|
||||
|
||||
// Optimistic name first, then the full record.
|
||||
expect(onPick).toHaveBeenNthCalledWith(1, { name: 'Louvre' })
|
||||
await waitFor(() => expect(onPick).toHaveBeenCalledTimes(2))
|
||||
expect(onPick).toHaveBeenLastCalledWith({
|
||||
name: 'Louvre Museum',
|
||||
address: 'Rue de Rivoli, Paris',
|
||||
lat: '48.8606',
|
||||
lng: '2.3376',
|
||||
google_place_id: 'ChIJ_louvre',
|
||||
google_ftid: '0x47e:0x1',
|
||||
osm_id: 'W7444,',
|
||||
website: 'https://louvre.fr',
|
||||
phone: '+33 1',
|
||||
})
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSRCH-005: falls back to the text search when the details hop fails', async () => {
|
||||
server.use(
|
||||
recordAutocomplete(),
|
||||
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ error: 'disabled' }, { status: 500 })),
|
||||
recordSearch(),
|
||||
)
|
||||
const { input, onPick } = setup()
|
||||
fireEvent.change(input, { target: { value: 'Lou' } })
|
||||
fireEvent.click(await screen.findByText('Louvre'))
|
||||
|
||||
await waitFor(() => expect(onPick).toHaveBeenCalledTimes(2))
|
||||
expect(searchBodies[0]).toEqual({ query: 'Louvre, Paris, France' })
|
||||
expect(onPick).toHaveBeenLastCalledWith(expect.objectContaining({ name: 'Louvre Museum', lat: '48.8606' }))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSRCH-006: also falls back when the details hop answers without coordinates', async () => {
|
||||
server.use(
|
||||
recordAutocomplete(),
|
||||
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: { name: 'Louvre' } })),
|
||||
recordSearch(),
|
||||
)
|
||||
const { input, onPick } = setup()
|
||||
fireEvent.change(input, { target: { value: 'Lou' } })
|
||||
fireEvent.click(await screen.findByText('Louvre'))
|
||||
|
||||
await waitFor(() => expect(searchBodies).toHaveLength(1))
|
||||
expect(onPick).toHaveBeenLastCalledWith(expect.objectContaining({ lat: '48.8606' }))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSRCH-007: restores the typed query and toasts when nothing resolves', async () => {
|
||||
server.use(
|
||||
recordAutocomplete(),
|
||||
http.get('/api/maps/details/:placeId', () => HttpResponse.json({}, { status: 500 })),
|
||||
recordSearch([]),
|
||||
)
|
||||
const { input, planner } = setup()
|
||||
fireEvent.change(input, { target: { value: 'Lou' } })
|
||||
fireEvent.click(await screen.findByText('Louvre'))
|
||||
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('places.mapsSearchError'))
|
||||
expect(input).toHaveValue('Lou')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSRCH-008: surfaces the server message when the fallback search rejects', async () => {
|
||||
server.use(
|
||||
recordAutocomplete(),
|
||||
http.get('/api/maps/details/:placeId', () => HttpResponse.json({}, { status: 500 })),
|
||||
http.post('/api/maps/search', () => HttpResponse.json({ error: 'Places API is disabled' }, { status: 502 })),
|
||||
)
|
||||
const { input, planner } = setup()
|
||||
fireEvent.change(input, { target: { value: 'Lou' } })
|
||||
fireEvent.click(await screen.findByText('Louvre'))
|
||||
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Places API is disabled'))
|
||||
expect(input).toHaveValue('Lou')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSRCH-009: a "lat, lng" query becomes coordinates without any lookup', async () => {
|
||||
server.use(recordAutocomplete(), recordSearch())
|
||||
const { input, onPick } = setup()
|
||||
fireEvent.change(input, { target: { value: '48.8566; 2.3522' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.search' }))
|
||||
|
||||
expect(onPick).toHaveBeenCalledWith({ lat: '48.8566', lng: '2.3522' })
|
||||
expect(input).toHaveValue('')
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
expect(autocompleteBodies).toHaveLength(0)
|
||||
expect(searchBodies).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSRCH-010: resolves a Google Maps URL and confirms it', async () => {
|
||||
server.use(
|
||||
recordAutocomplete(),
|
||||
http.post('/api/maps/resolve-url', () => HttpResponse.json({
|
||||
lat: 48.86, lng: 2.33, name: 'Louvre', address: 'Paris', google_ftid: '0x1:0x2',
|
||||
})),
|
||||
)
|
||||
const { input, onPick, planner } = setup()
|
||||
fireEvent.change(input, { target: { value: 'https://maps.app.goo.gl/abc' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.search' }))
|
||||
|
||||
await waitFor(() => expect(planner.toast.success).toHaveBeenCalledWith('places.urlResolved'))
|
||||
expect(onPick).toHaveBeenCalledWith({
|
||||
name: 'Louvre', address: 'Paris', lat: '48.86', lng: '2.33', google_ftid: '0x1:0x2',
|
||||
})
|
||||
expect(input).toHaveValue('')
|
||||
// URLs never hit the autocomplete debounce.
|
||||
expect(autocompleteBodies).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSRCH-011: falls through to the text search when the URL carries no coordinates', async () => {
|
||||
server.use(
|
||||
http.post('/api/maps/resolve-url', () => HttpResponse.json({ lat: null, lng: null })),
|
||||
recordSearch(),
|
||||
)
|
||||
const { input, planner } = setup()
|
||||
fireEvent.change(input, { target: { value: 'https://www.google.com/maps/place/Louvre' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.search' }))
|
||||
|
||||
await waitFor(() => expect(searchBodies).toHaveLength(1))
|
||||
expect(await screen.findByText('Louvre Museum')).toBeInTheDocument()
|
||||
expect(planner.toast.success).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSRCH-012: lists text results and applies the tapped one', async () => {
|
||||
server.use(recordSearch([LOUVRE, { name: 'Louvre Lens', address: 'Lens' }]))
|
||||
const { input, onPick } = setup()
|
||||
fireEvent.change(input, { target: { value: 'louvre museum' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(await screen.findByText('Louvre Museum')).toBeInTheDocument()
|
||||
expect(screen.getByText('Louvre Lens')).toBeInTheDocument()
|
||||
expect(screen.getByText('Rue de Rivoli, Paris')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Louvre Lens'))
|
||||
expect(onPick).toHaveBeenCalledWith(expect.objectContaining({ name: 'Louvre Lens', address: 'Lens', lat: undefined }))
|
||||
await waitFor(() => expect(screen.queryByText('Louvre Museum')).not.toBeInTheDocument())
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSRCH-013: an empty query does nothing', async () => {
|
||||
server.use(recordSearch())
|
||||
const { onPick } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.search' }))
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
expect(searchBodies).toHaveLength(0)
|
||||
expect(onPick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSRCH-014: reports the resolving state and blocks the button while searching', async () => {
|
||||
server.use(http.post('/api/maps/search', async () => {
|
||||
await delay(120)
|
||||
return HttpResponse.json({ places: [LOUVRE], source: 'osm' })
|
||||
}))
|
||||
const { input, onResolvingChange } = setup()
|
||||
fireEvent.change(input, { target: { value: 'louvre museum' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.search' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'common.search' })).toBeDisabled())
|
||||
expect(onResolvingChange).toHaveBeenCalledWith(true)
|
||||
await waitFor(() => expect(onResolvingChange).toHaveBeenLastCalledWith(false))
|
||||
expect(screen.getByRole('button', { name: 'common.search' })).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSRCH-015: toasts the fallback message when the search fails without a body', async () => {
|
||||
server.use(http.post('/api/maps/search', () => new HttpResponse(null, { status: 500 })))
|
||||
const { input, planner } = setup()
|
||||
fireEvent.change(input, { target: { value: 'louvre museum' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('places.mapsSearchError'))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSRCH-016: a failing autocomplete empties the dropdown', async () => {
|
||||
server.use(recordAutocomplete())
|
||||
const { input } = setup()
|
||||
fireEvent.change(input, { target: { value: 'Lou' } })
|
||||
expect(await screen.findByText('Louvre')).toBeInTheDocument()
|
||||
|
||||
server.use(http.post('/api/maps/autocomplete', () => HttpResponse.json({}, { status: 500 })))
|
||||
fireEvent.change(input, { target: { value: 'Louv' } })
|
||||
await waitFor(() => expect(screen.queryByText('Louvre')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSRCH-017: a superseded autocomplete is aborted and does not clear the newer list', async () => {
|
||||
server.use(http.post('/api/maps/autocomplete', async ({ request }) => {
|
||||
const body = await request.json() as { input: string }
|
||||
autocompleteBodies.push(body)
|
||||
if (body.input === 'Lou') {
|
||||
await delay(3000)
|
||||
return HttpResponse.json({ suggestions: [], source: 'osm' })
|
||||
}
|
||||
return HttpResponse.json({ suggestions: [SUGGESTION], source: 'osm' })
|
||||
}))
|
||||
const { input } = setup()
|
||||
fireEvent.change(input, { target: { value: 'Lou' } })
|
||||
await waitFor(() => expect(autocompleteBodies).toHaveLength(1))
|
||||
fireEvent.change(input, { target: { value: 'Louvre mus' } })
|
||||
|
||||
expect(await screen.findByText('Louvre')).toBeInTheDocument()
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
expect(screen.getByText('Louvre')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSRCH-018b: a suggestion without a second line searches on its main text alone', async () => {
|
||||
server.use(
|
||||
recordAutocomplete([{ placeId: 'sug-2', mainText: 'Louvre', secondaryText: '' }]),
|
||||
http.get('/api/maps/details/:placeId', () => HttpResponse.json({}, { status: 500 })),
|
||||
recordSearch(),
|
||||
)
|
||||
const { input } = setup()
|
||||
fireEvent.change(input, { target: { value: 'Lou' } })
|
||||
fireEvent.click(await screen.findByText('Louvre'))
|
||||
await waitFor(() => expect(searchBodies).toHaveLength(1))
|
||||
expect(searchBodies[0]).toEqual({ query: 'Louvre' })
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSRCH-018c: a response without a places array leaves the result list empty', async () => {
|
||||
server.use(http.post('/api/maps/search', () => HttpResponse.json({ source: 'osm' })))
|
||||
const { input, planner } = setup()
|
||||
fireEvent.change(input, { target: { value: 'louvre museum' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'common.search' })).not.toBeDisabled())
|
||||
expect(screen.queryByText('Louvre Museum')).not.toBeInTheDocument()
|
||||
expect(planner.toast.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSRCH-018d: a nameless result still renders and can be picked', async () => {
|
||||
server.use(recordSearch([{ lat: 48.86, lng: 2.33 }]))
|
||||
const { input, onPick } = setup()
|
||||
fireEvent.change(input, { target: { value: 'louvre museum' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
await waitFor(() => expect(searchBodies).toHaveLength(1))
|
||||
// The only unlabelled button is the result row; its two lines stay empty.
|
||||
const rows = (await screen.findAllByRole('button')).filter(b => !b.getAttribute('aria-label'))
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].textContent).toBe('')
|
||||
fireEvent.click(rows[0])
|
||||
expect(onPick).toHaveBeenCalledWith(expect.objectContaining({ name: undefined, lat: '48.86', lng: '2.33' }))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSRCH-018e: keys other than Enter do not trigger a search', async () => {
|
||||
server.use(recordSearch())
|
||||
const { input } = setup()
|
||||
fireEvent.change(input, { target: { value: 'louvre museum' } })
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
expect(searchBodies).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSRCH-018f: a URL that resolves to bare coordinates picks them without extras', async () => {
|
||||
server.use(http.post('/api/maps/resolve-url', () => HttpResponse.json({
|
||||
lat: 48.86, lng: 2.33, name: null, address: null, google_ftid: null,
|
||||
})))
|
||||
const { input, onPick, planner } = setup()
|
||||
fireEvent.change(input, { target: { value: 'https://goo.gl/maps/xyz' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.search' }))
|
||||
|
||||
await waitFor(() => expect(planner.toast.success).toHaveBeenCalledWith('places.urlResolved'))
|
||||
expect(onPick).toHaveBeenCalledWith({
|
||||
name: undefined, address: undefined, lat: '48.86', lng: '2.33', google_ftid: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSRCH-018: blurring the field dismisses the dropdown', async () => {
|
||||
server.use(recordAutocomplete())
|
||||
const { input } = setup()
|
||||
fireEvent.change(input, { target: { value: 'Lou' } })
|
||||
expect(await screen.findByText('Louvre')).toBeInTheDocument()
|
||||
fireEvent.blur(input)
|
||||
await waitFor(() => expect(screen.queryByText('Louvre')).not.toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import PlTimeFields from '../../../../src/mobile/screens/trip/sheets/PlTimeFields'
|
||||
import type { Assignment } from '../../../../src/types'
|
||||
import { buildPlanner } from '../../../helpers/mobileTrip'
|
||||
import { fireEvent, render, screen } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-PLTIME-001 to FE-MOB-PLTIME-012
|
||||
|
||||
const planner = buildPlanner()
|
||||
|
||||
function assignment(
|
||||
id: number,
|
||||
name: string,
|
||||
place_time: string | null,
|
||||
end_time: string | null = null,
|
||||
day_id = 1,
|
||||
): Assignment {
|
||||
return { id, day_id, place_id: id, order_index: 0, place: { id, name, place_time, end_time } } as unknown as Assignment
|
||||
}
|
||||
|
||||
const SELF = assignment(1, 'Museum', '10:00', '11:00')
|
||||
|
||||
interface Options {
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
dayAssignments?: Assignment[]
|
||||
hasTimeError?: boolean
|
||||
assignmentId?: number
|
||||
}
|
||||
|
||||
function setup({
|
||||
startTime = '10:00',
|
||||
endTime = '11:00',
|
||||
dayAssignments = [SELF],
|
||||
hasTimeError = false,
|
||||
assignmentId = 1,
|
||||
}: Options = {}) {
|
||||
const onChange = vi.fn()
|
||||
const view = render(
|
||||
<PlTimeFields
|
||||
planner={planner}
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
onChange={onChange}
|
||||
assignmentId={assignmentId}
|
||||
dayAssignments={dayAssignments}
|
||||
hasTimeError={hasTimeError}
|
||||
/>,
|
||||
)
|
||||
const inputs = Array.from(view.container.querySelectorAll('input[type="time"]')) as HTMLInputElement[]
|
||||
return { ...view, onChange, start: inputs[0], end: inputs[1] }
|
||||
}
|
||||
|
||||
describe('PlTimeFields', () => {
|
||||
it('FE-MOB-PLTIME-001: renders both labelled time inputs with their current values', () => {
|
||||
const { start, end } = setup()
|
||||
expect(screen.getByText('places.startTime')).toBeInTheDocument()
|
||||
expect(screen.getByText('places.endTime')).toBeInTheDocument()
|
||||
expect(start).toHaveValue('10:00')
|
||||
expect(end).toHaveValue('11:00')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTIME-002: editing the start reports the place_time field', () => {
|
||||
const { start, onChange } = setup()
|
||||
fireEvent.change(start, { target: { value: '09:15' } })
|
||||
expect(onChange).toHaveBeenCalledWith('place_time', '09:15')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTIME-003: editing the end reports the end_time field', () => {
|
||||
const { end, onChange } = setup()
|
||||
fireEvent.change(end, { target: { value: '12:45' } })
|
||||
expect(onChange).toHaveBeenCalledWith('end_time', '12:45')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTIME-004: shows the end-before-start warning only when the sheet flags it', () => {
|
||||
setup()
|
||||
expect(screen.queryByText('places.endTimeBeforeStart')).not.toBeInTheDocument()
|
||||
setup({ hasTimeError: true })
|
||||
expect(screen.getByText('places.endTimeBeforeStart')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTIME-005: stays quiet without a usable start time', () => {
|
||||
setup({ startTime: '', dayAssignments: [assignment(1, 'Museum', null), assignment(2, 'Park', '10:30')] })
|
||||
expect(screen.queryByText(/places.timeCollision/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTIME-006: stays quiet when the edited assignment is not in the day', () => {
|
||||
setup({ assignmentId: 99, dayAssignments: [SELF, assignment(2, 'Park', '10:30')] })
|
||||
expect(screen.queryByText(/places.timeCollision/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTIME-007: names the overlapping places of the same day', () => {
|
||||
setup({
|
||||
dayAssignments: [SELF, assignment(2, 'Park', '10:30', '12:00'), assignment(3, 'Cafe', '10:45')],
|
||||
})
|
||||
expect(screen.getByText(/places.timeCollision/)).toHaveTextContent('places.timeCollision Park, Cafe')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTIME-008: ignores places of other days', () => {
|
||||
setup({ dayAssignments: [SELF, assignment(2, 'Park', '10:30', '12:00', 2)] })
|
||||
expect(screen.queryByText(/places.timeCollision/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTIME-009: ignores untimed places', () => {
|
||||
setup({ dayAssignments: [SELF, assignment(2, 'Park', null)] })
|
||||
expect(screen.queryByText(/places.timeCollision/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTIME-010: back-to-back slots are not a collision', () => {
|
||||
setup({ dayAssignments: [SELF, assignment(2, 'Park', '11:00', '12:00')] })
|
||||
expect(screen.queryByText(/places.timeCollision/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTIME-011: an open-ended slot still collides with a place starting later', () => {
|
||||
setup({
|
||||
endTime: '',
|
||||
dayAssignments: [assignment(1, 'Museum', '10:00'), assignment(2, 'Park', '09:00', '10:30')],
|
||||
})
|
||||
expect(screen.getByText(/places.timeCollision/)).toHaveTextContent('Park')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTIME-012: a half-typed end time is discarded instead of widening the slot', () => {
|
||||
// With a complete 11:00 end the 10:30 place would overlap — '11:0' must not count.
|
||||
setup({ endTime: '11:0', dayAssignments: [SELF, assignment(2, 'Park', '10:30', '12:00')] })
|
||||
expect(screen.queryByText(/places.timeCollision/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,313 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
NOTE_COLORS,
|
||||
OTHER_BUBBLE_RADIUS,
|
||||
OWN_BUBBLE_RADIUS,
|
||||
QUICK_REACTIONS,
|
||||
buildCategoryColorMap,
|
||||
formatChatClockTime,
|
||||
formatChatDateSeparator,
|
||||
formatPollCountdown,
|
||||
getCategoryColor,
|
||||
hasUserVoted,
|
||||
isEmojiOnlyText,
|
||||
isPollActive,
|
||||
isPollExpired,
|
||||
isSameSender,
|
||||
noteCategoriesList,
|
||||
parseUtcDate,
|
||||
pollMaxVoteCount,
|
||||
shouldShowChatDateSeparator,
|
||||
sortNotes,
|
||||
splitPolls,
|
||||
totalPollVotes,
|
||||
type ChatMessage,
|
||||
type CollabNoteData,
|
||||
type CollabPollData,
|
||||
type PollVoter,
|
||||
} from '../../../../src/mobile/screens/trip/tabs/collabModel';
|
||||
|
||||
// FE-MOB-CLBM-001 to FE-MOB-CLBM-023
|
||||
|
||||
/** SQLite-style timestamp (UTC, no 'Z') for a given local wall-clock time, so
|
||||
* assertions on getHours() hold in any test-runner timezone. */
|
||||
function utcStampForLocal(y: number, monthIndex: number, d: number, h: number, min = 0): string {
|
||||
return new Date(y, monthIndex, d, h, min, 0, 0).toISOString().slice(0, -1);
|
||||
}
|
||||
|
||||
function msg(overrides: Partial<ChatMessage> = {}): ChatMessage {
|
||||
return {
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
user_id: 1,
|
||||
text: 'hi',
|
||||
reply_to: null,
|
||||
username: 'ada',
|
||||
avatar: null,
|
||||
avatar_url: null,
|
||||
created_at: '2026-07-15T10:00:00',
|
||||
reactions: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function note(overrides: Partial<CollabNoteData> = {}): CollabNoteData {
|
||||
return {
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
user_id: 1,
|
||||
title: 'Note',
|
||||
content: null,
|
||||
category: null,
|
||||
color: null,
|
||||
website: null,
|
||||
pinned: 0,
|
||||
username: 'ada',
|
||||
avatar: null,
|
||||
avatar_url: null,
|
||||
created_at: '2026-07-01T10:00:00.000Z',
|
||||
attachments: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function voter(user_id: number): PollVoter {
|
||||
return { id: user_id * 10, user_id, username: `u${user_id}`, avatar: null, avatar_url: null };
|
||||
}
|
||||
|
||||
function poll(overrides: Partial<CollabPollData> = {}): CollabPollData {
|
||||
return {
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
user_id: 1,
|
||||
question: 'Where to?',
|
||||
options: [],
|
||||
multiple_choice: false,
|
||||
is_closed: false,
|
||||
deadline: null,
|
||||
username: 'ada',
|
||||
avatar: null,
|
||||
avatar_url: null,
|
||||
created_at: '2026-07-01T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const t = (key: string, params?: Record<string, string | number>) =>
|
||||
params ? `${key}:${Object.entries(params).map(([k, v]) => `${k}=${v}`).join(',')}` : key;
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('collabModel — constants', () => {
|
||||
it('FE-MOB-CLBM-001: exposes the quick reaction set, bubble radii and note palette', () => {
|
||||
expect(QUICK_REACTIONS).toHaveLength(8);
|
||||
expect(QUICK_REACTIONS).toContain('❤️');
|
||||
expect(new Set(QUICK_REACTIONS).size).toBe(QUICK_REACTIONS.length);
|
||||
expect(OWN_BUBBLE_RADIUS).toBe('16px 16px 4px 16px');
|
||||
expect(OTHER_BUBBLE_RADIUS).toBe('4px 16px 16px 16px');
|
||||
expect(NOTE_COLORS).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collabModel — chat', () => {
|
||||
it('FE-MOB-CLBM-002: parseUtcDate reads a suffix-less SQLite timestamp as UTC', () => {
|
||||
expect(parseUtcDate('2026-07-15T10:30:00').getTime()).toBe(Date.UTC(2026, 6, 15, 10, 30, 0));
|
||||
expect(parseUtcDate('2026-07-15T10:30:00Z').getTime()).toBe(Date.UTC(2026, 6, 15, 10, 30, 0));
|
||||
});
|
||||
|
||||
it('FE-MOB-CLBM-003: isEmojiOnlyText accepts up to three emoji and nothing else', () => {
|
||||
expect(isEmojiOnlyText('👍')).toBe(true);
|
||||
expect(isEmojiOnlyText(' ❤️ ')).toBe(true);
|
||||
expect(isEmojiOnlyText('🔥🎉👏')).toBe(true);
|
||||
expect(isEmojiOnlyText('👨👩👧')).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-MOB-CLBM-004: isEmojiOnlyText rejects text and long emoji runs', () => {
|
||||
expect(isEmojiOnlyText('nice 👍')).toBe(false);
|
||||
expect(isEmojiOnlyText('🔥🎉👏😮')).toBe(false);
|
||||
expect(isEmojiOnlyText('')).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-MOB-CLBM-005: formatChatClockTime renders zero-padded 24h time', () => {
|
||||
expect(formatChatClockTime(utcStampForLocal(2026, 6, 15, 9, 5), false)).toBe('09:05');
|
||||
expect(formatChatClockTime(utcStampForLocal(2026, 6, 15, 23, 59), false)).toBe('23:59');
|
||||
});
|
||||
|
||||
it('FE-MOB-CLBM-006: formatChatClockTime maps midnight and noon correctly in 12h mode', () => {
|
||||
expect(formatChatClockTime(utcStampForLocal(2026, 6, 15, 0, 5), true)).toBe('12:05 AM');
|
||||
expect(formatChatClockTime(utcStampForLocal(2026, 6, 15, 12, 0), true)).toBe('12:00 PM');
|
||||
expect(formatChatClockTime(utcStampForLocal(2026, 6, 15, 15, 30), true)).toBe('3:30 PM');
|
||||
expect(formatChatClockTime(utcStampForLocal(2026, 6, 15, 7, 30), true)).toBe('7:30 AM');
|
||||
});
|
||||
|
||||
it('FE-MOB-CLBM-007: formatChatDateSeparator labels today and yesterday', () => {
|
||||
const now = new Date();
|
||||
expect(formatChatDateSeparator(now.toISOString(), t, 'en-US')).toBe('collab.chat.today');
|
||||
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
expect(formatChatDateSeparator(yesterday.toISOString(), t, 'en-US')).toBe('collab.chat.yesterday');
|
||||
});
|
||||
|
||||
it('FE-MOB-CLBM-008: formatChatDateSeparator falls back to a localized date', () => {
|
||||
expect(formatChatDateSeparator(utcStampForLocal(2020, 2, 5, 12), t, 'en-US')).toBe('Mar 5, 2020');
|
||||
});
|
||||
|
||||
it('FE-MOB-CLBM-009: shouldShowChatDateSeparator triggers on the first message and on day changes', () => {
|
||||
const a = msg({ created_at: utcStampForLocal(2026, 6, 15, 20) });
|
||||
const b = msg({ id: 2, created_at: utcStampForLocal(2026, 6, 15, 22) });
|
||||
const c = msg({ id: 3, created_at: utcStampForLocal(2026, 6, 16, 9) });
|
||||
|
||||
expect(shouldShowChatDateSeparator(a, undefined)).toBe(true);
|
||||
expect(shouldShowChatDateSeparator(b, a)).toBe(false);
|
||||
expect(shouldShowChatDateSeparator(c, b)).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-MOB-CLBM-010: isSameSender compares user ids loosely and needs both messages', () => {
|
||||
const a = msg({ user_id: 7 });
|
||||
const b = msg({ id: 2, user_id: 7 });
|
||||
const other = msg({ id: 3, user_id: 8 });
|
||||
|
||||
expect(isSameSender(a, b)).toBe(true);
|
||||
expect(isSameSender(a, other)).toBe(false);
|
||||
expect(isSameSender(a, undefined)).toBe(false);
|
||||
expect(isSameSender(undefined, b)).toBe(false);
|
||||
expect(isSameSender(a, { ...b, user_id: '7' } as unknown as ChatMessage)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collabModel — notes', () => {
|
||||
it('FE-MOB-CLBM-011: buildCategoryColorMap keeps the first colour seen per category', () => {
|
||||
const notes = [
|
||||
note({ id: 1, category: 'Ideas', color: '#ef4444' }),
|
||||
note({ id: 2, category: 'Ideas', color: '#10b981' }),
|
||||
note({ id: 3, category: 'Food', color: null }),
|
||||
note({ id: 4, category: null, color: '#3b82f6' }),
|
||||
];
|
||||
expect(buildCategoryColorMap(notes)).toEqual({ Ideas: '#ef4444' });
|
||||
});
|
||||
|
||||
it('FE-MOB-CLBM-012: getCategoryColor round-robins fresh categories onto the palette', () => {
|
||||
expect(getCategoryColor(null, {})).toBe(NOTE_COLORS[0]);
|
||||
expect(getCategoryColor(undefined, {})).toBe(NOTE_COLORS[0]);
|
||||
expect(getCategoryColor('Ideas', { Ideas: '#123456' })).toBe('#123456');
|
||||
expect(getCategoryColor('New', { A: 'x', B: 'y' })).toBe(NOTE_COLORS[2]);
|
||||
// wraps around once every palette slot is taken
|
||||
const full = Object.fromEntries(NOTE_COLORS.map((c, i) => [`c${i}`, c]));
|
||||
expect(getCategoryColor('New', full)).toBe(NOTE_COLORS[0]);
|
||||
});
|
||||
|
||||
it('FE-MOB-CLBM-013: noteCategoriesList returns distinct categories in first-appearance order', () => {
|
||||
const notes = [
|
||||
note({ id: 1, category: 'Ideas' }),
|
||||
note({ id: 2, category: null }),
|
||||
note({ id: 3, category: 'Food' }),
|
||||
note({ id: 4, category: 'Ideas' }),
|
||||
];
|
||||
expect(noteCategoriesList(notes)).toEqual(['Ideas', 'Food']);
|
||||
expect(noteCategoriesList([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-MOB-CLBM-014: sortNotes puts pinned notes first, then most recently touched', () => {
|
||||
const old = note({ id: 1, created_at: '2026-07-01T10:00:00.000Z' });
|
||||
const fresh = note({ id: 2, created_at: '2026-07-05T10:00:00.000Z' });
|
||||
const edited = note({ id: 3, created_at: '2026-06-01T10:00:00.000Z', updated_at: '2026-07-09T10:00:00.000Z' });
|
||||
const pinned = note({ id: 4, created_at: '2026-01-01T10:00:00.000Z', pinned: 1 });
|
||||
|
||||
expect(sortNotes([old, fresh, edited, pinned], null).map(n => n.id)).toEqual([4, 3, 2, 1]);
|
||||
// pinned wins no matter which side of the comparison it lands on
|
||||
expect(sortNotes([pinned, fresh], null).map(n => n.id)).toEqual([4, 2]);
|
||||
});
|
||||
|
||||
it('FE-MOB-CLBM-015: sortNotes filters by the active category and leaves the input untouched', () => {
|
||||
const input = [
|
||||
note({ id: 1, category: 'Food', created_at: '2026-07-01T10:00:00.000Z' }),
|
||||
note({ id: 2, category: 'Ideas', created_at: '2026-07-02T10:00:00.000Z' }),
|
||||
note({ id: 3, category: 'Food', created_at: '2026-07-03T10:00:00.000Z', pinned: true }),
|
||||
];
|
||||
const snapshot = input.map(n => n.id);
|
||||
|
||||
expect(sortNotes(input, 'Food').map(n => n.id)).toEqual([3, 1]);
|
||||
expect(sortNotes(input, 'Nope')).toEqual([]);
|
||||
expect(input.map(n => n.id)).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collabModel — polls', () => {
|
||||
const optA = { text: 'Rome', label: 'A', voters: [voter(1), voter(2)] };
|
||||
const optB = { text: 'Oslo', label: 'B', voters: [voter(3)] };
|
||||
|
||||
it('FE-MOB-CLBM-016: totalPollVotes and pollMaxVoteCount count across options', () => {
|
||||
const p = poll({ options: [optA, optB] });
|
||||
expect(totalPollVotes(p)).toBe(3);
|
||||
expect(pollMaxVoteCount(p)).toBe(2);
|
||||
expect(totalPollVotes(poll())).toBe(0);
|
||||
expect(pollMaxVoteCount(poll())).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-MOB-CLBM-017: vote counters tolerate missing option/voter arrays', () => {
|
||||
const broken = poll({
|
||||
options: [{ text: 'Rome', label: 'A' }] as unknown as CollabPollData['options'],
|
||||
});
|
||||
expect(totalPollVotes(broken)).toBe(0);
|
||||
expect(pollMaxVoteCount(broken)).toBe(0);
|
||||
expect(hasUserVoted(broken, 1)).toBe(false);
|
||||
|
||||
const noOptions = { ...poll(), options: undefined } as unknown as CollabPollData;
|
||||
expect(totalPollVotes(noOptions)).toBe(0);
|
||||
expect(pollMaxVoteCount(noOptions)).toBe(0);
|
||||
expect(hasUserVoted(noOptions, 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-MOB-CLBM-018: hasUserVoted matches ids across string/number storage', () => {
|
||||
const p = poll({ options: [optA, optB] });
|
||||
expect(hasUserVoted(p, 2)).toBe(true);
|
||||
expect(hasUserVoted(p, 9)).toBe(false);
|
||||
expect(hasUserVoted(p, '3' as unknown as number)).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-MOB-CLBM-019: isPollExpired only fires on a deadline in the past', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-07-15T12:00:00Z'));
|
||||
|
||||
expect(isPollExpired(null)).toBe(false);
|
||||
expect(isPollExpired('2026-07-15T11:59:00Z')).toBe(true);
|
||||
expect(isPollExpired('2026-07-15T12:00:00Z')).toBe(true);
|
||||
expect(isPollExpired('2026-07-15T12:01:00Z')).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-MOB-CLBM-020: isPollActive requires an open poll with an unexpired deadline', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-07-15T12:00:00Z'));
|
||||
|
||||
expect(isPollActive(poll())).toBe(true);
|
||||
expect(isPollActive(poll({ is_closed: true }))).toBe(false);
|
||||
expect(isPollActive(poll({ deadline: '2026-07-14T12:00:00Z' }))).toBe(false);
|
||||
expect(isPollActive(poll({ deadline: '2026-07-16T12:00:00Z' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-MOB-CLBM-021: splitPolls separates active from closed/expired', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-07-15T12:00:00Z'));
|
||||
|
||||
const open = poll({ id: 1 });
|
||||
const closed = poll({ id: 2, is_closed: true });
|
||||
const expired = poll({ id: 3, deadline: '2026-07-01T12:00:00Z' });
|
||||
|
||||
const { active, closed: done } = splitPolls([open, closed, expired]);
|
||||
expect(active.map(p => p.id)).toEqual([1]);
|
||||
expect(done.map(p => p.id)).toEqual([2, 3]);
|
||||
expect(splitPolls([])).toEqual({ active: [], closed: [] });
|
||||
});
|
||||
|
||||
it('FE-MOB-CLBM-022: formatPollCountdown picks the days/hours/minutes tier', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-07-15T12:00:00Z'));
|
||||
|
||||
expect(formatPollCountdown(null, t)).toBeNull();
|
||||
expect(formatPollCountdown('2026-07-15T11:00:00Z', t)).toBeNull();
|
||||
expect(formatPollCountdown('2026-07-17T16:00:00Z', t)).toBe('collab.polls.countdownDaysHours:d=2,h=4');
|
||||
expect(formatPollCountdown('2026-07-15T15:45:00Z', t)).toBe('collab.polls.countdownHoursMinutes:h=3,m=45');
|
||||
expect(formatPollCountdown('2026-07-15T12:12:00Z', t)).toBe('collab.polls.countdownMinutes:m=12');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,392 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
baseTotal,
|
||||
buildCostsCsv,
|
||||
categoryBreakdown,
|
||||
categoryFilterKeys,
|
||||
computeTotals,
|
||||
currencyOf,
|
||||
dayFilterKeys,
|
||||
filterBudgetItems,
|
||||
groupByDay,
|
||||
isUnfinished,
|
||||
memberShareOf,
|
||||
myPaidOf,
|
||||
myShareOf,
|
||||
tint,
|
||||
type CostsCtx,
|
||||
type CostsFilterState,
|
||||
type CostsSettlementFlow,
|
||||
} from '../../../../src/mobile/screens/trip/tabs/costsModel';
|
||||
import { buildBudgetItem } from '../../../helpers/factories';
|
||||
import type { BudgetItem } from '../../../../src/types';
|
||||
|
||||
// FE-MOB-CMOD-001 to FE-MOB-CMOD-027
|
||||
|
||||
const RATES: Record<string, number> = { EUR: 1, USD: 0.5, JPY: 0.01 };
|
||||
|
||||
const ctx: CostsCtx = {
|
||||
me: 1,
|
||||
// lower case on purpose — currencyOf has to normalise it
|
||||
tripCurrency: 'eur',
|
||||
convert: (amount, currency) => amount * (RATES[(currency || 'EUR').toUpperCase()] ?? 1),
|
||||
};
|
||||
|
||||
type Member = NonNullable<BudgetItem['members']>[number];
|
||||
type Payer = NonNullable<BudgetItem['payers']>[number];
|
||||
|
||||
function member(user_id: number, amount?: number | null): Member {
|
||||
return { user_id, paid: 0, username: `u${user_id}`, ...(amount === undefined ? {} : { amount }) };
|
||||
}
|
||||
|
||||
function payer(user_id: number, amount: number): Payer {
|
||||
return { user_id, amount, username: `u${user_id}` };
|
||||
}
|
||||
|
||||
function expense(overrides: Partial<BudgetItem> = {}): BudgetItem {
|
||||
return buildBudgetItem({ category: 'other', ...overrides });
|
||||
}
|
||||
|
||||
function filters(overrides: Partial<CostsFilterState> = {}): CostsFilterState {
|
||||
return { search: '', segment: 'all', categoryKey: '', dayKey: '', ...overrides };
|
||||
}
|
||||
|
||||
describe('costsModel — money maths', () => {
|
||||
it('FE-MOB-CMOD-001: uppercases the expense currency and falls back to the trip currency', () => {
|
||||
expect(currencyOf(expense({ currency: 'usd' }), ctx)).toBe('USD');
|
||||
expect(currencyOf(expense({ currency: null }), ctx)).toBe('EUR');
|
||||
expect(currencyOf(expense({ currency: '' }), ctx)).toBe('EUR');
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-002: converts the expense total into the base currency', () => {
|
||||
expect(baseTotal(expense({ total_price: 100, currency: 'USD' }), ctx)).toBe(50);
|
||||
expect(baseTotal(expense({ total_price: 100, currency: null }), ctx)).toBe(100);
|
||||
expect(baseTotal(expense({ total_price: 0 }), ctx)).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-003: myPaidOf only sums my own payer rows, converted', () => {
|
||||
const e = expense({ total_price: 100, currency: 'USD', payers: [payer(1, 60), payer(2, 40)] });
|
||||
expect(myPaidOf(e, ctx)).toBe(30);
|
||||
expect(myPaidOf(expense({ payers: [payer(2, 40)] }), ctx)).toBe(0);
|
||||
expect(myPaidOf(expense({ payers: undefined }), ctx)).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-004: memberShareOf prefers an explicit custom amount', () => {
|
||||
const e = expense({
|
||||
total_price: 100,
|
||||
currency: 'USD',
|
||||
members: [member(1, 70), member(2, 30)],
|
||||
});
|
||||
expect(memberShareOf(e, 1, ctx)).toBe(35);
|
||||
expect(memberShareOf(e, 2, ctx)).toBe(15);
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-005: an explicit zero share is honoured instead of falling back to the equal split', () => {
|
||||
const e = expense({ id: 3, total_price: 100, members: [member(1, 0), member(2, 100)] });
|
||||
expect(memberShareOf(e, 1, ctx)).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-006: memberShareOf falls back to the rotating equal split', () => {
|
||||
// id 3 % 3 members = rotation index 0, so user 1 carries the leftover cent
|
||||
const e = expense({ id: 3, total_price: 100, members: [member(1), member(2), member(3)] });
|
||||
expect(memberShareOf(e, 1, ctx)).toBe(33.34);
|
||||
expect(memberShareOf(e, 2, ctx)).toBe(33.33);
|
||||
expect(memberShareOf(e, 3, ctx)).toBe(33.33);
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-025: a zero-total expense splits into zero shares', () => {
|
||||
const e = expense({ id: 2, total_price: 0, members: [member(1), member(2)] });
|
||||
expect(memberShareOf(e, 1, ctx)).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-007: a non-participant has no share', () => {
|
||||
const e = expense({ total_price: 100, members: [member(2), member(3)] });
|
||||
expect(memberShareOf(e, 1, ctx)).toBe(0);
|
||||
expect(memberShareOf(expense({ members: undefined }), 1, ctx)).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-008: myShareOf resolves the share of ctx.me', () => {
|
||||
const e = expense({ id: 4, total_price: 80, currency: 'USD', members: [member(1), member(2)] });
|
||||
expect(myShareOf(e, ctx)).toBe(20);
|
||||
expect(myShareOf(e, ctx)).toBe(memberShareOf(e, 1, ctx));
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-009: isUnfinished flags totals nobody has actually paid', () => {
|
||||
expect(isUnfinished(expense({ total_price: 100, payers: [] }), ctx)).toBe(true);
|
||||
expect(isUnfinished(expense({ total_price: 100, payers: [payer(1, 0)] }), ctx)).toBe(true);
|
||||
expect(isUnfinished(expense({ total_price: 100, payers: undefined }), ctx)).toBe(true);
|
||||
expect(isUnfinished(expense({ total_price: 100, payers: [payer(1, 100)] }), ctx)).toBe(false);
|
||||
expect(isUnfinished(expense({ total_price: 0, payers: [] }), ctx)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('costsModel — computeTotals', () => {
|
||||
it('FE-MOB-CMOD-010: aggregates spend, my paid/share, settlement direction and outstanding', () => {
|
||||
const paid = expense({
|
||||
id: 10,
|
||||
total_price: 100,
|
||||
payers: [payer(1, 100)],
|
||||
members: [member(1), member(2)],
|
||||
});
|
||||
const unpaid = expense({
|
||||
id: 11,
|
||||
total_price: 50,
|
||||
currency: 'USD',
|
||||
payers: [],
|
||||
members: [member(2, 50)],
|
||||
});
|
||||
const flows: CostsSettlementFlow[] = [
|
||||
{ from: { user_id: 1, username: 'me' }, to: { user_id: 2, username: 'ada' }, amount: 20 },
|
||||
{ from: { user_id: 3, username: 'bo' }, to: { user_id: 1, username: 'me' }, amount: 5 },
|
||||
{ from: { user_id: 2, username: 'ada' }, to: { user_id: 3, username: 'bo' }, amount: 7 },
|
||||
];
|
||||
|
||||
expect(computeTotals([paid, unpaid], flows, ctx)).toEqual({
|
||||
totalSpend: 125,
|
||||
myPaid: 100,
|
||||
myShare: 50,
|
||||
owe: 20,
|
||||
owed: 5,
|
||||
outstanding: 25,
|
||||
outstandingCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-011: returns zeroes for an empty trip', () => {
|
||||
expect(computeTotals([], [], ctx)).toEqual({
|
||||
totalSpend: 0,
|
||||
myPaid: 0,
|
||||
myShare: 0,
|
||||
owe: 0,
|
||||
owed: 0,
|
||||
outstanding: 0,
|
||||
outstandingCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('costsModel — list filters and grouping', () => {
|
||||
// e1: I fronted it and am net owed. e2: someone else paid. e3: I paid exactly my own share.
|
||||
const e1 = expense({
|
||||
id: 20,
|
||||
name: 'Hotel Berlin',
|
||||
category: 'accommodation',
|
||||
expense_date: '2026-07-01',
|
||||
total_price: 100,
|
||||
payers: [payer(1, 100)],
|
||||
members: [member(1), member(2)],
|
||||
});
|
||||
const e2 = expense({
|
||||
id: 21,
|
||||
name: 'Sushi',
|
||||
category: 'food',
|
||||
expense_date: '2026-07-02',
|
||||
total_price: 60,
|
||||
payers: [payer(2, 60)],
|
||||
members: [member(1), member(2)],
|
||||
});
|
||||
const e3 = expense({
|
||||
id: 22,
|
||||
name: 'Museum ticket',
|
||||
category: 'activities',
|
||||
expense_date: null,
|
||||
total_price: 30,
|
||||
payers: [payer(1, 30)],
|
||||
members: [member(1)],
|
||||
});
|
||||
const all = [e1, e2, e3];
|
||||
|
||||
it('FE-MOB-CMOD-012: "all" keeps every expense and does not mutate the input', () => {
|
||||
const result = filterBudgetItems(all, filters(), ctx);
|
||||
expect(result).toEqual(all);
|
||||
expect(result).not.toBe(all);
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-013: "mine" keeps expenses I fronted money on', () => {
|
||||
expect(filterBudgetItems(all, filters({ segment: 'mine' }), ctx).map(e => e.id)).toEqual([20, 22]);
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-014: "owed" keeps expenses where I am net owed', () => {
|
||||
expect(filterBudgetItems(all, filters({ segment: 'owed' }), ctx).map(e => e.id)).toEqual([20]);
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-015: category, day and search narrow the list and combine', () => {
|
||||
expect(filterBudgetItems(all, filters({ categoryKey: 'food' }), ctx).map(e => e.id)).toEqual([21]);
|
||||
expect(filterBudgetItems(all, filters({ dayKey: '2026-07-02' }), ctx).map(e => e.id)).toEqual([21]);
|
||||
// search is trimmed and case-insensitive
|
||||
expect(filterBudgetItems(all, filters({ search: ' HOTEL ' }), ctx).map(e => e.id)).toEqual([20]);
|
||||
expect(filterBudgetItems(all, filters({ search: 'nothing' }), ctx)).toEqual([]);
|
||||
expect(
|
||||
filterBudgetItems(all, filters({ segment: 'mine', categoryKey: 'accommodation' }), ctx).map(e => e.id),
|
||||
).toEqual([20]);
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-016: groupByDay sorts newest first and sinks the no-date bucket', () => {
|
||||
const a = expense({ id: 30, expense_date: '2026-07-01' });
|
||||
const b = expense({ id: 31, expense_date: '2026-07-03' });
|
||||
const c = expense({ id: 32, expense_date: null });
|
||||
const d = expense({ id: 33, expense_date: '2026-07-01' });
|
||||
|
||||
expect(groupByDay([a, b, c, d])).toEqual([
|
||||
{ dateKey: '2026-07-03', items: [b] },
|
||||
{ dateKey: '2026-07-01', items: [a, d] },
|
||||
{ dateKey: '', items: [c] },
|
||||
]);
|
||||
expect(groupByDay([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-026: groupByDay sinks the no-date bucket even when it comes first', () => {
|
||||
const undated = expense({ id: 34, expense_date: null });
|
||||
const mid = expense({ id: 35, expense_date: '2026-07-02' });
|
||||
const late = expense({ id: 36, expense_date: '2026-07-04' });
|
||||
|
||||
expect(groupByDay([undated, mid, late]).map(g => g.dateKey)).toEqual(['2026-07-04', '2026-07-02', '']);
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-017: categoryFilterKeys lists only used categories, in canonical order', () => {
|
||||
const items = [
|
||||
expense({ category: 'food' }),
|
||||
expense({ category: 'accommodation' }),
|
||||
// legacy free-text value that catMeta folds into "flights"
|
||||
expense({ category: 'Flight' }),
|
||||
expense({ category: 'food' }),
|
||||
];
|
||||
expect(categoryFilterKeys(items)).toEqual(['accommodation', 'food', 'flights']);
|
||||
expect(categoryFilterKeys([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-018: dayFilterKeys returns distinct dates ascending, ignoring undated rows', () => {
|
||||
const items = [
|
||||
expense({ expense_date: '2026-07-03' }),
|
||||
expense({ expense_date: null }),
|
||||
expense({ expense_date: '2026-07-01' }),
|
||||
expense({ expense_date: '2026-07-01' }),
|
||||
];
|
||||
expect(dayFilterKeys(items)).toEqual(['2026-07-01', '2026-07-03']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('costsModel — breakdown and presentation', () => {
|
||||
it('FE-MOB-CMOD-019: categoryBreakdown sorts by amount and scales the bars against the largest', () => {
|
||||
const items = [
|
||||
expense({ category: 'accommodation', total_price: 300 }),
|
||||
expense({ category: 'food', total_price: 100 }),
|
||||
expense({ category: 'food', total_price: 200, currency: 'USD' }),
|
||||
expense({ category: 'transport', total_price: 0 }),
|
||||
];
|
||||
const bars = categoryBreakdown(items, ctx);
|
||||
|
||||
expect(bars.map(b => b.key)).toEqual(['accommodation', 'food']);
|
||||
expect(bars[0]).toEqual({ key: 'accommodation', amount: 300, widthPct: 100 });
|
||||
expect(bars[1].amount).toBe(200);
|
||||
expect(bars[1].widthPct).toBeCloseTo(66.667, 3);
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-020: categoryBreakdown drops categories without spend', () => {
|
||||
expect(categoryBreakdown([], ctx)).toEqual([]);
|
||||
expect(categoryBreakdown([expense({ category: 'food', total_price: 0 })], ctx)).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-021: tint turns a hex colour into rgba, with or without the leading hash', () => {
|
||||
expect(tint('#8B5CF6', 0.3)).toBe('rgba(139, 92, 246, 0.3)');
|
||||
expect(tint('000000', 1)).toBe('rgba(0, 0, 0, 1)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('costsModel — CSV export', () => {
|
||||
const t = (key: string) => key;
|
||||
|
||||
it('FE-MOB-CMOD-022: builds a semicolon CSV sorted by date with per-currency decimals', () => {
|
||||
const items = [
|
||||
expense({
|
||||
id: 40,
|
||||
name: 'Ryokan',
|
||||
category: 'accommodation',
|
||||
expense_date: '2026-07-16',
|
||||
total_price: 1500,
|
||||
currency: 'JPY',
|
||||
note: 'paid cash',
|
||||
}),
|
||||
expense({
|
||||
id: 41,
|
||||
name: 'Coffee',
|
||||
category: 'food',
|
||||
expense_date: '2026-07-15',
|
||||
total_price: 4.5,
|
||||
currency: null,
|
||||
note: null,
|
||||
}),
|
||||
];
|
||||
|
||||
const { filename, content } = buildCostsCsv(items, {
|
||||
base: 'EUR',
|
||||
ctx,
|
||||
locale: 'en-US',
|
||||
tripTitle: 'Trip: Tokyo/2026!',
|
||||
t,
|
||||
});
|
||||
|
||||
const rows = content.split('\r\n');
|
||||
expect(rows[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Note');
|
||||
expect(rows[1]).toBe('07/15/2026;Coffee;costs.cat.food;4.50;EUR;4.50;');
|
||||
// JPY is a zero-decimal currency, the base column stays at 2
|
||||
expect(rows[2]).toBe('07/16/2026;Ryokan;costs.cat.accommodation;1500;JPY;15.00;paid cash');
|
||||
expect(filename).toBe('costs-Trip Tokyo2026.csv');
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-023: escapes separators, quotes and newlines, and drops ticket JSON notes', () => {
|
||||
const items = [
|
||||
expense({
|
||||
id: 42,
|
||||
name: 'Dinner; drinks',
|
||||
category: 'food',
|
||||
expense_date: '',
|
||||
total_price: 20,
|
||||
note: 'say "hi"\nlater',
|
||||
}),
|
||||
expense({
|
||||
id: 43,
|
||||
name: 'Tickets',
|
||||
category: 'activities',
|
||||
expense_date: null,
|
||||
total_price: 10,
|
||||
note: 'TICKETJSON:{"items":[]}',
|
||||
}),
|
||||
];
|
||||
|
||||
const { content, filename } = buildCostsCsv(items, {
|
||||
base: 'EUR',
|
||||
ctx,
|
||||
locale: 'en-US',
|
||||
tripTitle: null,
|
||||
t,
|
||||
});
|
||||
|
||||
const rows = content.split('\r\n');
|
||||
expect(rows[1]).toContain('"Dinner; drinks"');
|
||||
expect(rows[1]).toContain('"say ""hi""\nlater"');
|
||||
// undated rows keep an empty date cell
|
||||
expect(rows[1].startsWith(';')).toBe(true);
|
||||
expect(rows[2].endsWith(';')).toBe(true);
|
||||
expect(filename).toBe('costs-trip.csv');
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-024: falls back to the raw ISO date when the locale is unusable', () => {
|
||||
const { content } = buildCostsCsv(
|
||||
[expense({ id: 44, name: 'Bus', category: 'transport', expense_date: '2026-07-16', total_price: 3 })],
|
||||
{ base: 'EUR', ctx, locale: '!!', t },
|
||||
);
|
||||
expect(content.split('\r\n')[1].startsWith('2026-07-16;Bus;')).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-MOB-CMOD-027: an expense without a recorded total exports as zero', () => {
|
||||
const bare = expense({ id: 45, name: 'Placeholder', category: 'other', expense_date: null });
|
||||
const { content } = buildCostsCsv([{ ...bare, total_price: undefined } as unknown as BudgetItem], {
|
||||
base: 'EUR',
|
||||
ctx,
|
||||
locale: 'en-US',
|
||||
t,
|
||||
});
|
||||
expect(content.split('\r\n')[1]).toBe(';Placeholder;costs.cat.other;0.00;EUR;0.00;');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { FileText, Image as ImageIcon, Ticket } from 'lucide-react';
|
||||
import {
|
||||
FILE_FILTERS,
|
||||
buildFileLinkLabels,
|
||||
formatFileDate,
|
||||
getFileTypeCategory,
|
||||
getFileTypeMeta,
|
||||
matchesFileFilter,
|
||||
sortFilesStarredFirst,
|
||||
type FileFilterId,
|
||||
} from '../../../../src/mobile/screens/trip/tabs/filesModel';
|
||||
import { buildPlace, buildReservation, buildTripFile } from '../../../helpers/factories';
|
||||
import type { TranslationFn, TripFile } from '../../../../src/types';
|
||||
|
||||
// FE-MOB-FILM-001 to FE-MOB-FILM-017
|
||||
|
||||
const t: TranslationFn = key => key;
|
||||
|
||||
describe('filesModel — type categorisation', () => {
|
||||
it('FE-MOB-FILM-001: detects wallet passes ahead of everything else', () => {
|
||||
expect(getFileTypeCategory({ mime_type: 'application/vnd.apple.pkpass', original_name: 'boardingpass' })).toBe('pass');
|
||||
// extension wins even when the browser sent a generic MIME
|
||||
expect(getFileTypeCategory({ mime_type: 'application/octet-stream', original_name: 'ticket.pkpass' })).toBe('pass');
|
||||
});
|
||||
|
||||
it('FE-MOB-FILM-002: detects PDFs and media', () => {
|
||||
expect(getFileTypeCategory({ mime_type: 'application/pdf', original_name: 'invoice.pdf' })).toBe('pdf');
|
||||
expect(getFileTypeCategory({ mime_type: 'image/jpeg', original_name: 'beach.jpg' })).toBe('image');
|
||||
// video folds into the image bucket (#823) since both open in the lightbox
|
||||
expect(getFileTypeCategory({ mime_type: 'video/mp4', original_name: 'clip.mp4' })).toBe('image');
|
||||
});
|
||||
|
||||
it('FE-MOB-FILM-003: detects spreadsheets by extension and by MIME', () => {
|
||||
expect(getFileTypeCategory({ mime_type: '', original_name: 'budget.xlsx' })).toBe('xls');
|
||||
expect(getFileTypeCategory({ mime_type: '', original_name: 'list.csv' })).toBe('xls');
|
||||
expect(getFileTypeCategory({ mime_type: '', original_name: 'sheet.ods' })).toBe('xls');
|
||||
expect(getFileTypeCategory({ mime_type: 'application/vnd.ms-excel', original_name: 'legacy' })).toBe('xls');
|
||||
expect(getFileTypeCategory({ mime_type: 'text/csv', original_name: 'export' })).toBe('xls');
|
||||
expect(getFileTypeCategory({ mime_type: 'application/vnd.oasis.opendocument.spreadsheet', original_name: 'x' })).toBe('xls');
|
||||
});
|
||||
|
||||
it('FE-MOB-FILM-004: falls back to "other" for plain attachments', () => {
|
||||
expect(getFileTypeCategory({ mime_type: 'application/zip', original_name: 'trip.zip' })).toBe('other');
|
||||
expect(getFileTypeCategory({ mime_type: null, original_name: null })).toBe('other');
|
||||
});
|
||||
|
||||
it('FE-MOB-FILM-005: getFileTypeMeta pairs the category with its tile icon, colour and caption', () => {
|
||||
expect(getFileTypeMeta({ mime_type: 'application/pdf', original_name: 'a.pdf' })).toEqual({
|
||||
category: 'pdf',
|
||||
icon: FileText,
|
||||
color: '#D6273B',
|
||||
label: 'PDF',
|
||||
});
|
||||
expect(getFileTypeMeta({ mime_type: 'image/png', original_name: 'a.png' }).icon).toBe(ImageIcon);
|
||||
expect(getFileTypeMeta({ mime_type: null, original_name: 'a.pkpass' }).icon).toBe(Ticket);
|
||||
expect(getFileTypeMeta({ mime_type: 'application/zip', original_name: 'a.zip' }).label).toBe('FILE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filesModel — filters', () => {
|
||||
const pdf = buildTripFile({ id: 1, mime_type: 'application/pdf', original_name: 'invoice.pdf' });
|
||||
const image = buildTripFile({ id: 2, mime_type: 'image/jpeg', original_name: 'beach.jpg' });
|
||||
const sheet = buildTripFile({ id: 3, mime_type: 'text/csv', original_name: 'budget.csv' });
|
||||
const pass = buildTripFile({ id: 4, mime_type: 'application/vnd.apple.pkpass', original_name: 'bp.pkpass' });
|
||||
const zip = buildTripFile({ id: 5, mime_type: 'application/zip', original_name: 'stuff.zip' });
|
||||
const starred = buildTripFile({ id: 6, mime_type: 'application/pdf', original_name: 's.pdf', starred: 1 });
|
||||
const collab = buildTripFile({ id: 7, mime_type: 'application/pdf', original_name: 'c.pdf', note_id: 42 });
|
||||
const all = [pdf, image, sheet, pass, zip, starred, collab];
|
||||
|
||||
const idsFor = (filter: FileFilterId) => all.filter(f => matchesFileFilter(f, filter)).map(f => f.id);
|
||||
|
||||
it('FE-MOB-FILM-006: the filter grid is defined in display order', () => {
|
||||
expect(FILE_FILTERS.map(f => f.id)).toEqual(['all', 'pdf', 'image', 'doc', 'starred', 'collab']);
|
||||
expect(FILE_FILTERS.every(f => f.labelKey.startsWith('files.filter'))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-MOB-FILM-007: "all" keeps everything, "pdf" and "image" keep their type', () => {
|
||||
expect(idsFor('all')).toEqual([1, 2, 3, 4, 5, 6, 7]);
|
||||
expect(idsFor('pdf')).toEqual([1, 6, 7]);
|
||||
expect(idsFor('image')).toEqual([2]);
|
||||
});
|
||||
|
||||
it('FE-MOB-FILM-008: "doc" is the catch-all for xls, passes and generic files', () => {
|
||||
expect(idsFor('doc')).toEqual([3, 4, 5]);
|
||||
});
|
||||
|
||||
it('FE-MOB-FILM-009: "starred" and "collab" read their flags', () => {
|
||||
expect(idsFor('starred')).toEqual([6]);
|
||||
expect(idsFor('collab')).toEqual([7]);
|
||||
expect(matchesFileFilter(buildTripFile({ starred: 0 }), 'starred')).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-MOB-FILM-010: the filter tiles fully partition the list', () => {
|
||||
const covered = new Set([...idsFor('pdf'), ...idsFor('image'), ...idsFor('doc')]);
|
||||
expect(covered.size).toBe(all.length);
|
||||
});
|
||||
|
||||
it('FE-MOB-FILM-011: an unknown filter id keeps every file', () => {
|
||||
expect(matchesFileFilter(pdf, 'bogus' as FileFilterId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filesModel — sorting and dates', () => {
|
||||
it('FE-MOB-FILM-012: sortFilesStarredFirst hoists starred files and keeps the rest stable', () => {
|
||||
const files = [
|
||||
buildTripFile({ id: 1 }),
|
||||
buildTripFile({ id: 2, starred: 1 }),
|
||||
buildTripFile({ id: 3 }),
|
||||
buildTripFile({ id: 4, starred: 1 }),
|
||||
];
|
||||
const sorted = sortFilesStarredFirst(files);
|
||||
|
||||
expect(sorted.map(f => f.id)).toEqual([2, 4, 1, 3]);
|
||||
expect(files.map(f => f.id)).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('FE-MOB-FILM-013: formatFileDate renders a compact day/month label', () => {
|
||||
expect(formatFileDate('2026-07-16T10:00:00.000Z', 'en-US')).toMatch(/Jul\s16|16\sJul/);
|
||||
expect(formatFileDate('2026-07-16T10:00:00.000Z', 'de-DE')).toMatch(/16\.\s?Juli/);
|
||||
});
|
||||
|
||||
it('FE-MOB-FILM-014: formatFileDate returns an empty string for missing or broken dates', () => {
|
||||
expect(formatFileDate(null, 'en-US')).toBe('');
|
||||
expect(formatFileDate(undefined, 'en-US')).toBe('');
|
||||
expect(formatFileDate('', 'en-US')).toBe('');
|
||||
expect(formatFileDate('not-a-date', 'en-US')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filesModel — link labels', () => {
|
||||
const places = [buildPlace({ id: 11, name: 'Louvre' }), buildPlace({ id: 12, name: 'Eiffel Tower' })];
|
||||
const reservations = [
|
||||
buildReservation({ id: 21, type: 'flight', title: 'LH123' }),
|
||||
buildReservation({ id: 22, type: 'hotel', title: 'Hotel Ritz' }),
|
||||
buildReservation({ id: 23, type: 'hotel', title: '' }),
|
||||
];
|
||||
const transportTypes = new Set(['flight', 'train']);
|
||||
|
||||
function file(overrides: Partial<TripFile>): TripFile {
|
||||
return buildTripFile({ id: 1, ...overrides });
|
||||
}
|
||||
|
||||
it('FE-MOB-FILM-015: merges the single and multi link columns and skips unknown ids', () => {
|
||||
const labels = buildFileLinkLabels(
|
||||
file({ place_id: 11, linked_place_ids: [11, 12, null, 99] }),
|
||||
places,
|
||||
reservations,
|
||||
transportTypes,
|
||||
t,
|
||||
);
|
||||
// 11 appears in both columns but is listed once; 99 does not exist any more
|
||||
expect(labels).toEqual(['files.sourcePlan · Louvre', 'files.sourcePlan · Eiffel Tower']);
|
||||
});
|
||||
|
||||
it('FE-MOB-FILM-016: splits reservations into transport and booking and appends the collab note', () => {
|
||||
const labels = buildFileLinkLabels(
|
||||
file({
|
||||
reservation_id: 21,
|
||||
linked_reservation_ids: [22, 23, null, 98],
|
||||
note_id: 5,
|
||||
}),
|
||||
places,
|
||||
reservations,
|
||||
transportTypes,
|
||||
t,
|
||||
);
|
||||
|
||||
expect(labels).toEqual([
|
||||
'files.sourceTransport · LH123',
|
||||
'files.sourceBooking · Hotel Ritz',
|
||||
// an untitled reservation repeats its own source label
|
||||
'files.sourceBooking · files.sourceBooking',
|
||||
'files.sourceCollab',
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-MOB-FILM-017: an unlinked file has no labels', () => {
|
||||
expect(buildFileLinkLabels(file({}), places, reservations, transportTypes, t)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
PRIORITY_COLOR,
|
||||
PRIORITY_LABEL,
|
||||
PRIORITY_LEVELS,
|
||||
filterTodoItems,
|
||||
formatWeight,
|
||||
groupPackingItems,
|
||||
isLastCustomItemInCategory,
|
||||
isPackingPlaceholder,
|
||||
isTodoOverdue,
|
||||
packingCategoryOrder,
|
||||
packingItemWeight,
|
||||
packingProgress,
|
||||
packingStatusFiltered,
|
||||
packingViewItems,
|
||||
sortTodoRows,
|
||||
todoCategories,
|
||||
todoCategoryOpenCount,
|
||||
todoCounts,
|
||||
} from '../../../../src/mobile/screens/trip/tabs/listsModel';
|
||||
import { STATUS_COLOR } from '../../../../src/mobile/screens/trip/tabs/tabModel';
|
||||
import { PACKING_PLACEHOLDER_NAME } from '../../../../src/components/Packing/packingListPanel.constants';
|
||||
import { buildPackingItem, buildTodoItem } from '../../../helpers/factories';
|
||||
|
||||
// FE-MOB-LSTM-001 to FE-MOB-LSTM-020
|
||||
|
||||
const TODAY = '2026-07-15';
|
||||
|
||||
describe('listsModel — packing', () => {
|
||||
it('FE-MOB-LSTM-001: packingViewItems splits the common pool from private items', () => {
|
||||
const common = buildPackingItem({ id: 1, is_private: 0 });
|
||||
const noFlag = buildPackingItem({ id: 2 });
|
||||
const personal = buildPackingItem({ id: 3, is_private: 1 });
|
||||
|
||||
expect(packingViewItems([common, noFlag, personal], 'common').map(i => i.id)).toEqual([1, 2]);
|
||||
expect(packingViewItems([common, noFlag, personal], 'personal').map(i => i.id)).toEqual([3]);
|
||||
});
|
||||
|
||||
it('FE-MOB-LSTM-002: packingCategoryOrder keeps first-appearance order and names the default bucket', () => {
|
||||
const items = [
|
||||
buildPackingItem({ id: 1, category: 'Clothes' }),
|
||||
buildPackingItem({ id: 2, category: null }),
|
||||
buildPackingItem({ id: 3, category: 'Tech' }),
|
||||
buildPackingItem({ id: 4, category: 'Clothes' }),
|
||||
];
|
||||
expect(packingCategoryOrder(items, 'General')).toEqual(['Clothes', 'General', 'Tech']);
|
||||
expect(packingCategoryOrder([], 'General')).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-MOB-LSTM-003: packingStatusFiltered honours all/open/done', () => {
|
||||
const open = buildPackingItem({ id: 1, checked: 0 });
|
||||
const done = buildPackingItem({ id: 2, checked: 1 });
|
||||
|
||||
expect(packingStatusFiltered([open, done], 'all').map(i => i.id)).toEqual([1, 2]);
|
||||
expect(packingStatusFiltered([open, done], 'open').map(i => i.id)).toEqual([1]);
|
||||
expect(packingStatusFiltered([open, done], 'done').map(i => i.id)).toEqual([2]);
|
||||
});
|
||||
|
||||
it('FE-MOB-LSTM-004: groupPackingItems buckets by category in first-encounter order', () => {
|
||||
const items = [
|
||||
buildPackingItem({ id: 1, category: 'Clothes', checked: 0 }),
|
||||
buildPackingItem({ id: 2, category: null, checked: 1 }),
|
||||
buildPackingItem({ id: 3, category: 'Clothes', checked: 1 }),
|
||||
buildPackingItem({ id: 4, category: 'Tech', checked: 0 }),
|
||||
];
|
||||
|
||||
expect(groupPackingItems(items, 'all', 'General').map(g => [g.category, g.items.map(i => i.id)])).toEqual([
|
||||
['Clothes', [1, 3]],
|
||||
['General', [2]],
|
||||
['Tech', [4]],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-MOB-LSTM-005: groupPackingItems drops categories emptied by the status filter', () => {
|
||||
const items = [
|
||||
buildPackingItem({ id: 1, category: 'Clothes', checked: 1 }),
|
||||
buildPackingItem({ id: 2, category: 'Tech', checked: 0 }),
|
||||
];
|
||||
|
||||
expect(groupPackingItems(items, 'open', 'General').map(g => g.category)).toEqual(['Tech']);
|
||||
expect(groupPackingItems(items, 'done', 'General').map(g => g.category)).toEqual(['Clothes']);
|
||||
expect(groupPackingItems([], 'all', 'General')).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-MOB-LSTM-006: packingProgress reports checked/total and a rounded percentage', () => {
|
||||
const items = [
|
||||
buildPackingItem({ id: 1, checked: 1 }),
|
||||
buildPackingItem({ id: 2, checked: 0 }),
|
||||
buildPackingItem({ id: 3, checked: 0 }),
|
||||
];
|
||||
expect(packingProgress(items)).toEqual({ checked: 1, total: 3, pct: 33 });
|
||||
expect(packingProgress([])).toEqual({ checked: 0, total: 0, pct: 0 });
|
||||
});
|
||||
|
||||
it('FE-MOB-LSTM-007: formatWeight switches to kilograms at 1000 g', () => {
|
||||
expect(formatWeight(232)).toBe('232 g');
|
||||
expect(formatWeight(232.4)).toBe('232 g');
|
||||
expect(formatWeight(999)).toBe('999 g');
|
||||
expect(formatWeight(1000)).toBe('1.0 kg');
|
||||
expect(formatWeight(1234)).toBe('1.2 kg');
|
||||
expect(formatWeight(0)).toBe('0 g');
|
||||
});
|
||||
|
||||
it('FE-MOB-LSTM-008: packingItemWeight multiplies unit weight by quantity', () => {
|
||||
expect(packingItemWeight({ weight_grams: 250, quantity: 3 })).toBe(750);
|
||||
// a missing quantity counts as one, a missing weight as zero
|
||||
expect(packingItemWeight({ weight_grams: 250, quantity: undefined })).toBe(250);
|
||||
expect(packingItemWeight({ weight_grams: null, quantity: 4 })).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-MOB-LSTM-009: isLastCustomItemInCategory guards the category placeholder reset', () => {
|
||||
const only = buildPackingItem({ id: 1, category: 'Tech', name: 'Charger' });
|
||||
const sibling = buildPackingItem({ id: 2, category: 'Tech', name: 'Cable' });
|
||||
const uncategorised = buildPackingItem({ id: 3, category: null, name: 'Socks' });
|
||||
const placeholder = buildPackingItem({ id: 4, category: 'Tech', name: PACKING_PLACEHOLDER_NAME });
|
||||
|
||||
expect(isLastCustomItemInCategory(only, [only])).toBe(true);
|
||||
expect(isLastCustomItemInCategory(only, [only, sibling])).toBe(false);
|
||||
expect(isLastCustomItemInCategory(uncategorised, [uncategorised])).toBe(false);
|
||||
expect(isLastCustomItemInCategory(placeholder, [placeholder])).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-MOB-LSTM-010: isPackingPlaceholder recognises the "..." row', () => {
|
||||
expect(isPackingPlaceholder({ name: PACKING_PLACEHOLDER_NAME })).toBe(true);
|
||||
expect(isPackingPlaceholder({ name: 'Charger' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listsModel — to-do', () => {
|
||||
it('FE-MOB-LSTM-011: isTodoOverdue needs an unchecked item with a past due date', () => {
|
||||
expect(isTodoOverdue(buildTodoItem({ due_date: '2026-07-14' }), TODAY)).toBe(true);
|
||||
expect(isTodoOverdue(buildTodoItem({ due_date: TODAY }), TODAY)).toBe(false);
|
||||
expect(isTodoOverdue(buildTodoItem({ due_date: '2026-07-16' }), TODAY)).toBe(false);
|
||||
expect(isTodoOverdue(buildTodoItem({ due_date: '2026-07-14', checked: 1 }), TODAY)).toBe(false);
|
||||
expect(isTodoOverdue(buildTodoItem({ due_date: null }), TODAY)).toBe(false);
|
||||
});
|
||||
|
||||
const open = buildTodoItem({ id: 1, name: 'Book train', category: 'Travel' });
|
||||
const mine = buildTodoItem({ id: 2, name: 'Pack', assigned_user_id: 7, category: 'Home' });
|
||||
const overdue = buildTodoItem({ id: 3, name: 'Visa', due_date: '2026-07-01', category: 'Travel' });
|
||||
const done = buildTodoItem({ id: 4, name: 'Passport', checked: 1, category: 'Travel' });
|
||||
const items = [open, mine, overdue, done];
|
||||
|
||||
it('FE-MOB-LSTM-012: filterTodoItems applies the four smart filters', () => {
|
||||
expect(filterTodoItems(items, 'all', 7, TODAY).map(i => i.id)).toEqual([1, 2, 3]);
|
||||
expect(filterTodoItems(items, 'done', 7, TODAY).map(i => i.id)).toEqual([4]);
|
||||
expect(filterTodoItems(items, 'my', 7, TODAY).map(i => i.id)).toEqual([2]);
|
||||
expect(filterTodoItems(items, 'overdue', 7, TODAY).map(i => i.id)).toEqual([3]);
|
||||
});
|
||||
|
||||
it('FE-MOB-LSTM-013: an unknown filter is treated as a category name', () => {
|
||||
expect(filterTodoItems(items, 'Travel', 7, TODAY).map(i => i.id)).toEqual([1, 3, 4]);
|
||||
expect(filterTodoItems(items, 'Nope', 7, TODAY)).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-MOB-LSTM-020: without a current user "my" collects the unassigned rows', () => {
|
||||
// Same comparison the desktop sidebar does (useTodoList.ts:74): a null user id
|
||||
// matches every unassigned item, unlike todoCounts which zeroes "my" instead.
|
||||
expect(filterTodoItems(items, 'my', null, TODAY).map(i => i.id)).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it('FE-MOB-LSTM-014: sortTodoRows floats overdue rows up and sinks done rows', () => {
|
||||
const sorted = sortTodoRows(items, false, TODAY);
|
||||
expect(sorted.map(i => i.id)).toEqual([3, 1, 2, 4]);
|
||||
// the input array stays untouched
|
||||
expect(items.map(i => i.id)).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('FE-MOB-LSTM-015: sortTodoRows only breaks ties by priority while the toggle is on', () => {
|
||||
const p3 = buildTodoItem({ id: 10, priority: 3 });
|
||||
const p1 = buildTodoItem({ id: 11, priority: 1 });
|
||||
const none = buildTodoItem({ id: 12, priority: 0 });
|
||||
const list = [p3, none, p1];
|
||||
|
||||
expect(sortTodoRows(list, false, TODAY).map(i => i.id)).toEqual([10, 12, 11]);
|
||||
expect(sortTodoRows(list, true, TODAY).map(i => i.id)).toEqual([11, 10, 12]);
|
||||
});
|
||||
|
||||
it('FE-MOB-LSTM-016: todoCategories returns distinct categories alphabetically', () => {
|
||||
expect(todoCategories(items)).toEqual(['Home', 'Travel']);
|
||||
expect(todoCategories([buildTodoItem({ category: null })])).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-MOB-LSTM-017: todoCategoryOpenCount counts only unchecked rows of that category', () => {
|
||||
expect(todoCategoryOpenCount(items, 'Travel')).toBe(2);
|
||||
expect(todoCategoryOpenCount(items, 'Home')).toBe(1);
|
||||
expect(todoCategoryOpenCount(items, 'Nope')).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-MOB-LSTM-018: todoCounts fills the badge row and zeroes "my" without a user', () => {
|
||||
expect(todoCounts(items, 7, TODAY)).toEqual({ total: 4, open: 3, done: 1, overdue: 1, my: 1 });
|
||||
expect(todoCounts(items, null, TODAY).my).toBe(0);
|
||||
expect(todoCounts([], 7, TODAY)).toEqual({ total: 0, open: 0, done: 0, overdue: 0, my: 0 });
|
||||
});
|
||||
|
||||
it('FE-MOB-LSTM-019: priority tokens reuse the shared status palette', () => {
|
||||
expect(PRIORITY_COLOR[1]).toBe(STATUS_COLOR.danger);
|
||||
expect(PRIORITY_COLOR[2]).toBe(STATUS_COLOR.pending);
|
||||
expect(PRIORITY_COLOR[3]).toBe(STATUS_COLOR.info);
|
||||
expect(PRIORITY_LABEL).toEqual({ 1: 'P1', 2: 'P2', 3: 'P3' });
|
||||
expect(PRIORITY_LEVELS).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,413 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Cloud, CloudLightning, CloudRain, CloudSnow, Sun, Wind } from 'lucide-react'
|
||||
import {
|
||||
breaksChronology, buildPlanRows, cityPillsForDay, findUpNext, getTransitMeta, hotelChipsForDay,
|
||||
hotelLegsForDay, itemHasTime, parseReservationMeta, transportSubtitle, weatherIconFor,
|
||||
type TransportEntry,
|
||||
} from '../../../../src/mobile/screens/trip/plan/planTimelineModel'
|
||||
import { getDisplayTimeForDay, type MergedItem } from '../../../../src/utils/dayMerge'
|
||||
import { buildAssignment, buildDayNote, buildPlace, buildReservation } from '../../../helpers/factories'
|
||||
import type {
|
||||
Accommodation, Assignment, Day, DayNote, Reservation, RouteSegment, TranslationFn,
|
||||
} from '../../../../src/types'
|
||||
|
||||
// FE-MOB-PTLM-001 to FE-MOB-PTLM-042
|
||||
|
||||
const DAYS = [
|
||||
{ id: 1, trip_id: 1, day_number: 1, date: '2026-05-01', title: null },
|
||||
{ id: 2, trip_id: 1, day_number: 2, date: '2026-05-02', title: 'Old Town' },
|
||||
{ id: 3, trip_id: 1, day_number: 3, date: '2026-05-03', title: null },
|
||||
] as unknown as Day[]
|
||||
|
||||
const DAY2 = DAYS[1]
|
||||
|
||||
function place(id: number, name: string, lat: number | null, lng: number | null, time: string | null = null) {
|
||||
return buildPlace({ id, name, lat, lng, place_time: time })
|
||||
}
|
||||
|
||||
function assignment(id: number, order: number, p: ReturnType<typeof place>): Assignment {
|
||||
return buildAssignment({ id, day_id: 2, order_index: order, place_id: p.id, place: p })
|
||||
}
|
||||
|
||||
function seg(from: [number, number], to: [number, number], overrides: Partial<RouteSegment> = {}): RouteSegment {
|
||||
return {
|
||||
mid: [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2],
|
||||
from,
|
||||
to,
|
||||
distance: 1200,
|
||||
duration: 600,
|
||||
walkingText: '15 min',
|
||||
drivingText: '3 min',
|
||||
distanceText: '1.2 km',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const placeItem = (a: Assignment): MergedItem => ({ type: 'place', sortKey: a.order_index, data: a })
|
||||
const noteItem = (n: DayNote): MergedItem => ({ type: 'note', sortKey: n.sort_order ?? 0, data: n })
|
||||
const transportItem = (r: TransportEntry): MergedItem => ({ type: 'transport', sortKey: 0, data: r })
|
||||
|
||||
function accommodation(overrides: Partial<Accommodation>): Accommodation {
|
||||
return {
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
place_id: null,
|
||||
start_day_id: 1,
|
||||
end_day_id: 3,
|
||||
check_in: null,
|
||||
check_out: null,
|
||||
place_name: 'Hotel Sacher',
|
||||
place_lat: 48.2,
|
||||
place_lng: 16.35,
|
||||
...overrides,
|
||||
} as unknown as Accommodation
|
||||
}
|
||||
|
||||
describe('planTimelineModel — metadata parsing', () => {
|
||||
it('FE-MOB-PTLM-001: returns an already-parsed metadata object untouched', () => {
|
||||
const meta = { airline: 'LH', legs: [] }
|
||||
expect(parseReservationMeta({ ...buildReservation(), metadata: meta } as unknown as Reservation)).toBe(meta)
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-002: parses a JSON metadata string', () => {
|
||||
expect(parseReservationMeta(buildReservation({ metadata: '{"airline":"LH"}' }))).toEqual({ airline: 'LH' })
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-003: heals a double-encoded metadata string', () => {
|
||||
const doubled = JSON.stringify(JSON.stringify({ airline: 'LH', flight_number: 'LH123' }))
|
||||
expect(parseReservationMeta(buildReservation({ metadata: doubled }))).toEqual({
|
||||
airline: 'LH', flight_number: 'LH123',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-004: falls back to an empty object for broken, empty or scalar metadata', () => {
|
||||
expect(parseReservationMeta(buildReservation({ metadata: '{not json' }))).toEqual({})
|
||||
expect(parseReservationMeta(buildReservation({ metadata: '' }))).toEqual({})
|
||||
expect(parseReservationMeta(buildReservation({ metadata: null }))).toEqual({})
|
||||
expect(parseReservationMeta(buildReservation({ metadata: '5' }))).toEqual({})
|
||||
// Double-encoded, with an inner payload that is broken or empty.
|
||||
expect(parseReservationMeta(buildReservation({ metadata: JSON.stringify('{not json') }))).toEqual({})
|
||||
expect(parseReservationMeta(buildReservation({ metadata: '""' }))).toEqual({})
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-005: reads transit metadata only from transit reservations with legs', () => {
|
||||
const legs = [{ mode: 'subway', line: 'U2' }]
|
||||
const transit = buildReservation({
|
||||
type: 'transit', metadata: JSON.stringify({ transit: { legs, transfers: 1, duration: 900 } }),
|
||||
})
|
||||
expect(getTransitMeta(transit)).toEqual({ legs, transfers: 1, duration: 900 })
|
||||
expect(getTransitMeta(buildReservation({ type: 'flight', metadata: JSON.stringify({ transit: { legs } }) }))).toBeNull()
|
||||
expect(getTransitMeta(buildReservation({ type: 'transit', metadata: '{}' }))).toBeNull()
|
||||
expect(getTransitMeta(buildReservation({ type: 'transit', metadata: '{"transit":{"legs":"nope"}}' }))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('planTimelineModel — transportSubtitle', () => {
|
||||
it('FE-MOB-PTLM-006: renders a synthetic leg as from → to', () => {
|
||||
const res = { ...buildReservation({ type: 'flight' }), __leg: { index: 0, total: 2, from: 'FRA', to: 'IST' } } as TransportEntry
|
||||
expect(transportSubtitle(res)).toBe('FRA → IST')
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-007: renders a leg without endpoints as an empty subtitle', () => {
|
||||
const res = { ...buildReservation({ type: 'flight' }), __leg: { index: 1, total: 2, from: null, to: null } } as TransportEntry
|
||||
expect(transportSubtitle(res)).toBe('')
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-008: joins airline, flight number and airports for a flight', () => {
|
||||
const res = buildReservation({
|
||||
type: 'flight',
|
||||
metadata: JSON.stringify({ airline: 'LH', flight_number: 'LH123', departure_airport: 'FRA', arrival_airport: 'HND' }),
|
||||
})
|
||||
expect(transportSubtitle(res)).toBe('LH · LH123 · FRA → HND')
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-009: omits the airport pair when neither airport is known', () => {
|
||||
const res = buildReservation({ type: 'flight', metadata: JSON.stringify({ airline: 'LH', flight_number: 'LH123' }) })
|
||||
expect(transportSubtitle(res)).toBe('LH · LH123')
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-010: labels platform and seat for a train', () => {
|
||||
const full = buildReservation({
|
||||
type: 'train', metadata: JSON.stringify({ train_number: 'ICE 599', platform: '7', seat: '32A' }),
|
||||
})
|
||||
expect(transportSubtitle(full)).toBe('ICE 599 · Gl. 7 · Sitz 32A')
|
||||
expect(transportSubtitle(buildReservation({ type: 'train', metadata: '{"train_number":"ICE 599"}' }))).toBe('ICE 599')
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-011: falls back to the location for any other type', () => {
|
||||
expect(transportSubtitle(buildReservation({ type: 'bus', location: 'Central Station' }))).toBe('Central Station')
|
||||
expect(transportSubtitle(buildReservation({ type: 'bus', location: null }))).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('planTimelineModel — buildPlanRows', () => {
|
||||
const museum = assignment(11, 0, place(101, 'Museum', 48.1, 16.1))
|
||||
const park = assignment(12, 1, place(102, 'Park', 48.2, 16.2))
|
||||
|
||||
it('FE-MOB-PTLM-012: maps places, notes and transports to keyed rows', () => {
|
||||
const note = buildDayNote({ id: 41, day_id: 2, text: 'Buy tickets' })
|
||||
const bus = buildReservation({ id: 51, type: 'bus', day_id: 2, title: 'Bus 13A' })
|
||||
const rows = buildPlanRows({
|
||||
merged: [placeItem(museum), noteItem(note), transportItem(bus)],
|
||||
reservations: [bus],
|
||||
routeSegments: [],
|
||||
dayId: 2,
|
||||
})
|
||||
expect(rows.map(r => [r.kind, r.key])).toEqual([
|
||||
['place', 'pl-11'],
|
||||
['note', 'note-41'],
|
||||
['transport', 'tr-51'],
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-013: links a place row to the reservation booked on it', () => {
|
||||
const dinner = buildReservation({ id: 52, type: 'restaurant', day_id: 2, assignment_id: 11 })
|
||||
const rows = buildPlanRows({
|
||||
merged: [placeItem(museum), placeItem(park)], reservations: [dinner], routeSegments: [], dayId: 2,
|
||||
})
|
||||
const [first, second] = rows.filter(r => r.kind === 'place')
|
||||
expect(first.kind === 'place' && first.linkedRes).toBe(dinner)
|
||||
expect(second.kind === 'place' && second.linkedRes).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-014: keys a synthetic leg row by its leg index', () => {
|
||||
const legRes = { ...buildReservation({ id: 60, type: 'flight', day_id: 2 }), __leg: { index: 1, total: 2, from: 'IST', to: 'NRT' } } as TransportEntry
|
||||
const rows = buildPlanRows({ merged: [transportItem(legRes)], reservations: [], routeSegments: [], dayId: 2 })
|
||||
expect(rows[0].key).toBe('tr-60-leg1')
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-015: recognises a transit booking as its own row kind', () => {
|
||||
const transit = buildReservation({
|
||||
id: 61, type: 'transit', day_id: 2,
|
||||
metadata: JSON.stringify({ transit: { legs: [{ mode: 'subway', line: 'U2' }], transfers: 0 } }),
|
||||
})
|
||||
const rows = buildPlanRows({ merged: [transportItem(transit)], reservations: [], routeSegments: [], dayId: 2 })
|
||||
expect(rows[0].kind).toBe('transit')
|
||||
expect(rows[0].kind === 'transit' && rows[0].transit.legs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-016: hides a car rental on the days between pickup and drop-off', () => {
|
||||
const car = buildReservation({ id: 62, type: 'car', day_id: 1, end_day_id: 3 })
|
||||
expect(buildPlanRows({ merged: [transportItem(car)], reservations: [], routeSegments: [], dayId: 2 })).toEqual([])
|
||||
expect(buildPlanRows({ merged: [transportItem(car)], reservations: [], routeSegments: [], dayId: 1 })).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-017: slots a connector between two located places and tags its origin', () => {
|
||||
const leg = seg([48.1, 16.1], [48.2, 16.2])
|
||||
const rows = buildPlanRows({
|
||||
merged: [placeItem(museum), placeItem(park)], reservations: [], routeSegments: [leg], dayId: 2,
|
||||
})
|
||||
expect(rows.map(r => r.kind)).toEqual(['place', 'conn', 'place'])
|
||||
const conn = rows[1]
|
||||
expect(conn.kind === 'conn' && conn.seg).toBe(leg)
|
||||
expect(conn.kind === 'conn' && conn.assignmentId).toBe(11)
|
||||
expect(conn.key).toBe('conn-pl-11')
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-018: connects across an intervening note', () => {
|
||||
const note = buildDayNote({ id: 41, day_id: 2 })
|
||||
const rows = buildPlanRows({
|
||||
merged: [placeItem(museum), noteItem(note), placeItem(park)],
|
||||
reservations: [],
|
||||
routeSegments: [seg([48.1, 16.1], [48.2, 16.2])],
|
||||
dayId: 2,
|
||||
})
|
||||
expect(rows.map(r => r.kind)).toEqual(['place', 'conn', 'note', 'place'])
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-019: draws no connector when a transport is the hop between the places', () => {
|
||||
const bus = buildReservation({ id: 51, type: 'bus', day_id: 2 })
|
||||
const rows = buildPlanRows({
|
||||
merged: [placeItem(museum), transportItem(bus), placeItem(park)],
|
||||
reservations: [],
|
||||
routeSegments: [seg([48.1, 16.1], [48.2, 16.2])],
|
||||
dayId: 2,
|
||||
})
|
||||
expect(rows.map(r => r.kind)).toEqual(['place', 'transport', 'place'])
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-020: skips places without coordinates and unmatched segments', () => {
|
||||
const nowhere = assignment(13, 2, place(103, 'Idea', null, null))
|
||||
const rows = buildPlanRows({
|
||||
merged: [placeItem(nowhere), placeItem(museum)],
|
||||
reservations: [],
|
||||
routeSegments: [seg([49.9, 17.9], [48.2, 16.2])],
|
||||
dayId: 2,
|
||||
})
|
||||
expect(rows.map(r => r.kind)).toEqual(['place', 'place'])
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-021: consumes each segment only once', () => {
|
||||
const again = assignment(14, 2, place(104, 'Museum again', 48.1, 16.1))
|
||||
const parkAgain = assignment(15, 3, place(105, 'Park again', 48.2, 16.2))
|
||||
const rows = buildPlanRows({
|
||||
merged: [placeItem(museum), placeItem(park), placeItem(again), placeItem(parkAgain)],
|
||||
reservations: [],
|
||||
routeSegments: [seg([48.1, 16.1], [48.2, 16.2])],
|
||||
dayId: 2,
|
||||
})
|
||||
// Only the first Museum → Park hop finds the single matching leg.
|
||||
expect(rows.filter(r => r.kind === 'conn')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('planTimelineModel — hotel chips and legs', () => {
|
||||
it('FE-MOB-PTLM-022: orders check-out before check-in before an ongoing stay', () => {
|
||||
const chips = hotelChipsForDay(DAY2, DAYS, [
|
||||
accommodation({ id: 1, start_day_id: 1, end_day_id: 3, place_name: 'Long Stay' }),
|
||||
accommodation({ id: 2, start_day_id: 2, end_day_id: 3, place_name: 'New Hotel', check_in: '15:00' }),
|
||||
accommodation({ id: 3, start_day_id: 1, end_day_id: 2, place_name: 'Old Hotel', check_out: '11:00' }),
|
||||
])
|
||||
expect(chips).toEqual([
|
||||
{ key: 'out-3', variant: 'checkout', name: 'Old Hotel', time: '11:00' },
|
||||
{ key: 'in-2', variant: 'checkin', name: 'New Hotel', time: '15:00' },
|
||||
{ key: 'stay-1', variant: 'stay', name: 'Long Stay', time: null },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-023: falls back to the reservation title and drops unnamed stays', () => {
|
||||
const chips = hotelChipsForDay(DAY2, DAYS, [
|
||||
accommodation({ id: 4, place_name: null, reservation_title: 'Airbnb Wieden' }),
|
||||
accommodation({ id: 5, place_name: null, reservation_title: null }),
|
||||
])
|
||||
expect(chips).toEqual([{ key: 'stay-4', variant: 'stay', name: 'Airbnb Wieden', time: null }])
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-042: leaves the chip time empty when the stay has no check-in or check-out', () => {
|
||||
const chips = hotelChipsForDay(DAY2, DAYS, [
|
||||
accommodation({ id: 9, start_day_id: 1, end_day_id: 2, place_name: 'Old Hotel' }),
|
||||
accommodation({ id: 10, start_day_id: 2, end_day_id: 3, place_name: 'New Hotel' }),
|
||||
])
|
||||
expect(chips.map(c => [c.variant, c.time])).toEqual([['checkout', null], ['checkin', null]])
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-024: ignores accommodations outside the day range', () => {
|
||||
expect(hotelChipsForDay(DAY2, DAYS, [accommodation({ id: 6, start_day_id: 1, end_day_id: 1 })])).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-025: picks the hotel bookend legs out of the calculated segments', () => {
|
||||
const hotel = accommodation({ id: 7, start_day_id: 1, end_day_id: 3, place_lat: 48.0, place_lng: 16.0 })
|
||||
const out = seg([48.0, 16.0], [48.1, 16.1])
|
||||
const back = seg([48.2, 16.2], [48.0, 16.0])
|
||||
const legs = hotelLegsForDay(DAY2, DAYS, [hotel], [out, back, seg([48.1, 16.1], [48.2, 16.2])])
|
||||
expect(legs.top).toEqual({ seg: out, name: 'Hotel Sacher' })
|
||||
expect(legs.bottom).toEqual({ seg: back, name: 'Hotel Sacher' })
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-026: returns no legs when no segment touches the hotel', () => {
|
||||
const hotel = accommodation({ id: 8, start_day_id: 1, end_day_id: 3, place_lat: 48.0, place_lng: 16.0 })
|
||||
expect(hotelLegsForDay(DAY2, DAYS, [hotel], [seg([48.1, 16.1], [48.2, 16.2])])).toEqual({ top: null, bottom: null })
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-027: returns no legs without an accommodation on the day', () => {
|
||||
expect(hotelLegsForDay(DAY2, DAYS, [], [seg([48.1, 16.1], [48.2, 16.2])])).toEqual({ top: null, bottom: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('planTimelineModel — cityPillsForDay', () => {
|
||||
const t = ((key: string, params?: Record<string, string | number>) =>
|
||||
params ? `${key}:${Object.values(params).join(',')}` : key) as unknown as TranslationFn
|
||||
|
||||
it('FE-MOB-PTLM-028: splits a transfer title into one pill per city', () => {
|
||||
expect(cityPillsForDay({ ...DAY2, title: 'Tokyo → Kyoto ' } as Day, t)).toEqual(['Tokyo', 'Kyoto'])
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-029: keeps a plain title as a single pill', () => {
|
||||
expect(cityPillsForDay(DAY2, t)).toEqual(['Old Town'])
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-030: falls back to the day number for blank or arrow-only titles', () => {
|
||||
expect(cityPillsForDay({ ...DAY2, title: ' ' } as Day, t)).toEqual(['planner.dayN:2'])
|
||||
expect(cityPillsForDay({ ...DAY2, title: ' → ' } as Day, t)).toEqual(['planner.dayN:2'])
|
||||
expect(cityPillsForDay(undefined, t)).toEqual(['planner.dayN:0'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('planTimelineModel — findUpNext', () => {
|
||||
const early = assignment(11, 0, place(101, 'Breakfast', 48.1, 16.1, '09:00'))
|
||||
const noon = assignment(12, 1, place(102, 'Museum', 48.2, 16.2, '11:00'))
|
||||
const late = assignment(13, 2, place(103, 'Dinner', 48.3, 16.3, '19:00'))
|
||||
|
||||
it('FE-MOB-PTLM-031: returns null for a day without stops', () => {
|
||||
expect(findUpNext(DAY2, [], new Date(2026, 4, 2, 10, 0))).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-032: counts down to the next stop still ahead today', () => {
|
||||
const up = findUpNext(DAY2, [late, early, noon], new Date(2026, 4, 2, 10, 15))
|
||||
expect(up?.assignment.id).toBe(12)
|
||||
expect(up?.minutesUntil).toBe(45)
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-033: falls back to the first timed stop once the day is over', () => {
|
||||
const up = findUpNext(DAY2, [early, noon, late], new Date(2026, 4, 2, 23, 30))
|
||||
expect(up?.assignment.id).toBe(11)
|
||||
expect(up?.minutesUntil).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-034: never counts down on a day that is not today', () => {
|
||||
const up = findUpNext(DAY2, [late, noon, early], new Date(2026, 4, 1, 8, 0))
|
||||
expect(up?.assignment.id).toBe(11)
|
||||
expect(up?.minutesUntil).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-035: uses the manual order when no stop carries a time', () => {
|
||||
const a = assignment(21, 1, place(201, 'Park', 48.1, 16.1))
|
||||
const b = assignment(22, 0, place(202, 'Cafe', 48.2, 16.2))
|
||||
const up = findUpNext(DAY2, [a, b], new Date(2026, 4, 2, 10, 0))
|
||||
expect(up?.assignment.id).toBe(22)
|
||||
expect(up?.minutesUntil).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-036: treats a day without a date as not today', () => {
|
||||
const up = findUpNext({ ...DAY2, date: null } as unknown as Day, [noon, early], new Date(2026, 4, 2, 10, 0))
|
||||
expect(up?.assignment.id).toBe(11)
|
||||
expect(findUpNext(undefined, [noon, early], new Date(2026, 4, 2, 10, 0))?.assignment.id).toBe(11)
|
||||
})
|
||||
})
|
||||
|
||||
describe('planTimelineModel — chronology helpers', () => {
|
||||
it('FE-MOB-PTLM-037: detects which merged items carry a time', () => {
|
||||
expect(itemHasTime(placeItem(assignment(11, 0, place(101, 'Museum', 48.1, 16.1, '09:00'))), 2)).toBe(true)
|
||||
expect(itemHasTime(placeItem(assignment(12, 1, place(102, 'Park', 48.2, 16.2))), 2)).toBe(false)
|
||||
expect(itemHasTime(noteItem(buildDayNote({ id: 41, time: '10:30' })), 2)).toBe(true)
|
||||
expect(itemHasTime(noteItem(buildDayNote({ id: 42, time: null })), 2)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-038: reads a transport time through the per-day display time', () => {
|
||||
const bus = buildReservation({ id: 51, type: 'bus', day_id: 2, reservation_time: '2026-05-02T08:00' })
|
||||
expect(itemHasTime(transportItem(bus), 2)).toBe(true)
|
||||
const car = buildReservation({ id: 52, type: 'car', day_id: 1, end_day_id: 3, reservation_time: '2026-05-01T08:00' })
|
||||
expect(itemHasTime(transportItem(car), 2)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-039: reports an order that puts a later time before an earlier one', () => {
|
||||
const nine = placeItem(assignment(11, 0, place(101, 'Breakfast', 48.1, 16.1, '09:00')))
|
||||
const eleven = placeItem(assignment(12, 1, place(102, 'Museum', 48.2, 16.2, '11:00')))
|
||||
expect(breaksChronology([nine, eleven], 2, getDisplayTimeForDay)).toBe(false)
|
||||
expect(breaksChronology([eleven, nine], 2, getDisplayTimeForDay)).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-MOB-PTLM-040: lets untimed items sit anywhere between timed ones', () => {
|
||||
const nine = placeItem(assignment(11, 0, place(101, 'Breakfast', 48.1, 16.1, '09:00')))
|
||||
const free = placeItem(assignment(12, 1, place(102, 'Park', 48.2, 16.2)))
|
||||
const eleven = noteItem(buildDayNote({ id: 41, time: '11:00' }))
|
||||
expect(breaksChronology([nine, free, eleven], 2, getDisplayTimeForDay)).toBe(false)
|
||||
|
||||
const bus = transportItem(buildReservation({ id: 51, type: 'bus', day_id: 2, reservation_time: '2026-05-02T07:00' }))
|
||||
const getDisplayTime = vi.fn(getDisplayTimeForDay)
|
||||
expect(breaksChronology([nine, bus], 2, getDisplayTime)).toBe(true)
|
||||
expect(getDisplayTime).toHaveBeenCalledWith(bus.data, 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('planTimelineModel — weatherIconFor', () => {
|
||||
it('FE-MOB-PTLM-041: maps the known conditions and defaults to a cloud', () => {
|
||||
expect(weatherIconFor('Clear')).toBe(Sun)
|
||||
expect(weatherIconFor('Rain')).toBe(CloudRain)
|
||||
expect(weatherIconFor('Thunderstorm')).toBe(CloudLightning)
|
||||
expect(weatherIconFor('Snow')).toBe(CloudSnow)
|
||||
expect(weatherIconFor('Haze')).toBe(Wind)
|
||||
expect(weatherIconFor('Tornado')).toBe(Cloud)
|
||||
expect(weatherIconFor(undefined)).toBe(Cloud)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ReservationTraveler } from '@trek/shared'
|
||||
import {
|
||||
CountPill, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow,
|
||||
} from '../../../../src/mobile/screens/trip/tabs/tabChrome'
|
||||
import { fireEvent, render, screen } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-TABCHR-001 to FE-MOB-TABCHR-014
|
||||
|
||||
function traveler(overrides: Partial<ReservationTraveler>): ReservationTraveler {
|
||||
return {
|
||||
user_id: 1, username: 'Ada', avatar: null, avatar_url: null, is_guest: 0,
|
||||
...overrides,
|
||||
} as unknown as ReservationTraveler
|
||||
}
|
||||
|
||||
describe('tabChrome', () => {
|
||||
it('FE-MOB-TABCHR-001: TabScroller puts its children in the scroll body with dock + top clearance', () => {
|
||||
const { container } = render(<TabScroller><p>panel body</p></TabScroller>)
|
||||
const body = screen.getByText('panel body').parentElement as HTMLElement
|
||||
expect(body).toHaveClass('overflow-y-auto')
|
||||
expect(body.className).toContain('--bottom-nav-h')
|
||||
expect(body.className).toContain('--m-safe-top')
|
||||
expect(container.firstElementChild).toHaveClass('h-full')
|
||||
})
|
||||
|
||||
it('FE-MOB-TABCHR-002: CountPill renders its count', () => {
|
||||
render(<CountPill>7</CountPill>)
|
||||
expect(screen.getByText('7')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TABCHR-003: SectionHeader exposes the open state and its count pill', () => {
|
||||
render(<SectionHeader label="Confirmed" count={3} open onToggle={vi.fn()} />)
|
||||
const header = screen.getByRole('button', { name: /Confirmed/ })
|
||||
expect(header).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
// Open → chevron points up.
|
||||
expect(header.querySelector('.lucide-chevron-up')).not.toBeNull()
|
||||
expect(header.querySelector('.lucide-chevron-down')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-TABCHR-004: SectionHeader flips the chevron and drops the pill when collapsed without a count', () => {
|
||||
render(<SectionHeader label="Pending" open={false} onToggle={vi.fn()} />)
|
||||
const header = screen.getByRole('button', { name: 'Pending' })
|
||||
expect(header).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(header.querySelector('.lucide-chevron-down')).not.toBeNull()
|
||||
expect(header.textContent).toBe('Pending')
|
||||
})
|
||||
|
||||
it('FE-MOB-TABCHR-005: SectionHeader reports taps', () => {
|
||||
const onToggle = vi.fn()
|
||||
render(<SectionHeader label="Pending" open={false} onToggle={onToggle} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Pending' }))
|
||||
expect(onToggle).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-TABCHR-006: Field pairs its label with the value box and takes the caller width', () => {
|
||||
const { container } = render(<Field label="Departure" className="flex-1">08:15</Field>)
|
||||
expect(screen.getByText('Departure')).toBeInTheDocument()
|
||||
const value = screen.getByText('08:15')
|
||||
expect(value).not.toHaveClass('tabular-nums')
|
||||
expect(container.firstElementChild).toHaveClass('flex-1')
|
||||
})
|
||||
|
||||
it('FE-MOB-TABCHR-007: Field aligns numbers when asked to', () => {
|
||||
render(<Field label="Price" tabular>12,50 €</Field>)
|
||||
expect(screen.getByText('12,50 €')).toHaveClass('tabular-nums')
|
||||
})
|
||||
|
||||
it('FE-MOB-TABCHR-008: StatusDot paints the passed token', () => {
|
||||
const { container } = render(<StatusDot color="var(--m-st-confirmed)" />)
|
||||
expect(container.firstElementChild).toHaveStyle({ background: 'var(--m-st-confirmed)' })
|
||||
})
|
||||
|
||||
it('FE-MOB-TABCHR-009: TravelerAvatars renders nothing when nobody is assigned', () => {
|
||||
const { container } = render(<TravelerAvatars travelers={[]} label="Travelers" />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-MOB-TABCHR-010: TravelerAvatars shows the label and one chip per traveler', () => {
|
||||
render(
|
||||
<TravelerAvatars
|
||||
travelers={[traveler({ user_id: 1, username: 'Ada' }), traveler({ user_id: 2, username: 'Bob' })]}
|
||||
label="Travelers"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Travelers')).toBeInTheDocument()
|
||||
expect(screen.getByText('Ada')).toBeInTheDocument()
|
||||
expect(screen.getByText('Bob')).toBeInTheDocument()
|
||||
// No avatar → initial only.
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TABCHR-011: TravelerAvatars prefers avatar_url and falls back to the raw avatar path', () => {
|
||||
const { container } = render(
|
||||
<TravelerAvatars
|
||||
travelers={[
|
||||
traveler({ user_id: 1, username: 'Ada', avatar_url: '/uploads/avatars/ada.png' }),
|
||||
traveler({ user_id: 2, username: 'Bob', avatar: 'bob.jpg' }),
|
||||
traveler({ user_id: 3, username: 'Cid', avatar: 'https://idp.example/cid.png' }),
|
||||
]}
|
||||
label="Travelers"
|
||||
/>,
|
||||
)
|
||||
const srcs = [...container.querySelectorAll('img')].map(i => i.getAttribute('src'))
|
||||
expect(srcs).toEqual([
|
||||
'/uploads/avatars/ada.png',
|
||||
'/uploads/avatars/bob.jpg',
|
||||
'https://idp.example/cid.png',
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-MOB-TABCHR-012: TravelerAvatars marks accountless guests', () => {
|
||||
render(<TravelerAvatars travelers={[traveler({ user_id: 9, username: 'Zoe', is_guest: 1 })]} label="Travelers" />)
|
||||
expect(screen.getByText('Guest')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-TABCHR-013: TravelerFilterRow toggles a member and dims the inactive ones', () => {
|
||||
const onToggle = vi.fn()
|
||||
render(
|
||||
<TravelerFilterRow
|
||||
members={[{ id: 1, username: 'Ada' }, { id: 2, username: 'Bob', avatar_url: '/uploads/avatars/bob.png' }]}
|
||||
active={new Set([1])}
|
||||
onToggle={onToggle}
|
||||
label="Filter by traveler"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByLabelText('Filter by traveler')).toBeInTheDocument()
|
||||
const ada = screen.getByTitle('Ada')
|
||||
const bob = screen.getByTitle('Bob')
|
||||
expect(ada.className).toContain('opacity-100')
|
||||
expect(bob.className).toContain('opacity-40')
|
||||
expect(bob.querySelector('img')).toHaveAttribute('src', '/uploads/avatars/bob.png')
|
||||
|
||||
fireEvent.click(bob)
|
||||
expect(onToggle).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-TABCHR-014: TravelerFilterRow keeps everyone at full opacity while no filter is set', () => {
|
||||
render(
|
||||
<TravelerFilterRow
|
||||
members={[{ id: 1, username: 'Ada' }, { id: 2, username: 'Bob' }]}
|
||||
active={new Set()}
|
||||
onToggle={vi.fn()}
|
||||
label="Filter by traveler"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTitle('Ada').className).toContain('opacity-100')
|
||||
expect(screen.getByTitle('Bob').className).toContain('opacity-100')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
TRANSPORT_TYPE_COLOR,
|
||||
groupTransports,
|
||||
orderedEndpoints,
|
||||
parseTransportMeta,
|
||||
} from '../../../../src/mobile/screens/trip/tabs/transportsModel';
|
||||
import { buildDay, buildReservation } from '../../../helpers/factories';
|
||||
import type { Day, Reservation } from '../../../../src/types';
|
||||
|
||||
// FE-MOB-TRPM-001 to FE-MOB-TRPM-014
|
||||
|
||||
function res(overrides: Partial<Reservation> = {}): Reservation {
|
||||
return buildReservation({ type: 'flight', status: 'confirmed', ...overrides });
|
||||
}
|
||||
|
||||
describe('transportsModel — metadata', () => {
|
||||
it('FE-MOB-TRPM-001: exposes a chip colour per transport type', () => {
|
||||
expect(TRANSPORT_TYPE_COLOR.flight).toBe('#3b82f6');
|
||||
expect(TRANSPORT_TYPE_COLOR.transit).toBe('#7c3aed');
|
||||
expect(TRANSPORT_TYPE_COLOR.transport_other).toBe(TRANSPORT_TYPE_COLOR.car);
|
||||
expect(TRANSPORT_TYPE_COLOR.nope).toBeUndefined();
|
||||
});
|
||||
|
||||
it('FE-MOB-TRPM-002: parses a JSON metadata string', () => {
|
||||
const meta = parseTransportMeta(
|
||||
res({ metadata: '{"airline":"LH","flight_number":"LH123","seat":"14A"}' }),
|
||||
);
|
||||
expect(meta).toEqual({ airline: 'LH', flight_number: 'LH123', seat: '14A' });
|
||||
});
|
||||
|
||||
it('FE-MOB-TRPM-003: passes an already-parsed metadata object through', () => {
|
||||
const parsed = { transit: { legs: [{ mode: 'subway', line: 'U2' }] } };
|
||||
expect(parseTransportMeta({ ...res(), metadata: parsed } as unknown as Reservation)).toEqual(parsed);
|
||||
});
|
||||
|
||||
it('FE-MOB-TRPM-004: returns an empty object for missing or broken metadata', () => {
|
||||
expect(parseTransportMeta(res({ metadata: null }))).toEqual({});
|
||||
expect(parseTransportMeta(res({ metadata: '' }))).toEqual({});
|
||||
expect(parseTransportMeta(res({ metadata: '{not json' }))).toEqual({});
|
||||
});
|
||||
|
||||
it('FE-MOB-TRPM-005: orderedEndpoints sorts waypoints by sequence without mutating', () => {
|
||||
const endpoints = [
|
||||
{ role: 'to' as const, sequence: 2, name: 'Rome', code: null, lat: 0, lng: 0, timezone: null, local_time: null, local_date: null },
|
||||
{ role: 'from' as const, sequence: 0, name: 'Berlin', code: null, lat: 0, lng: 0, timezone: null, local_time: null, local_date: null },
|
||||
{ role: 'stop' as const, sequence: 1, name: 'Munich', code: null, lat: 0, lng: 0, timezone: null, local_time: null, local_date: null },
|
||||
];
|
||||
const r = res({ endpoints });
|
||||
|
||||
expect(orderedEndpoints(r).map(e => e.name)).toEqual(['Berlin', 'Munich', 'Rome']);
|
||||
expect(endpoints.map(e => e.name)).toEqual(['Rome', 'Berlin', 'Munich']);
|
||||
expect(orderedEndpoints(res({ endpoints: undefined }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-MOB-TRPM-006: orderedEndpoints treats a missing sequence as zero', () => {
|
||||
const rome = { role: 'to' as const, sequence: 1, name: 'Rome', code: null, lat: 0, lng: 0, timezone: null, local_time: null, local_date: null };
|
||||
const berlin = { role: 'from' as const, name: 'Berlin', code: null, lat: 0, lng: 0, timezone: null, local_time: null, local_date: null };
|
||||
const pair = (list: unknown[]) =>
|
||||
orderedEndpoints(res({ endpoints: list as unknown as Reservation['endpoints'] })).map(e => e.name);
|
||||
|
||||
expect(pair([rome, berlin])).toEqual(['Berlin', 'Rome']);
|
||||
expect(pair([berlin, rome])).toEqual(['Berlin', 'Rome']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transportsModel — groupTransports', () => {
|
||||
const days: Day[] = [
|
||||
buildDay({ id: 100, date: '2026-07-01' }),
|
||||
buildDay({ id: 101, date: '2026-07-02' }),
|
||||
buildDay({ id: 102, date: '2026-07-03' }),
|
||||
];
|
||||
|
||||
it('FE-MOB-TRPM-007: sorts by reservation date and time', () => {
|
||||
const late = res({ id: 1, reservation_time: '2026-07-02T18:00' });
|
||||
const early = res({ id: 2, reservation_time: '2026-07-02T06:30' });
|
||||
const first = res({ id: 3, reservation_time: '2026-07-01T23:00' });
|
||||
|
||||
expect(groupTransports([late, early, first], days).confirmed.map(r => r.id)).toEqual([3, 2, 1]);
|
||||
});
|
||||
|
||||
it('FE-MOB-TRPM-008: falls back to the linked day date, treating a date-only value as midnight', () => {
|
||||
const viaDay = res({ id: 1, reservation_time: null, day_id: 100 });
|
||||
const dateOnly = res({ id: 2, reservation_time: '2026-07-01' });
|
||||
const withTime = res({ id: 3, reservation_time: '2026-07-01T08:00' });
|
||||
|
||||
// both undated-but-day-linked entries key on 00:00, so created_at breaks the tie
|
||||
const sorted = groupTransports(
|
||||
[
|
||||
withTime,
|
||||
{ ...dateOnly, created_at: '2025-01-02T00:00:00.000Z' },
|
||||
{ ...viaDay, created_at: '2025-01-01T00:00:00.000Z' },
|
||||
],
|
||||
days,
|
||||
).confirmed;
|
||||
expect(sorted.map(r => r.id)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('FE-MOB-TRPM-009: a hotel keys on its accommodation start day, not its day_id', () => {
|
||||
const hotel = res({
|
||||
id: 1,
|
||||
type: 'hotel',
|
||||
reservation_time: null,
|
||||
day_id: 102,
|
||||
accommodation_start_day_id: 100,
|
||||
});
|
||||
const flight = res({ id: 2, reservation_time: '2026-07-02T09:00' });
|
||||
|
||||
expect(groupTransports([flight, hotel], days).confirmed.map(r => r.id)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('FE-MOB-TRPM-010: a hotel without an accommodation start day still uses day_id', () => {
|
||||
const hotel = res({ id: 1, type: 'hotel', reservation_time: null, day_id: 102 });
|
||||
const flight = res({ id: 2, reservation_time: '2026-07-02T09:00' });
|
||||
|
||||
expect(groupTransports([flight, hotel], days).confirmed.map(r => r.id)).toEqual([2, 1]);
|
||||
});
|
||||
|
||||
it('FE-MOB-TRPM-011: undated entries and entries on unknown days sink to the bottom', () => {
|
||||
const dated = res({ id: 1, reservation_time: '2026-07-02T09:00' });
|
||||
const undated = res({ id: 2, reservation_time: null, day_id: null, created_at: '2025-01-01T00:00:00.000Z' });
|
||||
const unknownDay = res({ id: 3, reservation_time: null, day_id: 999, created_at: '2025-01-02T00:00:00.000Z' });
|
||||
|
||||
expect(groupTransports([undated, unknownDay, dated], days).confirmed.map(r => r.id)).toEqual([1, 2, 3]);
|
||||
// same result whichever side of the comparison the undated entry lands on
|
||||
expect(groupTransports([undated, dated], days).confirmed.map(r => r.id)).toEqual([1, 2]);
|
||||
expect(groupTransports([dated, undated], days).confirmed.map(r => r.id)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('FE-MOB-TRPM-012: splits confirmed, pending and automated transit', () => {
|
||||
const confirmed = res({ id: 1, reservation_time: '2026-07-01T08:00' });
|
||||
const pending = res({ id: 2, status: 'pending', reservation_time: '2026-07-01T09:00' });
|
||||
const cancelled = res({ id: 3, status: 'cancelled', reservation_time: '2026-07-01T07:00' });
|
||||
const transitConfirmed = res({ id: 4, type: 'transit', reservation_time: '2026-07-01T10:00' });
|
||||
const transitPending = res({ id: 5, type: 'transit', status: 'pending', reservation_time: '2026-07-01T06:00' });
|
||||
|
||||
const groups = groupTransports([confirmed, pending, cancelled, transitConfirmed, transitPending], days);
|
||||
|
||||
expect(groups.confirmed.map(r => r.id)).toEqual([1]);
|
||||
// anything not confirmed lands in pending, still chronological
|
||||
expect(groups.pending.map(r => r.id)).toEqual([3, 2]);
|
||||
// transit is peeled off regardless of status
|
||||
expect(groups.transit.map(r => r.id)).toEqual([5, 4]);
|
||||
});
|
||||
|
||||
it('FE-MOB-TRPM-013: an empty list yields three empty groups', () => {
|
||||
expect(groupTransports([], days)).toEqual({ confirmed: [], pending: [], transit: [] });
|
||||
expect(groupTransports([res({ id: 1, reservation_time: '2026-07-01T08:00' })], []).confirmed).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-TRPM-014: entries without a created_at still sort deterministically', () => {
|
||||
const a = { ...res({ id: 1, reservation_time: '2026-07-02T09:00' }), created_at: undefined };
|
||||
const b = { ...res({ id: 2, reservation_time: '2026-07-02T09:00' }), created_at: undefined };
|
||||
|
||||
expect(groupTransports([a, b], days).confirmed.map(r => r.id)).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,649 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WeatherResult } from '@trek/shared'
|
||||
import { assignmentsApi, reservationsApi, weatherApi } from '../../../../src/api/client'
|
||||
import { useMPlanTimeline } from '../../../../src/mobile/screens/trip/plan/useMPlanTimeline'
|
||||
import type { TransportEntry } from '../../../../src/mobile/screens/trip/plan/planTimelineModel'
|
||||
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { usePluginStore, type ActivePlugin } from '../../../../src/store/pluginStore'
|
||||
import { useTripStore } from '../../../../src/store/tripStore'
|
||||
import type { Accommodation, Day, RouteSegment } from '../../../../src/types'
|
||||
import { buildAssignment, buildDayNote, buildPlace, buildReservation } from '../../../helpers/factories'
|
||||
import { buildPlanner, buildTripActions } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { act, renderHook, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-PLTL-001 to FE-MOB-PLTL-045
|
||||
|
||||
// The connector calculation is its own hook with real OSRM calls — stubbed here so
|
||||
// the timeline sees exactly the legs a test wants to match against.
|
||||
const routeCalc = vi.hoisted(() => ({ segments: [] as unknown[] }))
|
||||
vi.mock('../../../../src/hooks/useRouteCalculation', () => ({
|
||||
useRouteCalculation: () => ({ routeSegments: routeCalc.segments }),
|
||||
}))
|
||||
|
||||
const DAYS = [
|
||||
{ id: 1, trip_id: 1, day_number: 1, date: '2026-05-01', title: null },
|
||||
{ id: 2, trip_id: 1, day_number: 2, date: '2026-05-02', title: 'Old Town' },
|
||||
{ id: 3, trip_id: 1, day_number: 3, date: '2026-05-03', title: null },
|
||||
] as unknown as Day[]
|
||||
|
||||
const MUSEUM = buildPlace({ id: 101, name: 'Museum', lat: 48, lng: 16.1 })
|
||||
const PARK = buildPlace({ id: 102, name: 'Park', lat: 48, lng: 16.3 })
|
||||
const CAFE = buildPlace({ id: 103, name: 'Cafe', lat: 48, lng: 16.2 })
|
||||
|
||||
const A_MUSEUM = buildAssignment({ id: 11, day_id: 2, order_index: 0, place_id: 101, place: MUSEUM })
|
||||
const A_PARK = buildAssignment({ id: 12, day_id: 2, order_index: 1, place_id: 102, place: PARK })
|
||||
const A_CAFE = buildAssignment({ id: 13, day_id: 2, order_index: 2, place_id: 103, place: CAFE })
|
||||
|
||||
// Timed stops without coordinates — the up-next pick needs times, not a map.
|
||||
const TIMED_EARLY = buildAssignment({
|
||||
id: 21, day_id: 2, order_index: 0, place_id: 201,
|
||||
place: buildPlace({ id: 201, name: 'Breakfast', lat: null, lng: null, place_time: '09:00' }),
|
||||
})
|
||||
const TIMED_LATE = buildAssignment({
|
||||
id: 22, day_id: 2, order_index: 1, place_id: 202,
|
||||
place: buildPlace({ id: 202, name: 'Museum', lat: null, lng: null, place_time: '11:00' }),
|
||||
})
|
||||
|
||||
const HOTEL = {
|
||||
id: 71, trip_id: 1, start_day_id: 1, end_day_id: 3,
|
||||
place_name: 'Hotel Sacher', place_lat: 48, place_lng: 16.05,
|
||||
} as unknown as Accommodation
|
||||
|
||||
const FORECAST = {
|
||||
temp: 20.4, temp_max: 24, temp_min: 12, main: 'Rain', description: 'light rain', type: 'forecast',
|
||||
} as WeatherResult
|
||||
|
||||
function seg(from: [number, number], to: [number, number]): RouteSegment {
|
||||
return {
|
||||
mid: [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2],
|
||||
from, to, distance: 1200, duration: 600,
|
||||
walkingText: '15 min', drivingText: '3 min', distanceText: '1.2 km',
|
||||
}
|
||||
}
|
||||
|
||||
const BASE_SETTINGS = {
|
||||
time_format: '24h', date_format: 'DD.MM.YYYY', default_currency: 'EUR', distance_unit: 'km',
|
||||
}
|
||||
|
||||
function makePlanner(overrides: Record<string, unknown> = {}): TripPlanner {
|
||||
return buildPlanner({
|
||||
tripId: 1,
|
||||
days: DAYS,
|
||||
selectedDayId: 2,
|
||||
assignments: { '2': [A_CAFE, A_MUSEUM, A_PARK] },
|
||||
places: [MUSEUM, PARK, CAFE],
|
||||
reservations: [],
|
||||
tripAccommodations: [],
|
||||
settings: BASE_SETTINGS,
|
||||
...overrides,
|
||||
} as unknown as Partial<TripPlanner>)
|
||||
}
|
||||
|
||||
/** Renders and settles the weather request so no state update lands outside act(). */
|
||||
async function renderTimeline(planner: TripPlanner) {
|
||||
const view = renderHook(() => useMPlanTimeline(planner))
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve() })
|
||||
return { ...view, planner }
|
||||
}
|
||||
|
||||
describe('useMPlanTimeline', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
usePluginStore.setState({ plugins: [] })
|
||||
routeCalc.segments = []
|
||||
vi.spyOn(weatherApi, 'get').mockResolvedValue(FORECAST)
|
||||
vi.spyOn(assignmentsApi, 'updateTransport').mockResolvedValue({})
|
||||
vi.spyOn(reservationsApi, 'updatePositions').mockResolvedValue({})
|
||||
vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
})
|
||||
|
||||
// The plugin store is reset in beforeEach, not here: a setState while the hook
|
||||
// is still mounted (cleanup runs after this) would re-render outside act().
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-001: resolves the selected day and sorts its stops by order index', async () => {
|
||||
const { result } = await renderTimeline(makePlanner())
|
||||
expect(result.current.day?.id).toBe(2)
|
||||
expect(result.current.rows.map(r => r.key)).toEqual(['pl-11', 'pl-12', 'pl-13'])
|
||||
expect(result.current.timeFormat).toBe('24h')
|
||||
expect(result.current.language).toBe('en')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-002: folds day notes from the store into the timeline', async () => {
|
||||
seedStore(useTripStore, { dayNotes: { '2': [buildDayNote({ id: 41, day_id: 2, sort_order: 0.5 })] } })
|
||||
const { result } = await renderTimeline(makePlanner({ assignments: { '2': [A_MUSEUM, A_PARK] } }))
|
||||
expect(result.current.rows.map(r => r.key)).toEqual(['pl-11', 'note-41', 'pl-12'])
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-003: slots the calculated legs in as connectors', async () => {
|
||||
routeCalc.segments = [seg([48, 16.1], [48, 16.3]), seg([48, 16.3], [48, 16.2])]
|
||||
const { result } = await renderTimeline(makePlanner())
|
||||
expect(result.current.rows.map(r => r.kind)).toEqual(['place', 'conn', 'place', 'conn', 'place'])
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-004: exposes the hotel bookend legs and header chips', async () => {
|
||||
const out = seg([48, 16.05], [48, 16.1])
|
||||
const back = seg([48, 16.2], [48, 16.05])
|
||||
routeCalc.segments = [out, back]
|
||||
const { result } = await renderTimeline(makePlanner({ tripAccommodations: [HOTEL] }))
|
||||
expect(result.current.hotelLegs.top?.seg).toBe(out)
|
||||
expect(result.current.hotelLegs.bottom?.seg).toBe(back)
|
||||
expect(result.current.hotelChips).toEqual([
|
||||
{ key: 'stay-71', variant: 'stay', name: 'Hotel Sacher', time: null },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-005: derives nothing while no day is selected', async () => {
|
||||
const { result } = await renderTimeline(makePlanner({ selectedDayId: null }))
|
||||
expect(result.current.day).toBeUndefined()
|
||||
expect(result.current.rows).toEqual([])
|
||||
expect(result.current.merged).toEqual([])
|
||||
expect(result.current.hotelChips).toEqual([])
|
||||
expect(result.current.hotelLegs).toEqual({ top: null, bottom: null })
|
||||
expect(result.current.upNext).toBeNull()
|
||||
expect(weatherApi.get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-006: keeps every day-scoped action inert without a selected day', async () => {
|
||||
const planner = makePlanner({ selectedDayId: null })
|
||||
const { result } = await renderTimeline(planner)
|
||||
act(() => {
|
||||
result.current.moveRow({ type: 'place', sortKey: 0, data: A_MUSEUM }, 'down')
|
||||
result.current.removeAssignment(A_MUSEUM)
|
||||
result.current.renameDay('Nope')
|
||||
result.current.exportGoogleMaps()
|
||||
result.current.setLegMode(11, 'walking')
|
||||
result.current.addTransport()
|
||||
})
|
||||
await act(async () => { await result.current.optimize() })
|
||||
expect(planner.handleRemoveAssignment).not.toHaveBeenCalled()
|
||||
expect(planner.handleUpdateDayTitle).not.toHaveBeenCalled()
|
||||
expect(window.open).not.toHaveBeenCalled()
|
||||
expect(assignmentsApi.updateTransport).not.toHaveBeenCalled()
|
||||
expect(planner.tripActions.reorderAssignments).not.toHaveBeenCalled()
|
||||
// The transport sheet still opens — it just has no day to attach to.
|
||||
expect(planner.setTransportModalDayId).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-007: requests the forecast for the first located stop', async () => {
|
||||
const { result } = await renderTimeline(makePlanner())
|
||||
await waitFor(() => expect(result.current.weather).not.toBeNull())
|
||||
expect(weatherApi.get).toHaveBeenCalledWith(48, 16.1, '2026-05-02')
|
||||
expect(result.current.weatherTemp).toBe(20)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-008: converts the forecast to Fahrenheit on request', async () => {
|
||||
const planner = makePlanner({ settings: { ...BASE_SETTINGS, temperature_unit: 'fahrenheit' } })
|
||||
const { result } = await renderTimeline(planner)
|
||||
await waitFor(() => expect(result.current.weatherTemp).toBe(69))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-009: drops a forecast the service flagged as an error', async () => {
|
||||
vi.mocked(weatherApi.get).mockResolvedValue({ ...FORECAST, error: 'no_forecast' })
|
||||
const { result } = await renderTimeline(makePlanner())
|
||||
expect(result.current.weather).toBeNull()
|
||||
expect(result.current.weatherTemp).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-010: survives a rejected forecast request', async () => {
|
||||
vi.mocked(weatherApi.get).mockRejectedValue(new Error('offline'))
|
||||
const { result } = await renderTimeline(makePlanner())
|
||||
expect(result.current.weather).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-011: anchors the forecast to the day hotel when no stop has coordinates', async () => {
|
||||
const vague = buildAssignment({
|
||||
id: 14, day_id: 2, order_index: 0, place_id: 104,
|
||||
place: buildPlace({ id: 104, name: 'Idea', lat: null, lng: null }),
|
||||
})
|
||||
await renderTimeline(makePlanner({ assignments: { '2': [vague] }, tripAccommodations: [HOTEL] }))
|
||||
expect(weatherApi.get).toHaveBeenCalledWith(48, 16.05, '2026-05-02')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-012: skips the forecast without any coordinates at all', async () => {
|
||||
const vague = buildAssignment({
|
||||
id: 14, day_id: 2, order_index: 0, place_id: 104,
|
||||
place: buildPlace({ id: 104, name: 'Idea', lat: null, lng: null }),
|
||||
})
|
||||
await renderTimeline(makePlanner({ assignments: { '2': [vague] } }))
|
||||
expect(weatherApi.get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-045: drops a forecast that settles after the timeline is gone', async () => {
|
||||
let resolveWeather: (w: WeatherResult) => void = () => {}
|
||||
vi.mocked(weatherApi.get).mockReturnValueOnce(new Promise(res => { resolveWeather = res }))
|
||||
const first = renderHook(() => useMPlanTimeline(makePlanner()))
|
||||
first.unmount()
|
||||
await act(async () => { resolveWeather(FORECAST); await Promise.resolve() })
|
||||
expect(first.result.current.weather).toBeNull()
|
||||
|
||||
let failWeather: (e: Error) => void = () => {}
|
||||
vi.mocked(weatherApi.get).mockReturnValueOnce(new Promise((_res, rej) => { failWeather = rej }))
|
||||
const second = renderHook(() => useMPlanTimeline(makePlanner()))
|
||||
second.unmount()
|
||||
await act(async () => { failWeather(new Error('offline')); await Promise.resolve() })
|
||||
expect(second.result.current.weather).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-013: toggles an expanded transit row on and off', async () => {
|
||||
const { result } = await renderTimeline(makePlanner())
|
||||
act(() => { result.current.toggleTransit('tr-61') })
|
||||
expect(result.current.openTransitKeys.has('tr-61')).toBe(true)
|
||||
act(() => { result.current.toggleTransit('tr-62') })
|
||||
expect([...result.current.openTransitKeys].sort()).toEqual(['tr-61', 'tr-62'])
|
||||
act(() => { result.current.toggleTransit('tr-61') })
|
||||
expect(result.current.openTransitKeys.has('tr-61')).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-014: counts down on today and never on any other day', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2026, 4, 2, 10, 15))
|
||||
const today = makePlanner({ assignments: { '2': [TIMED_EARLY, TIMED_LATE] } })
|
||||
const { result } = renderHook(() => useMPlanTimeline(today))
|
||||
expect(result.current.upNext).toEqual({ assignment: TIMED_LATE, minutesUntil: 45 })
|
||||
|
||||
const past = makePlanner({
|
||||
assignments: { '2': [TIMED_EARLY, TIMED_LATE] },
|
||||
days: DAYS.map(d => (d.id === 2 ? { ...d, date: '2026-04-30' } : d)),
|
||||
})
|
||||
const other = renderHook(() => useMPlanTimeline(past))
|
||||
expect(other.result.current.upNext).toEqual({ assignment: TIMED_EARLY, minutesUntil: null })
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-015: re-evaluates the countdown on the half-minute tick', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2026, 4, 2, 10, 0))
|
||||
const planner = makePlanner({ assignments: { '2': [TIMED_EARLY, TIMED_LATE] } })
|
||||
const { result } = renderHook(() => useMPlanTimeline(planner))
|
||||
expect(result.current.upNext).toEqual({ assignment: TIMED_LATE, minutesUntil: 60 })
|
||||
|
||||
vi.setSystemTime(new Date(2026, 4, 2, 11, 30))
|
||||
act(() => { vi.advanceTimersByTime(30_000) })
|
||||
expect(result.current.upNext).toEqual({ assignment: TIMED_EARLY, minutesUntil: null })
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-016: reorders the day when a stop is moved down', async () => {
|
||||
const planner = makePlanner()
|
||||
const { result } = await renderTimeline(planner)
|
||||
await act(async () => { result.current.moveRow(result.current.merged[0], 'down') })
|
||||
await waitFor(() => expect(planner.tripActions.reorderAssignments).toHaveBeenCalledWith(1, 2, [12, 11, 13]))
|
||||
expect(planner.pushUndo).toHaveBeenCalledWith('undo.reorder', expect.any(Function))
|
||||
expect(planner.updateRouteForDay).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-017: restores the previous order through the undo entry', async () => {
|
||||
const planner = makePlanner()
|
||||
const { result } = await renderTimeline(planner)
|
||||
await act(async () => { result.current.moveRow(result.current.merged[2], 'up') })
|
||||
await waitFor(() => expect(planner.pushUndo).toHaveBeenCalled())
|
||||
const undo = vi.mocked(planner.pushUndo).mock.calls[0][1] as () => Promise<void>
|
||||
await undo()
|
||||
expect(planner.tripActions.reorderAssignments).toHaveBeenLastCalledWith(1, 2, [11, 12, 13])
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-018: ignores a move past either end of the timeline', async () => {
|
||||
const planner = makePlanner()
|
||||
const { result } = await renderTimeline(planner)
|
||||
await act(async () => {
|
||||
result.current.moveRow(result.current.merged[0], 'up')
|
||||
result.current.moveRow(result.current.merged[2], 'down')
|
||||
result.current.moveRow({ type: 'place', sortKey: 9, data: A_MUSEUM }, 'down')
|
||||
})
|
||||
expect(planner.tripActions.reorderAssignments).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-019: refuses to move a timed stop past another timed stop', async () => {
|
||||
const nine = buildAssignment({
|
||||
id: 21, day_id: 2, order_index: 0, place_id: 201,
|
||||
place: buildPlace({ id: 201, name: 'Breakfast', lat: 48, lng: 16.1, place_time: '09:00' }),
|
||||
})
|
||||
const eleven = buildAssignment({
|
||||
id: 22, day_id: 2, order_index: 1, place_id: 202,
|
||||
place: buildPlace({ id: 202, name: 'Museum', lat: 48, lng: 16.2, place_time: '11:00' }),
|
||||
})
|
||||
const planner = makePlanner({ assignments: { '2': [nine, eleven] } })
|
||||
const { result } = await renderTimeline(planner)
|
||||
await act(async () => { result.current.moveRow(result.current.merged[0], 'down') })
|
||||
expect(planner.toast.info).toHaveBeenCalledWith('dayplan.cannotBreakChronology')
|
||||
expect(planner.tripActions.reorderAssignments).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-020: lets an untimed stop move between timed ones', async () => {
|
||||
const nine = buildAssignment({
|
||||
id: 21, day_id: 2, order_index: 0, place_id: 201,
|
||||
place: buildPlace({ id: 201, name: 'Breakfast', lat: 48, lng: 16.1, place_time: '09:00' }),
|
||||
})
|
||||
const free = buildAssignment({
|
||||
id: 22, day_id: 2, order_index: 1, place_id: 202,
|
||||
place: buildPlace({ id: 202, name: 'Park', lat: 48, lng: 16.2 }),
|
||||
})
|
||||
const eleven = buildAssignment({
|
||||
id: 23, day_id: 2, order_index: 2, place_id: 203,
|
||||
place: buildPlace({ id: 203, name: 'Museum', lat: 48, lng: 16.3, place_time: '11:00' }),
|
||||
})
|
||||
const planner = makePlanner({ assignments: { '2': [nine, free, eleven] } })
|
||||
const { result } = await renderTimeline(planner)
|
||||
await act(async () => { result.current.moveRow(result.current.merged[1], 'up') })
|
||||
await waitFor(() => expect(planner.tripActions.reorderAssignments).toHaveBeenCalledWith(1, 2, [22, 21, 23]))
|
||||
expect(planner.toast.info).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-021: persists note and transport positions alongside the reorder', async () => {
|
||||
const bus = buildReservation({
|
||||
id: 51, type: 'bus', title: 'Bus 13A', day_id: 2, day_positions: { 2: 1.5 },
|
||||
})
|
||||
seedStore(useTripStore, {
|
||||
dayNotes: { '2': [buildDayNote({ id: 41, day_id: 2, sort_order: 0.5 })] },
|
||||
reservations: [bus],
|
||||
})
|
||||
const planner = makePlanner({ assignments: { '2': [A_MUSEUM, A_PARK] }, reservations: [bus] })
|
||||
const { result } = await renderTimeline(planner)
|
||||
expect(result.current.merged.map(m => m.type)).toEqual(['place', 'note', 'place', 'transport'])
|
||||
|
||||
await act(async () => { result.current.moveRow(result.current.merged[3], 'up') })
|
||||
|
||||
await waitFor(() => expect(reservationsApi.updatePositions).toHaveBeenCalledWith(
|
||||
1, [{ id: 51, day_plan_position: 2 / 3 }], 2,
|
||||
))
|
||||
expect(planner.tripActions.reorderAssignments).toHaveBeenCalledWith(1, 2, [11, 12])
|
||||
expect(planner.tripActions.updateDayNote).toHaveBeenCalledWith(1, 2, 41, { sort_order: 1 / 3 })
|
||||
// The optimistic store write lands before the round trip so the merge stays stable.
|
||||
expect(useTripStore.getState().reservations[0]).toMatchObject({
|
||||
id: 51, day_plan_position: 2 / 3, day_positions: { 2: 2 / 3 },
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-022: writes per-leg positions of a multi-leg flight back as an object', async () => {
|
||||
const flight = buildReservation({
|
||||
id: 60, type: 'flight', title: 'FRA → CTS', day_id: 2,
|
||||
metadata: JSON.stringify({
|
||||
// The third leg departs on day 3, so it never reaches this day's timeline.
|
||||
legs: [{ from: 'FRA', to: 'IST' }, { from: 'IST', to: 'NRT' }, { dep_day_id: 3, from: 'NRT', to: 'CTS' }],
|
||||
}),
|
||||
})
|
||||
const dinner = buildReservation({ id: 70, type: 'restaurant', title: 'Dinner', day_id: 3 })
|
||||
seedStore(useTripStore, { reservations: [flight, dinner] })
|
||||
const planner = makePlanner({ assignments: {}, reservations: [flight] })
|
||||
const { result } = await renderTimeline(planner)
|
||||
expect(result.current.rows.map(r => r.key)).toEqual(['tr-60-leg0', 'tr-60-leg1'])
|
||||
|
||||
await act(async () => { result.current.moveRow(result.current.merged[1], 'up') })
|
||||
|
||||
await waitFor(() => expect(planner.tripActions.updateReservation).toHaveBeenCalled())
|
||||
const [tripId, resId, payload] = vi.mocked(planner.tripActions.updateReservation).mock.calls[0]
|
||||
expect([tripId, resId]).toEqual([1, 60])
|
||||
const meta = (payload as unknown as { metadata: { legs: Array<{ day_positions?: Record<string, number> }> } }).metadata
|
||||
// Positions are base + (idx + 1) / (group + 1) with base -1: the whole group sits before slot 0.
|
||||
expect(meta.legs[0].day_positions).toEqual({ 2: -1 + 2 / 3 })
|
||||
expect(meta.legs[1].day_positions).toEqual({ 2: -1 + 1 / 3 })
|
||||
expect(meta.legs[2].day_positions).toBeUndefined()
|
||||
// Metadata must land in the store as an object, and only this booking is touched.
|
||||
expect(typeof useTripStore.getState().reservations[0].metadata).toBe('object')
|
||||
expect(useTripStore.getState().reservations[1]).toBe(dinner)
|
||||
// No assignments on the day — nothing to undo, and no reorder call.
|
||||
expect(planner.pushUndo).not.toHaveBeenCalled()
|
||||
expect(planner.tripActions.reorderAssignments).not.toHaveBeenCalled()
|
||||
expect(planner.updateRouteForDay).toHaveBeenCalledWith(2)
|
||||
|
||||
// A second move now reads the object metadata back out of the store.
|
||||
await act(async () => { result.current.moveRow(result.current.merged[1], 'up') })
|
||||
await waitFor(() => expect(planner.tripActions.updateReservation).toHaveBeenCalledTimes(2))
|
||||
const second = vi.mocked(planner.tripActions.updateReservation).mock.calls[1][2] as unknown as
|
||||
{ metadata: { legs: Array<{ day_positions?: Record<string, number> }> } }
|
||||
expect(second.metadata.legs[0].day_positions).toEqual({ 2: -1 + 2 / 3 })
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-023: skips leg positions when the booking or its metadata is unusable', async () => {
|
||||
const flight = buildReservation({
|
||||
id: 60, type: 'flight', title: 'FRA → NRT', day_id: 2,
|
||||
metadata: JSON.stringify({ legs: [{ from: 'FRA', to: 'IST' }, { from: 'IST', to: 'NRT' }] }),
|
||||
})
|
||||
const planner = makePlanner({ assignments: {}, reservations: [flight] })
|
||||
const { result } = await renderTimeline(planner)
|
||||
// Booking missing from the store entirely.
|
||||
await act(async () => { result.current.moveRow(result.current.merged[1], 'up') })
|
||||
expect(planner.tripActions.updateReservation).not.toHaveBeenCalled()
|
||||
|
||||
// Present, but with metadata that no longer parses into legs.
|
||||
seedStore(useTripStore, { reservations: [{ ...flight, metadata: '{not json' }] })
|
||||
await act(async () => { result.current.moveRow(result.current.merged[1], 'up') })
|
||||
expect(planner.tripActions.updateReservation).not.toHaveBeenCalled()
|
||||
expect(planner.updateRouteForDay).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-044: slots a timed transport that carries no saved position', async () => {
|
||||
const bus = buildReservation({
|
||||
id: 51, type: 'bus', title: 'Bus 13A', day_id: 2, reservation_time: '2026-05-02T08:00',
|
||||
})
|
||||
const dinner = buildReservation({ id: 52, type: 'restaurant', title: 'Dinner', day_id: 3 })
|
||||
seedStore(useTripStore, { reservations: [bus, dinner] })
|
||||
const planner = makePlanner({ assignments: { '2': [A_MUSEUM, A_PARK] }, reservations: [bus, dinner] })
|
||||
const { result } = await renderTimeline(planner)
|
||||
expect(result.current.merged.map(m => m.type)).toEqual(['place', 'place', 'transport'])
|
||||
|
||||
await act(async () => { result.current.moveRow(result.current.merged[2], 'up') })
|
||||
|
||||
await waitFor(() => expect(reservationsApi.updatePositions).toHaveBeenCalledWith(
|
||||
1, [{ id: 51, day_plan_position: 0.5 }], 2,
|
||||
))
|
||||
// Only the moved booking gains a per-day position; the other day's booking is untouched.
|
||||
expect(useTripStore.getState().reservations.map(r => r.day_positions)).toEqual([{ 2: 0.5 }, undefined])
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-024: surfaces a failed reorder as an error toast', async () => {
|
||||
const actions = buildTripActions()
|
||||
actions.reorderAssignments.mockRejectedValue(new Error('offline'))
|
||||
const planner = makePlanner({ tripActions: actions })
|
||||
const { result } = await renderTimeline(planner)
|
||||
await act(async () => { result.current.moveRow(result.current.merged[0], 'down') })
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('offline'))
|
||||
expect(planner.updateRouteForDay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-025: falls back to the generic message for a non-Error failure', async () => {
|
||||
const actions = buildTripActions()
|
||||
actions.reorderAssignments.mockRejectedValue('nope')
|
||||
const planner = makePlanner({ tripActions: actions })
|
||||
const { result } = await renderTimeline(planner)
|
||||
await act(async () => { result.current.moveRow(result.current.merged[0], 'down') })
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('trip.toast.reorderError'))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-026: removes and edits a stop through the planner', async () => {
|
||||
const planner = makePlanner()
|
||||
const { result } = await renderTimeline(planner)
|
||||
act(() => {
|
||||
result.current.removeAssignment(A_PARK)
|
||||
result.current.editAssignment(A_PARK)
|
||||
})
|
||||
expect(planner.handleRemoveAssignment).toHaveBeenCalledWith(2, 12)
|
||||
expect(planner.openPlaceEditor).toHaveBeenCalledWith(PARK, 12)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-027: does not open the editor for a stop missing from the pool', async () => {
|
||||
const planner = makePlanner({ places: [] })
|
||||
const { result } = await renderTimeline(planner)
|
||||
act(() => { result.current.editAssignment(A_PARK) })
|
||||
expect(planner.openPlaceEditor).not.toHaveBeenCalled()
|
||||
expect(result.current.fullPlaceOf(A_PARK)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-028: resolves the full pool entry behind a timeline stop', async () => {
|
||||
const { result } = await renderTimeline(makePlanner())
|
||||
expect(result.current.fullPlaceOf(A_CAFE)).toBe(CAFE)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-029: opens a blank place form from the add bar', async () => {
|
||||
const planner = makePlanner()
|
||||
const { result } = await renderTimeline(planner)
|
||||
act(() => { result.current.addPlace() })
|
||||
expect(planner.setEditingPlace).toHaveBeenCalledWith(null)
|
||||
expect(planner.setEditingAssignmentId).toHaveBeenCalledWith(null)
|
||||
expect(planner.setPrefillCoords).toHaveBeenCalledWith(null)
|
||||
expect(planner.setShowPlaceForm).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-030: opens blank booking and transport forms from the add bar', async () => {
|
||||
const planner = makePlanner()
|
||||
const { result } = await renderTimeline(planner)
|
||||
act(() => {
|
||||
result.current.addBooking()
|
||||
result.current.addTransport()
|
||||
})
|
||||
expect(planner.setEditingReservation).toHaveBeenCalledWith(null)
|
||||
expect(planner.setBookingForAssignmentId).toHaveBeenCalledWith(null)
|
||||
expect(planner.setShowReservationModal).toHaveBeenCalledWith(true)
|
||||
expect(planner.setEditingTransport).toHaveBeenCalledWith(null)
|
||||
expect(planner.setTransitPrefill).toHaveBeenCalledWith(null)
|
||||
expect(planner.setTransportModalAutomated).toHaveBeenCalledWith(false)
|
||||
expect(planner.setTransportModalDayId).toHaveBeenCalledWith(2)
|
||||
expect(planner.setShowTransportModal).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-031: edits a transport row through the transport modal and anything else through the booking modal', async () => {
|
||||
const bus = buildReservation({ id: 51, type: 'bus', title: 'Bus 13A', day_id: 2 })
|
||||
const dinner = buildReservation({ id: 52, type: 'restaurant', title: 'Dinner', day_id: 2 })
|
||||
const planner = makePlanner({ reservations: [bus, dinner] })
|
||||
const { result } = await renderTimeline(planner)
|
||||
// The row carries a leg-expanded copy; the full reservation is looked up by id.
|
||||
act(() => { result.current.editTransport({ ...bus, __leg: { index: 0, total: 2, from: null, to: null } } as TransportEntry) })
|
||||
expect(planner.setEditingTransport).toHaveBeenCalledWith(bus)
|
||||
expect(planner.setShowTransportModal).toHaveBeenCalledWith(true)
|
||||
|
||||
act(() => { result.current.editTransport(dinner as TransportEntry) })
|
||||
expect(planner.setEditingReservation).toHaveBeenCalledWith(dinner)
|
||||
expect(planner.setShowReservationModal).toHaveBeenCalledWith(true)
|
||||
|
||||
// A row whose booking is no longer in the pool edits the row itself.
|
||||
const orphan = buildReservation({ id: 99, type: 'taxi', title: 'Taxi', day_id: 2 }) as TransportEntry
|
||||
act(() => { result.current.editTransport(orphan) })
|
||||
expect(planner.setEditingTransport).toHaveBeenLastCalledWith(orphan)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-032: opens the journey view for a transit row, falling back to the passed row', async () => {
|
||||
const transit = buildReservation({ id: 61, type: 'transit', title: 'U2', day_id: 2 })
|
||||
const planner = makePlanner({ reservations: [transit] })
|
||||
const { result } = await renderTimeline(planner)
|
||||
act(() => { result.current.openTransitJourney({ ...transit, title: 'stale' } as TransportEntry) })
|
||||
expect(planner.setTransitJourney).toHaveBeenCalledWith(transit)
|
||||
|
||||
const orphan = buildReservation({ id: 99, type: 'transit', title: 'Gone', day_id: 2 }) as TransportEntry
|
||||
act(() => { result.current.openTransitJourney(orphan) })
|
||||
expect(planner.setTransitJourney).toHaveBeenLastCalledWith(orphan)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-033: optimizes the day order and offers an undo', async () => {
|
||||
const planner = makePlanner()
|
||||
const { result } = await renderTimeline(planner)
|
||||
await act(async () => { await result.current.optimize() })
|
||||
expect(planner.tripActions.reorderAssignments).toHaveBeenCalledWith(1, 2, [11, 13, 12])
|
||||
expect(planner.pushUndo).toHaveBeenCalledWith('undo.optimize', expect.any(Function))
|
||||
expect(planner.updateRouteForDay).toHaveBeenCalledWith(2)
|
||||
expect(planner.toast.success).toHaveBeenCalledWith('dayplan.toast.routeOptimized')
|
||||
|
||||
const undo = vi.mocked(planner.pushUndo).mock.calls[0][1] as () => Promise<void>
|
||||
await undo()
|
||||
expect(planner.tripActions.reorderAssignments).toHaveBeenLastCalledWith(1, 2, [11, 12, 13])
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-034: reports when the accommodation anchored the optimized route', async () => {
|
||||
const planner = makePlanner({ tripAccommodations: [HOTEL] })
|
||||
const { result } = await renderTimeline(planner)
|
||||
await act(async () => { await result.current.optimize() })
|
||||
expect(planner.toast.success).toHaveBeenCalledWith('dayplan.toast.routeOptimizedFromHotel')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-035: refuses to optimize fewer than two movable stops', async () => {
|
||||
const planner = makePlanner({ assignments: { '2': [A_MUSEUM] } })
|
||||
const { result } = await renderTimeline(planner)
|
||||
await act(async () => { await result.current.optimize() })
|
||||
expect(planner.toast.info).toHaveBeenCalledWith('dayplan.toast.needTwoPlaces')
|
||||
expect(planner.tripActions.reorderAssignments).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-036: reports a failed optimize as an error toast', async () => {
|
||||
const actions = buildTripActions()
|
||||
actions.reorderAssignments.mockRejectedValueOnce(new Error('server down'))
|
||||
const planner = makePlanner({ tripActions: actions })
|
||||
const { result } = await renderTimeline(planner)
|
||||
await act(async () => { await result.current.optimize() })
|
||||
expect(planner.toast.error).toHaveBeenCalledWith('server down')
|
||||
expect(planner.toast.success).not.toHaveBeenCalled()
|
||||
|
||||
actions.reorderAssignments.mockRejectedValue('nope')
|
||||
await act(async () => { await result.current.optimize() })
|
||||
expect(planner.toast.error).toHaveBeenLastCalledWith('trip.toast.reorderError')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-037: exports the day as a Google Maps route', async () => {
|
||||
const { result } = await renderTimeline(makePlanner())
|
||||
act(() => { result.current.exportGoogleMaps() })
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://www.google.com/maps/dir/48,16.1/48,16.3/48,16.2', '_blank', 'noopener,noreferrer',
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-038: opens nothing when the day has no located stops', async () => {
|
||||
const vague = buildAssignment({
|
||||
id: 14, day_id: 2, order_index: 0, place_id: 104,
|
||||
place: buildPlace({ id: 104, name: 'Idea', lat: null, lng: null }),
|
||||
})
|
||||
const { result } = await renderTimeline(makePlanner({ assignments: { '2': [vague] } }))
|
||||
act(() => { result.current.exportGoogleMaps() })
|
||||
expect(window.open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-039: renames the day with a trimmed title', async () => {
|
||||
const planner = makePlanner()
|
||||
const { result } = await renderTimeline(planner)
|
||||
act(() => { result.current.renameDay(' Old Town ') })
|
||||
expect(planner.handleUpdateDayTitle).toHaveBeenCalledWith(2, 'Old Town')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-040: offers the built-in travel modes plus every plugin profile', async () => {
|
||||
usePluginStore.setState({
|
||||
plugins: [
|
||||
{ id: 'ev', name: 'EV', type: 'integration', icon: null, routeProfiles: [{ id: 'eco', label: 'Eco' }, { id: 'fast', label: 'Fast' }] },
|
||||
{ id: 'plain', name: 'Plain', type: 'integration', icon: null },
|
||||
] as ActivePlugin[],
|
||||
})
|
||||
const { result } = await renderTimeline(makePlanner())
|
||||
expect(result.current.routeModeOptions).toEqual([
|
||||
{ key: 'driving', label: 'Driving' },
|
||||
{ key: 'walking', label: 'Walking' },
|
||||
{ key: 'plugin:ev/eco', label: 'Eco' },
|
||||
{ key: 'plugin:ev/fast', label: 'Fast' },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-041: sets a leg mode optimistically and persists it', async () => {
|
||||
seedStore(useTripStore, { assignments: { '2': [A_MUSEUM, A_PARK] } })
|
||||
const { result } = await renderTimeline(makePlanner())
|
||||
await act(async () => { result.current.setLegMode(11, 'walking') })
|
||||
expect(useTripStore.getState().assignments['2'].map(a => a.leg_transport_mode)).toEqual(['walking', undefined])
|
||||
expect(assignmentsApi.updateTransport).toHaveBeenCalledWith(1, 11, 'walking')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-042: reverts to the server state when the leg mode fails to save', async () => {
|
||||
seedStore(useTripStore, { assignments: { '2': [A_MUSEUM] } })
|
||||
vi.mocked(assignmentsApi.updateTransport).mockRejectedValue(new Error('conflict'))
|
||||
const planner = makePlanner()
|
||||
const { result } = await renderTimeline(planner)
|
||||
await act(async () => { result.current.setLegMode(11, null) })
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('conflict'))
|
||||
expect(planner.tripActions.refreshDays).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
// The store may hold no assignments for the day yet (nothing loaded) — the call still goes out.
|
||||
it('FE-MOB-PLTL-043: uses the generic message for a non-Error leg-mode failure', async () => {
|
||||
vi.mocked(assignmentsApi.updateTransport).mockRejectedValue('boom')
|
||||
const planner = makePlanner()
|
||||
const { result } = await renderTimeline(planner)
|
||||
await act(async () => { result.current.setLegMode(11, 'driving') })
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.unknownError'))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user