Files
TREK/client/tests/unit/mobile/trip/MPlaceSheet.test.tsx
T
Maurice c6b3f609b7 fix: dev green after the Studio, place picture and tour merges
The ids of long catalogues (fonts, shapes, patterns) read as a checked string
in the book tools' schemas, so restore_book_page and the page edit stay under
their ceilings. The lightbox takes its reading direction from the language.
Baselines follow the removed avatar upload and the lower counts, the doubled
NEWER constants and three unused imports go, the locale indexes are sorted,
and the place gallery, picture, sheet, inspector weight and paddle tests
follow the merged behaviour.
2026-10-23 21:58:43 +02:00

554 lines
26 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../../helpers/msw/server'
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 { __clearEnrichmentCacheForTests } from '../../../../src/components/Planner/placeEnrichment'
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-032
vi.mock('../../../../src/utils/fileDownload', () => ({ openFile: vi.fn() }))
// The gallery says "no pictures" through the shared toast, the sheet's own writes through planner.toast.
const galleryToast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }))
vi.mock('../../../../src/components/shared/Toast', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../../src/components/shared/Toast')>()),
useToast: () => galleryToast,
}))
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),
},
isTourPlace: vi.fn<TripPlanner['isTourPlace']>(() => false),
can: vi.fn(() => true),
reservations: [] as unknown[],
TRANSPORT_TYPES: new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']),
setEditingTransport: vi.fn(),
setTransportModalDayId: vi.fn(),
setTransportModalAutomated: vi.fn(),
setShowTransportModal: vi.fn(),
setEditingReservation: vi.fn(),
setShowReservationModal: vi.fn(),
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)
// The enrichment cache outlives a test: an earlier case's photos for the same place would open the gallery.
sessionStorage.clear()
__clearEnrichmentCacheForTests()
galleryToast.info.mockClear()
})
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()
// The picture opens the place's photos; it is changed in the editor.
expect(screen.getByRole('button', { name: 'Show photos' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Change image' })).not.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()
// The files row itself is a button now (it expands the list), so match the
// upload control by its exact name rather than a substring.
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: the picture opens a gallery of its own picture and the photos of its sources, and writes nothing', async () => {
server.use(
http.post('/api/maps/enrichment', () => HttpResponse.json({
photos: [{
key: 'khm~p0', url: '/api/maps/place-photo/khm~p0/bytes', attribution: 'Alice', license: 'CC BY 4.0',
licenseUrl: null, sourceUrl: 'https://commons.wikimedia.org/wiki/File:KHM.jpg', source: 'wikimedia',
}],
facts: [],
description: null,
})),
)
const { planner } = renderSheet()
expect(document.querySelector('input[type="file"][accept*="image"]')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Show photos' }))
// The custom image is already there, so the gallery opens on it at once.
const gallery = await screen.findByRole('dialog', { name: 'Show photos' })
expect(gallery.querySelector('img')).toHaveAttribute('src', '/uploads/places/khm.jpg')
// The sources' photos join it as they arrive.
expect(await screen.findByText('1 / 2')).toBeInTheDocument()
expect(planner.tripActions.uploadPlaceImage).not.toHaveBeenCalled()
expect(planner.tripActions.updatePlace).not.toHaveBeenCalled()
})
it('FE-MOB-PLSH-020: a place without a picture asks the sources first, and says so when they have none', async () => {
let asked = 0
server.use(
http.post('/api/maps/enrichment', () => {
asked += 1
return HttpResponse.json({ photos: [], facts: [], description: null })
}),
)
renderSheet(makePlanner({ selectedPlace: { ...PLACE, image_url: null } }))
expect(screen.queryByRole('button', { name: 'Upload image' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Remove image' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Show photos' }))
await waitFor(() => expect(galleryToast.info).toHaveBeenCalledWith('No pictures found for this place.'))
expect(asked).toBe(1)
expect(screen.queryByRole('dialog', { name: 'Show photos' })).not.toBeInTheDocument()
})
it('FE-MOB-PLSH-021: without a picture or a source to ask, the picture is a plain picture', () => {
const planner = makePlanner({ selectedPlace: { ...PLACE, image_url: null, lat: null, lng: null } })
renderSheet(planner)
expect(screen.queryByRole('button', { name: 'Show photos' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Change image' })).not.toBeInTheDocument()
})
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)
// A place with coordinates reaches more than one map app, so the circle
// opens the picker instead of guessing which one was meant.
fireEvent.click(screen.getByRole('button', { name: 'Navigation' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Google Maps' }))
// Name and address rather than bare coordinates (#1278): the place has both,
// so Google opens the entry instead of an unlabelled pin.
expect(window.open).toHaveBeenCalledWith(
'https://www.google.com/maps/search/?api=1&query=Kunsthistorisches%20Museum%2C%20Maria-Theresien-Platz', '_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 image' })).not.toBeInTheDocument()
// Looking at the photos is no edit, so a reader keeps it.
expect(screen.getByRole('button', { name: 'Show photos' })).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: 'Navigation' })).toBeInTheDocument()
})
it('FE-MOB-PLSH-026: collapses the track colour picker again when the sheet closes', () => {
const place = { ...PLACE, route_geometry: 'x', route_color: null } as unknown as Place
const planner = makePlanner({ selectedPlace: place })
const shell = makeShell()
const { rerender } = render(<MPlaceSheet planner={planner} shell={shell} />)
fireEvent.click(screen.getByRole('button', { name: /Automatic color/ }))
expect(screen.getAllByRole('button', { name: /Automatic color/ })[0]).toHaveAttribute('aria-expanded', 'true')
// The component stays mounted across selections — only MSheet's children unmount.
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
rerender(<MPlaceSheet planner={makePlanner({ selectedPlace: null })} shell={shell} />)
rerender(<MPlaceSheet planner={planner} shell={shell} />)
expect(screen.getAllByRole('button', { name: /Automatic color/ })[0]).toHaveAttribute('aria-expanded', 'false')
})
// ── The booking attached to this stop (#2012) ──
const bookingPlanner = (res: Record<string, unknown>) => makePlanner({
selectedAssignmentId: 11,
reservations: [res],
})
it('FE-MOB-PLSH-027: shows the booking attached to the day assignment (#2012)', () => {
renderSheet(bookingPlanner({ id: 7, assignment_id: 11, title: 'Ferry to Corfu', status: 'pending', type: 'ferry' }))
expect(screen.getByText('Ferry to Corfu')).toBeInTheDocument()
})
it('FE-MOB-PLSH-028: a transport booking opens the transport editor, not the reservation modal', () => {
const planner = bookingPlanner({ id: 7, assignment_id: 11, title: 'Ferry to Corfu', status: 'pending', type: 'ferry', day_id: 2 })
renderSheet(planner)
fireEvent.click(screen.getByRole('button', { name: 'Edit Reservation' }))
expect(planner.setEditingTransport).toHaveBeenCalledWith(expect.objectContaining({ id: 7 }))
expect(planner.setTransportModalDayId).toHaveBeenCalledWith(2)
expect(planner.setShowTransportModal).toHaveBeenCalledWith(true)
expect(planner.setShowReservationModal).not.toHaveBeenCalled()
// and the place sheet gets out of the way
expect(planner.setSelectedPlaceId).toHaveBeenCalledWith(null)
})
it('FE-MOB-PLSH-029: a non-transport booking opens the reservation modal', () => {
const planner = bookingPlanner({ id: 8, assignment_id: 11, title: 'Museum tour', status: 'confirmed', type: 'activity' })
renderSheet(planner)
fireEvent.click(screen.getByRole('button', { name: 'Edit Reservation' }))
expect(planner.setEditingReservation).toHaveBeenCalledWith(expect.objectContaining({ id: 8 }))
expect(planner.setShowReservationModal).toHaveBeenCalledWith(true)
expect(planner.setShowTransportModal).not.toHaveBeenCalled()
})
it('FE-MOB-PLSH-030: a viewer without edit rights sees the booking but gets no live button', () => {
const planner = makePlanner({
selectedAssignmentId: 11,
reservations: [{ id: 7, assignment_id: 11, title: 'Ferry to Corfu', status: 'pending', type: 'ferry' }],
can: vi.fn(() => false),
})
renderSheet(planner)
// Still readable — just not a control that would do nothing when pressed.
expect(screen.getByText('Ferry to Corfu')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Edit Reservation' })).not.toBeInTheDocument()
const row = screen.getByText('Ferry to Corfu').closest('button') as HTMLButtonElement
expect(row.disabled).toBe(true)
})
it('FE-MOB-PLSH-030b: day_edit alone is not enough for a plain booking, and vice versa', () => {
// A ferry needs day_edit; a member who only has reservation_edit gets no button.
const planner = makePlanner({
selectedAssignmentId: 11,
reservations: [{ id: 7, assignment_id: 11, title: 'Ferry to Corfu', status: 'pending', type: 'ferry' }],
can: vi.fn((perm: string) => perm === 'reservation_edit'),
})
renderSheet(planner)
const row = screen.getByText('Ferry to Corfu').closest('button') as HTMLButtonElement
expect(row.disabled).toBe(true)
})
it('FE-MOB-PLSH-032: the description and the note are rendered markdown, as on the desktop inspector (#2337)', () => {
const marked = {
...PLACE,
description: '# Kunsthistorisches\n\nThe **Habsburg** collections.',
notes: 'Book the *skip-the-line* ticket',
} as unknown as Place
renderSheet(makePlanner({ selectedPlace: marked }) as unknown as TripPlanner)
expect(screen.getByRole('heading', { name: 'Kunsthistorisches' })).toBeInTheDocument()
expect(screen.getByText('Habsburg').tagName).toBe('STRONG')
expect(screen.getByText('skip-the-line').tagName).toBe('EM')
})
it('FE-MOB-PLSH-031: a booking on another assignment is not shown here', () => {
renderSheet(bookingPlanner({ id: 9, assignment_id: 999, title: 'Someone elses ferry', status: 'pending', type: 'ferry' }))
expect(screen.queryByText('Someone elses ferry')).not.toBeInTheDocument()
})
})