Files
TREK/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx
T
Maurice b7951baf23 fix(ci): green the dev gates after the native and popup rounds
- RTL: the measured pills and centred controls use logical forms or a marked line
- i18n: the two native widget numbers are positions, not counts (NOT_PLURAL)
- Types: styleOverride on the lazy GL map, the location and shared-file payloads,
  the capabilities fallback, the transport sheet's stay metadata
- ESLint: latest-value refs move to layout effects, usePresence keeps the last
  open state in state, the booking import opens clean with a shared document
  kept in the list (the reset effect used to clear it again)
- Layers: the phone import sheet lives under mobile/ and reads through tripRepo;
  the offline host check moves to utils/tileUrl
- Sizes: plugin layer geometry, hardNavigate and the exit animations get
  modules of their own
- Tests follow the status radios, the stay chip overview, the dock + button
  for the first list and the field token; store registry lists the new stores
2026-11-06 18:24:25 +01:00

1218 lines
62 KiB
TypeScript

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 { useAuthStore } from '../../../../src/store/authStore'
import { server } from '../../../helpers/msw/server'
import { http, HttpResponse } from 'msw'
import { buildBudgetItem, buildUser } from '../../../helpers/factories'
import { useSettingsStore } from '../../../../src/store/settingsStore'
import { isBlurred } from '../../../helpers/bookingCodeBlur'
import type { Day, Reservation, TripMember } from '../../../../src/types'
import { buildPlanner } from '../../../helpers/mobileTrip'
import { plannerSave } from '../../../helpers/bookingSave'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-TRFRM-001 to FE-MOB-TRFRM-062
//
// 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, places }: { value: { name: string } | null; onChange: (l: unknown) => void; places?: { name: string }[] }) => (
<input
aria-label="location-select"
data-picks={(places ?? []).map(p => p.name).join('|')}
value={value?.name ?? ''}
onChange={e => onChange(LOCATIONS[e.target.value] ?? null)}
/>
),
}))
vi.mock('../../../../src/components/Planner/TransitSearchPanel', () => ({
default: ({ day, places, onAdd, initialFrom, initialTime, walk }: {
day: { id: number }
places: { name: string }[]
onAdd: (p: Record<string, unknown>) => void
initialFrom: { name: string } | null
initialTime: string | null
walk: { fromWalk: string } | null
}) => (
<div>
<span data-testid="transit-day">{day.id}</span>
<span data-testid="transit-walk">{walk?.fromWalk ?? ''}</span>
<span data-testid="transit-places">{places.map(p => p.name).join(',')}</span>
<span data-testid="transit-from">{initialFrom?.name ?? ''}</span>
<span data-testid="transit-time">{initialTime ?? ''}</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, canAttach = true, attached = [], linkable = [], onLink }: {
files: File[]
onAdd: (f: File[]) => void
onRemove: (i: number) => void
canAttach?: boolean
attached?: { id: number; original_name: string }[]
linkable?: { id: number; original_name: string }[]
onLink?: (f: { id: number; original_name: string }) => Promise<boolean>
}) => (
<div>
{canAttach && <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>
))}
{attached.map(f => <span key={f.id}>{`attached-${f.original_name}`}</span>)}
{linkable.map(f => (
<button key={f.id} type="button" onClick={() => { void onLink?.(f) }}>{`link-${f.original_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 }
/** A save that stores `result` and runs the sheet's follow-up, as the planner does; null stores nothing. */
function makeSave(result: { id: number } | null = { id: 99 }) {
return result ? plannerSave(result).save : vi.fn(async (_payload: SavePayload) => undefined)
}
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('radio', { 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: {},
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).toEqual({})
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-046: a train row without a picked station no longer anchors the arrival', async () => {
const handleSaveTransport = makeSave()
renderSheet(makePlanner({ handleSaveTransport }))
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.train' }))
typeTitle('Osaka departure')
setValue(locationInputs()[0], 'osaka')
setValue(daySelects()[0], '12')
setValue(timeInputs()[0], '07:45')
// The arrival row carries a day and a time but never gets a station.
setValue(daySelects()[1], '13')
setValue(timeInputs()[1], '09:12')
await submit()
const payload = handleSaveTransport.mock.calls[0][0]
expect(payload.endpoints).toEqual([expect.objectContaining({ role: 'from', name: 'Osaka Station' })])
expect(payload.day_id).toBe(12)
expect(payload.reservation_time).toBe('2026-05-02T07:45')
expect(payload.end_day_id).toBeNull()
expect(payload.reservation_end_time).toBeNull()
})
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 and local_date seed both its arrival and its departure —
// without metadata.legs the endpoint is the only record of the stop's day.
expect(timeInputs().map(i => i.value)).toEqual(['08:00', '08:40', '08:40', '09:35'])
expect(daySelects().map(s => s.value)).toEqual(['12', '12', '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('radio', { 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-047: each segment of a layover saves its own booking code (#1943)', async () => {
const handleSaveTransport = makeSave()
renderSheet(makePlanner({ handleSaveTransport }))
typeTitle('FRA-IST-HND')
// A direct flight writes no legs, so only the booking's own field is offered.
expect(screen.getAllByPlaceholderText('reservations.confirmationPlaceholder')).toHaveLength(1)
fireEvent.click(screen.getByRole('button', { name: 'reservations.layover.addStop' }))
setValue(airportInputs()[0], 'FRA')
setValue(airportInputs()[1], 'IST')
setValue(airportInputs()[2], 'HND')
const codes = screen.getAllByPlaceholderText('reservations.confirmationPlaceholder')
expect(codes).toHaveLength(3)
setValue(codes[0], 'ABC123')
setValue(codes[1], 'XYZ789')
setValue(codes[2], 'BOOK1')
await submit()
const payload = handleSaveTransport.mock.calls[0][0]
const legs = (payload.metadata as { legs: Record<string, unknown>[] }).legs
expect(legs.map(l => l.confirmation_number)).toEqual(['ABC123', 'XYZ789'])
// The booking's own reference stays on its column, never on a segment.
expect(payload.confirmation_number).toBe('BOOK1')
expect(payload.metadata).not.toHaveProperty('confirmation_number')
})
it('FE-MOB-TRFRM-048: a re-save on the phone keeps the stored segment codes (#1943)', async () => {
const handleSaveTransport = makeSave()
const editingTransport = {
id: 45, trip_id: 1, type: 'flight', title: 'FRA-IST-HND', status: 'pending',
day_id: 11, end_day_id: 12, confirmation_number: 'BOOK1',
metadata: {
legs: [
{ from: 'FRA', to: 'IST', confirmation_number: 'ABC123', dep_day_id: 11, dep_time: '10:20', arr_day_id: 11, arr_time: '14:05' },
{ from: 'IST', to: 'HND', confirmation_number: 'XYZ789', dep_day_id: 11, dep_time: '16:30', arr_day_id: 12, arr_time: '09:00' },
],
},
endpoints: [
{ id: 1, reservation_id: 45, 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: 45, 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: 45, 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 }))
const codes = screen.getAllByPlaceholderText('reservations.confirmationPlaceholder') as HTMLInputElement[]
expect(codes.map(i => i.value)).toEqual(['ABC123', 'XYZ789', 'BOOK1'])
await submit('common.update')
const legs = (handleSaveTransport.mock.calls[0][0].metadata as { legs: Record<string, unknown>[] }).legs
expect(legs.map(l => l.confirmation_number)).toEqual(['ABC123', 'XYZ789'])
})
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).toEqual({})
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-029b: a parsed price keeps the currency it was quoted in (#2525)', async () => {
const handleSaveTransport = makeSave()
const planner = makePlanner({
transportPrefill: { type: 'train', title: 'Amtrak', metadata: { price: 117, priceCurrency: 'USD' }, endpoints: [] },
handleSaveTransport,
})
renderSheet(planner)
await submit()
// Sent without it, the server stored 117 in the trip currency.
expect(handleSaveTransport.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 117, category: 'transport', currency: 'USD' })
})
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: 'Create expense' })).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: 'Create expense' }))
})
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, time: '08:15' },
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')
// A leg picked from the timeline's connector menu carries its departure (#2398).
expect(screen.getByTestId('transit-time')).toHaveTextContent('08:15')
expect(screen.getByTestId('transit-walk')).toBeEmptyDOMElement()
})
it('FE-MOB-TRFRM-039b: a leg between two city walks hands the panel what it says of them (CW-08)', () => {
const walk = { dayId: 12, fromWalk: 'Old town', toWalk: 'Harbour', ends: null, endsNextDay: false, endsAtLeast: false, nextStarts: '13:00' }
renderSheet(makePlanner({
transportModalAutomated: true,
transportModalDayId: 12,
transitPrefill: { from: { name: 'Old bridge', lat: 52.51, lng: 13.42 }, to: null, time: null, walk },
}))
expect(screen.getByTestId('transit-walk')).toHaveTextContent('Old town')
})
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' }, undefined)
})
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'])
})
// ── Blur booking codes in the edit sheet (#2457) ───────────────────────────
describe('blur booking codes (#2457)', () => {
/** Both sources the phone reads the preference from: the settings store and planner.settings. */
function plannerWithBlur(on: boolean, overrides: Record<string, unknown> = {}) {
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, blur_booking_codes: on } })
return makePlanner({
settings: { time_format: '24h', date_format: 'DD.MM.YYYY', default_currency: 'EUR', distance_unit: 'km', blur_booking_codes: on },
...overrides,
})
}
const codeFields = () => screen.getAllByPlaceholderText('reservations.confirmationPlaceholder') as HTMLInputElement[]
const layover = () => ({
id: 45, trip_id: 1, type: 'flight', title: 'FRA-IST-HND', status: 'pending',
day_id: 11, end_day_id: 12, confirmation_number: 'BOOK1',
metadata: {
legs: [
{ from: 'FRA', to: 'IST', confirmation_number: 'ABC123', dep_day_id: 11, dep_time: '10:20', arr_day_id: 11, arr_time: '14:05' },
{ from: 'IST', to: 'HND', confirmation_number: 'XYZ789', dep_day_id: 11, dep_time: '16:30', arr_day_id: 12, arr_time: '09:00' },
],
},
endpoints: [
{ id: 1, reservation_id: 45, 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: 45, 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: 45, 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)
it('FE-MOB-TRFRM-049: the booking code and every segment code are blurred while the setting is on', () => {
renderSheet(plannerWithBlur(true, { editingTransport: layover() }))
const codes = codeFields()
expect(codes.map(i => i.value)).toEqual(['ABC123', 'XYZ789', 'BOOK1'])
expect(codes.map(i => isBlurred(i))).toEqual([true, true, true])
})
it('FE-MOB-TRFRM-050: focusing a code field reveals it for editing, leaving it hides it again', () => {
renderSheet(plannerWithBlur(true, { editingTransport: layover() }))
const field = codeFields()[2]
expect(isBlurred(field)).toBe(true)
act(() => field.focus())
expect(isBlurred(field)).toBe(false)
act(() => field.blur())
expect(isBlurred(field)).toBe(true)
})
it('FE-MOB-TRFRM-051: with the setting off every code field stays plain', () => {
renderSheet(plannerWithBlur(false, { editingTransport: layover() }))
expect(codeFields().map(i => isBlurred(i))).toEqual([false, false, false])
})
it('FE-MOB-TRFRM-052: blurred codes still save unchanged', async () => {
const handleSaveTransport = makeSave()
renderSheet(plannerWithBlur(true, { editingTransport: layover(), handleSaveTransport }))
await submit('common.update')
const payload = handleSaveTransport.mock.calls[0][0]
const legs = (payload.metadata as { legs: Record<string, unknown>[] }).legs
expect(legs.map(l => l.confirmation_number)).toEqual(['ABC123', 'XYZ789'])
expect(payload.confirmation_number).toBe('BOOK1')
})
})
it('FE-MOB-TRFRM-053: the manual From and To fields offer the trip places that have a location, each once (#2468)', () => {
renderSheet(makePlanner({
places: [
{ id: 1, name: 'Fushimi Inari', lat: 34.97, lng: 135.77 },
{ id: 2, name: 'No pin yet', lat: null, lng: null },
{ id: 3, name: 'Fushimi Inari', lat: 34.97, lng: 135.77 },
],
}))
fireEvent.click(screen.getByRole('button', { name: 'reservations.type.bus' }))
const fields = locationInputs()
expect(fields).toHaveLength(2)
for (const field of fields) expect(field).toHaveAttribute('data-picks', 'Fushimi Inari')
})
// ── Files and costs of a saved transport (#2084) ───────────────────────────
const SHINKANSEN = {
id: 61, trip_id: 1, type: 'train', title: 'Shinkansen', status: 'confirmed', day_id: 12, metadata: {}, endpoints: [],
} as unknown as Reservation
const TRIP_FILES = [
{ id: 7, trip_id: 1, reservation_id: 61, original_name: 'eticket.pdf', url: '/uploads/eticket.pdf', deleted_at: null },
{ id: 8, trip_id: 1, original_name: 'railpass.pdf', url: '/uploads/railpass.pdf', deleted_at: null },
]
it('FE-MOB-TRFRM-054: an edited transport lists its files and links another trip file to itself', async () => {
seedStore(useAuthStore, { user: buildUser({ id: 1 }) })
seedStore(useTripStore, { trip: { id: 1, user_id: 1, currency: 'EUR' } as never, files: TRIP_FILES as never })
let body: unknown = null
server.use(
http.post('/api/trips/1/files/8/link', async ({ request }) => {
body = await request.json()
return HttpResponse.json({ success: true })
}),
http.get('/api/trips/1/files', () => HttpResponse.json({ files: TRIP_FILES })),
)
renderSheet(makePlanner({ editingTransport: SHINKANSEN }))
expect(screen.getByText('attached-eticket.pdf')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'link-railpass.pdf' }))
await waitFor(() => expect(body).toEqual({ reservation_id: 61 }))
})
it('FE-MOB-TRFRM-055: without upload permission the files of a transport are still shown, without the picker', () => {
seedStore(useTripStore, { files: TRIP_FILES as never })
renderSheet(makePlanner({ editingTransport: SHINKANSEN, canUploadFiles: false }))
expect(screen.getByText('attached-eticket.pdf')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'attach-file' })).not.toBeInTheDocument()
// Nobody is signed in here, so linking is not offered either.
expect(screen.queryByRole('button', { name: 'link-railpass.pdf' })).not.toBeInTheDocument()
})
it('FE-MOB-TRFRM-056: a linked expense of the transport opens in the cost sheet for editing', () => {
const fare = buildBudgetItem({ id: 90, trip_id: 1, name: 'Train fare', total_price: 140, reservation_id: 61 })
seedStore(useTripStore, { trip: { id: 1, currency: 'EUR' } as never, budgetItems: [fare] })
const onOpenExpense = vi.fn()
renderSheet(makePlanner({ editingTransport: SHINKANSEN }), onOpenExpense)
fireEvent.click(screen.getByText('Train fare'))
expect(onOpenExpense).toHaveBeenCalledWith({ editItem: fare })
})
it('FE-MOB-TRFRM-057: a new transport has nothing to link its costs to yet', () => {
seedStore(useTripStore, { trip: { id: 1, currency: 'EUR' } as never, budgetItems: [buildBudgetItem({ id: 91, trip_id: 1, name: 'Loose' })] })
renderSheet(makePlanner())
expect(screen.getByRole('button', { name: 'Create expense' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Link' })).not.toBeInTheDocument()
})
it('FE-MOB-TRFRM-058: files attached while editing a transport are uploaded against it (#2534)', async () => {
const handleSaveTransport = makeSave(SHINKANSEN)
const addFile = vi.fn(async (_tripId: number, _form: FormData) => undefined)
renderSheet(makePlanner({ editingTransport: SHINKANSEN, handleSaveTransport, tripActions: { addFile } }))
fireEvent.click(screen.getByRole('button', { name: 'attach-file' }))
await submit('common.update')
expect(addFile).toHaveBeenCalledTimes(1)
const fd = addFile.mock.calls[0][1] as FormData
expect(fd.get('reservation_id')).toBe('61')
expect(fd.get('description')).toBe('Shinkansen')
expect((fd.get('file') as File).name).toBe('extra.pdf')
})
it('FE-MOB-TRFRM-061: a failed upload keeps the sheet and the file left, and the retry updates the same booking (#2883)', async () => {
const { save, failures } = plannerSave({ id: 99 })
const addFile = vi.fn(async (_tripId: number, _form: FormData) => undefined)
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('413'))
const planner = makePlanner({ importReviewActive: true, handleSaveTransport: save, tripActions: { addFile } })
renderSheet(planner)
typeTitle('Flight with docs')
fireEvent.click(screen.getByRole('button', { name: 'attach-file' }))
fireEvent.click(screen.getByRole('button', { name: 'attach-file' }))
await submit()
await waitFor(() => expect(failures).toHaveLength(1))
expect(planner.advanceImportReview).not.toHaveBeenCalled()
expect(screen.getAllByRole('button', { name: 'drop-extra.pdf' })).toHaveLength(1)
expect(save.mock.calls[0][1]?.saved).toBeUndefined()
await submit()
await waitFor(() => expect(planner.advanceImportReview).toHaveBeenCalledTimes(1))
expect(save.mock.calls[1][1]?.saved).toEqual({ id: 99 })
expect(addFile).toHaveBeenCalledTimes(3)
expect((addFile.mock.calls[2][1] as FormData).get('reservation_id')).toBe('99')
})
it('FE-MOB-TRFRM-062: the next transport of an import review is added, not written over the one saved before it', async () => {
// The review keeps the sheet open and only swaps the draft, so the open flag never changes.
const { save } = plannerSave({ id: 99 })
const review = { importReviewActive: true, handleSaveTransport: save }
const first = makePlanner({ ...review, transportPrefill: { type: 'bus', title: 'Shuttle', metadata: {}, endpoints: [] } })
const { rerender, onOpenExpense } = renderSheet(first)
await submit()
await waitFor(() => expect(first.advanceImportReview).toHaveBeenCalledTimes(1))
const second = makePlanner({ ...review, transportPrefill: { type: 'train', title: 'Amtrak', metadata: {}, endpoints: [] } })
rerender(<MTransportFormSheet planner={second} onOpenExpense={onOpenExpense} />)
expect(screen.getByPlaceholderText('reservations.titlePlaceholder')).toHaveValue('Amtrak')
await submit()
await waitFor(() => expect(second.advanceImportReview).toHaveBeenCalledTimes(1))
expect(save).toHaveBeenCalledTimes(2)
expect(save.mock.calls[1][1]?.saved).toBeUndefined()
})
it('FE-MOB-TRFRM-059: a failed transport edit keeps the attached files off the server', async () => {
const handleSaveTransport = vi.fn(async (_payload: SavePayload) => undefined)
const addFile = vi.fn(async (_tripId: number, _form: FormData) => undefined)
renderSheet(makePlanner({ editingTransport: SHINKANSEN, handleSaveTransport, tripActions: { addFile } }))
fireEvent.click(screen.getByRole('button', { name: 'attach-file' }))
await submit('common.update')
expect(handleSaveTransport).toHaveBeenCalled()
expect(addFile).not.toHaveBeenCalled()
})
it('FE-MOB-TRFRM-060: editing a bus with a stored price sends an empty metadata object, not null, so the price survives (#2233)', async () => {
const handleSaveTransport = makeSave()
const editingTransport = {
id: 62, trip_id: 1, type: 'bus', title: 'Airport shuttle', status: 'confirmed', day_id: 12,
metadata: { price: '45', priceCurrency: 'EUR' }, endpoints: [],
} as unknown as Reservation
renderSheet(makePlanner({ editingTransport, handleSaveTransport }))
typeTitle('Airport shuttle (late)')
await submit('common.update')
// The server reads null as "clear the column" and only carries the expense
// price over into an object, so null here would wipe price / priceCurrency.
expect(handleSaveTransport.mock.calls[0][0].metadata).toEqual({})
})
})