// 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() 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() // 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() 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() // 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() 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() 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() 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() expect(screen.queryByRole('button', { name: /Edit details/ })).not.toBeInTheDocument() rerender() 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() 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() 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() 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() // 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() // 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() 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() 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() expect(document.querySelector('.collab-note-md')?.textContent).toContain('first note') const second = { ...makeReservation(), id: 8, title: 'Zoo → Hbf', notes: '' } rerender() 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() 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() 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('') }) })