mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
b7951baf23
- 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
1218 lines
62 KiB
TypeScript
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({})
|
|
})
|
|
})
|