import { describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '../../../helpers/render'
import { useSettingsStore } from '../../../../src/store/settingsStore'
import { isBlurred } from '../../../helpers/bookingCodeBlur'
import {
ConnRow, HotelConnRow, NoteRow, PlaceRow, PlanScheduleRow, ReorderStack, TransitRow, TransportRow,
} from '../../../../src/mobile/screens/trip/plan/MPlanTimelineRows'
import type { TransitMeta, TransportEntry } from '../../../../src/mobile/screens/trip/plan/planTimelineModel'
import type { PluginDayScheduleItem } from '../../../../src/api/client'
import type { Assignment, DayNote, Place, Reservation, RouteSegment, TranslationFn } from '../../../../src/types'
// FE-MOB-PLROW-001 to FE-MOB-PLROW-044
// Same echo strategy as tests/helpers/mobileTrip: assertions stay on keys, not copy.
const t: TranslationFn = (key, params) =>
params ? `${key}:${Object.values(params).join(',')}` : key
const chrome = (editing = false) => ({ editing, t, language: 'en', timeFormat: '24h' })
const REORDER = reorder
function assignment(over: Record = {}): Assignment {
return {
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,
category: { icon: 'Landmark', color: '#a855f7' },
},
...over,
} as unknown as Assignment
}
const SEG: RouteSegment = {
mid: [35.7, 139.7], from: [35.71, 139.79], to: [35.65, 139.7],
distance: 5400, duration: 900,
walkingText: '1 h 5 min', drivingText: '14 min', distanceText: '5.4 km',
}
describe('ReorderStack', () => {
it('FE-MOB-PLROW-001: renders both arrows with their labels and fires them', () => {
const onUp = vi.fn()
const onDown = vi.fn()
render()
fireEvent.click(screen.getByLabelText('dayplan.moveUp'))
fireEvent.click(screen.getByLabelText('dayplan.moveDown'))
expect(onUp).toHaveBeenCalledTimes(1)
expect(onDown).toHaveBeenCalledTimes(1)
})
it('FE-MOB-PLROW-002: disables the arrow that would fall off the list', () => {
render()
expect(screen.getByLabelText('dayplan.moveUp')).toBeDisabled()
expect(screen.getByLabelText('dayplan.moveDown')).toBeDisabled()
})
it('FE-MOB-PLROW-003: stops the click from opening the row it sits in', () => {
const rowClick = vi.fn()
const onUp = vi.fn()
render(
,
)
fireEvent.click(screen.getByLabelText('dayplan.moveUp'))
expect(onUp).toHaveBeenCalled()
expect(rowClick).not.toHaveBeenCalled()
})
})
describe('PlaceRow', () => {
const props = {
assignment: assignment(),
fullPlace: undefined,
linkedReservations: [],
chrome: chrome(),
reorder: REORDER,
onOpen: vi.fn(),
onEdit: vi.fn(),
onRemove: vi.fn(),
}
it('FE-MOB-PLROW-004: shows name, formatted time and the address subtitle', () => {
render()
expect(screen.getByText('Museum')).toBeInTheDocument()
expect(screen.getByText('09:30')).toBeInTheDocument()
expect(screen.getByText('Museum Rd 1')).toBeInTheDocument()
})
it('FE-MOB-PLROW-005: falls back to the description when there is no address', () => {
const a = assignment({
place: { id: 101, name: 'Museum', address: null, description: 'Ukiyo-e wing' },
})
render()
expect(screen.getByText('Ukiyo-e wing')).toBeInTheDocument()
})
it('FE-MOB-PLROW-006: drops the meta line entirely when there is neither time nor subtitle', () => {
const a = assignment({ place: { id: 101, name: 'Museum' } })
const { container } = render()
expect(container.textContent).toBe('Museum')
})
it('FE-MOB-PLROW-007: a linked booking replaces the subtitle and adds the booking badge', () => {
const linkedRes = { id: 31, status: 'confirmed', confirmation_number: 'X9K' } as unknown as Reservation
render()
expect(screen.getByText('dayplan.confirmed · #X9K')).toBeInTheDocument()
expect(screen.getByText('mobileTrip.resBadge')).toBeInTheDocument()
})
it('FE-MOB-PLROW-008: a pending booking without a number shows just the status', () => {
const linkedRes = { id: 31, status: 'pending', confirmation_number: null } as unknown as Reservation
render()
expect(screen.getByText('dayplan.pendingRes')).toBeInTheDocument()
})
it('FE-MOB-PLROW-009: renders the pool photo of the place in go mode', () => {
const fullPlace = {
id: 101, name: 'Museum', image_url: '/uploads/places/museum.jpg',
google_place_id: null, osm_id: null, lat: 35.71, lng: 139.79,
} as unknown as Place
render()
expect(screen.getByAltText('Museum')).toHaveAttribute('src', '/uploads/places/museum.jpg')
})
it('FE-MOB-PLROW-010: falls back to the assignment projection when the pool entry is missing', () => {
const a = assignment({
place: { id: 101, name: 'Museum', image_url: '/uploads/places/thumb.jpg', category: null },
})
render()
expect(screen.getByAltText('Museum')).toHaveAttribute('src', '/uploads/places/thumb.jpg')
})
it('FE-MOB-PLROW-011: tapping the row opens the place', () => {
const onOpen = vi.fn()
render()
fireEvent.click(screen.getByText('Museum'))
expect(onOpen).toHaveBeenCalledTimes(1)
})
it('FE-MOB-PLROW-012: edit mode swaps the avatar for edit/remove circles plus the reorder slot', () => {
const onEdit = vi.fn()
const onRemove = vi.fn()
const onOpen = vi.fn()
render(
,
)
expect(screen.getByTestId('reorder')).toBeInTheDocument()
fireEvent.click(screen.getByLabelText('common.edit'))
fireEvent.click(screen.getByLabelText('planner.removeFromDay'))
expect(onEdit).toHaveBeenCalledTimes(1)
expect(onRemove).toHaveBeenCalledTimes(1)
// the circles stop propagation so the row does not also open
expect(onOpen).not.toHaveBeenCalled()
})
})
describe('TransportRow', () => {
const FLIGHT = {
id: 21, type: 'flight', title: 'LH 714 to Tokyo', day_id: 2,
reservation_time: '2026-05-02T08:15', reservation_end_time: '2026-05-02T18:40',
metadata: { airline: 'Lufthansa', flight_number: 'LH714', departure_airport: 'MUC', arrival_airport: 'HND' },
} as unknown as TransportEntry
const base = { dayId: 2, chrome: chrome(), reorder: REORDER, onOpen: vi.fn() }
it('FE-MOB-PLROW-013: shows a single-day booking as a start–end range with the flight subtitle', () => {
const { container } = render()
expect(screen.getByText('LH 714 to Tokyo')).toBeInTheDocument()
expect(container.textContent).toContain('08:15 – 18:40')
expect(screen.getByText('Lufthansa · LH714 · MUC → HND')).toBeInTheDocument()
})
it('FE-MOB-PLROW-014: a multi-day span shows only the day-relevant time', () => {
const car = {
id: 22, type: 'car', title: 'Rental car', day_id: 1, end_day_id: 3,
reservation_time: '2026-05-01T09:00', reservation_end_time: '2026-05-03T17:00',
location: 'Hertz Shinjuku',
} as unknown as TransportEntry
const { container, unmount } = render()
expect(container.textContent).toContain('09:00')
expect(container.textContent).not.toContain('–')
expect(screen.getByText('Hertz Shinjuku')).toBeInTheDocument()
unmount()
const dropOff = render()
expect(dropOff.container.textContent).toContain('17:00')
})
it('FE-MOB-PLROW-015: a per-leg row is subtitled with its own from → to', () => {
const leg = { ...FLIGHT, __leg: { index: 1, total: 2, from: 'FRA', to: 'HND' } } as TransportEntry
render()
expect(screen.getByText('FRA → HND')).toBeInTheDocument()
})
it('FE-MOB-PLROW-016: a per-leg row without endpoints has no subtitle', () => {
const leg = { ...FLIGHT, __leg: { index: 0, total: 2, from: null, to: null } } as TransportEntry
const { container } = render()
expect(container.textContent).not.toContain('Lufthansa')
})
it('FE-MOB-PLROW-017: a train subtitle carries number, platform and seat', () => {
const train = {
id: 23, type: 'train', title: 'ICE to Hamburg', day_id: 2,
metadata: JSON.stringify({ train_number: 'ICE 599', platform: '7', seat: '21A' }),
} as unknown as TransportEntry
render()
expect(screen.getByText('ICE 599 · Gl. 7 · Sitz 21A')).toBeInTheDocument()
})
it('FE-MOB-PLROW-018: an untimed booking of an unknown type still renders its title', () => {
const other = { id: 24, type: 'yoga_retreat', title: 'Sunrise yoga', day_id: 2 } as unknown as TransportEntry
const { container } = render()
expect(screen.getByText('Sunrise yoga')).toBeInTheDocument()
expect(container.textContent).toBe('Sunrise yoga')
})
it('FE-MOB-PLROW-019: tapping the row opens it', () => {
const onOpen = vi.fn()
render()
fireEvent.click(screen.getByText('LH 714 to Tokyo'))
expect(onOpen).toHaveBeenCalledTimes(1)
})
it('FE-MOB-PLROW-020: edit mode shows the reorder slot instead of the avatar', () => {
render()
expect(screen.getByTestId('reorder')).toBeInTheDocument()
})
})
describe('TransitRow', () => {
const TRANSIT_RES = {
id: 25, type: 'transit', title: 'Shibuya → Asakusa', day_id: 2,
reservation_time: '2026-05-02T10:05', reservation_end_time: '2026-05-02T10:47',
} as unknown as TransportEntry
const TRANSIT: TransitMeta = {
transfers: 1,
duration: 2520,
legs: [
{ mode: 'WALK', duration: 240, to: { name: 'Shibuya Sta.' } },
{
mode: 'SUBWAY', line: 'G', line_color: '#FF9500', line_text_color: '#ffffff',
duration: 1500, stops: 9,
from: { name: 'Shibuya Sta.', time: '10:05', track: '3' },
to: { name: 'Asakusa Sta.', time: '10:30' },
},
{ mode: 'WALK', duration: 30, to: { name: 'Hotel' } },
],
}
const base = {
res: TRANSIT_RES, transit: TRANSIT, dayId: 2, chrome: chrome(), reorder: REORDER,
onToggle: vi.fn(), onOpenJourney: vi.fn(),
}
it('FE-MOB-PLROW-021: collapsed row shows the from → to headline and the time range', () => {
const { container } = render()
expect(screen.getByText('Shibuya → Asakusa')).toBeInTheDocument()
expect(container.textContent).toContain('10:05 – 10:47')
})
it('FE-MOB-PLROW-022: the strip drops sub-minute walks and keeps the line badge', () => {
render()
// one badge only — the collapsed strip; the 30s walk is filtered out
expect(screen.getAllByText('G')).toHaveLength(1)
expect(screen.queryByText('transit.min:1 · transit.walkTo:Hotel')).not.toBeInTheDocument()
})
it('FE-MOB-PLROW-041: a walk without a duration stays in the strip, like in the expanded list', () => {
const transit: TransitMeta = {
legs: [
{ mode: 'WALK', to: { name: 'Shibuya Sta.' } },
{ mode: 'SUBWAY', line: 'G', duration: 1500, from: { name: 'Shibuya Sta.' }, to: { name: 'Asakusa Sta.' } },
],
}
const { container } = render()
// the strip renders both legs: the footprint chip plus the line badge
const strip = container.querySelector('.flex-wrap') as HTMLElement
expect(strip.children).toHaveLength(2)
expect(screen.getByText('G')).toBeInTheDocument()
})
it('FE-MOB-PLROW-023: expanding renders every leg with duration, stops and platform', () => {
render()
expect(screen.getAllByText('G')).toHaveLength(2)
expect(screen.getByText('transit.min:4 · transit.walkTo:Shibuya Sta.')).toBeInTheDocument()
expect(screen.getByText('Shibuya Sta. → Asakusa Sta.')).toBeInTheDocument()
expect(screen.getByText('transit.min:25 · transit.stops:9 · transit.platform:3')).toBeInTheDocument()
expect(screen.getByText('transit.min:1 · transit.walkTo:Hotel')).toBeInTheDocument()
})
it('FE-MOB-PLROW-024: tapping the header toggles the row', () => {
const onToggle = vi.fn()
render()
fireEvent.click(screen.getByText('Shibuya → Asakusa'))
expect(onToggle).toHaveBeenCalledTimes(1)
})
it('FE-MOB-PLROW-025: an untimed single-leg journey falls back to the raw title and the mode badge', () => {
const res = { id: 26, type: 'transit', title: 'Airport shuttle', day_id: 2 } as unknown as TransportEntry
const transit: TransitMeta = { legs: [{ mode: 'BUS', line: null, duration: 0, from: {}, to: {} }] }
const { container } = render()
expect(screen.getByText('Airport shuttle')).toBeInTheDocument()
expect(screen.getAllByText('BUS')).toHaveLength(2)
expect(container.textContent).not.toContain('10:05')
})
it('FE-MOB-PLROW-038: survives uncoloured lines, missing durations and unnamed stops', () => {
const res = {
id: 27, type: 'transit', title: 'Depot → Bridge', day_id: 2, reservation_time: '2026-05-02T06:00',
} as unknown as TransportEntry
const transit: TransitMeta = {
legs: [
{ mode: 'TRAM', line: '17', from: { name: 'Depot' }, to: { name: 'Bridge' } },
{ mode: 'WALK', duration: 600 },
],
}
const { container } = render()
expect(container.textContent).toContain('06:00')
// no end time — the chip stays a single value
expect(container.textContent).not.toContain('–')
// once as the row headline, once as the expanded leg
expect(screen.getAllByText('Depot → Bridge')).toHaveLength(2)
expect(screen.getByText('transit.min:10 · transit.walkTo:')).toBeInTheDocument()
})
it('FE-MOB-PLROW-026: edit mode opens the journey view and keeps the reorder slot', () => {
const onOpenJourney = vi.fn()
render()
expect(screen.getByTestId('reorder')).toBeInTheDocument()
fireEvent.click(screen.getByLabelText('common.edit'))
expect(onOpenJourney).toHaveBeenCalledTimes(1)
})
})
describe('ConnRow', () => {
it('FE-MOB-PLROW-027: defaults to the driving duration and the distance', () => {
const { container } = render()
expect(screen.getByText('14 min')).toBeInTheDocument()
expect(screen.getByText('· 5.4 km')).toBeInTheDocument()
expect(container.querySelector('button')).toBeNull()
})
it('FE-MOB-PLROW-028: a walking leg shows the walking duration', () => {
render()
expect(screen.getByText('1 h 5 min')).toBeInTheDocument()
})
it('FE-MOB-PLROW-029: a plugin leg uses its own duration text and note', () => {
render()
expect(screen.getByText('22 min')).toBeInTheDocument()
expect(screen.getByText('25 min charge')).toBeInTheDocument()
})
it('FE-MOB-PLROW-030: becomes a button when the leg mode can be changed', () => {
const onTap = vi.fn()
render()
fireEvent.click(screen.getByRole('button'))
expect(onTap).toHaveBeenCalledTimes(1)
})
})
describe('PlanScheduleRow', () => {
const item = (over: Partial = {}): PluginDayScheduleItem => ({
pluginId: 'ev', id: 'charge-1', dayId: 2, minutes: 95, label: 'Charging stop', tone: 'success',
...over,
})
it('FE-MOB-PLROW-031: renders the label with formatted minutes and the tone colour', () => {
const { container } = render()
expect(screen.getByText('Charging stop')).toBeInTheDocument()
expect(container.textContent).toContain('1 h 35 min')
expect(container.querySelector('svg')).toHaveStyle({ color: '#10b981' })
})
it('FE-MOB-PLROW-032: an unknown tone falls back to the default colour and minutes stay optional', () => {
const { container } = render(
,
)
expect(container.textContent).toBe('Charging stop')
expect(container.querySelector('svg')).toHaveStyle({ color: '#4F46E5' })
})
})
describe('HotelConnRow', () => {
it('FE-MOB-PLROW-033: puts the hotel above the leg at the top of the day and below it at the bottom', () => {
const { container, unmount } = render()
const top = container.firstElementChild as HTMLElement
expect(top.children[0].textContent).toBe('Hotel Sacher')
expect(top.children[1].textContent).toContain('14 min')
expect(top.children[1].textContent).toContain('· 5.4 km')
unmount()
const bottom = render()
.container.firstElementChild as HTMLElement
expect(bottom.children[0].textContent).toContain('14 min')
expect(bottom.children[1].textContent).toBe('Hotel Sacher')
})
it('FE-MOB-PLROW-039: a bookend leg reads its travel mode like every other connector', () => {
const seg = { ...SEG, mode: 'plugin:ev/fastest', durationText: '22 min', noteText: '25 min charge' }
render()
expect(screen.getByText('22 min')).toBeInTheDocument()
expect(screen.getByText('25 min charge')).toBeInTheDocument()
expect(screen.queryByText('1 h 5 min')).not.toBeInTheDocument()
})
it('FE-MOB-PLROW-040: a walking bookend leg shows the walking duration', () => {
render()
expect(screen.getByText('1 h 5 min')).toBeInTheDocument()
expect(screen.queryByText('14 min')).not.toBeInTheDocument()
})
})
describe('NoteRow', () => {
const note = (over: Record = {}): DayNote => ({
id: 41, day_id: 2, text: 'Buy museum tickets\nCash only', time: '09:30 at the kiosk',
icon: 'Ticket', sort_order: 0,
...over,
} as unknown as DayNote)
const base = { chrome: chrome(), reorder: REORDER, onEdit: vi.fn() }
it('FE-MOB-PLROW-034: splits the leading time off and keeps the rest of the note below it', () => {
render()
expect(screen.getByText('09:30')).toBeInTheDocument()
expect(screen.getByText('Buy museum tickets')).toBeInTheDocument()
// Two separate blocks since #1629: the extra title lines and the detail are
// each rendered Markdown, so they cannot be joined into one string.
expect(screen.getByText('Cash only')).toBeInTheDocument()
expect(screen.getByText('at the kiosk')).toBeInTheDocument()
})
it('FE-MOB-PLROW-039: renders the detail as Markdown and tints the row with the note colour (#1629)', () => {
render()
expect(screen.getByText('early').tagName).toBe('STRONG')
const card = screen.getByText('Ferry').closest('div[style]') as HTMLElement
expect(card.style.background).toContain('220, 38, 38')
})
it('FE-MOB-PLROW-035: a free-text time stays in the subtitle and shows no time chip', () => {
render()
expect(screen.getByText('before leaving')).toBeInTheDocument()
expect(screen.queryByText('09:30')).not.toBeInTheDocument()
})
it('FE-MOB-PLROW-036: a bare one-line note renders title only', () => {
const { container } = render()
expect(container.textContent).toBe('Pack the day bag')
})
it('FE-MOB-PLROW-037: is inert in go mode and editable in edit mode', () => {
const onEdit = vi.fn()
const { unmount } = render()
fireEvent.click(screen.getByText('Buy museum tickets'))
expect(onEdit).not.toHaveBeenCalled()
unmount()
render()
fireEvent.click(screen.getByText('Buy museum tickets'))
expect(onEdit).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('reorder')).toBeInTheDocument()
})
it('FE-MOB-PLROW-041: a link in the note body opens the link instead of the sheet', () => {
const onEdit = vi.fn()
render()
fireEvent.click(screen.getByRole('link', { name: 'the map' }))
expect(onEdit).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('Buy museum tickets'))
expect(onEdit).toHaveBeenCalledTimes(1)
})
it('FE-MOB-PLROW-045: extra title lines keep their line breaks and turn a pasted URL into a link (#2576)', () => {
const url = 'https://www.google.com/maps/place/Museum/@48.1371079,11.5753822,17z/data=!3m1!4b1!4m6!3m5!1s0x0'
const { container } = render()
expect(screen.getByRole('link', { name: url })).toHaveAttribute('href', url)
// remark-breaks turns the kept newline into a
; joined with a space it would not be there.
expect(container.querySelector('.collab-note-md br')).not.toBeNull()
})
})
// ── Blur booking codes on the plan row (#2457) ────────────────────────────────
describe('PlaceRow blur booking codes (#2457)', () => {
const props = {
assignment: assignment(),
fullPlace: undefined,
chrome: chrome(),
reorder: REORDER,
onOpen: vi.fn(),
onEdit: vi.fn(),
onRemove: vi.fn(),
}
const blurOn = (on: boolean) =>
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, blur_booking_codes: on } })
it('FE-MOB-PLROW-042: the booking code in the row subtitle is blurred while the setting is on', () => {
blurOn(true)
const linkedRes = { id: 31, status: 'confirmed', confirmation_number: 'ROW-SECRET' } as unknown as Reservation
render()
expect(isBlurred(screen.getByText(/ROW-SECRET/))).toBe(true)
})
it('FE-MOB-PLROW-043: the extra booking lines of a stop with several bookings blur their codes too', () => {
blurOn(true)
const first = { id: 31, title: 'Lunch', status: 'confirmed', confirmation_number: 'ROW-ONE' } as unknown as Reservation
const second = { id: 32, title: 'Tour', status: 'pending', confirmation_number: 'ROW-TWO' } as unknown as Reservation
render()
expect(isBlurred(screen.getByText(/ROW-ONE/))).toBe(true)
expect(isBlurred(screen.getByText(/ROW-TWO/))).toBe(true)
})
it('FE-MOB-PLROW-044: with the setting off the code stays plain', () => {
blurOn(false)
const linkedRes = { id: 31, status: 'confirmed', confirmation_number: 'ROW-PLAIN' } as unknown as Reservation
render()
expect(isBlurred(screen.getByText(/ROW-PLAIN/))).toBe(false)
})
})