import { beforeEach, describe, expect, it, vi } from 'vitest' import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render' import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' import type { PluginDaySchedule } from '../../../../src/components/Plugins/PluginDaySchedule' import type { MPlanTimelineController } from '../../../../src/mobile/screens/trip/plan/useMPlanTimeline' import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import type { PlanRow, TransitMeta, TransportEntry } from '../../../../src/mobile/screens/trip/plan/planTimelineModel' import type { MergedItem } from '../../../../src/utils/dayMerge' import type { Assignment, Day, DayNote, Place, RouteSegment } from '../../../../src/types' import MPlanTimeline from '../../../../src/mobile/screens/trip/plan/MPlanTimeline' // FE-MOB-PLTL-001 to FE-MOB-PLTL-049 const mocks = vi.hoisted(() => ({ tl: {} as Record, schedule: {} as PluginDaySchedule, })) vi.mock('../../../../src/mobile/screens/trip/plan/useMPlanTimeline', () => ({ useMPlanTimeline: () => mocks.tl, })) vi.mock('../../../../src/components/Plugins/PluginDaySchedule', async importOriginal => { const actual = await importOriginal() return { ...actual, usePluginDaySchedule: () => mocks.schedule } }) const DAY = { id: 2, trip_id: 1, day_number: 2, date: '2026-05-02', title: 'Tokyo → Kyoto' } as unknown as Day const MUSEUM = { id: 11, day_id: 2, place_id: 101, order_index: 0, place: { id: 101, name: 'Museum', place_time: '09:30', address: 'Museum Rd 1', lat: 35.71, lng: 139.79 }, } as unknown as Assignment const PARK = { id: 12, day_id: 2, place_id: 102, order_index: 1, place: { id: 102, name: 'Ueno Park', lat: 35.72, lng: 139.77 }, } as unknown as Assignment const NOTE = { id: 41, day_id: 2, text: 'Buy museum tickets', time: '08:00', icon: 'Ticket', sort_order: 0, } as unknown as DayNote const FLIGHT = { id: 21, type: 'flight', title: 'LH 714 to Tokyo', day_id: 2, reservation_time: '2026-05-02T08:15', } as unknown as TransportEntry const TRANSIT_RES = { id: 22, type: 'transit', title: 'Shibuya → Asakusa', day_id: 2, reservation_time: '2026-05-02T10:05', } as unknown as TransportEntry const TRANSIT: TransitMeta = { legs: [{ mode: 'SUBWAY', line: 'G', line_color: '#FF9500', duration: 900, from: { name: 'Shibuya Sta.' }, to: { name: 'Asakusa Sta.' }, }], } const SEG: RouteSegment = { mid: [35.7, 139.7], from: [35.71, 139.79], to: [35.72, 139.77], distance: 2100, duration: 600, walkingText: '26 min', drivingText: '7 min', distanceText: '2.1 km', } const M_PLACE: MergedItem = { type: 'place', sortKey: 0, data: MUSEUM } const M_FLIGHT: MergedItem = { type: 'transport', sortKey: 1, data: FLIGHT } const M_TRANSIT: MergedItem = { type: 'transport', sortKey: 2, data: TRANSIT_RES } const M_NOTE: MergedItem = { type: 'note', sortKey: 3, data: NOTE } const M_PLACE2: MergedItem = { type: 'place', sortKey: 4, data: PARK } const MERGED = [M_PLACE, M_FLIGHT, M_TRANSIT, M_NOTE, M_PLACE2] const ROWS: PlanRow[] = [ { key: 'pl-11', kind: 'place', item: M_PLACE, assignment: MUSEUM, linkedReservations: [] }, { key: 'conn-pl-11', kind: 'conn', seg: SEG, assignmentId: 11 }, { key: 'tr-21', kind: 'transport', item: M_FLIGHT, res: FLIGHT }, { key: 'tr-22', kind: 'transit', item: M_TRANSIT, res: TRANSIT_RES, transit: TRANSIT }, { key: 'note-41', kind: 'note', item: M_NOTE, note: NOTE }, { key: 'pl-12', kind: 'place', item: M_PLACE2, assignment: PARK, linkedReservations: [] }, { key: 'conn-orphan', kind: 'conn', seg: { ...SEG, mode: 'walking' } }, ] const EMPTY_SCHEDULE: PluginDaySchedule = { byAssignment: {}, byReservation: {}, byPosition: {}, minutesByDay: {}, } function buildTl(over: Record = {}): MPlanTimelineController { return { day: DAY, rows: ROWS, merged: MERGED, hotelLegs: { top: null, bottom: null }, hotelChips: [], weather: null, weatherTemp: null, weatherPlaceName: null, upNext: { assignment: MUSEUM, minutesUntil: 45 }, language: 'en', timeFormat: '24h', openTransitKeys: new Set(), toggleTransit: vi.fn(), moveRow: vi.fn(), removeAssignment: vi.fn(), editAssignment: vi.fn(), editTransport: vi.fn(), openTransitJourney: vi.fn(), addPlace: vi.fn(), addBooking: vi.fn(), addTransport: vi.fn(), optimize: vi.fn(async () => undefined), canExportRoute: true, exportGoogleMaps: vi.fn(), exportCoMaps: vi.fn(), renameDay: vi.fn(), fullPlaceOf: vi.fn(() => undefined as Place | undefined), routeModeOptions: [ { key: 'driving', label: 'Driving' }, { key: 'walking', label: 'Walking' }, { key: 'plugin:ev/fastest', label: 'EV fastest' }, ], setLegMode: vi.fn(), transitLegFor: vi.fn(() => null), planTransitLeg: vi.fn(), ...over, } as unknown as MPlanTimelineController } /** The scrollable timeline card — its inline top offset encodes the layout mode. */ const card = (container: HTMLElement) => container.querySelector('.overflow-y-auto') as HTMLElement function renderTimeline( tlOver: Record = {}, plannerOver: Partial = {}, shellOver: Partial = {}, ) { mocks.tl = buildTl(tlOver) as unknown as Record const planner = buildPlanner(plannerOver) const shell = buildShell(shellOver) return { planner, shell, ...render() } } describe('MPlanTimeline', () => { beforeEach(() => { mocks.schedule = EMPTY_SCHEDULE }) describe('go mode — up next card', () => { it('FE-MOB-PLTL-001: shows the next stop with its time, subtitle and countdown', () => { renderTimeline() expect(screen.getByText('mobileTrip.upNext')).toBeInTheDocument() expect(screen.getByText('mobileTrip.inCountdown:transit.min:45')).toBeInTheDocument() expect(screen.getAllByText('Museum').length).toBeGreaterThan(0) expect(screen.getAllByText('09:30').length).toBeGreaterThan(0) expect(screen.getAllByText('Museum Rd 1').length).toBeGreaterThan(0) }) it('FE-MOB-PLTL-002: tapping the card selects the place and its assignment', () => { const { planner } = renderTimeline() fireEvent.click(screen.getByText('mobileTrip.upNext')) expect(planner.handlePlaceClick).toHaveBeenCalledWith(101, 11) }) it('FE-MOB-PLTL-003: hides the countdown, time chip and subtitle when the stop has none', () => { const bare = { ...MUSEUM, place: { id: 101, name: 'Museum' } } as unknown as Assignment renderTimeline({ upNext: { assignment: bare, minutesUntil: null }, rows: [], merged: [] }) expect(screen.queryByText(/mobileTrip.inCountdown/)).not.toBeInTheDocument() expect(screen.queryByText('09:30')).not.toBeInTheDocument() expect(screen.queryByText('Museum Rd 1')).not.toBeInTheDocument() }) it('FE-MOB-PLTL-004: collapses the reserved space when the day has no up next', () => { const { container, unmount } = renderTimeline() expect(card(container).style.top).toContain('216px') unmount() const empty = renderTimeline({ upNext: null, rows: [], merged: [] }) expect(screen.queryByText('mobileTrip.upNext')).not.toBeInTheDocument() expect(card(empty.container).style.top).toContain('102px') }) }) describe('timeline header', () => { it('FE-MOB-PLTL-005: renders the accommodation chips and the weather chip', () => { renderTimeline({ hotelChips: [ { key: 'out-1', variant: 'checkout', name: 'Hotel Sacher', time: '11:00:00' }, { key: 'in-2', variant: 'checkin', name: 'Ryokan Kyoto', time: '15:00:00' }, { key: 'stay-3', variant: 'stay', name: 'Capsule Tokyo', time: null }, ], weather: { main: 'Rain', temp: 17 }, weatherTemp: 17, }) expect(screen.getByText(/Hotel Sacher/)).toHaveTextContent('Hotel Sacher · 11:00') expect(screen.getByText(/Ryokan Kyoto/)).toHaveTextContent('Ryokan Kyoto · 15:00') expect(screen.getByText('Capsule Tokyo')).toBeInTheDocument() expect(screen.getByText('17°')).toBeInTheDocument() }) it('FE-MOB-PLTL-005b: the weather chip names its anchor place in the accessible label (#2167)', () => { renderTimeline({ weather: { main: 'Rain', temp: 17 }, weatherTemp: 17, weatherPlaceName: 'Shibuya', }) // echoT renders t('day.weatherFor', { name }) as 'day.weatherFor:Shibuya'. expect(screen.getByRole('button', { name: 'day.overview · day.weatherFor:Shibuya' })).toBeInTheDocument() expect(screen.getByTitle('Shibuya')).toBeInTheDocument() }) it('FE-MOB-PLTL-006: a stay chip opens the stay editor for members who may edit days (#2210)', () => { const { shell } = renderTimeline({ hotelChips: [{ key: 'in-3', variant: 'checkin', name: 'Capsule Tokyo', time: '15:00:00', accId: 3, placeId: 301 }], }) fireEvent.click(screen.getByRole('button', { name: 'day.checkIn · Capsule Tokyo · 15:00' })) expect(shell.openSheet).toHaveBeenCalledWith('accommodation', { dayId: 2, accId: 3, from: 'timeline' }) }) it('FE-MOB-PLTL-006b: without day_edit the chip opens the hotel place instead', () => { const { planner, shell } = renderTimeline( { hotelChips: [{ key: 'stay-3', variant: 'stay', name: 'Capsule Tokyo', time: null, accId: 3, placeId: 301 }] }, { can: vi.fn(() => false) }, ) fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.stay · Capsule Tokyo' })) expect(planner.handlePlaceClick).toHaveBeenCalledWith(301) expect(shell.openSheet).not.toHaveBeenCalled() }) it('FE-MOB-PLTL-006c: a stay without a place still leads a read-only member to the day sheet', () => { const { planner, shell } = renderTimeline( { hotelChips: [{ key: 'out-3', variant: 'checkout', name: 'Capsule Tokyo', time: '11:00', accId: 3, placeId: null }] }, { can: vi.fn(() => false) }, ) fireEvent.click(screen.getByRole('button', { name: 'day.checkOut · Capsule Tokyo · 11:00' })) expect(shell.openSheet).toHaveBeenCalledWith('day', { dayId: 2 }) expect(planner.handlePlaceClick).not.toHaveBeenCalled() }) it('FE-MOB-PLTL-007: still renders, with the day pill, without chips or weather (#2004)', () => { const { container } = renderTimeline() // The pill is the only way into the day sheet, so it may not depend on the // day happening to have an accommodation or a weather reading. expect(container.querySelector('.border-b')).not.toBeNull() expect(screen.getByRole('button', { name: 'day.overview' })).toBeInTheDocument() }) it('FE-MOB-PLTL-008: draws the hotel bookend legs above and below the rows', () => { renderTimeline({ hotelLegs: { top: { seg: SEG, name: 'Hotel Sacher' }, bottom: { seg: SEG, name: 'Ryokan Kyoto' }, }, }) expect(screen.getByText('Hotel Sacher')).toBeInTheDocument() expect(screen.getByText('Ryokan Kyoto')).toBeInTheDocument() }) }) describe('rows', () => { it('FE-MOB-PLTL-009: renders one row per kind', () => { renderTimeline() expect(screen.getAllByText('Museum').length).toBeGreaterThan(0) expect(screen.getByText('Ueno Park')).toBeInTheDocument() expect(screen.getByText('LH 714 to Tokyo')).toBeInTheDocument() expect(screen.getByText('Shibuya → Asakusa')).toBeInTheDocument() expect(screen.getByText('Buy museum tickets')).toBeInTheDocument() expect(screen.getByText('7 min')).toBeInTheDocument() }) it('FE-MOB-PLTL-010: a place row selects its place', () => { const { planner } = renderTimeline() fireEvent.click(screen.getByText('Ueno Park')) expect(planner.handlePlaceClick).toHaveBeenCalledWith(102, 12) }) it('FE-MOB-PLTL-011: a place row without a place still reports the assignment', () => { const orphan = { id: 13, day_id: 2, place_id: 103, order_index: 2, place: null } as unknown as Assignment const rows: PlanRow[] = [{ key: 'pl-13', kind: 'place', item: M_PLACE, assignment: orphan, linkedReservations: [] }] const { planner, container } = renderTimeline({ rows }) fireEvent.click(container.querySelector('.cursor-pointer.items-center') as HTMLElement) expect(planner.handlePlaceClick).toHaveBeenCalledWith(null, 13) }) it('FE-MOB-PLTL-012: a transport row opens the transport sheet in go mode', () => { const { shell } = renderTimeline() fireEvent.click(screen.getByText('LH 714 to Tokyo')) expect(shell.openSheet).toHaveBeenCalledWith('transport', { reservationId: 21 }) }) it('FE-MOB-PLTL-013: a transport row opens the editor in edit mode', () => { const { shell } = renderTimeline({}, {}, { mode: 'edit' }) fireEvent.click(screen.getByText('LH 714 to Tokyo')) expect(mocks.tl.editTransport).toHaveBeenCalledWith(FLIGHT) expect(shell.openSheet).not.toHaveBeenCalledWith('transport', { reservationId: 21 }) }) it('FE-MOB-PLTL-014: a transit row toggles by its row key and renders its legs when open', () => { const { unmount } = renderTimeline() fireEvent.click(screen.getByText('Shibuya → Asakusa')) expect(mocks.tl.toggleTransit).toHaveBeenCalledWith('tr-22') expect(screen.queryByText('Shibuya Sta. → Asakusa Sta.')).not.toBeInTheDocument() unmount() renderTimeline({ openTransitKeys: new Set(['tr-22']) }) expect(screen.getByText('Shibuya Sta. → Asakusa Sta.')).toBeInTheDocument() expect(screen.getByText('transit.min:15')).toBeInTheDocument() }) it('FE-MOB-PLTL-015: a note row opens the note sheet with its note in edit mode', () => { const { shell } = renderTimeline({}, {}, { mode: 'edit' }) fireEvent.click(screen.getByText('Buy museum tickets')) expect(shell.openSheet).toHaveBeenCalledWith('note', { dayId: 2, note: NOTE }) }) it('FE-MOB-PLTL-016: the reorder arrows move the merged item they belong to', () => { renderTimeline({}, {}, { mode: 'edit' }) const ups = screen.getAllByLabelText('dayplan.moveUp') const downs = screen.getAllByLabelText('dayplan.moveDown') // one stack per reorderable row: place, transport, transit, note, place expect(ups).toHaveLength(5) expect(ups[0]).toBeDisabled() expect(downs[4]).toBeDisabled() fireEvent.click(downs[0]) fireEvent.click(ups[4]) expect(mocks.tl.moveRow).toHaveBeenNthCalledWith(1, M_PLACE, 'down') expect(mocks.tl.moveRow).toHaveBeenNthCalledWith(2, M_PLACE2, 'up') }) }) describe('Tours read-only timeline integration', () => { const tour = { ...MUSEUM, id: 91, place_id: 901, place: { ...MUSEUM.place, id: 901, name: 'Assigned Tour' }, tour_place_id: 901 } as Assignment const item: MergedItem = { type: 'place', sortKey: 0, data: tour } const row: PlanRow = { key: 'pl-91', kind: 'place', item, assignment: tour, linkedReservations: [], invalidTour: true } it('shows one localized invalid-endpoint warning and selects the Tour through the shared Place path', () => { const { planner } = renderTimeline({ rows: [row], merged: [item], upNext: null }, { toursEnabled: true }) expect(screen.getAllByText('tours.dayRoute.endpointUnknown')).toHaveLength(1) fireEvent.click(screen.getByText('Assigned Tour')) expect(planner.handlePlaceClick).toHaveBeenCalledWith(901, 91) expect(screen.queryByRole('dialog')).not.toBeInTheDocument() expect(screen.queryByText('tour-planner')).not.toBeInTheDocument() }) it('does not show the warning for valid status or with Tours disabled', () => { const validRow: PlanRow = { ...row, invalidTour: false } const first = renderTimeline({ rows: [validRow], merged: [item], upNext: null }, { toursEnabled: true }) expect(screen.queryByText('tours.dayRoute.endpointUnknown')).not.toBeInTheDocument() first.unmount() renderTimeline({ rows: [row], merged: [item], upNext: null }, { toursEnabled: false }) expect(screen.queryByText('tours.dayRoute.endpointUnknown')).not.toBeInTheDocument() }) }) describe('per-segment travel mode', () => { /** The timeline only offers the mode menu in edit mode, where many buttons exist. */ const renderEditing = () => renderTimeline({}, {}, { mode: 'edit' }) const connector = () => screen.getByText('7 min').closest('button') as HTMLElement it('FE-MOB-PLTL-017: a connector with an origin assignment opens the mode menu', () => { renderEditing() fireEvent.click(connector()) expect(screen.getByText('Driving')).toBeInTheDocument() expect(screen.getByText('Walking')).toBeInTheDocument() expect(screen.getByText('EV fastest')).toBeInTheDocument() expect(screen.getByText('dayplan.transportMode.useDefault')).toBeInTheDocument() }) it('FE-MOB-PLTL-018: picking a mode sets it on the origin assignment', () => { renderEditing() fireEvent.click(connector()) fireEvent.click(screen.getByText('Walking')) expect(mocks.tl.setLegMode).toHaveBeenCalledWith(11, 'walking') }) it('FE-MOB-PLTL-019: the default entry clears the override', () => { renderEditing() fireEvent.click(connector()) fireEvent.click(screen.getByText('dayplan.transportMode.useDefault')) expect(mocks.tl.setLegMode).toHaveBeenCalledWith(11, null) }) it('FE-MOB-PLTL-046: a leg with a transit search behind it offers Public transit and opens it (#2398)', () => { const leg = { from: { name: 'Museum', lat: 35.71, lng: 139.79 }, to: { name: 'Ueno Park', lat: 35.72, lng: 139.77 }, time: '09:30' } renderTimeline({ transitLegFor: vi.fn(() => leg) }, {}, { mode: 'edit' }) fireEvent.click(connector()) expect(mocks.tl.transitLegFor).toHaveBeenCalledWith(SEG) fireEvent.click(screen.getByText('transit.title')) expect(mocks.tl.planTransitLeg).toHaveBeenCalledWith(leg) expect(mocks.tl.setLegMode).not.toHaveBeenCalled() }) it('FE-MOB-PLTL-047: without a transit search for the leg the menu keeps to the road profiles', () => { renderEditing() fireEvent.click(connector()) expect(screen.getByText('Driving')).toBeInTheDocument() expect(screen.queryByText('transit.title')).not.toBeInTheDocument() }) it('FE-MOB-PLTL-020: read-only members get no tappable connectors', () => { renderTimeline({}, { can: vi.fn(() => false) }, { mode: 'edit' }) expect(screen.getByText('7 min').closest('button')).toBeNull() }) it('FE-MOB-PLTL-020b: go mode keeps the connectors read-only even with edit rights', () => { renderTimeline() expect(screen.getByText('7 min').closest('button')).toBeNull() }) }) describe('plugin day schedule', () => { it('FE-MOB-PLTL-021: slots contributions under their anchors and at the day edges', () => { mocks.schedule = { byAssignment: { 2: { 11: [{ pluginId: 'ev', id: 'a1', dayId: 2, assignmentId: 11, minutes: 35, label: 'Charging stop', tone: 'success' }] } }, byReservation: { 2: { 21: [{ pluginId: 'ev', id: 'r1', dayId: 2, reservationId: 21, minutes: 45, label: 'Security', tone: 'warn' }], 22: [{ pluginId: 'ev', id: 'r2', dayId: 2, reservationId: 22, label: 'Ticket check', tone: 'default' }], }, }, byPosition: { 2: { start: [{ pluginId: 'ev', id: 's1', dayId: 2, position: 'start', minutes: 10, label: 'Morning prep', tone: 'default' }], end: [{ pluginId: 'ev', id: 'e1', dayId: 2, position: 'end', minutes: 20, label: 'Evening wrap', tone: 'danger' }], }, }, minutesByDay: { 2: 110 }, } renderTimeline() expect(screen.getByText('Charging stop')).toBeInTheDocument() expect(screen.getByText('Security')).toBeInTheDocument() expect(screen.getByText('Ticket check')).toBeInTheDocument() expect(screen.getByText('Morning prep')).toBeInTheDocument() expect(screen.getByText('Evening wrap')).toBeInTheDocument() }) it('FE-MOB-PLTL-022: contributes nothing while no day is selected', () => { mocks.schedule = { byAssignment: { 2: { 11: [{ pluginId: 'ev', id: 'a1', dayId: 2, assignmentId: 11, minutes: 35, label: 'Charging stop', tone: 'success' }] } }, byReservation: {}, byPosition: { 2: { start: [{ pluginId: 'ev', id: 's1', dayId: 2, position: 'start', label: 'Morning prep', tone: 'default' }], end: [] } }, minutesByDay: { 2: 35 }, } const rows: PlanRow[] = [{ key: 'pl-11', kind: 'place', item: M_PLACE, assignment: MUSEUM, linkedReservations: [] }] renderTimeline({ day: undefined, rows }) expect(screen.queryByText('Charging stop')).not.toBeInTheDocument() expect(screen.queryByText('Morning prep')).not.toBeInTheDocument() }) }) describe('empty day', () => { it('FE-MOB-PLTL-023: shows the mascot empty state in go mode', () => { const { container } = renderTimeline({ rows: [], merged: [], upNext: null }) expect(screen.getByText('dayplan.emptyDay')).toBeInTheDocument() expect(container.querySelector('.trek--guide')).toBeTruthy() }) it('FE-MOB-PLTL-024: replaces it with the action grid in edit mode', () => { renderTimeline({ rows: [], merged: [], upNext: null }, {}, { mode: 'edit' }) expect(screen.queryByText('dayplan.emptyDay')).not.toBeInTheDocument() expect(screen.getByText('mobileTrip.addPlaceShort')).toBeInTheDocument() }) }) describe('edit mode', () => { it('FE-MOB-PLTL-025: reserves the header space and hides the up-next card', () => { const { container } = renderTimeline({}, {}, { mode: 'edit' }) expect(screen.queryByText('mobileTrip.upNext')).not.toBeInTheDocument() expect(card(container).style.top).toContain('140px') }) it('FE-MOB-PLTL-026: splits the day title into city pills', () => { renderTimeline({}, {}, { mode: 'edit' }) expect(screen.getByText('Tokyo')).toBeInTheDocument() expect(screen.getByText('Kyoto')).toBeInTheDocument() }) it('FE-MOB-PLTL-027: falls back to the day number when the day has no title', () => { renderTimeline({ day: { ...DAY, title: null } }, {}, { mode: 'edit' }) expect(screen.getByText('planner.dayN:2')).toBeInTheDocument() // …and the day pill next to it stays icon-only so the name is not doubled. expect(screen.getByRole('button', { name: 'day.overview' })).toHaveTextContent('') }) it('FE-MOB-PLTL-028: the pencil renames the day and Enter commits the change', () => { renderTimeline({}, {}, { mode: 'edit' }) fireEvent.click(screen.getByLabelText('mobileTrip.renameDay')) const input = screen.getByPlaceholderText('mobileTrip.dayTitlePlaceholder') as HTMLInputElement expect(input.value).toBe('Tokyo → Kyoto') fireEvent.change(input, { target: { value: 'Osaka' } }) fireEvent.keyDown(input, { key: 'Enter' }) expect(mocks.tl.renameDay).toHaveBeenCalledWith('Osaka') // committing closes the field again — the pills come back from the store expect(screen.queryByPlaceholderText('mobileTrip.dayTitlePlaceholder')).not.toBeInTheDocument() expect(screen.getByText('Tokyo')).toBeInTheDocument() }) it('FE-MOB-PLTL-029: Escape leaves the title untouched', () => { renderTimeline({}, {}, { mode: 'edit' }) fireEvent.click(screen.getByLabelText('mobileTrip.renameDay')) const input = screen.getByPlaceholderText('mobileTrip.dayTitlePlaceholder') fireEvent.change(input, { target: { value: 'Osaka' } }) fireEvent.keyDown(input, { key: 'Escape' }) expect(mocks.tl.renameDay).not.toHaveBeenCalled() expect(screen.getByText('Tokyo')).toBeInTheDocument() }) it('FE-MOB-PLTL-030: blurring an unchanged draft does not rename', () => { renderTimeline({}, {}, { mode: 'edit' }) fireEvent.click(screen.getByLabelText('mobileTrip.renameDay')) fireEvent.blur(screen.getByPlaceholderText('mobileTrip.dayTitlePlaceholder')) expect(mocks.tl.renameDay).not.toHaveBeenCalled() }) it('FE-MOB-PLTL-031: the calendar button opens the day reorder sheet', () => { const { shell } = renderTimeline({}, {}, { mode: 'edit' }) fireEvent.click(screen.getByLabelText('dayplan.reorderDays')) expect(shell.openSheet).toHaveBeenCalledWith('days') }) it('FE-MOB-PLTL-032: undo is disabled until there is something to undo', () => { const { unmount } = renderTimeline({}, {}, { mode: 'edit' }) expect(screen.getByText('undo.button').closest('button')).toBeDisabled() unmount() const { planner } = renderTimeline( {}, { canUndo: true, lastActionLabel: 'undo.reorder' }, { mode: 'edit' }, ) const button = screen.getByText('undo.button').closest('button') as HTMLButtonElement expect(button).toHaveAttribute('title', 'undo.tooltip:undo.reorder') fireEvent.click(button) expect(planner.handleUndo).toHaveBeenCalledTimes(1) }) it('FE-MOB-PLTL-033: every action tile fires its planner action', () => { const { shell } = renderTimeline({}, {}, { mode: 'edit' }) fireEvent.click(screen.getByText('mobileTrip.addPlaceShort')) fireEvent.click(screen.getByText('mobileTrip.addNoteShort')) fireEvent.click(screen.getByText('mobileTrip.addBookingShort')) fireEvent.click(screen.getByText('mobileTrip.addTransportShort')) fireEvent.click(screen.getByText('dayplan.optimize')) fireEvent.click(screen.getByText('mobileTrip.googleMaps')) expect(mocks.tl.addPlace).toHaveBeenCalledTimes(1) expect(shell.openSheet).toHaveBeenCalledWith('note', { dayId: 2 }) expect(mocks.tl.addBooking).toHaveBeenCalledTimes(1) expect(mocks.tl.addTransport).toHaveBeenCalledTimes(1) expect(mocks.tl.optimize).toHaveBeenCalledTimes(1) expect(mocks.tl.exportGoogleMaps).toHaveBeenCalledTimes(1) }) it('FE-MOB-PLTL-049: a day with no route to hand over offers no Google Maps or CoMaps tile (#2476)', () => { // A moving day whose only content is the flight: the export would be a drive // from one hotel to the other, so the two tiles are left out, not left dead. renderTimeline({ canExportRoute: false }, {}, { mode: 'edit' }) expect(screen.queryByText('mobileTrip.googleMaps')).not.toBeInTheDocument() expect(screen.queryByText('mobileTrip.coMaps')).not.toBeInTheDocument() expect(screen.getByText('dayplan.optimize')).toBeInTheDocument() }) it('FE-MOB-PLTL-034: the note tile is inert while no day is selected', () => { const { shell } = renderTimeline({ day: undefined, rows: [], merged: [] }, {}, { mode: 'edit' }) fireEvent.click(screen.getByText('mobileTrip.addNoteShort')) expect(shell.openSheet).not.toHaveBeenCalled() }) it('FE-MOB-PLTL-038: renaming starts from an empty draft while no day is loaded', () => { renderTimeline({ day: undefined, rows: [], merged: [] }, {}, { mode: 'edit' }) fireEvent.click(screen.getByLabelText('mobileTrip.renameDay')) const input = screen.getByPlaceholderText('mobileTrip.dayTitlePlaceholder') as HTMLInputElement expect(input.value).toBe('') fireEvent.change(input, { target: { value: 'Nara' } }) fireEvent.blur(input) expect(mocks.tl.renameDay).toHaveBeenCalledWith('Nara') }) it('FE-MOB-PLTL-035: read-only members never enter edit mode', () => { renderTimeline({}, { can: vi.fn(() => false) }, { mode: 'edit' }) expect(screen.queryByText('mobileTrip.addPlaceShort')).not.toBeInTheDocument() expect(screen.getByText('mobileTrip.upNext')).toBeInTheDocument() }) it('FE-MOB-PLTL-036: place rows expose edit and remove in edit mode', () => { renderTimeline({}, {}, { mode: 'edit' }) const edits = screen.getAllByLabelText('common.edit') fireEvent.click(edits[0]) fireEvent.click(screen.getAllByLabelText('planner.removeFromDay')[0]) expect(mocks.tl.editAssignment).toHaveBeenCalledWith(MUSEUM) expect(mocks.tl.removeAssignment).toHaveBeenCalledWith(MUSEUM) }) it('FE-MOB-PLTL-048: without place_edit a place row keeps remove and reorder but loses its edit circle (#2446)', () => { renderTimeline({}, { can: vi.fn((action: string) => action !== 'place_edit') as TripPlanner['can'] }, { mode: 'edit' }) // the place row keeps its remove circle and loses the edit one; the // transport and note rows keep theirs, editing those is a day right const row = screen.getByText('Museum').closest('[role="button"]') as HTMLElement expect(within(row).getByLabelText('planner.removeFromDay')).toBeInTheDocument() expect(within(row).queryByLabelText('common.edit')).not.toBeInTheDocument() fireEvent.click(within(row).getByLabelText('planner.removeFromDay')) expect(mocks.tl.removeAssignment).toHaveBeenCalledWith(MUSEUM) expect(mocks.tl.editAssignment).not.toHaveBeenCalled() }) it('FE-MOB-PLTL-037: the transit row opens the journey view from its edit circle', () => { renderTimeline({}, {}, { mode: 'edit' }) const transitCard = screen.getByText('Shibuya → Asakusa').closest('.mt-1\\.5') as HTMLElement fireEvent.click(within(transitCard).getByLabelText('common.edit')) expect(mocks.tl.openTransitJourney).toHaveBeenCalledWith(TRANSIT_RES) }) }) describe('day swipe (#2051)', () => { const DAYS = [ { id: 1, trip_id: 1, day_number: 1 }, DAY, { id: 3, trip_id: 1, day_number: 3 }, ] as unknown as Day[] const panel = (container: HTMLElement) => container.firstElementChild as HTMLElement /** A committing left swipe across the whole panel. */ function swipe(el: HTMLElement, from = 300, to = 180) { fireEvent.touchStart(el, { touches: [{ clientX: from, clientY: 300 }] }) fireEvent.touchMove(el, { touches: [{ clientX: from - 20, clientY: 300 }] }) fireEvent.touchMove(el, { touches: [{ clientX: to, clientY: 300 }] }) fireEvent.touchEnd(el, { touches: [], changedTouches: [{ clientX: to, clientY: 300 }] }) } it('FE-MOB-PLTL-039: swiping the panel selects the next day without re-framing the map', async () => { const { container, planner } = renderTimeline({}, { days: DAYS, selectedDayId: 2 }) swipe(panel(container)) // skipFit must be true: the map stays mounted under the timeline, and // re-fitting it here would move it somewhere nobody asked for. await waitFor(() => expect(planner.handleSelectDay).toHaveBeenCalledWith(3, true)) }) it('FE-MOB-PLTL-040: the header chip strip is excluded from the swipe zone', () => { const { container } = renderTimeline({}, { days: DAYS, selectedDayId: 2 }) expect(container.querySelector('[data-hswipe-ignore]')).toBeInTheDocument() }) it('FE-MOB-PLTL-041: the live region is present and silent until a swipe lands', () => { const { container } = renderTimeline({}, { days: DAYS, selectedDayId: 2 }) const live = container.querySelector('[role="status"]') as HTMLElement expect(live).toHaveAttribute('aria-live', 'polite') expect(live).toHaveAttribute('aria-atomic', 'true') expect(live).toHaveTextContent('') }) it('FE-MOB-PLTL-042: a committed swipe announces the day it reached', async () => { const { container } = renderTimeline({}, { days: DAYS, selectedDayId: 2 }) swipe(panel(container)) await waitFor(() => expect(container.querySelector('[role="status"]')) .toHaveTextContent('mobileTrip.dayAnnounce:3,3')) }) it('FE-MOB-PLTL-043: edit mode keeps both the swipe and the long-press reorder', async () => { const { container, planner } = renderTimeline({}, { days: DAYS, selectedDayId: 2 }, { mode: 'edit' }) swipe(panel(container)) await waitFor(() => expect(planner.handleSelectDay).toHaveBeenCalledWith(3, true)) expect(card(container)).toHaveAttribute('data-touch-drag') }) it('FE-MOB-PLTL-044: a plain tap on a place row still opens it', () => { const { planner } = renderTimeline({}, { days: DAYS, selectedDayId: 2 }) fireEvent.click(screen.getByText('Ueno Park')) expect(planner.handlePlaceClick).toHaveBeenCalledWith(102, 12) }) it('FE-MOB-PLTL-045: nothing narrows touch-action, and the excluded strip stays tappable', () => { const { container, shell } = renderTimeline({}, { days: DAYS, selectedDayId: 2 }) // touch-action intersects down the tree, so a pan-y here would take the // header strip's own horizontal pan with it. expect(panel(container).style.touchAction).toBe('') expect(card(container).style.touchAction).toBe('') fireEvent.click(screen.getByLabelText('day.overview')) expect(shell.openSheet).toHaveBeenCalledWith('day', { dayId: 2 }) }) }) })