Files
TREK/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx
Maurice 9f0caf8539 feat(tours): public transit between two city walks in the day plan, from the first walk's end at the time it ends (CW-08)
"Public transit" on the connector between two city walks in the day plan,
on the desktop and on the phone, now opens the existing search from where
the first walk really ends to where the next one starts, at the
coordinates the day's route draws, named after the first walk's last stop
and the next walk's first stop. A loop ends at its first stop again. A stop
without a name takes the name of the trip place it stands for, and a walk
whose stops cannot be read on this device names that end "End of" or
"Start of" the walk. The search opens on the walks' day and departs when
the first walk ends: its start that day, the time on the day's entry
before the place's own, plus its planned total, a total entered by hand
before walking and stays. The assignment's own end time is not used.

Above the search a note says when the first walk ends, "at the earliest"
where a stay or the walking time is not known yet, and when the next walk
starts. Without a start or a total no time is made up: the time field
stays empty and Search waits until one is chosen. A walk that only ends
after midnight says so, and the time field stays empty as well. On any other day, or for
any other leg, the search opens as before, with 09:00 as its default. A
connection that arrives after the next walk starts says so in its card,
before its legs are opened and before it is taken. Nothing is saved until
"Add to day", which keeps the connection as its own journey in the plan as
before; both walks stay two walks on foot and nothing is moved.

A journey saved before a walk that now arrives after the walk starts says
so on its row in the plan, on the desktop and the phone, while the plan is
looked at. Nothing is rewritten. The desktop no longer draws a connector of
nothing between a stop and a booking at the same spot, as the phone already
did, so a journey saved at the walks' ends adds no walk on either side. A
leg leaving a loop tour no longer departs at the tour's start time.

The leg is told by the walk it leaves, so two walks of a day that end at
one spot, such as two loops from the hotel, each search from their own end
and time, and a stop between two walks keeps the leg an ordinary one. Only
the leg asked for last opens the search, and none opens once the plan is
left while the walks' stops are still being read.

Both views run on one shared hook, the walk's stops are read through one
helper the stop list uses as well, and the walk's start on a day is worked
out in one place for the times shown in the detail and here.
2026-10-20 07:09:57 +00:00

1174 lines
59 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 { resetAllStores, seedStore } from '../../../helpers/store'
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-TRFRM-001 to FE-MOB-TRFRM-059
//
// 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 }
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: {},
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('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-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' })
})
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-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({})
})
})