Files
TREK/client/src/components/Planner/TransitJourneyModal.test.tsx
T
Maurice 0dd07befa7 feat(planner): bring the plan tab and every trip dialog into the new look
The Plan tab was the last trip tab still in the old design. It now reads
like Bookings, Costs, Lists, Files and Collab, without losing a function:

- Day plan: each day is a card with a head band (day tile with weather,
  stay and rental pills, a "+" menu for place, stay, transport, transit
  and note), rows with "..." menus, white badges and a white route bar.
- Places column: add, search and filters in three rows, a show dropdown,
  category and rating filters, and a selection bar at the foot.
- Place inspector, day details and the stay editor on the shared dialog
  frame; the place form in the editor look with the name in the head band.
- Clicking a booking or transport anywhere on the plan opens the booking
  detail popup first; Edit leads to the editor. The popup is one host
  shared with the Bookings and Transports tabs.
- Files and Collab dialogs, notes, polls, links and chat moved to the same
  frame; shared DialogShell and dialogParts hold the building blocks.
- The map hover card shows the place photo and follows the theme.

Every new and reworded string is translated into all locales, and a few
older English leftovers (help center, cost split, import hints) with them.
Phone screens keep their own look.
2026-09-27 21:00:19 +02:00

276 lines
14 KiB
TypeScript

// FE-PLANNER-TRANSITJOURNEY-001 to 018 — the journey view for a saved transit entry.
import { render, screen, waitFor, fireEvent } 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, buildReservation } from '../../../tests/helpers/factories'
import TransitJourneyModal from './TransitJourneyModal'
function makeReservation() {
return {
...buildReservation({ id: 7, type: 'transit', title: 'Fernsehturm → Zoo', reservation_time: '2025-06-01T08:30:00', status: 'confirmed' }),
metadata: {
transit: {
provider: 'transitous', duration: 1800, transfers: 1, walk_seconds: 240,
legs: [
{ mode: 'WALK', duration: 240, from: { name: 'Start' }, to: { name: 'Alexanderplatz' } },
{ mode: 'SUBWAY', line: 'U2', line_color: '#FF3300', line_text_color: '#FFFFFF', headsign: 'Ruhleben', agency: 'BVG', duration: 1440, stops: 6, from: { name: 'Alexanderplatz', time: '08:36', track: '2' }, to: { name: 'Zoo', time: '09:00' } },
],
},
},
endpoints: [
{ role: 'from', sequence: 0, name: 'Fernsehturm', code: null, lat: 52.52, lng: 13.4, timezone: 'Europe/Berlin', local_date: null, local_time: null },
{ role: 'to', sequence: 1, name: 'Zoo', code: null, lat: 52.5, lng: 13.33, timezone: 'Europe/Berlin', local_date: null, local_time: null },
],
} as any
}
/** The journey's title in the head band, a button that turns into the rename field. */
const titleButton = () => screen.getByRole('button', { name: /^Fernsehturm/ })
function makeProps(overrides = {}) {
return {
reservation: makeReservation(),
onClose: vi.fn(),
onSave: vi.fn().mockResolvedValue({}),
onDelete: vi.fn().mockResolvedValue({}),
onChangeRoute: vi.fn(),
canEdit: true,
...overrides,
}
}
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any)
})
describe('TransitJourneyModal', () => {
it('FE-PLANNER-TRANSITJOURNEY-001: shows summary, line badge, platform and legs', () => {
render(<TransitJourneyModal {...makeProps()} />)
expect(screen.getByText('U2')).toBeInTheDocument()
// stat tiles: value + caption
expect(screen.getByText('Transfers')).toBeInTheDocument()
expect(screen.getByText('Walking')).toBeInTheDocument()
expect(screen.getByText(/Platform 2/)).toBeInTheDocument()
expect(screen.getByText(/Ruhleben/)).toBeInTheDocument()
expect(screen.getByText(/BVG/)).toBeInTheDocument()
})
it('FE-PLANNER-TRANSITJOURNEY-002: inline title rename + notes save as a minimal field payload', async () => {
const user = userEvent.setup()
const onSave = vi.fn().mockResolvedValue({})
render(<TransitJourneyModal {...makeProps({ onSave })} />)
// The title renames inline in the header: a click on it opens the field.
await user.click(titleButton())
const titleInput = screen.getByDisplayValue('Fernsehturm → Zoo')
await user.clear(titleInput)
await user.type(titleInput, 'Zum Zoo')
await user.keyboard('{Enter}')
await user.type(screen.getByPlaceholderText(/notes/i), 'Take **coffee**')
await user.click(screen.getByRole('button', { name: /^Save$/ }))
await waitFor(() => expect(onSave).toHaveBeenCalled())
expect(onSave).toHaveBeenCalledWith({ title: 'Zum Zoo', notes: 'Take **coffee**' })
})
it('FE-PLANNER-TRANSITJOURNEY-006: no status or booking-code fields; notes support a markdown preview', async () => {
const user = userEvent.setup()
render(<TransitJourneyModal {...makeProps()} />)
expect(screen.queryByText('Status')).not.toBeInTheDocument()
expect(screen.queryByText(/Booking code|Confirmation/i)).not.toBeInTheDocument()
await user.type(screen.getByPlaceholderText(/notes/i), '**bold** note')
await user.click(screen.getByRole('button', { name: 'Preview' }))
const bold = document.querySelector('.collab-note-md strong')
expect(bold?.textContent).toBe('bold')
})
it('FE-PLANNER-TRANSITJOURNEY-008: existing notes open rendered as markdown, not raw text', () => {
const res = { ...makeReservation(), notes: 'bring **wefwe** along' }
render(<TransitJourneyModal {...makeProps({ reservation: res })} />)
// Preview tab is active on open: bold is rendered, no raw asterisks visible.
const bold = document.querySelector('.collab-note-md strong')
expect(bold?.textContent).toBe('wefwe')
expect(screen.queryByDisplayValue(/\*\*wefwe\*\*/)).not.toBeInTheDocument()
})
it('FE-PLANNER-TRANSITJOURNEY-007: the markdown toolbar wraps the note text', async () => {
const user = userEvent.setup()
render(<TransitJourneyModal {...makeProps()} />)
const area = screen.getByPlaceholderText(/notes/i) as HTMLTextAreaElement
await user.type(area, 'coffee')
area.setSelectionRange(0, 6)
await user.click(screen.getByRole('button', { name: 'Bold' }))
expect(area.value).toBe('**coffee**')
await user.click(screen.getByRole('button', { name: 'Checklist' }))
expect((screen.getByPlaceholderText(/notes/i) as HTMLTextAreaElement).value).toMatch(/^- \[ \] /)
})
it('FE-PLANNER-TRANSITJOURNEY-003: change route triggers onChangeRoute', async () => {
const user = userEvent.setup()
const onChangeRoute = vi.fn()
render(<TransitJourneyModal {...makeProps({ onChangeRoute })} />)
await user.click(screen.getByRole('button', { name: /Change route/ }))
expect(onChangeRoute).toHaveBeenCalled()
})
it('FE-PLANNER-TRANSITJOURNEY-017: edit details hands off to the full transport editor', async () => {
const user = userEvent.setup()
const onEditDetails = vi.fn()
render(<TransitJourneyModal {...makeProps({ onEditDetails })} />)
await user.click(screen.getByRole('button', { name: /Edit details/ }))
expect(onEditDetails).toHaveBeenCalled()
})
it('FE-PLANNER-TRANSITJOURNEY-018: edit details stays hidden without the callback or edit rights', () => {
const { rerender } = render(<TransitJourneyModal {...makeProps()} />)
expect(screen.queryByRole('button', { name: /Edit details/ })).not.toBeInTheDocument()
rerender(<TransitJourneyModal {...makeProps({ onEditDetails: vi.fn(), canEdit: false })} />)
expect(screen.queryByRole('button', { name: /Edit details/ })).not.toBeInTheDocument()
})
it('FE-PLANNER-TRANSITJOURNEY-004: delete asks for confirmation, then calls onDelete', async () => {
const user = userEvent.setup()
const onDelete = vi.fn().mockResolvedValue({})
render(<TransitJourneyModal {...makeProps({ onDelete })} />)
await user.click(screen.getByRole('button', { name: /^Delete$/ }))
expect(onDelete).not.toHaveBeenCalled()
// Confirm dialog appears — confirm it.
const confirmBtns = await screen.findAllByRole('button', { name: /Delete/ })
await user.click(confirmBtns[confirmBtns.length - 1])
await waitFor(() => expect(onDelete).toHaveBeenCalled())
})
it('FE-PLANNER-TRANSITJOURNEY-005: read-only without edit rights — no delete/save/change-route', () => {
render(<TransitJourneyModal {...makeProps({ canEdit: false })} />)
expect(screen.queryByRole('button', { name: /^Delete$/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Change route/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^Save$/ })).not.toBeInTheDocument()
// The round X in the head band and the Close that finishes the footer.
expect(screen.getAllByRole('button', { name: /^Close$/ })).toHaveLength(2)
})
it('FE-PLANNER-TRANSITJOURNEY-009: metadata stored as a JSON string is parsed into the itinerary', () => {
const res = makeReservation()
res.metadata = JSON.stringify(res.metadata)
render(<TransitJourneyModal {...makeProps({ reservation: res })} />)
expect(screen.getByText('U2')).toBeInTheDocument()
expect(screen.getByText('Transfers')).toBeInTheDocument()
})
it('FE-PLANNER-TRANSITJOURNEY-010: unparsable metadata degrades to a journey without an itinerary', () => {
const res = { ...makeReservation(), metadata: '{not json' }
render(<TransitJourneyModal {...makeProps({ reservation: res })} />)
// Title/date header still renders, the stat tiles and legs do not.
expect(screen.getByText('Fernsehturm')).toBeInTheDocument()
expect(screen.queryByText('Transfers')).not.toBeInTheDocument()
expect(screen.queryByText('U2')).not.toBeInTheDocument()
})
it('FE-PLANNER-TRANSITJOURNEY-011: a zero-duration, zero-walk journey renders em dashes, not "0 min"', () => {
const res = makeReservation()
res.metadata = { transit: { ...res.metadata.transit, duration: 0, walk_seconds: 12, transfers: 0 } }
render(<TransitJourneyModal {...makeProps({ reservation: res })} />)
// Duration + walking tiles both fall back to the dash; transfers shows 0.
expect(screen.getAllByText('—')).toHaveLength(2)
expect(screen.getByText('0')).toBeInTheDocument()
})
it('FE-PLANNER-TRANSITJOURNEY-012: Escape while renaming restores the previous title', async () => {
const user = userEvent.setup()
render(<TransitJourneyModal {...makeProps()} />)
await user.click(titleButton())
const input = screen.getByDisplayValue('Fernsehturm → Zoo')
await user.clear(input)
await user.type(input, 'Scrapped')
await user.keyboard('{Escape}')
// Inline editing closed and the original title is back — save stays disabled.
expect(screen.queryByDisplayValue('Scrapped')).not.toBeInTheDocument()
expect(screen.getByText('Fernsehturm')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /^Save$/ })).toBeDisabled()
})
it('FE-PLANNER-TRANSITJOURNEY-013: blurring the title input closes inline editing but keeps the typed name', async () => {
const user = userEvent.setup()
render(<TransitJourneyModal {...makeProps()} />)
await user.click(titleButton())
const input = screen.getByDisplayValue('Fernsehturm → Zoo')
await user.clear(input)
await user.type(input, 'Zum Zoo')
fireEvent.blur(input)
expect(screen.queryByDisplayValue('Zum Zoo')).not.toBeInTheDocument()
expect(screen.getByText('Zum Zoo')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /^Save$/ })).toBeEnabled()
})
it('FE-PLANNER-TRANSITJOURNEY-014: switching to a different reservation re-seeds title and notes', () => {
const first = { ...makeReservation(), notes: 'first note' }
const { rerender } = render(<TransitJourneyModal {...makeProps({ reservation: first })} />)
expect(document.querySelector('.collab-note-md')?.textContent).toContain('first note')
const second = { ...makeReservation(), id: 8, title: 'Zoo → Hbf', notes: '' }
rerender(<TransitJourneyModal {...makeProps({ reservation: second })} />)
expect(screen.getByText('Hbf')).toBeInTheDocument()
// No notes on the new entry → the write tab is active again.
expect(screen.getByPlaceholderText(/notes/i)).toBeInTheDocument()
})
})
describe('TransitJourneyModal (mobile viewport)', () => {
const desktopWidth = window.innerWidth
beforeEach(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 })
})
afterEach(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: desktopWidth })
})
function makeMobileReservation() {
const res = makeReservation()
res.metadata = {
transit: {
provider: 'transitous', duration: 1800, transfers: 1, walk_seconds: 240,
legs: [
{ mode: 'WALK', duration: 240, from: { name: 'Start' }, to: { name: 'Alexanderplatz' } },
{
mode: 'SUBWAY', line: 'U2', line_color: '#FF3300', line_text_color: '#FFFFFF',
headsign: 'Ruhleben', agency: 'BVG', duration: 1440, stops: 6,
from: { name: 'Alexanderplatz', time: '08:36', track: '2' },
to: { name: 'Zoo', time: '09:00' },
},
// No line/colour/headsign/agency and no times — exercises every fallback.
{ mode: 'BUS', from: { name: 'Zoo' }, to: { name: 'Kudamm' } },
],
},
}
return res
}
it('FE-PLANNER-TRANSITJOURNEY-015: renders the per-leg rail with platform, headsign and meta line', () => {
render(<TransitJourneyModal {...makeProps({ reservation: makeMobileReservation() })} />)
expect(screen.getByText('U2')).toBeInTheDocument()
expect(screen.getByText(/Platform 2/)).toBeInTheDocument()
expect(screen.getByText('Ruhleben')).toBeInTheDocument()
// mins · stops · agency collapse into one quiet line.
expect(screen.getByText(/24 min · 6 stops · BVG/)).toBeInTheDocument()
expect(screen.getByText('08:36')).toBeInTheDocument()
// The meta-less leg falls back to its mode as the badge and shows no meta line.
expect(screen.getByText('BUS')).toBeInTheDocument()
expect(screen.getByText('Kudamm')).toBeInTheDocument()
})
it('FE-PLANNER-TRANSITJOURNEY-016: mobile stat tiles stay iconless but keep value + caption', () => {
render(<TransitJourneyModal {...makeProps({ reservation: makeMobileReservation() })} />)
expect(screen.getByText('Transfers')).toBeInTheDocument()
expect(screen.getByText('Walking')).toBeInTheDocument()
expect(screen.getByText('1')).toBeInTheDocument()
// Delete collapses to an icon-only button on a phone.
const deleteBtn = screen.getByRole('button', { name: 'Delete' })
expect(deleteBtn.textContent?.trim()).toBe('')
})
})