Files
TREK/client/src/components/Planner/TransitSearchPanel.test.tsx
T
Maurice bee500880e fix(i18n): every language says one file and five files its own way
Finishes the plural work the previous wip commit started. t() picks a
count string's form by the language's own Intl.PluralRules, and every
locale now spells out the forms its language reaches with whole
numbers, about 160 count strings in all: one, few and many in Russian,
Ukrainian and Polish, one and few in Czech and Slovak, five forms in
Arabic, one in the others, none in Japanese, Korean, Chinese, Thai,
Vietnamese and Indonesian. Each language was translated and then
reviewed on its own. English no longer reads "1 files", "1 updates
available" or "photo(s)".

Count labels shown next to a number now take that number too: the
atlas and admin statistics, the trip cards, journey and shared trip
pills, packing templates, receipts, storage usage, the Studio export
note. A heading that shared its key with a counted label keeps a key of
its own (the boarding pass heading, the Bookings tab), so languages that
put a noun after a number in the singular keep a plural heading. The
plugin hosts chip passed its count through .replace() after t() and
never got a form; three toasts sent the count as text. Mail and push
texts for shared photos and suppressed storage failures pick their form
in code through pluralForm.

A whole number sent as text counts too, which is how in-app
notification params arrive, and the plural rule follows the language of
the strings on screen while another one still loads.

The parity check knows which forms each language needs and refuses
missing and impossible ones. It also reads keys that contain a colon
now: it skipped them, which hid 58 missing plugin permission texts in
Catalan and 35 in Vietnamese, both translated. The client parity test
covers every language of the registry instead of a hand-kept list of 19,
and the placeholder spec lets a form spell its number out only where
its category is that one number. Brazilian Portuguese error screens
were in Breton, and an Arabic dashboard label held Hebrew text.
2026-10-08 00:01:59 +00:00

702 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// FE-PLANNER-TRANSIT-001 to FE-PLANNER-TRANSIT-030 — the transit search panel
// (embedded as the TransportModal's Automated mode).
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { buildUser, buildDay, buildPlace } from '../../../tests/helpers/factories'
import type { Accommodation, Day } from '../../types'
import TransitSearchPanel, { buildQuickPicks, dayBookingStops } from './TransitSearchPanel'
const { transitApiMock, toastErrors } = vi.hoisted(() => ({
transitApiMock: { geocode: vi.fn(), plan: vi.fn() },
toastErrors: [] as string[],
}))
vi.mock('../../api/client', async (importOriginal) => {
const actual = await importOriginal() as object
return { ...actual, transitApi: transitApiMock }
})
vi.mock('../shared/Toast', () => ({
useToast: () => ({ error: (m: string) => { toastErrors.push(m) }, success: vi.fn() }),
}))
// Berlin, summer time (UTC+2): 06:30Z departs 08:30 local, 07:00Z arrives 09:00.
const ITINERARY = {
startTime: '2025-06-01T06:30:00Z',
endTime: '2025-06-01T07:00:00Z',
duration: 1800,
transfers: 1,
walkSeconds: 240,
legs: [
{ mode: 'WALK', from: { name: 'Start', lat: 52.52, lng: 13.4, time: '2025-06-01T06:30:00Z', scheduledTime: null, track: null }, to: { name: 'Alexanderplatz', lat: 52.521, lng: 13.41, time: '2025-06-01T06:34:00Z', scheduledTime: null, track: null }, duration: 240, distance: 300, headsign: null, line: null, lineColor: null, lineTextColor: null, agency: null, intermediateStops: 0 },
{ mode: 'SUBWAY', from: { name: 'Alexanderplatz', lat: 52.521, lng: 13.41, time: '2025-06-01T06:36:00Z', scheduledTime: null, track: '2' }, to: { name: 'Zoologischer Garten', lat: 52.507, lng: 13.332, time: '2025-06-01T07:00:00Z', scheduledTime: null, track: null }, duration: 1440, distance: null, headsign: 'Ruhleben', line: 'U2', lineColor: '#FF3300', lineTextColor: '#FFFFFF', agency: 'BVG', intermediateStops: 6 },
],
}
const stop = (name: string, lat: number, lng: number, time: string, track: string | null = null) =>
({ name, lat, lng, time, scheduledTime: null, track })
// The shape MOTIS actually returns for the reported connection: every journey is
// bracketed in walking legs, and a change between two stations adds one in the
// middle (#2106).
const WALK_BRACKETED = {
startTime: '2025-06-01T06:30:00Z',
endTime: '2025-06-01T09:10:00Z',
duration: 9600,
transfers: 1,
walkSeconds: 1080,
legs: [
{ mode: 'WALK', from: stop('Aachen, Bushof', 50.777, 6.09, '2025-06-01T06:30:00Z'), to: stop('Aachen Hbf', 50.768, 6.091, '2025-06-01T06:34:00Z'), duration: 240, distance: 300, headsign: null, line: null, lineColor: null, lineTextColor: null, agency: null, intermediateStops: 0 },
{ mode: 'HIGHSPEED_RAIL', from: stop('Aachen Hbf', 50.768, 6.091, '2025-06-01T06:40:00Z', '2'), to: stop('Koeln Hbf', 50.943, 6.959, '2025-06-01T07:30:00Z'), duration: 3000, distance: null, headsign: 'Koeln', line: 'ICE 10', lineColor: null, lineTextColor: null, agency: 'DB', intermediateStops: 1 },
{ mode: 'WALK', from: stop('Koeln Hbf', 50.943, 6.959, '2025-06-01T07:32:00Z'), to: stop('Koeln Messe/Deutz', 50.941, 6.974, '2025-06-01T07:40:00Z'), duration: 480, distance: 600, headsign: null, line: null, lineColor: null, lineTextColor: null, agency: null, intermediateStops: 0 },
{ mode: 'HIGHSPEED_RAIL', from: stop('Koeln Messe/Deutz', 50.941, 6.974, '2025-06-01T07:50:00Z', '11'), to: stop('Frankfurt(Main)Hbf', 50.107, 8.663, '2025-06-01T08:57:00Z'), duration: 4020, distance: null, headsign: 'Frankfurt', line: 'ICE 610', lineColor: null, lineTextColor: null, agency: 'DB', intermediateStops: 2 },
{ mode: 'WALK', from: stop('Frankfurt(Main)Hbf', 50.107, 8.663, '2025-06-01T09:00:00Z'), to: stop('Frankfurt Flughafen Fernbf', 50.053, 8.57, '2025-06-01T09:10:00Z'), duration: 600, distance: 800, headsign: null, line: null, lineColor: null, lineTextColor: null, agency: null, intermediateStops: 0 },
],
}
const day = buildDay({ id: 10, trip_id: 1, date: '2025-06-01', title: 'Berlin Day' })
function makeProps(overrides = {}) {
return {
day,
days: [day],
places: [buildPlace({ id: 1, name: 'Fernsehturm', lat: 52.5208, lng: 13.4094 })],
accommodations: [],
onAdd: vi.fn().mockResolvedValue({}),
...overrides,
}
}
async function pickFromAndTo(user: ReturnType<typeof userEvent.setup>) {
// Quick picks (the day's places) appear on focus with an empty query.
const [fromInput, toInput] = screen.getAllByPlaceholderText('Search stop or station…')
await user.click(fromInput)
await user.click(await screen.findByText('Fernsehturm'))
transitApiMock.geocode.mockResolvedValueOnce({ results: [{ name: 'Zoologischer Garten', lat: 52.507, lng: 13.332, type: 'STOP', area: 'Berlin' }] })
await user.click(toInput)
await user.type(toInput, 'Zoo')
await user.click(await screen.findByText(/Zoologischer Garten/))
}
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
toastErrors.length = 0
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any)
})
describe('TransitSearchPanel', () => {
it('FE-PLANNER-TRANSIT-001: renders from/to pickers, modes and preferences', () => {
render(<TransitSearchPanel {...makeProps()} />)
expect(screen.getAllByPlaceholderText('Search stop or station…')).toHaveLength(2)
expect(screen.getByText('Subway')).toBeInTheDocument()
expect(screen.getByText('Fewer transfers')).toBeInTheDocument()
})
it('FE-PLANNER-TRANSIT-002: searching lists itineraries with times, transfers and line badges', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY] })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
// Local Berlin times, U2 badge, 1 transfer.
expect(await screen.findByText(/08:30 – 09:00/)).toBeInTheDocument()
expect(screen.getByText('U2')).toBeInTheDocument()
expect(screen.getByText('1 transfer')).toBeInTheDocument()
})
it('FE-PLANNER-TRANSIT-003: adding a route builds a transport payload with local times + endpoints', async () => {
const user = userEvent.setup()
const onAdd = vi.fn().mockResolvedValue({})
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY] })
render(<TransitSearchPanel {...makeProps({ onAdd })} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await user.click(await screen.findByText(/08:30 – 09:00/))
await user.click(await screen.findByRole('button', { name: 'Add to day' }))
await waitFor(() => expect(onAdd).toHaveBeenCalled())
const payload = onAdd.mock.calls[0][0]
expect(payload.type).toBe('transit') // first-class transit type (#1065)
expect(payload.title).toBe('Fernsehturm → Zoologischer Garten')
expect(payload.day_id).toBe(10)
expect(payload.reservation_time).toBe('2025-06-01T08:30')
expect(payload.reservation_end_time).toBe('2025-06-01T09:00')
expect(payload.status).toBe('confirmed')
// from + to endpoints (single transit leg → no transfer stops)
expect(payload.endpoints).toHaveLength(2)
expect(payload.endpoints[0]).toMatchObject({ role: 'from', name: 'Fernsehturm', timezone: 'Europe/Berlin' })
expect(payload.endpoints[1]).toMatchObject({ role: 'to', name: 'Zoologischer Garten' })
// compact itinerary stored for the detail modal
expect(payload.metadata.transit.provider).toBe('transitous')
expect(payload.metadata.transit.legs).toHaveLength(2)
expect(payload.metadata.transit.legs[1]).toMatchObject({ mode: 'SUBWAY', line: 'U2', line_color: '#FF3300', headsign: 'Ruhleben' })
})
it('FE-PLANNER-TRANSIT-004: search failure shows the empty state, not a crash', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockRejectedValueOnce(new Error('boom'))
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
expect(await screen.findByText(/No connections found/)).toBeInTheDocument()
})
it('FE-PLANNER-TRANSIT-005: preference "fewer transfers" re-ranks the list', async () => {
const user = userEvent.setup()
const direct = { ...ITINERARY, startTime: '2025-06-01T06:40:00Z', endTime: '2025-06-01T07:20:00Z', duration: 2400, transfers: 0 }
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY, direct] })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await screen.findByText(/08:30 – 09:00/)
await user.click(screen.getByText('Fewer transfers'))
const cards = screen.getAllByText(/–/).filter(el => el.textContent?.match(/\d{2}:\d{2} – \d{2}:\d{2}/))
// The direct (0-transfer) itinerary now ranks first.
expect(cards[0].textContent).toContain('08:40')
})
// Cross-timezone route (#1479): origin Europe/Berlin (UTC+2 in June),
// destination Europe/London (UTC+1). Same wall-clock 09:00 resolves to a
// different UTC instant depending on which end the user anchors to.
async function pickFromAndLondon(user: ReturnType<typeof userEvent.setup>) {
const [fromInput, toInput] = screen.getAllByPlaceholderText('Search stop or station…')
await user.click(fromInput)
await user.click(await screen.findByText('Fernsehturm'))
transitApiMock.geocode.mockResolvedValueOnce({ results: [{ name: 'London Victoria', lat: 51.5074, lng: -0.1278, type: 'STOP', area: 'England' }] })
await user.click(toInput)
await user.type(toInput, 'Lon')
await user.click(await screen.findByText(/London Victoria/))
}
/**
* An empty result is ambiguous without the backend's name: the fallback to
* Transitous is silent, so "no connections" could mean the provider has no
* data here or that the provider you picked never ran (#1699).
*/
it('FE-PLANNER-TRANSIT-006b: the empty state names the backend that answered', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [], provider: 'transitous' })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndLondon(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
expect(await screen.findByText(/No connections found via Transitous/)).toBeInTheDocument()
})
it('FE-PLANNER-TRANSIT-006c: falls back to the unnamed message when the server sends no provider', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndLondon(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
expect(await screen.findByText(/^No connections found\./)).toBeInTheDocument()
})
it('FE-PLANNER-TRANSIT-007: depart-by anchors the entered time to the origin timezone', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndLondon(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled())
// Default 09:00 in Berlin (UTC+2) → 07:00Z.
expect(transitApiMock.plan.mock.calls[0][0]).toMatchObject({ arriveBy: false, time: '2025-06-01T07:00:00.000Z' })
})
it('FE-PLANNER-TRANSIT-008: arrive-by anchors the entered time to the destination timezone', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndLondon(user)
await user.click(screen.getByRole('button', { name: 'Arrive' }))
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled())
// 09:00 arrival in London (UTC+1) → 08:00Z, not 07:00Z.
expect(transitApiMock.plan.mock.calls[0][0]).toMatchObject({ arriveBy: true, time: '2025-06-01T08:00:00.000Z' })
})
it('FE-PLANNER-TRANSIT-008b: initialTime seeds the departure time', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] })
render(<TransitSearchPanel {...makeProps({
initialFrom: { name: 'Fernsehturm', lat: 52.5208, lng: 13.4094 },
initialTo: { name: 'London Victoria', lat: 51.5074, lng: -0.1278 },
initialTime: '11:00',
})} />)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled())
// 11:00 in Berlin (UTC+2) anchors depart-by to the origin zone → 09:00Z.
expect(transitApiMock.plan.mock.calls[0][0]).toMatchObject({ time: '2025-06-01T09:00:00.000Z' })
})
it('FE-PLANNER-TRANSIT-009: arrive-by lists the latest-arriving itinerary first', async () => {
const user = userEvent.setup()
// MOTIS returns arrive-by results ascending, deadline-adjacent last (#1479).
const later = { ...ITINERARY, startTime: '2025-06-01T07:10:00Z', endTime: '2025-06-01T07:40:00Z' }
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY, later] })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: 'Arrive' }))
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await screen.findByText(/09:10 – 09:40/)
const cards = screen.getAllByText(/–/).filter(el => el.textContent?.match(/\d{2}:\d{2} – \d{2}:\d{2}/))
// The itinerary arriving closest to the requested arrival time leads.
expect(cards[0].textContent).toContain('09:40')
expect(cards[1].textContent).toContain('09:00')
})
it('FE-PLANNER-TRANSIT-006: swap exchanges from and to', async () => {
const user = userEvent.setup()
render(<TransitSearchPanel {...makeProps()} />)
const [fromInput] = screen.getAllByPlaceholderText('Search stop or station…')
await user.click(fromInput)
await user.click(await screen.findByText('Fernsehturm'))
await user.click(screen.getByLabelText('Swap'))
const inputs = screen.getAllByPlaceholderText('Search stop or station…')
expect((inputs[0] as HTMLInputElement).value).toBe('')
expect((inputs[1] as HTMLInputElement).value).toBe('Fernsehturm')
})
// ── quick picks ─────────────────────────────────────────────────────────────
it('FE-PLANNER-TRANSIT-010: quick picks add located accommodations and drop duplicates', async () => {
const user = userEvent.setup()
const accommodations = [
{ place_lat: 52.5, place_lng: 13.3, place_name: 'Hotel Berlin' },
// No coordinates / no name — neither may become a quick pick.
{ place_lat: null, place_lng: null, place_name: 'Unlocated Inn' },
{ place_lat: 52.4, place_lng: 13.2, place_name: null },
] as unknown as Accommodation[]
render(<TransitSearchPanel {...makeProps({
places: [
buildPlace({ id: 1, name: 'Fernsehturm', lat: 52.5208, lng: 13.4094 }),
buildPlace({ id: 2, name: 'Fernsehturm', lat: 52.5208, lng: 13.4094 }),
buildPlace({ id: 3, name: 'No coords', lat: null, lng: null }),
],
accommodations,
})} />)
const [fromInput] = screen.getAllByPlaceholderText('Search stop or station…')
await user.click(fromInput)
expect(await screen.findByText('Hotel Berlin')).toBeInTheDocument()
expect(screen.getAllByText('Fernsehturm')).toHaveLength(1)
expect(screen.queryByText('Unlocated Inn')).not.toBeInTheDocument()
expect(screen.queryByText('No coords')).not.toBeInTheDocument()
})
it('FE-PLANNER-TRANSIT-010b: the stay the day starts or ends in survives a day full of stops (#2538)', async () => {
const user = userEvent.setup()
const prev = buildDay({ id: 9, trip_id: 1, day_number: 1, date: '2025-05-31' })
const today = buildDay({ id: 10, trip_id: 1, day_number: 2, date: '2025-06-01', title: 'Berlin Day' })
const next = buildDay({ id: 11, trip_id: 1, day_number: 3, date: '2025-06-02' })
const stay = (id: number, name: string, start: number, end: number, lat: number) =>
({ id, trip_id: 1, place_id: 100 + id, start_day_id: start, end_day_id: end, place_name: name, place_lat: lat, place_lng: 13.38 }) as Accommodation
const accommodations = [
stay(2, 'Later Hostel', 11, 11, 52.3),
stay(1, 'Hotel Adlon', 9, 11, 52.5163),
]
// Nine located stops: the old list kept the first eight places and cut every stay.
const places = Array.from({ length: 9 }, (_, i) => buildPlace({ id: i + 1, name: `Stop ${i + 1}`, lat: 52.4 + i / 100, lng: 13.4 }))
render(<TransitSearchPanel {...makeProps({ day: today, days: [prev, today, next], places, accommodations })} />)
const [fromInput, toInput] = screen.getAllByPlaceholderText('Search stop or station…')
await user.click(fromInput)
const picks = (await screen.findAllByRole('button', { name: /Hotel Adlon|Stop \d|Hostel/ })).map(b => b.textContent)
expect(picks[0]).toBe('Hotel Adlon')
expect(picks.slice(1)).toEqual(['Stop 1', 'Stop 2', 'Stop 3', 'Stop 4', 'Stop 5', 'Stop 6', 'Stop 7', 'Stop 8'])
await user.click(screen.getByRole('button', { name: 'Hotel Adlon' }))
expect(fromInput).toHaveValue('Hotel Adlon')
await user.click(toInput)
expect(await screen.findByRole('button', { name: 'Hotel Adlon' })).toBeInTheDocument()
})
it('FE-PLANNER-TRANSIT-010c: a transfer day offers the stay it leaves and the one it arrives at, then the other stays', async () => {
const user = userEvent.setup()
const d1 = buildDay({ id: 9, trip_id: 1, day_number: 1, date: '2025-05-31' })
const d2 = buildDay({ id: 10, trip_id: 1, day_number: 2, date: '2025-06-01', title: 'Berlin Day' })
const d3 = buildDay({ id: 11, trip_id: 1, day_number: 3, date: '2025-06-02' })
const d4 = buildDay({ id: 12, trip_id: 1, day_number: 4, date: '2025-06-03' })
const stay = (id: number, name: string, start: number, end: number, lat: number) =>
({ id, trip_id: 1, place_id: 100 + id, start_day_id: start, end_day_id: end, place_name: name, place_lat: lat, place_lng: 13.38 }) as Accommodation
const accommodations = [
stay(3, 'Final Inn', 12, 12, 52.2),
stay(2, 'Arrival Hotel', 10, 11, 52.3),
stay(1, 'Departure Hotel', 9, 10, 52.5),
]
render(<TransitSearchPanel {...makeProps({ day: d2, days: [d1, d2, d3, d4], accommodations })} />)
await user.click(screen.getAllByPlaceholderText('Search stop or station…')[0])
const picks = (await screen.findAllByRole('button', { name: /Hotel|Inn|Fernsehturm/ })).map(b => b.textContent)
expect(picks).toEqual(['Departure Hotel', 'Arrival Hotel', 'Fernsehturm', 'Final Inn'])
})
it('FE-PLANNER-TRANSIT-011: hovering a quick pick and a geocode result toggles the row background', async () => {
const user = userEvent.setup()
render(<TransitSearchPanel {...makeProps()} />)
const [fromInput, toInput] = screen.getAllByPlaceholderText('Search stop or station…')
await user.click(fromInput)
const quick = (await screen.findByText('Fernsehturm')).closest('button') as HTMLButtonElement
fireEvent.mouseEnter(quick)
expect(quick.style.background).toBe('var(--bg-hover)')
fireEvent.mouseLeave(quick)
expect(quick.style.background).toBe('none')
transitApiMock.geocode.mockResolvedValueOnce({ results: [{ name: 'Ostbahnhof', lat: 52.51, lng: 13.43, type: 'STOP', area: 'Berlin' }] })
await user.click(toInput)
await user.type(toInput, 'Ost')
const hit = (await screen.findByText('Ostbahnhof')).closest('button') as HTMLButtonElement
fireEvent.mouseEnter(hit)
expect(hit.style.background).toBe('var(--bg-hover)')
fireEvent.mouseLeave(hit)
expect(hit.style.background).toBe('none')
})
it('FE-PLANNER-TRANSIT-012: a failing stop lookup clears the result list instead of throwing', async () => {
const user = userEvent.setup()
transitApiMock.geocode.mockRejectedValueOnce(new Error('offline'))
render(<TransitSearchPanel {...makeProps()} />)
const [, toInput] = screen.getAllByPlaceholderText('Search stop or station…')
await user.click(toInput)
await user.type(toInput, 'Zoo')
await waitFor(() => expect(transitApiMock.geocode).toHaveBeenCalled())
// No dropdown rows, and the panel still works.
await waitFor(() => expect(screen.queryByText('Fernsehturm')).not.toBeInTheDocument())
expect(screen.getByRole('button', { name: /^Search$/ })).toBeInTheDocument()
})
it('FE-PLANNER-TRANSIT-013: a query shorter than two characters never reaches the API', async () => {
const user = userEvent.setup()
render(<TransitSearchPanel {...makeProps()} />)
const [, toInput] = screen.getAllByPlaceholderText('Search stop or station…')
await user.type(toInput, 'Z')
await new Promise(r => setTimeout(r, 350))
expect(transitApiMock.geocode).not.toHaveBeenCalled()
})
// ── filters ─────────────────────────────────────────────────────────────────
it('FE-PLANNER-TRANSIT-014: deselecting a mode narrows the requested modes, the last one cannot be dropped', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndTo(user)
for (const label of ['Train', 'Subway', 'Tram', 'Ferry', 'Cable car']) {
await user.click(screen.getByText(label))
}
// Only "Bus" is left — clicking it must not empty the selection.
await user.click(screen.getByText('Bus'))
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled())
expect(transitApiMock.plan.mock.calls[0][0].modes).toBe('BUS,COACH')
})
it('FE-PLANNER-TRANSIT-015: re-enabling every mode sends no mode filter at all', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndTo(user)
await user.click(screen.getByText('Ferry'))
await user.click(screen.getByText('Ferry'))
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled())
expect(transitApiMock.plan.mock.calls[0][0].modes).toBeUndefined()
})
it('FE-PLANNER-TRANSIT-016: switching back to Depart resets arriveBy', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: 'Arrive' }))
await user.click(screen.getByRole('button', { name: 'Depart' }))
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled())
expect(transitApiMock.plan.mock.calls[0][0].arriveBy).toBe(false)
})
it('FE-PLANNER-TRANSIT-017: "Less walking" ranks the least-walking itinerary first, "Best route" restores the original order', async () => {
const user = userEvent.setup()
const lessWalking = { ...ITINERARY, startTime: '2025-06-01T06:45:00Z', endTime: '2025-06-01T07:25:00Z', walkSeconds: 60 }
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY, lessWalking] })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await screen.findByText(/08:30 – 09:00/)
await user.click(screen.getByText('Less walking'))
const headings = () => screen.getAllByText(/\d{2}:\d{2} – \d{2}:\d{2}/)
expect(headings()[0].textContent).toContain('08:45')
await user.click(screen.getByText('Best route'))
expect(headings()[0].textContent).toContain('08:30')
})
// ── search + add edge cases ─────────────────────────────────────────────────
it('FE-PLANNER-TRANSIT-018: a day without a date never triggers a search', async () => {
const user = userEvent.setup()
const dateless = buildDay({ id: 11, trip_id: 1, date: null, title: 'Unscheduled' } as unknown as Partial<Day>)
render(<TransitSearchPanel {...makeProps({ day: dateless, days: [dateless] })} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
expect(transitApiMock.plan).not.toHaveBeenCalled()
})
it('FE-PLANNER-TRANSIT-019: an unresolvable coordinate falls back to UTC instead of throwing', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY] })
render(<TransitSearchPanel {...makeProps({
places: [buildPlace({ id: 9, name: 'Nowhere', lat: 999, lng: 999 })],
})} />)
const [fromInput, toInput] = screen.getAllByPlaceholderText('Search stop or station…')
await user.click(fromInput)
await user.click(await screen.findByText('Nowhere'))
transitApiMock.geocode.mockResolvedValueOnce({ results: [{ name: 'Zoologischer Garten', lat: 52.507, lng: 13.332, type: 'STOP', area: 'Berlin' }] })
await user.click(toInput)
await user.type(toInput, 'Zoo')
await user.click(await screen.findByText(/Zoologischer Garten/))
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled())
// 09:00 read as UTC because tz-lookup rejected the coordinate.
expect(transitApiMock.plan.mock.calls[0][0].time).toBe('2025-06-01T09:00:00.000Z')
})
it('FE-PLANNER-TRANSIT-020: a multi-transfer itinerary persists an endpoint per transfer stop', async () => {
const user = userEvent.setup()
const onAdd = vi.fn().mockResolvedValue({})
const twoLegs = {
...ITINERARY,
transfers: 2,
legs: [
ITINERARY.legs[1],
{ ...ITINERARY.legs[1], mode: 'BUS', line: '100', from: { name: 'Zoologischer Garten', lat: 52.507, lng: 13.332, time: '2025-06-01T06:50:00Z', scheduledTime: null, track: null }, to: { name: 'Hauptbahnhof', lat: 52.525, lng: 13.369, time: '2025-06-01T07:00:00Z', scheduledTime: null, track: null } },
],
}
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [twoLegs] })
render(<TransitSearchPanel {...makeProps({ onAdd })} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await user.click(await screen.findByText(/08:30 – 09:00/))
await user.click(await screen.findByRole('button', { name: 'Add to day' }))
await waitFor(() => expect(onAdd).toHaveBeenCalled())
const endpoints = onAdd.mock.calls[0][0].endpoints
expect(endpoints).toHaveLength(3)
expect(endpoints[1]).toMatchObject({ role: 'stop', sequence: 1, name: 'Zoologischer Garten', local_time: '09:00' })
expect(endpoints[2]).toMatchObject({ role: 'to', sequence: 2 })
})
it('FE-PLANNER-TRANSIT-021: an after-midnight arrival is attached to the following trip day', async () => {
const user = userEvent.setup()
const onAdd = vi.fn().mockResolvedValue({})
const nextDay = buildDay({ id: 11, trip_id: 1, date: '2025-06-02', title: 'Day 2' })
const overnight = { ...ITINERARY, startTime: '2025-06-01T21:00:00Z', endTime: '2025-06-01T23:30:00Z' }
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [overnight] })
render(<TransitSearchPanel {...makeProps({ onAdd, days: [day, nextDay] })} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await user.click(await screen.findByText(/23:00 – 01:30/))
await user.click(await screen.findByRole('button', { name: 'Add to day' }))
await waitFor(() => expect(onAdd).toHaveBeenCalled())
const payload = onAdd.mock.calls[0][0]
expect(payload.day_id).toBe(10)
expect(payload.end_day_id).toBe(11)
expect(payload.reservation_end_time).toBe('2025-06-02T01:30')
})
it('FE-PLANNER-TRANSIT-022: a failing save toasts instead of leaving the button stuck', async () => {
const user = userEvent.setup()
const onAdd = vi.fn().mockRejectedValue(new Error('save failed'))
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY] })
render(<TransitSearchPanel {...makeProps({ onAdd })} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await user.click(await screen.findByText(/08:30 – 09:00/))
await user.click(await screen.findByRole('button', { name: 'Add to day' }))
await waitFor(() => expect(toastErrors).toContain('Unknown error'))
expect(await screen.findByRole('button', { name: 'Add to day' })).toBeEnabled()
})
it('FE-PLANNER-TRANSIT-023: losing the day date between search and add blocks the save', async () => {
const user = userEvent.setup()
const onAdd = vi.fn().mockResolvedValue({})
const dateless = buildDay({ id: 10, trip_id: 1, date: null, title: 'Unscheduled' } as unknown as Partial<Day>)
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY] })
const props = makeProps({ onAdd })
const { rerender } = render(<TransitSearchPanel {...props} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await user.click(await screen.findByText(/08:30 – 09:00/))
rerender(<TransitSearchPanel {...props} day={dateless} days={[dateless]} />)
await user.click(await screen.findByRole('button', { name: 'Add to day' }))
expect(onAdd).not.toHaveBeenCalled()
})
// ── itinerary rendering ─────────────────────────────────────────────────────
it('FE-PLANNER-TRANSIT-024: durations of an hour or more render as hours and minutes', async () => {
const user = userEvent.setup()
const long = { ...ITINERARY, duration: 5400, endTime: '2025-06-01T08:00:00Z' }
const exact = { ...ITINERARY, startTime: '2025-06-01T10:00:00Z', endTime: '2025-06-01T11:00:00Z', duration: 3600 }
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [long, exact] })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
expect(await screen.findByText('1 h 30 min')).toBeInTheDocument()
expect(screen.getByText('1 h')).toBeInTheDocument()
})
it('FE-PLANNER-TRANSIT-025: every transit mode gets its own line badge icon', async () => {
const user = userEvent.setup()
const leg = (mode: string, line: string) => ({
...ITINERARY.legs[1], mode, line, lineColor: null, lineTextColor: null,
})
const mixed = {
...ITINERARY,
legs: [leg('BUS', 'B1'), leg('TRAM', 'M4'), leg('FERRY', 'F10'), leg('FUNICULAR', 'FU'), leg('AERIAL_LIFT', 'AL'), leg('SUBWAY', 'U2'), leg('HIGHSPEED_RAIL', 'ICE 599')],
}
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [mixed] })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await screen.findByText('B1')
const icon = (text: string) => screen.getAllByText(text)[0].closest('span')?.querySelector('svg')?.getAttribute('class')
expect(icon('B1')).toContain('lucide-bus')
expect(icon('M4')).toContain('lucide-tram-front')
expect(icon('F10')).toContain('lucide-sailboat')
expect(icon('FU')).toContain('lucide-cable-car')
expect(icon('AL')).toContain('lucide-cable-car')
expect(icon('U2')).toContain('lucide-train-front-tunnel')
// Anything else (rail and friends) falls through to TrainFront, which stays
// distinct from the tram glyph.
expect(icon('ICE 599')).toContain('lucide-train-front')
expect(icon('ICE 599')).not.toContain('tunnel')
})
it('FE-PLANNER-TRANSIT-026: a leg without a usable timestamp renders an empty time cell, not "Invalid Date"', async () => {
const user = userEvent.setup()
const broken = {
...ITINERARY,
legs: [
{ ...ITINERARY.legs[1], from: { ...ITINERARY.legs[1].from, time: null } },
{ ...ITINERARY.legs[1], line: 'U5', from: { ...ITINERARY.legs[1].from, name: 'Frankfurter Tor', time: 'not-a-time' } },
],
}
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [broken] })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await user.click(await screen.findByText(/08:30 – 09:00/))
expect(await screen.findByText('Frankfurter Tor')).toBeInTheDocument()
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument()
})
it('FE-PLANNER-TRANSIT-027: an expanded itinerary lists agencies, platform and walking distance', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY] })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await user.click(await screen.findByText(/08:30 – 09:00/))
expect(await screen.findByText('BVG')).toBeInTheDocument()
expect(screen.getByText(/Platform 2/)).toBeInTheDocument()
// Rows are anchored at the leg's start, so the access walk carries the picked
// origin and not the station the next row already names (#2106).
expect(screen.getByText('Start')).toBeInTheDocument()
expect(screen.queryByText('Walk to Alexanderplatz')).not.toBeInTheDocument()
expect(screen.getByText('300 m')).toBeInTheDocument()
expect(screen.getByText('6 stops')).toBeInTheDocument()
})
// #2106 — the walking legs printed their DESTINATION as the row heading, which is
// the stop the next row already names. The stop you actually get off at is a
// walking leg's origin, so it had no row at all and disappeared from the card.
it('FE-PLANNER-TRANSIT-028: the stop you get off at is listed, with the arrival time', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [WALK_BRACKETED] })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await user.click(await screen.findByText(/08:30 – 11:10/))
// The final train's arrival station, which is where the closing walk begins.
expect(await screen.findByText(/Frankfurt\(Main\)Hbf/)).toBeInTheDocument()
// Its arrival time, which the old WALK branch blanked. Not a time the card
// header already prints, or this would pass without the fix.
expect(screen.getByText('11:00')).toBeInTheDocument()
})
it('FE-PLANNER-TRANSIT-029: a walk between two stations names the station it starts from', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [WALK_BRACKETED] })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await user.click(await screen.findByText(/08:30 – 11:10/))
// The change happens on foot between two different stations: the one you leave
// is its own row now, at the time the first train gets in.
expect(await screen.findByText('Koeln Hbf')).toBeInTheDocument()
expect(screen.getByText('09:32')).toBeInTheDocument()
// And the walk is still marked as one, in the badge row rather than the heading.
expect(screen.getAllByText('Walking').length).toBe(3)
})
it('FE-PLANNER-TRANSIT-030: no row is headed with the destination of its own walk', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [WALK_BRACKETED] })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await user.click(await screen.findByText(/08:30 – 11:10/))
await screen.findByText(/Frankfurt\(Main\)Hbf/)
expect(screen.queryByText(/^Walk to /)).not.toBeInTheDocument()
// The picked origin survives instead of being replaced by the first station.
expect(screen.getByText('Aachen, Bushof')).toBeInTheDocument()
})
})
describe('quick picks from the bookings of the day (#1506)', () => {
const day = buildDay({ id: 2, date: '2026-07-08' }) as Day
const flight = {
id: 5, type: 'flight', day_id: 2, end_day_id: 2, status: 'confirmed', title: 'LH 190',
endpoints: [
{ role: 'from', name: 'FRA', lat: 50.03, lng: 8.56, local_date: '2026-07-08', local_time: '09:00', sequence: 0 },
{ role: 'to', name: 'BER Airport', lat: 52.36, lng: 13.5, local_date: '2026-07-08', local_time: '10:10', sequence: 1 },
],
}
const nextDayTrain = {
id: 6, type: 'train', day_id: 3, end_day_id: 3, status: 'confirmed', title: 'ICE',
endpoints: [{ role: 'from', name: 'Berlin Hbf', lat: 52.52, lng: 13.37, local_date: '2026-07-09', local_time: null, sequence: 0 }],
}
const undatedFerry = {
id: 7, type: 'ferry', day_id: 1, end_day_id: 2, status: 'confirmed', title: 'Ferry',
endpoints: [
{ role: 'from', name: 'Kiel', lat: 54.3, lng: 10.1, local_date: null, local_time: null, sequence: 0 },
{ role: 'to', name: 'Oslo', lat: 59.9, lng: 10.7, local_date: null, local_time: null, sequence: 1 },
],
}
const taxi = { id: 8, type: 'taxi', day_id: 2, status: 'confirmed', title: 'Taxi', endpoints: [{ role: 'from', name: 'Home', lat: 1, lng: 1, local_date: '2026-07-08', sequence: 0 }] }
it('FE-PLANNER-TRANSIT-031: the airports and stations of the day are offered, other days and non-carriers are not', () => {
const stops = dayBookingStops(day, [flight, nextDayTrain, undatedFerry, taxi] as any)
expect(stops.map(s => s.name)).toEqual(['FRA', 'BER Airport', 'Oslo'])
})
it('FE-PLANNER-TRANSIT-032: they come right after the stays and are never cut by the cap', () => {
const places = Array.from({ length: 12 }, (_, i) => buildPlace({ id: 100 + i, name: `P${i}`, lat: 52 + i / 100, lng: 13 }))
const picks = buildQuickPicks(day, [day], places as any, [], dayBookingStops(day, [flight] as any))
expect(picks.slice(0, 2).map(p => p.name)).toEqual(['FRA', 'BER Airport'])
expect(picks).toHaveLength(2 + 8)
})
})