fix(roadtrip): the rail passes the strict and lint gates, and its tests describe the new markup

The day head falls back to metric when no distance unit is set, VISIT_BOX is no longer exported, the day head is split into smaller parts and the booking icon is created once. The component tests now check the pill badges, the day tile and the ride cards instead of the old boxes, and both baselines are lowered to the new counts.
This commit is contained in:
Maurice
2026-10-11 14:01:18 +02:00
parent e8fd41b9b4
commit 32606d20a6
13 changed files with 458 additions and 267 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 407,
"@typescript-eslint/no-misused-promises": 877,
"@typescript-eslint/no-misused-promises": 876,
"no-console": 88,
"complexity": 231,
"@typescript-eslint/no-explicit-any": 154,
+1 -1
View File
@@ -72,7 +72,7 @@
"src/components/Roadtrip/RoadtripCorridorPanel.tsx": 4,
"src/components/Roadtrip/RoadtripSidebarBadges.tsx": 4,
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 2,
"src/components/Roadtrip/RoadtripSidebarHeaders.tsx": 2,
"src/components/Roadtrip/RoadtripSidebarHeaders.tsx": 1,
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 5,
"src/components/Roadtrip/RoadtripTrackModal.tsx": 4,
"src/components/Roadtrip/ServiceStopSection.tsx": 2,
@@ -217,6 +217,8 @@ describe('DayPlanSidebar day header', () => {
render(<DayPlanSidebar {...makeDefaultProps({ days, accommodations })} />);
expect(dayHeader('Day 1').style.alignItems).toBe('center');
expect(dayHeader('Day 1').querySelector('[data-dp="day-pill"]')).toBeNull();
expect(dayHeader('Day 2').style.alignItems).toBe('flex-start');
// The head is a grid now, so the pills can sit on a row of their own under the name.
expect(dayHeader('Day 2').style.display).toBe('grid');
expect(dayHeader('Day 2').style.alignItems).toBe('start');
});
});
+1 -1
View File
@@ -79,7 +79,7 @@ export function MoreButton({ label, items, size = 26, alwaysVisible = false, cla
/** A time or time range as a white badge, like the other facts on a card. */
/** A small white badge on a row's second line, for what the row says about when. */
export function WhiteBadge({ children, icon, className = '', quiet = false }: {
export function WhiteBadge({ children, icon, className = '' }: {
children: ReactNode
icon?: ReactNode
className?: string
@@ -11,37 +11,58 @@ const classes = (el: Element | null): string[] => (el?.getAttribute('class') ??
const of = (list: string): string[] => list.split(' ').sort()
describe('FigureBadge', () => {
it('FE-FIGURE-BADGE-001: the neutral shell is the one the stay, the walk and the fill-up wore before', () => {
const { container } = render(<FigureBadge lead={<Footprints size={9} aria-hidden />} value="240 m" />)
const shell = container.firstElementChild!
expect(classes(shell)).toEqual(of('inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border border-edge'))
expect(classes(shell.children[0])).toEqual(of('flex items-center gap-0.5 px-1 bg-surface-tertiary text-content-faint'))
expect(classes(shell.children[1])).toEqual(
of('flex items-center border-s bg-surface-card px-1.5 font-semibold tabular-nums border-edge text-content-secondary'),
)
expect(shell.children[1]).toHaveTextContent('240 m')
it('FE-FIGURE-BADGE-001: the neutral badge is the white pill of the day plan, the word and the figure split by a hairline', () => {
const { container } = render(<FigureBadge lead={<Footprints size={10} aria-hidden />} value="240 m" />)
const pill = container.firstElementChild!
expect(classes(pill)).toEqual(of(
'inline-flex h-[20px] flex-none items-center gap-1 self-start whitespace-nowrap rounded-full bg-surface-card px-2 font-geist font-normal leading-none tabular-nums shadow-sm ring-1 ring-inset ring-edge-faint',
))
expect(classes(pill.children[0])).toEqual(of('flex min-w-0 items-center gap-1 text-content-faint'))
expect(pill.children[1]).toHaveAttribute('aria-hidden')
expect(classes(pill.children[1])).toEqual(expect.arrayContaining(['w-px', 'bg-edge']))
expect(classes(pill.children[2])).toEqual(of('flex items-center text-content-secondary'))
expect(pill.children[2]).toHaveTextContent('240 m')
})
it('FE-FIGURE-BADGE-002: the warning tone edges both halves in the warning colour itself', () => {
const { container } = render(<FigureBadge tone="warning" dir="ltr" lead="!" value="+30 min" />)
const shell = container.firstElementChild!
expect(shell).toHaveAttribute('dir', 'ltr')
expect(classes(shell)).toContain('border-warning')
expect(classes(shell.children[0])).toEqual(expect.arrayContaining(['bg-warning-soft', 'text-warning']))
expect(classes(shell.children[1])).toEqual(expect.arrayContaining(['border-warning', 'text-warning']))
it('FE-FIGURE-BADGE-002: the warning tone sets both halves in the warning colour, a booking tone only its word', () => {
const warning = render(<FigureBadge tone="warning" dir="ltr" lead="!" value="+30 min" />)
const pill = warning.container.firstElementChild!
expect(pill).toHaveAttribute('dir', 'ltr')
expect(classes(pill.children[0])).toContain('text-warning')
expect(classes(pill.children[2])).toContain('text-warning')
warning.unmount()
const booked = render(<FigureBadge tone="success" caption lead="Booked" value="#42" />)
const bookedPill = booked.container.firstElementChild as Element
expect(classes(bookedPill.children[0])).toContain('text-success')
expect(classes(bookedPill.children[2])).toContain('text-content-secondary')
booked.unmount()
const pending = render(<FigureBadge tone="pending" caption lead="Pending" value="#42" />)
const pendingPill = pending.container.firstElementChild as Element
expect(classes(pendingPill.children[0])).toContain('text-warning')
expect(classes(pendingPill.children[2])).toContain('text-content-secondary')
})
it('FE-FIGURE-BADGE-003: a word in the lead is a caption, a figure can be quiet, and without one the sign stands alone', () => {
const caption = render(<FigureBadge caption lead="Stay" value="+" faint valueSize="micro" />)
const shell = caption.container.firstElementChild!
expect(classes(shell.children[0])).toEqual(expect.arrayContaining(['font-geist', 'font-semibold', 'uppercase', 'tracking-[0.12em]']))
expect(classes(shell.children[1])).toEqual(expect.arrayContaining(['text-content-faint']))
expect((shell.children[1] as HTMLElement).style.fontSize).toContain('8px')
const pill = caption.container.firstElementChild!
expect(classes(pill.children[0])).toEqual(expect.arrayContaining(['uppercase', 'tracking-[.05em]']))
expect(classes(pill.children[2])).toEqual(expect.arrayContaining(['text-content-faint']))
caption.unmount()
const sign = render(<FigureBadge lead={<Footprints size={9} aria-label="Walk" />} />)
expect(sign.container.firstElementChild!.children).toHaveLength(1)
const sign = render(<FigureBadge lead={<Footprints size={10} aria-label="Walk" />} />)
const signPill = sign.container.firstElementChild!
expect(signPill.children).toHaveLength(1)
// Alone, the sign takes the figure's ink rather than the quiet one of a word beside a figure.
expect(classes(signPill.children[0])).toContain('text-content-secondary')
expect(screen.getByLabelText('Walk')).toBeInTheDocument()
sign.unmount()
const shrinking = render(<FigureBadge shrink lead="Hotel" value="1" />)
const shrinkPill = shrinking.container.firstElementChild as Element
expect(classes(shrinkPill)).toEqual(expect.arrayContaining(['min-w-0', 'shrink', 'overflow-hidden']))
expect(classes(shrinkPill)).not.toContain('flex-none')
})
it('FE-FIGURE-BADGE-004: as a control it is a focusable button role that answers the keys meaning press and keeps them from the row', () => {
@@ -17,9 +17,9 @@ describe('Road trip rail styles', () => {
expect(RAIL_DASH).not.toHaveProperty('borderStyle')
})
it('FE-ROADTRIP-RAILSTYLE-003: captions and day badges are tracked capitals in a quiet tone, and a day badge stays at medium weight', () => {
expect(classes(STAT_LABEL)).toEqual(expect.arrayContaining(['uppercase', 'tracking-[0.15em]', 'text-content-faint']))
expect(classes(DAY_BADGE)).toEqual(expect.arrayContaining(['uppercase', 'tracking-[0.09em]', 'font-medium', 'text-content-muted']))
it('FE-ROADTRIP-RAILSTYLE-003: captions are the eyebrow of the dialogs, and a day badge is the round pill of the day plan in a quiet tone at normal weight', () => {
expect(classes(STAT_LABEL)).toEqual(expect.arrayContaining(['uppercase', 'tracking-[.08em]', 'font-bold', 'text-content-faint']))
expect(classes(DAY_BADGE)).toEqual(expect.arrayContaining(['rounded-full', 'uppercase', 'tracking-[.05em]', 'font-normal', 'text-content-secondary']))
expect(classes(DAY_BADGE)).not.toContain('font-semibold')
})
})
@@ -135,20 +135,30 @@ describe('RoadtripSidebar', () => {
expect(total('Stops')).toBe('5')
})
it('FE-ROADTRIP-SIDEBAR-083: pulled narrow, the rail stops counting stops, in its head and on every day', () => {
it('FE-ROADTRIP-SIDEBAR-083: pulled narrow, the head stops counting stops, and a day gives its count up once its own row runs out of room', () => {
const wide = wrap(<RoadtripSidebar routes={routes({ totalDistance: 250000, totalDuration: 9000, totalStops: 5 })} />)
expect(screen.getByText('2 stops')).toBeInTheDocument()
wide.unmount()
railWidth.value = 300
wrap(<RoadtripSidebar routes={routes({ totalDistance: 250000, totalDuration: 9000, totalStops: 5 })} />)
const narrow = wrap(<RoadtripSidebar routes={routes({ totalDistance: 250000, totalDuration: 9000, totalStops: 5 })} />)
expect(total('Distance')).toBe('250 km')
expect(total('Driving time')).toBe('2 h 30 min')
const head = screen.getByText('Distance').closest('header')!
// The figure goes with its hairline, so the two left share the width.
// The tile goes, so the two left share the width.
expect(within(head).queryByText('Stops')).toBeNull()
expect(head.querySelectorAll('span.w-px')).toHaveLength(1)
expect(screen.queryByText('2 stops')).toBeNull()
expect(head.firstElementChild!.children).toHaveLength(2)
// The day measures its own pill row, and at this width its count still fits.
expect(screen.getByText('2 stops')).toBeInTheDocument()
narrow.unmount()
// Narrower still, the count keeps only its figure and the drive only its distance.
railWidth.value = 150
wrap(<RoadtripSidebar routes={routes({ totalDistance: 250000, totalDuration: 9000, totalStops: 5 })} />)
const dayHead = screen.getByRole('heading', { name: 'Day 1' }).closest('header')!
expect(within(dayHead).queryByText('2 stops')).toBeNull()
expect(within(dayHead).getByLabelText('2 stops')).toHaveTextContent(/^2$/)
expect(within(dayHead).getByText('100 km')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-002: chains the stops with the drive between them', () => {
@@ -669,14 +679,14 @@ describe('RoadtripSidebar', () => {
expect(onFollowTrack).toHaveBeenCalledWith(1)
// The rail draws no vias, so the tint is the only place a drive shaped by hand
// differs from one the router picked on its own.
expect(badge.className).toContain('bg-accent-subtle')
expect(badge).toHaveClass('bg-accent')
rerender(
<TranslationProvider>
<RoadtripSidebar routes={routes({ days: [day({ stops })] })} onFollowTrack={onFollowTrack} />
</TranslationProvider>,
)
expect(screen.getByRole('button', { name: 'Track' }).className).not.toContain('bg-accent-subtle')
expect(screen.getByRole('button', { name: 'Track' })).not.toHaveClass('bg-accent')
})
it('FE-ROADTRIP-SIDEBAR-019: a viewer is offered no track badge at all', () => {
@@ -1160,21 +1170,19 @@ describe('RoadtripSidebar with a ride (#2428)', () => {
})
}
it('FE-ROADTRIP-SIDEBAR-049: a same-day ride is one block: the booking and its minutes, both codes with their timetable, the places and the check-in, and no number', () => {
it('FE-ROADTRIP-SIDEBAR-049: a same-day ride is one card: the booking, both timetable clocks, the codes as one route pill and the check-in, and no number', () => {
wrap(<RoadtripSidebar routes={routes({ days: [flightDay()], totalStops: 2 })} />)
expect(screen.getByText('LH 2020')).toBeInTheDocument()
expect(screen.getByText('1 h 10 min')).toBeInTheDocument()
expect(screen.queryByText('LH 2020 · 1 h 10 min')).toBeNull()
// Each code, each timetable clock and each place once.
// The minutes the ride takes are left to the booking.
expect(screen.queryByText('1 h 10 min')).toBeNull()
// Each code once, in the route pill, and both timetable clocks once, in the arrival column.
expect(screen.getAllByText('HAM')).toHaveLength(1)
expect(screen.getAllByText('MUC')).toHaveLength(1)
expect(screen.getAllByText('13:20')).toHaveLength(1)
expect(screen.getAllByText('14:30')).toHaveLength(1)
expect(screen.getByText('Hamburg Airport')).toBeInTheDocument()
expect(screen.getByText('Munich Airport')).toBeInTheDocument()
// What the clocks are is said to a screen reader, not printed a second time.
expect(screen.getByText('Departure 13:20')).toHaveClass('sr-only')
expect(screen.getByText('Arrival 14:30')).toHaveClass('sr-only')
expect(screen.getByText('HAM').parentElement).toContainElement(screen.getByText('MUC'))
expect(screen.getAllByText('13:20 \u2013 14:30')).toHaveLength(1)
// The places stand behind the route pill as its tooltip, not as lines of their own.
expect(screen.queryByText('Hamburg Airport')).toBeNull()
expect(screen.queryByText('Munich Airport')).toBeNull()
// The check-in, labelled, where an unlabelled 12:20 used to stand in the arrival column.
expect(screen.getByText('Check-in')).toBeInTheDocument()
expect(screen.getByText('12:20')).toBeInTheDocument()
@@ -1184,13 +1192,13 @@ describe('RoadtripSidebar with a ride (#2428)', () => {
expect(screen.getByText('2 stops')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-050: the ride block opens the booking; the road out of the arrival offers no other ways', () => {
it('FE-ROADTRIP-SIDEBAR-050: the ride card opens the booking; the road out of the arrival offers no other ways', () => {
const onOpenBooking = vi.fn()
const onAskAlternatives = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [flightDay()], totalStops: 2 })} onOpenBooking={onOpenBooking} onAskAlternatives={onAskAlternatives} />)
// One control for the whole ride, not one per terminal and one for the band.
expect(screen.getAllByRole('button', { name: /Open booking$/ })).toHaveLength(1)
fireEvent.click(screen.getByText('Hamburg Airport'))
fireEvent.click(screen.getByText('HAM'))
fireEvent.click(screen.getByText('LH 2020'))
expect(onOpenBooking).toHaveBeenCalledTimes(2)
expect(onOpenBooking).toHaveBeenCalledWith(70)
@@ -1329,7 +1337,8 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
const drop = screen.getByText('Sixt Airport').closest('button')!
expect(within(drop).getByText('Return')).toBeInTheDocument()
expect(within(drop).getAllByText('11:30')).toHaveLength(1)
expect(within(drop).getByText('HAM')).toBeInTheDocument()
// The desk word takes the pill a terminal's code would, so the code is not said beside it.
expect(within(drop).queryByText('HAM')).toBeNull()
// A desk has no check-in.
expect(screen.queryByText('Check-in')).toBeNull()
expect(screen.queryByText('3')).not.toBeInTheDocument()
@@ -1350,7 +1359,7 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
expect(within(rows[0] as HTMLElement).getByText('Tank runs out here')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-053: a table pinned to a stop, or booked at its place on its day, hangs under the stop as a chip that opens it; one for no stop is listed under the day', () => {
it('FE-ROADTRIP-SIDEBAR-053: a table pinned to a stop, or booked at its place on its day, sits on the stop as a pill that opens it; one for no stop is listed under the day', () => {
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' })]
const reservations = [
booking({ id: 11, title: 'Tisch Bullerei', reservation_time: '2026-10-05T19:30', assignment_id: 1 }),
@@ -1361,21 +1370,22 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
]
const onOpenBooking = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })], totalStops: 2 })} reservations={reservations} onOpenBooking={onOpenBooking} canEditBookings />)
expect(screen.getByText('Tisch Bullerei')).toBeInTheDocument()
expect(screen.getByText('19:30')).toBeInTheDocument()
expect(screen.getByText('Café Niederegger')).toBeInTheDocument()
// Each booking is a pill on its stop's own badge row, named by its title and status.
const table = within(screen.getByText('Hamburg').closest('li') as HTMLElement).getByRole('button', { name: 'Tisch Bullerei, Confirmed' })
expect(table).toHaveTextContent('19:30')
expect(within(screen.getByText('Lübeck').closest('li') as HTMLElement).getByRole('button', { name: 'Café Niederegger, Confirmed' })).toBeInTheDocument()
expect(screen.getByText('Also booked this day')).toBeInTheDocument()
expect(screen.getByText('Elbphilharmonie')).toBeInTheDocument()
// Another day's booking is not this day's, and the night is the stay, not a chip.
expect(screen.queryByText('Tomorrow')).not.toBeInTheDocument()
expect(screen.queryByText('Hotel Hafen')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Tisch Bullerei'))
expect(screen.getByRole('button', { name: 'Elbphilharmonie, Confirmed' })).toBeInTheDocument()
// Another day's booking is not this day's, and the night is the stay, not a pill.
expect(screen.queryByRole('button', { name: /^Tomorrow/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^Hotel Hafen/ })).not.toBeInTheDocument()
fireEvent.click(table)
expect(onOpenBooking).toHaveBeenCalledWith(11)
// The chips are the stop's, not stops: the count stays at two.
// The pills are the stop's, not stops: the count stays at two.
expect(screen.getByText('2 stops')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-058: for a reader who may not edit bookings, a table is a plain chip while a taxi still opens its detail view', () => {
it('FE-ROADTRIP-SIDEBAR-058: for a reader who may not edit bookings, a table is a plain pill while a taxi still opens its detail view', () => {
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' })]
const reservations = [
booking({ id: 11, title: 'Tisch Bullerei', reservation_time: '2026-10-05T19:30', assignment_id: 1 }),
@@ -1384,11 +1394,12 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
const onOpenBooking = vi.fn()
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })], totalStops: 2 })} reservations={reservations} onOpenBooking={onOpenBooking} />)
// The table is there to read, not a button that does nothing; the taxi is a button.
expect(screen.getByText('Tisch Bullerei').closest('button')).toBeNull()
expect(screen.getByText('Taxi zum Hafen').closest('button')).not.toBeNull()
fireEvent.click(screen.getByText('Tisch Bullerei'))
expect(screen.queryByRole('button', { name: /Tisch Bullerei/ })).toBeNull()
const taxi = screen.getByRole('button', { name: 'Taxi zum Hafen, Confirmed' })
expect(taxi).toHaveTextContent('18:30')
fireEvent.click(screen.getByText('19:30'))
expect(onOpenBooking).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('Taxi zum Hafen'))
fireEvent.click(taxi)
expect(onOpenBooking).toHaveBeenCalledWith(12)
})
})
@@ -1739,78 +1750,78 @@ describe('RoadtripSidebar with a flight the drive gets to late (#2460)', () => {
},
})
}
const block = () => screen.getByText('LH 2078 HAM-MUC (ohne Endpunkte)').closest('button')!.children[1] as HTMLElement
const TITLE = 'LH 2078 HAM-MUC (ohne Endpunkte)'
/** The ride is one card, and the card is the button that opens its booking. */
const card = () => screen.getByText(TITLE).closest('button')!
it('FE-ROADTRIP-SIDEBAR-075: a missed flight says so on its check-in and on the day, with no delta against a clock nobody sees', () => {
it('FE-ROADTRIP-SIDEBAR-075: a flight the drive cannot catch takes no check-in pill and no missed pill, and the card keeps its title as typed and each code once', () => {
wrap(<RoadtripSidebar routes={routes({ days: [flightDay(579)], totalStops: 2 })} onOpenBooking={vi.fn()} />)
// The title as typed, the duration beside the check-in, each code once and the places without it.
const title = screen.getByText('LH 2078 HAM-MUC (ohne Endpunkte)')
const title = screen.getByText(TITLE)
expect(title.className).not.toContain('uppercase')
expect(within(block()).getByText('Missed').closest('.flex-wrap')).toHaveTextContent('2 h 5 min')
expect(title.parentElement).not.toHaveTextContent('2 h 5 min')
expect(within(card()).getByText('15:15 \u2013 17:20')).toBeInTheDocument()
expect(screen.getAllByText('HAM')).toHaveLength(1)
expect(screen.getByText('Hamburg')).toBeInTheDocument()
expect(screen.getByText('Munich')).toBeInTheDocument()
expect(screen.getAllByText('MUC')).toHaveLength(1)
expect(screen.queryByText(/\(HAM\)/)).toBeNull()
// The check-in carries the finding; the separate late pill is gone.
expect(within(block()).getByText('Missed')).toBeInTheDocument()
expect(within(block()).getByText('there at 23:54')).toBeInTheDocument()
// Neither the check-in nor a finding about it, and no delta against a clock nobody sees.
expect(screen.queryByText('Check-in')).toBeNull()
expect(screen.queryByText('Missed')).toBeNull()
expect(screen.queryByText('there at 23:54')).toBeNull()
expect(screen.queryByText('+9 h 39 min')).toBeNull()
expect(block().className).toContain('border-warning')
expect(screen.queryByText('2 h 5 min')).toBeNull()
const header = screen.getByText('Day 3').closest('header')!
expect(within(header).getByText('Flight missed')).toBeInTheDocument()
expect(within(header).queryByText('Flight missed')).toBeNull()
})
it('FE-ROADTRIP-SIDEBAR-076: a folded day still says its flight is missed', () => {
it('FE-ROADTRIP-SIDEBAR-076: a folded day keeps its head and its drive, with no missed pill', () => {
wrap(<RoadtripSidebar routes={routes({ days: [flightDay(579)], totalStops: 2 })} collapsedDayIds={new Set([3])} onToggleDay={vi.fn()} />)
const header = screen.getByText('Day 3').closest('header')!
expect(within(header).getByText('Flight missed')).toBeInTheDocument()
expect(header).toHaveAttribute('aria-expanded', 'false')
expect(within(header).getByText(/km in/)).toBeInTheDocument()
expect(within(header).queryByText('Flight missed')).toBeNull()
})
it('FE-ROADTRIP-SIDEBAR-077: late for the check-in but in time for the flight says by how much, and leaves the day alone', () => {
it('FE-ROADTRIP-SIDEBAR-077: late for the check-in but in time for the flight leaves both the card and the day without a pill', () => {
wrap(<RoadtripSidebar routes={routes({ days: [flightDay(25)], totalStops: 2 })} />)
expect(within(block()).getByText('Check-in')).toBeInTheDocument()
expect(within(block()).getByText('25 min late')).toBeInTheDocument()
expect(block().className).not.toContain('border-warning')
expect(within(card()).queryByText('Check-in')).toBeNull()
expect(within(card()).queryByText('25 min late')).toBeNull()
expect(screen.queryByText('Flight missed')).toBeNull()
})
it('FE-ROADTRIP-SIDEBAR-078: a train is boarded, and a missed one is named as a train', () => {
it('FE-ROADTRIP-SIDEBAR-078: a train in time is boarded, and a missed one says nothing on the card', () => {
const onTime = wrap(<RoadtripSidebar routes={routes({ days: [flightDay(null, 'train')], totalStops: 2 })} />)
expect(screen.getByText('Boarding')).toBeInTheDocument()
expect(screen.getByText('14:15')).toBeInTheDocument()
onTime.unmount()
wrap(<RoadtripSidebar routes={routes({ days: [flightDay(579, 'train')], totalStops: 2 })} />)
expect(within(screen.getByText('Day 3').closest('header')!).getByText('Train missed')).toBeInTheDocument()
expect(screen.queryByText('Boarding')).toBeNull()
expect(within(screen.getByText('Day 3').closest('header')!).queryByText('Train missed')).toBeNull()
})
it('FE-ROADTRIP-SIDEBAR-079: the badge explains itself in a sentence, and a booking clock names what it is, not a time you set', () => {
it('FE-ROADTRIP-SIDEBAR-079: the check-in explains itself in a sentence, and the booking clocks name what they are, not a time you set', () => {
vi.useFakeTimers()
try {
wrap(<RoadtripSidebar routes={routes({ days: [flightDay(579)], totalStops: 2 })} />)
wrap(<RoadtripSidebar routes={routes({ days: [flightDay(null)], totalStops: 2 })} />)
const hover = (el: HTMLElement) => {
fireEvent.mouseEnter(el)
act(() => { vi.advanceTimersByTime(300) })
}
hover(screen.getByText('there at 23:54').parentElement!)
expect(screen.getByRole('tooltip')).toHaveTextContent(
'The drive reaches Hamburg at 23:54. LH 2078 HAM-MUC (ohne Endpunkte) departs at 15:15, check-in closes at 14:15.',
)
fireEvent.mouseLeave(screen.getByText('there at 23:54').parentElement!)
hover(screen.getByText('14:15').parentElement!)
expect(screen.getByRole('tooltip')).toHaveTextContent(`${TITLE} departs at 15:15, check-in closes at 14:15.`)
fireEvent.mouseLeave(screen.getByText('14:15').parentElement!)
hover(screen.getByText('15:15').parentElement!)
expect(screen.getByRole('tooltip')).toHaveTextContent('Departure 15:15')
hover(screen.getByText('15:15 \u2013 17:20'))
expect(screen.getByRole('tooltip')).toHaveTextContent('Departure 15:15, Arrival 17:20')
expect(screen.queryByText('Time you set')).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('FE-ROADTRIP-SIDEBAR-080: a screen reader hears the ride, its clocks and that it is missed, then what pressing it does', () => {
it('FE-ROADTRIP-SIDEBAR-080: a screen reader hears the ride, its clocks and its route, then what pressing it does', () => {
wrap(<RoadtripSidebar routes={routes({ days: [flightDay(579)], totalStops: 2 })} onOpenBooking={vi.fn()} />)
const button = screen.getByText('LH 2078 HAM-MUC (ohne Endpunkte)').closest('button')!
const button = card()
expect(button).not.toHaveAttribute('aria-label')
expect(button).toHaveAccessibleName(expect.stringMatching(/Departure 15:15\s*Arrival 17:20.*Missed\s*there at 23:54\s*2 h 5 min\s*Open booking$/))
expect(button).toHaveAccessibleName(expect.stringMatching(/^LH 2078 HAM-MUC \(ohne Endpunkte\)\s*15:15\s*\u2013\s*17:20\s*HAM\s*MUC\s*Open booking$/))
})
})
@@ -49,8 +49,15 @@ const booking = (over: Partial<Reservation> & Pick<Reservation, 'id' | 'type' |
const railOf = (row: Element): Element => row.firstElementChild!
const lines = (row: Element): number => railOf(row).querySelectorAll(':scope > .w-\\[1\\.5px\\]').length
/**
* A booking card keeps its rail beside the button rather than inside it: both line
* stubs are always drawn, and only the ones that carry the chain take the edge colour.
*/
const cardLines = (card: Element): number =>
((card.parentElement as Element).firstElementChild as Element).querySelectorAll(':scope > .w-\\[1\\.5px\\].bg-edge').length
describe('TerminalStop', () => {
it('FE-ROADTRIP-BOOKINGROWS-001: a departure on a row of its own reads its code, its timetable clock and the check-in, and opens the booking', () => {
it('FE-ROADTRIP-BOOKINGROWS-001: a departure on a row of its own reads its place, its code as a pill, its timetable clock and the check-in, and opens the booking', () => {
const onOpen = vi.fn()
const { container } = wrap(
<TerminalStop
@@ -61,8 +68,8 @@ describe('TerminalStop', () => {
onOpen={onOpen}
/>,
)
const row = screen.getByRole('button')
expect(row).toBeEnabled()
const card = screen.getByRole('button')
expect(card).toBeEnabled()
expect(screen.getByText('HAM')).toBeInTheDocument()
expect(screen.getByText('Hamburg Airport')).toBeInTheDocument()
// The timetable's clock, its day carry and its words for a screen reader.
@@ -72,28 +79,56 @@ describe('TerminalStop', () => {
expect(screen.getByText('Check-in')).toBeInTheDocument()
expect(screen.getByText('13:15')).toBeInTheDocument()
expect(container.querySelector('svg.lucide-plane')).not.toBeNull()
// What pressing it does comes after the row's own content.
expect(row.lastElementChild).toHaveTextContent('Open booking')
expect(row.lastElementChild).toHaveClass('sr-only')
expect(lines(row)).toBe(2)
expect(tooltipOf(screen.getByText('Hamburg Airport'))).toBe('Open booking')
fireEvent.click(row)
// What pressing it does comes after the card's own content.
expect(card.lastElementChild).toHaveTextContent('Open booking')
expect(card.lastElementChild).toHaveClass('sr-only')
expect(cardLines(card)).toBe(2)
fireEvent.click(card)
expect(onOpen).toHaveBeenCalledTimes(1)
})
it('FE-ROADTRIP-BOOKINGROWS-001b: with its booking at hand the card reads the booking title, as the day plan does', () => {
wrap(
<TerminalStop
stop={stop({ time: '13:15', carrier: carrier() })}
booking={booking({ id: 7, type: 'flight', title: 'Lufthansa to Munich' })}
entry={entry()}
late={[]}
continues
/>,
)
expect(screen.getByText('Lufthansa to Munich')).toBeInTheDocument()
expect(screen.queryByText('Hamburg Airport')).toBeNull()
expect(screen.getByText('HAM')).toBeInTheDocument()
})
it('FE-ROADTRIP-BOOKINGROWS-001c: a check-in the drive cannot make takes no pill on the card, neither the check-in nor that it is missed', () => {
wrap(
<TerminalStop
stop={stop({ time: '13:15', carrier: carrier() })}
entry={entry()}
late={[warning({ code: 'late', minutes: 180 })]}
continues
/>,
)
expect(screen.queryByText('Check-in')).toBeNull()
expect(screen.queryByText('Missed')).toBeNull()
expect(screen.getByText('HAM')).toBeInTheDocument()
})
it('FE-ROADTRIP-BOOKINGROWS-002: a hire desk says which one it is, falls back to the arrival clock and stays inert without a booking to open', () => {
const desk = carrier({ type: 'car', role: 'pickup', code: null, at: null, title: 'Sixt' })
const first = wrap(
<TerminalStop stop={stop({ name: 'Sixt Hamburg', carrier: desk })} entry={entry({ arrival: '09:40' })} late={[]} continues={false} starts />,
)
const row = screen.getByRole('button')
expect(row).toBeDisabled()
const card = screen.getByRole('button')
expect(card).toBeDisabled()
expect(screen.getByText('Pickup')).toBeInTheDocument()
expect(screen.getByText('09:40')).toBeInTheDocument()
expect(screen.queryByText('Open booking')).toBeNull()
expect(first.container.querySelector('svg.lucide-car')).not.toBeNull()
// The first row of the day on the last stop of it: no line either way.
expect(lines(row)).toBe(0)
expect(cardLines(card)).toBe(0)
first.unmount()
// Neither a timetable nor a drive arrival: the clock column stays empty.
@@ -158,7 +193,7 @@ describe('RideBlock', () => {
})
const seg = { distance: 600000, duration: 4500, durationText: '1 h 15 min', mode: 'flight' } as RouteSegment
it('FE-ROADTRIP-BOOKINGROWS-005: a ride on one day is one block: its title, both ends with their clocks, the places, the check-in and the minutes', () => {
it('FE-ROADTRIP-BOOKINGROWS-005: a ride on one day is one card: its title, both clocks in the arrival column, the route as one pill and the check-in', () => {
const onOpen = vi.fn()
wrap(
<RideBlock
@@ -171,25 +206,41 @@ describe('RideBlock', () => {
onOpen={onOpen}
/>,
)
const row = screen.getByRole('button')
const block = row.children[1]
expect(block).toHaveClass('border-edge-faint')
const card = screen.getByRole('button')
expect(screen.getByText('LH 2078')).toBeInTheDocument()
expect(screen.getByText('HAM')).toBeInTheDocument()
expect(screen.getByText('MUC')).toBeInTheDocument()
expect(screen.getByText('Departure 15:15')).toHaveClass('sr-only')
expect(screen.getByText('Arrival 16:30')).toHaveClass('sr-only')
expect(screen.getByText('Hamburg Airport')).toBeInTheDocument()
expect(screen.getByText('Munich Airport')).toHaveClass('text-end')
// Both ends in one route pill, the places behind it as its tooltip.
const route = screen.getByText('HAM').parentElement!
expect(route).toContainElement(screen.getByText('MUC'))
expect(tooltipOf(route.closest('.rounded-full') as Element)).toBe('Hamburg Airport \u2192 Munich Airport')
const clock = screen.getByText('15:15 \u2013 16:30')
expect(tooltipOf(clock)).toBe('Departure 15:15, Arrival 16:30')
expect(screen.getByText('Check-in')).toBeInTheDocument()
expect(screen.getByText('1 h 15 min')).toBeInTheDocument()
expect(tooltipOf(screen.getByText('1 h 15 min').parentElement!)).toBe('Duration per the booking')
expect(tooltipOf(screen.getByText('LH 2078'))).toBe('Open booking')
fireEvent.click(row)
expect(screen.getByText('13:15')).toBeInTheDocument()
// The minutes the ride takes are the booking's business, not the rail's.
expect(screen.queryByText('1 h 15 min')).toBeNull()
expect(card.lastElementChild).toHaveTextContent('Open booking')
expect(card.lastElementChild).toHaveClass('sr-only')
fireEvent.click(card)
expect(onOpen).toHaveBeenCalledTimes(1)
})
it('FE-ROADTRIP-BOOKINGROWS-006: a ride the drive cannot catch edges the block in the warning colour, its check-in standing alone without minutes', () => {
it('FE-ROADTRIP-BOOKINGROWS-005b: with its booking at hand the card reads the booking title', () => {
wrap(
<RideBlock
departure={departure}
arrival={arrival}
booking={booking({ id: 7, type: 'flight', title: 'Hamburg to Munich' })}
entries={[entry(), entry()]}
late={[]}
seg={seg}
continues
/>,
)
expect(screen.getByText('Hamburg to Munich')).toBeInTheDocument()
expect(screen.queryByText('LH 2078')).toBeNull()
})
it('FE-ROADTRIP-BOOKINGROWS-006: a ride the drive cannot catch shows neither its check-in nor that it is missed on its card', () => {
wrap(
<RideBlock
departure={departure}
@@ -201,17 +252,16 @@ describe('RideBlock', () => {
starts
/>,
)
const row = screen.getByRole('button')
expect(row).toBeDisabled()
expect(row.children[1]).toHaveClass('border-warning')
expect(screen.getByText('Missed')).toBeInTheDocument()
expect(screen.getByText('there at 16:15')).toBeInTheDocument()
// No minutes on the timetable: the check-in stands alone.
expect(screen.getByText('Missed').closest('.flex-wrap')!.children).toHaveLength(1)
expect(lines(row)).toBe(0)
const card = screen.getByRole('button')
expect(card).toBeDisabled()
expect(screen.queryByText('Missed')).toBeNull()
expect(screen.queryByText('Check-in')).toBeNull()
// The route pill stands alone on the card's second line.
expect(((screen.getByText('HAM').closest('.rounded-full') as Element).parentElement as Element).children).toHaveLength(1)
expect(cardLines(card)).toBe(0)
})
it('FE-ROADTRIP-BOOKINGROWS-007: a booking with no codes, no clocks and no minutes keeps its strip and drops the lines it has nothing for', () => {
it('FE-ROADTRIP-BOOKINGROWS-007: a booking with no codes, no clocks and no minutes keeps its title and the route between the names', () => {
wrap(
<RideBlock
departure={stop({ name: 'Kiel', carrier: carrier({ type: 'ferry', code: null, at: null, title: 'Color Line' }) })}
@@ -222,19 +272,18 @@ describe('RideBlock', () => {
continues
/>,
)
const block = screen.getByRole('button').children[1]
// Title and strip only: no place line under chips that already are the names, and no
// check-in or minutes row.
expect(block.children).toHaveLength(2)
const strip = block.children[1]
expect(strip.children).toHaveLength(5)
expect(strip.children[0]).toHaveTextContent('Kiel')
expect(strip.children[1]).toBeEmptyDOMElement()
expect(strip.children[3]).toBeEmptyDOMElement()
expect(strip.children[4]).toHaveTextContent('Oslo')
const card = screen.getByRole('button')
// The title line and the pill line, and no sentence for a screen reader without a booking to open.
expect(card.children).toHaveLength(2)
const [titleLine, pills] = Array.from(card.children)
// No clock beside the title.
expect(titleLine.children).toHaveLength(1)
expect(titleLine).toHaveTextContent('Color Line')
expect(pills.children).toHaveLength(1)
expect(screen.getByText('Kiel').parentElement).toContainElement(screen.getByText('Oslo'))
})
it('FE-ROADTRIP-BOOKINGROWS-008: the minutes stand alone when the timetable gives no check-in', () => {
it('FE-ROADTRIP-BOOKINGROWS-008: a timetable with only a departure reads that clock alone, with no check-in and no minutes', () => {
wrap(
<RideBlock
departure={stop({ name: 'Kiel', carrier: carrier({ type: 'ferry', code: null, at: '14:00' }) })}
@@ -246,40 +295,49 @@ describe('RideBlock', () => {
/>,
)
expect(screen.queryByText('Check-in')).toBeNull()
expect(screen.getByText('1 h 15 min')).toBeInTheDocument()
expect(screen.queryByText('1 h 15 min')).toBeNull()
expect(screen.getByText('14:00')).toBeInTheDocument()
})
})
describe('BookingChips', () => {
const table = booking({ id: 1, type: 'restaurant', title: 'Fischereihafen', reservation_time: '2026-06-01T19:30' })
const train = booking({ id: 2, type: 'train', title: 'ICE 1007' })
const train = booking({ id: 2, type: 'train', title: 'ICE 1007', status: 'confirmed' })
it('FE-ROADTRIP-BOOKINGROWS-009: a chip opens when this reader may open it, and the rest stay plain chips', () => {
it('FE-ROADTRIP-BOOKINGROWS-009: a pill opens when this reader may open it, and the rest stay plain pills that name their booking in the tooltip', () => {
const onOpen = vi.fn()
const { container } = wrap(<BookingChips bookings={[table, train]} continues canEdit={false} onOpen={onOpen} />)
// Each pill says it is a reservation and carries its time; the title is in its name.
expect(screen.getAllByText('Reservation')).toHaveLength(2)
expect(screen.getByText('19:30')).toBeInTheDocument()
// The table has only its editor, which this reader may not open; the train has a view.
expect(screen.getByText('Fischereihafen').closest('button')).toBeNull()
const ride = screen.getByRole('button')
expect(ride).toHaveTextContent('ICE 1007')
expect(screen.queryByRole('button', { name: /Fischereihafen/ })).toBeNull()
expect(tooltipOf(screen.getByText('19:30').parentElement!)).toBe('Fischereihafen (Pending)')
const ride = screen.getByRole('button', { name: 'ICE 1007, Confirmed' })
fireEvent.click(ride)
expect(onOpen).toHaveBeenCalledWith(2)
expect(container.querySelector('.w-\\[1\\.5px\\]')).not.toBeNull()
})
it('FE-ROADTRIP-BOOKINGROWS-010: an editor opens every chip, and without a way to open any they are all plain', () => {
it('FE-ROADTRIP-BOOKINGROWS-009b: the word takes the status colour, green once confirmed and amber while pending', () => {
wrap(<BookingChips bookings={[table, train]} continues={false} canEdit={false} />)
const [pending, confirmed] = screen.getAllByText('Reservation')
expect(pending).toHaveClass('text-warning')
expect(confirmed).toHaveClass('text-success')
})
it('FE-ROADTRIP-BOOKINGROWS-010: an editor opens every pill, and without a way to open any they are all plain', () => {
const onOpen = vi.fn()
const editor = wrap(<BookingChips bookings={[table, train]} continues={false} canEdit onOpen={onOpen} />)
expect(screen.getAllByRole('button')).toHaveLength(2)
fireEvent.click(screen.getByText('Fischereihafen'))
fireEvent.click(screen.getByRole('button', { name: 'Fischereihafen, Pending' }))
expect(onOpen).toHaveBeenCalledWith(1)
expect(editor.container.querySelector('.w-\\[1\\.5px\\]')).toBeNull()
editor.unmount()
wrap(<BookingChips bookings={[table, train]} continues={false} canEdit />)
expect(screen.queryByRole('button')).toBeNull()
expect(screen.getByText('ICE 1007')).toBeInTheDocument()
expect(screen.getAllByText('Reservation')).toHaveLength(2)
})
it('FE-ROADTRIP-BOOKINGROWS-011: the clock follows the traveller\'s format', () => {
@@ -345,7 +345,7 @@ export function ReservationPill({ booking, arrival, canEdit, onOpen }: {
}): React.ReactElement {
const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const Icon = bookingIcon(booking.type)
const icon = React.createElement(bookingIcon(booking.type), { size: 10, strokeWidth: 2.2, 'aria-hidden': true })
const confirmed = booking.status === 'confirmed'
const status = confirmed ? t('reservations.confirmed') : t('reservations.pending')
const clock = bookingClock(booking)
@@ -357,7 +357,7 @@ export function ReservationPill({ booking, arrival, canEdit, onOpen }: {
<FigureBadge
caption
tone={confirmed ? 'success' : 'pending'}
lead={<><Icon size={10} strokeWidth={2.2} aria-hidden />{t('places.formReservation')}</>}
lead={<>{icon}{t('places.formReservation')}</>}
value={shownTime ?? undefined}
tooltip={`${booking.title} (${status})`}
onActivate={open ? () => open() : undefined}
@@ -4,6 +4,7 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { TranslationProvider } from '../../i18n'
import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
import type { RouteSegment } from '../../types'
import { useSettingsStore } from '../../store/settingsStore'
import { DayHeader, TripSummary } from './RoadtripSidebarHeaders'
import { preferences, resetRailStores, seedRailSettings, tooltipOf } from '../../../tests/helpers/roadtripRail'
@@ -44,7 +45,7 @@ function routes(over: Partial<RoadtripRoutes> = {}): RoadtripRoutes {
}
describe('TripSummary', () => {
it('FE-ROADTRIP-HEADERS-001: three figures under their captions, the whole numbers set apart from the units and decimals', () => {
it('FE-ROADTRIP-HEADERS-001: three figures on tiles under their captions, the whole numbers set apart from the units and decimals', () => {
const { container } = wrap(<TripSummary routes={routes()} narrow={false} />)
const value = (caption: string): Element => screen.getByText(caption).parentElement!.lastElementChild!
expect(value('Distance')).toHaveTextContent('691.6 km')
@@ -54,50 +55,97 @@ describe('TripSummary', () => {
expect([...value('Distance').querySelectorAll('.text-content-muted')].map(u => u.textContent)).toEqual(['.6', ' km'])
expect([...value('Driving time').querySelectorAll('.text-content-muted')].map(u => u.textContent)).toEqual([' h ', ' min'])
expect(value('Stops').children).toHaveLength(0)
expect(container.querySelectorAll('span.w-px')).toHaveLength(2)
// One tile per figure, in as many equal columns.
const grid = (container.querySelector('header') as HTMLElement).firstElementChild as HTMLElement
expect(grid.children).toHaveLength(3)
expect(grid.style.gridTemplateColumns).toBe('repeat(3, minmax(0, 1fr))')
expect(screen.queryByText('Still working out the rest of the drive')).toBeNull()
})
it('FE-ROADTRIP-HEADERS-002: pulled narrow the stops give way, and while legs still land the figures say they are partial', () => {
const { container } = wrap(<TripSummary routes={routes({ loading: true })} narrow />)
expect(screen.queryByText('Stops')).toBeNull()
expect(container.querySelectorAll('span.w-px')).toHaveLength(1)
expect(((container.querySelector('header') as HTMLElement).firstElementChild as HTMLElement).children).toHaveLength(2)
expect(container.querySelector('header')!.lastElementChild).toHaveTextContent('Still working out the rest of the drive')
})
})
describe('DayHeader', () => {
it('FE-ROADTRIP-HEADERS-003: the day reads its number, its title, its date, its drive and its stops', () => {
/** The width the pill row measures, so a case can pull the rail narrow. */
const rowWidth = (px: number) => vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(px)
afterEach(() => { vi.restoreAllMocks() })
it('FE-ROADTRIP-HEADERS-003: the day reads its number and short date on the tile, its title, its drive and its stops', () => {
const { container } = wrap(<DayHeader day={day({ title: 'Along the coast', date: '2026-06-02' })} />)
const header = container.querySelector('header')!
expect(container.firstElementChild).toBe(header)
expect(screen.getByRole('heading', { name: 'Day 2' }).parentElement).toBe(header)
expect(screen.getByText('Along the coast')).toBeInTheDocument()
expect(container.querySelector('time')).toHaveAttribute('dateTime', '2026-06-02')
// A day with a title of its own reads the title as its heading; the number is on the tile.
expect(screen.getByRole('heading', { name: 'Along the coast' })).toBeInTheDocument()
const tile = header.firstElementChild as HTMLElement
expect(tile.firstElementChild).toHaveTextContent('2')
const time = container.querySelector('time') as HTMLElement
expect(time).toHaveAttribute('dateTime', '2026-06-02')
// The tile holds the short form; the full date is what a screen reader hears.
expect(time).toHaveTextContent('2.6.')
expect(time).toHaveAttribute('aria-label')
expect(time.getAttribute('aria-label')).not.toBe('2.6.')
expect(screen.getByText('152 km in 1 h 38 min')).toBeInTheDocument()
expect(screen.getByText('2 stops')).toBeInTheDocument()
// Read-only: no control, and the bottom rule over the list below.
// Read-only: no control, no chevron, and the head band's neutral wash.
expect(header).not.toHaveAttribute('role')
expect(header).toHaveClass('mb-1', 'border-b')
expect(header).not.toHaveAttribute('style')
expect(container.querySelector('svg.lucide-chevron-down, svg.lucide-chevron-right')).toBeNull()
expect(header.style.background).toBe('color-mix(in srgb, var(--accent) 7%, transparent)')
})
it('FE-ROADTRIP-HEADERS-004: pulled narrow the count gives way, and a day of only its night has no drive and no count', () => {
const narrow = wrap(<DayHeader day={day()} narrow />)
expect(screen.queryByText('2 stops')).toBeNull()
it('FE-ROADTRIP-HEADERS-003b: without a title the heading is the day itself, and a 12-hour reader gets the month first on the tile', () => {
useSettingsStore.setState({ settings: { distance_unit: 'metric', time_format: '12h' } as never })
const { container } = wrap(<DayHeader day={day({ date: '2026-06-02' })} />)
expect(screen.getByRole('heading', { name: 'Day 2' })).toBeInTheDocument()
expect(container.querySelector('time')).toHaveTextContent('6/2')
})
it('FE-ROADTRIP-HEADERS-004: the pills never wrap but give way in steps as the row narrows, and a day of only its night has no drive and no count', () => {
// Room for everything: the drive with its time, the count with its word. The old
// narrow flag no longer decides it; the row measures its own room.
const wide = wrap(<DayHeader day={day()} narrow />)
expect(screen.getByText('152 km in 1 h 38 min')).toBeInTheDocument()
narrow.unmount()
expect(screen.getByText('2 stops')).toBeInTheDocument()
wide.unmount()
// First the count drops its word, keeping it for a screen reader.
rowWidth(220)
const step1 = wrap(<DayHeader day={day()} />)
expect(screen.getByText('152 km in 1 h 38 min')).toBeInTheDocument()
expect(screen.getByLabelText('2 stops')).toHaveTextContent(/^2$/)
step1.unmount()
// Then the drive drops its time.
rowWidth(150)
const step2 = wrap(<DayHeader day={day()} />)
expect(screen.getByText('152 km')).toBeInTheDocument()
expect(screen.queryByText(/km in/)).toBeNull()
expect(screen.getByLabelText('2 stops')).toBeInTheDocument()
step2.unmount()
// Last the count goes.
rowWidth(80)
const step3 = wrap(<DayHeader day={day()} />)
expect(screen.getByText('152 km')).toBeInTheDocument()
expect(screen.queryByLabelText('2 stops')).toBeNull()
step3.unmount()
vi.restoreAllMocks()
wrap(<DayHeader day={day({ legs: [], stops: [stop({ assignmentId: 1, name: 'Hotel' })] })} />)
expect(screen.queryByText(/km in/)).toBeNull()
expect(screen.queryByText(/stop/)).toBeNull()
})
it('FE-ROADTRIP-HEADERS-005: the whole header folds the day on a click, Enter or Space, and says whether it is open', () => {
it('FE-ROADTRIP-HEADERS-005: the whole header folds the day on a click, Enter or Space, says whether it is open, and its chevron says the same', () => {
const onToggle = vi.fn()
const { container, rerender } = wrap(<DayHeader day={day()} onToggle={onToggle} />)
const header = screen.getByRole('button', { expanded: true })
const header = screen.getByRole('button', { name: 'Day 2', expanded: true })
expect(header.tagName).toBe('HEADER')
expect(container.querySelector('svg.lucide-chevron-down')).not.toBeNull()
fireEvent.click(header)
fireEvent.keyDown(header, { key: 'Enter' })
fireEvent.keyDown(header, { key: ' ' })
@@ -106,7 +154,8 @@ describe('DayHeader', () => {
rerender(<TranslationProvider><DayHeader day={day()} onToggle={onToggle} collapsed /></TranslationProvider>)
expect(container.querySelector('header')).toHaveAttribute('aria-expanded', 'false')
expect(container.querySelector('header')).not.toHaveClass('border-b')
expect(container.querySelector('svg.lucide-chevron-right')).not.toBeNull()
expect(container.querySelector('svg.lucide-chevron-down')).toBeNull()
})
it('FE-ROADTRIP-HEADERS-006: a day over the driving limit says by how much, and what the limit counts', () => {
@@ -123,7 +172,7 @@ describe('DayHeader', () => {
expect(tooltipOf(badge)).toBe('This day has no route around it, so the drive uses it. Every other day still avoids what it can.')
})
it('FE-ROADTRIP-HEADERS-008: a ride the drive reaches too late is named in the header', () => {
it('FE-ROADTRIP-HEADERS-008: a ride the drive reaches too late takes no pill in the head, which keeps to the facts of the day', () => {
const departure = stop({
assignmentId: 3, name: 'Hamburg Airport', time: '13:15',
carrier: { reservationId: 9, type: 'flight', role: 'departure', title: 'LH 2078', code: 'HAM', at: '15:15' },
@@ -136,31 +185,37 @@ describe('DayHeader', () => {
})}
/>,
)
expect(screen.getByText('Flight missed')).toHaveClass('text-warning')
expect(screen.queryByText('Flight missed')).toBeNull()
expect(screen.getByText('152 km in 1 h 38 min')).toBeInTheDocument()
})
it('FE-ROADTRIP-HEADERS-009: the track badge follows a track, names the one it follows, and is tinted once the day carries vias', () => {
it('FE-ROADTRIP-HEADERS-009: the track pill follows a track without folding the day, names the one it follows, and is filled once the day carries vias', () => {
const onFollowTrack = vi.fn()
const plain = wrap(<DayHeader day={day()} onFollowTrack={onFollowTrack} />)
const onToggle = vi.fn()
const plain = wrap(<DayHeader day={day()} onFollowTrack={onFollowTrack} onToggle={onToggle} />)
const badge = screen.getByRole('button', { name: 'Track' })
expect(badge).toHaveClass('bg-surface-card')
expect(tooltipOf(badge)).toBe('Make this day follow an imported track')
fireEvent.click(badge)
expect(onFollowTrack).toHaveBeenCalledWith(1)
expect(onToggle).not.toHaveBeenCalled()
// A key pressed on the pill belongs to the pill, not to the band around it.
fireEvent.keyDown(badge, { key: 'Enter' })
expect(onToggle).not.toHaveBeenCalled()
plain.unmount()
wrap(<DayHeader day={day()} onFollowTrack={onFollowTrack} viaCount={3} trackName="Küstenweg" />)
const shaped = screen.getByRole('button', { name: 'Track' })
expect(shaped).toHaveClass('bg-accent-subtle')
expect(shaped).toHaveClass('bg-accent', 'text-accent-text')
expect(tooltipOf(shaped)).toBe('Currently following Küstenweg')
})
it('FE-ROADTRIP-HEADERS-010: while the map draws days in colour, the header is washed with its own', () => {
it('FE-ROADTRIP-HEADERS-010: while the map draws days in colour, the header band and the day tile are washed with its own', () => {
preferences({ roadtrip_day_colors: true })
const { container } = wrap(<DayHeader day={day({ dayNumber: 2 })} />)
const header = container.querySelector('header')!
expect(header.style.backgroundImage).toContain('linear-gradient(180deg, color-mix(in srgb, #ff9f0a 13%, var(--bg-secondary))')
// The DOM spells the hue its own way here, so only the mix is pinned.
expect(header.style.borderBottomColor).toMatch(/^color-mix\(in srgb, .+ 30%, transparent\)$/)
expect(header.style.background).toMatch(/^color-mix\(in srgb, .+ 11%, transparent\)$/)
expect((header.firstElementChild as HTMLElement).style.background).toMatch(/^color-mix\(in srgb, .+ 22%, var\(--bg-card\)\)$/)
})
})
@@ -54,7 +54,7 @@ function splitValue(value: string): React.ReactNode {
*/
export function TripSummary({ routes, narrow }: { routes: RoadtripRoutes; narrow: boolean }): React.ReactElement {
const { t } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const distanceUnit = useSettingsStore(s => s.settings.distance_unit) || 'metric'
const cells: [string, string][] = [
// `totalStops` already leaves the service stops out, so the head and the day pills
// below count the same thing without either of them recounting the other's stops.
@@ -107,6 +107,116 @@ function pillWidth(text: string): number {
return 16 + 11 + 4 + text.length * 6.6
}
/**
* How far the pill row gives way for its width: 0 shows everything, 1 the stop count
* without its word, 2 the drive without its time as well, 3 no stop count at all. A null
* drive or count is a pill the day does not show; an unmeasured row (0) fits everything.
*/
function pillStep(width: number, drive: { full: string; short: string } | null, count: { full: string; short: string } | null, others: string[]): number {
const needed = (driveText: string | undefined, countText: string | undefined) =>
[driveText, countText, ...others]
.filter((x): x is string => x !== undefined)
.reduce((sum, text, i) => sum + pillWidth(text) + (i ? 4 : 0), 0)
const fits = (w: number) => width === 0 || w <= width
if (fits(needed(drive?.full, count?.full))) return 0
if (fits(needed(drive?.full, count?.short))) return 1
return fits(needed(drive?.short, count?.short)) ? 2 : 3
}
/** The texts of the pills after the drive and the count, for reckoning the row's width. */
function flagTexts(day: RoadtripDay, followable: boolean, t: ReturnType<typeof useTranslation>['t']): string[] {
return [
day.dayWarning ? t('roadtrip.limit.dayOver', { time: formatDurationShort((day.dayWarning.minutes - day.dayWarning.limitMinutes) * 60) }) : null,
day.avoidMissed?.length ? t('roadtrip.avoid.missed', { classes: day.avoidMissed.map(cls => t(`roadtrip.avoid.${cls}`)).join(', ') }) : null,
followable ? t('roadtrip.track.badge') : null,
].filter((x): x is string => !!x)
}
/**
* The day's number on a raised tile, the way the day plan badges a day, with its date
* folded in under it where the day plan keeps the weather.
*/
function DayTile({ day, tint }: { day: RoadtripDay; tint: string | null }): React.ReactElement {
const { language } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === "12h"
return (
<span
className="flex w-9 flex-none flex-col items-center overflow-hidden rounded-[10px] font-geist font-bold tabular-nums shadow-sm"
style={{
background: tint ? `color-mix(in srgb, ${tint} 22%, var(--bg-card))` : 'var(--bg-card)',
color: tint ? 'var(--text-secondary)' : 'var(--text-muted)',
}}
>
<span className={`grid w-full place-items-center ${day.date ? 'h-6' : 'h-8'}`} style={{ ...fs(12), lineHeight: 1 }}>
{day.dayNumber}
</span>
{day.date ? (
<>
<span aria-hidden className="h-px w-[60%] flex-none bg-current opacity-25" />
<time dateTime={day.date} aria-label={formatDate(day.date, language)} className="grid h-6 w-full place-items-center font-semibold" style={{ ...fs(9.5), lineHeight: 1 }}>
{shortDate(day.date, is12h)}
</time>
</>
) : null}
</span>
)
}
/** The pills after the drive and the count: the limit, the missed avoidance and the track. */
function DayFlags({ day, pill, warn, onFollowTrack, viaCount, trackName }: {
day: RoadtripDay
pill: string
warn: string
onFollowTrack?: (dayId: number) => void
viaCount?: number
trackName?: string
}): React.ReactElement {
const { t } = useTranslation()
return (
<>
{day.dayWarning ? (
<Tooltip label={t('roadtrip.limit.hint')}>
<span className={warn}>
<AlertTriangle size={11} className="shrink-0" aria-hidden />
{t('roadtrip.limit.dayOver', {
time: formatDurationShort((day.dayWarning.minutes - day.dayWarning.limitMinutes) * 60),
})}
</span>
</Tooltip>
) : null}
{/* The other half of "where possible": a day with no untolled crossing comes
back on the toll road, and saying nothing reads as the switch being broken. */}
{day.avoidMissed?.length ? (
<Tooltip label={t('roadtrip.avoid.missedHint')}>
<span className={pill}>
<Ban size={11} className="shrink-0 text-content-faint" aria-hidden />
{t('roadtrip.avoid.missed', {
classes: day.avoidMissed.map(cls => t(`roadtrip.avoid.${cls}`)).join(', '),
})}
</span>
</Tooltip>
) : null}
{/* Which road this day takes is one of its facts. Filled once the day carries
vias: the rail draws none of them, so this pill is the only place a drive
shaped by hand differs from one the router picked on its own. */}
{onFollowTrack ? (
<Tooltip label={trackName ? t('roadtrip.track.current', { name: trackName }) : t('roadtrip.track.hint')}>
<button
type="button"
onClick={e => { e.stopPropagation(); onFollowTrack(day.dayId) }}
className={`${DAY_BADGE} shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
viaCount ? 'bg-accent text-accent-text' : 'bg-surface-card hover:text-content'
}`}
>
<Spline size={11} className="shrink-0" aria-hidden />
{t('roadtrip.track.badge')}
</button>
</Tooltip>
) : null}
</>
)
}
/** The head band's resting and hover wash, the day plan's own. */
const HEAD_REST = NEUTRAL_TINT
const HEAD_HOVER = 'color-mix(in srgb, var(--accent) 10%, transparent)'
@@ -130,9 +240,8 @@ export function DayHeader({ day, collapsed, onToggle, onFollowTrack, viaCount, t
viaCount?: number
trackName?: string
}>): React.ReactElement {
const { t, language } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const is12h = useSettingsStore(s => s.settings.time_format) === "12h"
const { t } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit) || 'metric'
// The colour the map draws this day in, or none while the map draws one blue line for
// the whole trip. With it on, the tile wears the colour and the band a tenth of it, so
// a green line on the map finds its card.
@@ -148,29 +257,20 @@ export function DayHeader({ day, collapsed, onToggle, onFollowTrack, viaCount, t
// 1 the stop count drops its word, 2 the drive drops its time, 3 the stop count goes.
// The row's width is measured; each pill's is reckoned from its text, which at this
// size is close enough to choose a step without rendering every step to try it.
const row = useElementSize<HTMLDivElement>()
const { ref: rowRef, width: rowWidth } = useElementSize<HTMLDivElement>()
const driveFull = t('roadtrip.leg.driveText', {
distance: formatDistance(day.distance / 1000, distanceUnit),
time: formatDurationShort(day.duration),
})
const driveShort = formatDistance(day.distance / 1000, distanceUnit)
const stopsFull = t('roadtrip.day.stopCount', { count: stops })
const others = [
day.dayWarning ? t('roadtrip.limit.dayOver', { time: formatDurationShort((day.dayWarning.minutes - day.dayWarning.limitMinutes) * 60) }) : null,
day.avoidMissed?.length ? t('roadtrip.avoid.missed', { classes: day.avoidMissed.map(cls => t(`roadtrip.avoid.${cls}`)).join(', ') }) : null,
onFollowTrack ? t('roadtrip.track.badge') : null,
].filter((x): x is string => !!x)
const hasDrive = day.legs.length > 0
const hasStops = hasDrive && stops > 0
const needed = (drive: string, stopText: string | null) =>
[hasDrive ? drive : null, hasStops ? stopText : null, ...others]
.filter((x): x is string => x !== null)
.reduce((sum, text, i) => sum + pillWidth(text) + (i ? 4 : 0), 0)
const fits = (w: number) => row.width === 0 || w <= row.width
const compact = fits(needed(driveFull, stopsFull)) ? 0
: fits(needed(driveFull, String(stops))) ? 1
: fits(needed(driveShort, String(stops))) ? 2
: 3
const compact = pillStep(
rowWidth,
hasDrive ? { full: driveFull, short: driveShort } : null,
hasDrive && stops > 0 ? { full: stopsFull, short: String(stops) } : null,
flagTexts(day, !!onFollowTrack, t),
)
return (
<header
// The whole band folds the day and the chevron at its end says so: the band is what
@@ -195,33 +295,13 @@ export function DayHeader({ day, collapsed, onToggle, onFollowTrack, viaCount, t
}`}
style={{ background: rest }}
>
{/* The day's number on a raised tile, the way the day plan badges a day, with its
date folded in under it where the day plan keeps the weather. */}
<span
className="flex w-9 flex-none flex-col items-center overflow-hidden rounded-[10px] font-geist font-bold tabular-nums shadow-sm"
style={{
background: tint ? `color-mix(in srgb, ${tint} 22%, var(--bg-card))` : 'var(--bg-card)',
color: tint ? 'var(--text-secondary)' : 'var(--text-muted)',
}}
>
<span className={`grid w-full place-items-center ${day.date ? 'h-6' : 'h-8'}`} style={{ ...fs(12), lineHeight: 1 }}>
{day.dayNumber}
</span>
{day.date ? (
<>
<span aria-hidden className="h-px w-[60%] flex-none bg-current opacity-25" />
<time dateTime={day.date} aria-label={formatDate(day.date, language)} className="grid h-6 w-full place-items-center font-semibold" style={{ ...fs(9.5), lineHeight: 1 }}>
{shortDate(day.date, is12h)}
</time>
</>
) : null}
</span>
<DayTile day={day} tint={tint} />
<div className="min-w-0 flex-1 pt-px">
<h3 className="m-0 min-w-0 truncate font-bold text-content" style={fs(13.5, 'body')}>
{day.title || dayName}
</h3>
<div ref={row.ref} className="mt-1.5 flex min-w-0 flex-nowrap items-center gap-1 overflow-hidden" style={{ fontSize: FS.meta }}>
<div ref={rowRef} className="mt-1.5 flex min-w-0 flex-nowrap items-center gap-1 overflow-hidden" style={{ fontSize: FS.meta }}>
{/* A day that is only the night booked for it has no drive to sum up and no
stop to count; the hotel row below says everything it has to say. */}
{day.legs.length > 0 ? (
@@ -238,45 +318,7 @@ export function DayHeader({ day, collapsed, onToggle, onFollowTrack, viaCount, t
{compact >= 1 ? stops : stopsFull}
</span>
) : null}
{day.dayWarning ? (
<Tooltip label={t('roadtrip.limit.hint')}>
<span className={warn}>
<AlertTriangle size={11} className="shrink-0" aria-hidden />
{t('roadtrip.limit.dayOver', {
time: formatDurationShort((day.dayWarning.minutes - day.dayWarning.limitMinutes) * 60),
})}
</span>
</Tooltip>
) : null}
{/* The other half of "where possible": a day with no untolled crossing comes
back on the toll road, and saying nothing reads as the switch being broken. */}
{day.avoidMissed?.length ? (
<Tooltip label={t('roadtrip.avoid.missedHint')}>
<span className={pill}>
<Ban size={11} className="shrink-0 text-content-faint" aria-hidden />
{t('roadtrip.avoid.missed', {
classes: day.avoidMissed.map(cls => t(`roadtrip.avoid.${cls}`)).join(', '),
})}
</span>
</Tooltip>
) : null}
{/* Which road this day takes is one of its facts. Filled once the day carries
vias: the rail draws none of them, so this pill is the only place a drive
shaped by hand differs from one the router picked on its own. */}
{onFollowTrack ? (
<Tooltip label={trackName ? t('roadtrip.track.current', { name: trackName }) : t('roadtrip.track.hint')}>
<button
type="button"
onClick={e => { e.stopPropagation(); onFollowTrack(day.dayId) }}
className={`${DAY_BADGE} shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
viaCount ? 'bg-accent text-accent-text' : 'bg-surface-card hover:text-content'
}`}
>
<Spline size={11} className="shrink-0" aria-hidden />
{t('roadtrip.track.badge')}
</button>
</Tooltip>
) : null}
<DayFlags day={day} pill={pill} warn={warn} onFollowTrack={onFollowTrack} viaCount={viaCount} trackName={trackName} />
</div>
</div>
@@ -7,24 +7,26 @@ vi.mock('../../i18n/TranslationContext', () => ({ useTranslation: () => ({ t: (k
it('edits the shared stay without changing the day ending', () => {
const edit = vi.fn(), toggle = vi.fn(async () => {})
render(<VisitControls endDay={{ active: false, onToggle: toggle }} stay={{ minutes: 90, onEdit: edit }} />)
fireEvent.click(screen.getByRole('button', { name: 'roadtrip.stop.stayShort 1 h 30 min' }))
// The caption is the section's eyebrow now, so the button reads the figure alone.
expect(screen.getByText('roadtrip.stop.stayShort')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '1 h 30 min' }))
expect(edit).toHaveBeenCalledOnce()
expect(toggle).not.toHaveBeenCalled()
})
it('keeps stay editing when daily travel times are off', () => {
render(<VisitControls stay={{ minutes: null, onEdit: vi.fn() }} />)
expect(screen.getByRole('button', { name: 'roadtrip.stop.stayShort +' })).toBeEnabled()
expect(screen.getByRole('button', { name: '+' })).toBeEnabled()
expect(screen.queryByRole('button', { name: 'roadtrip.window.endHere' })).not.toBeInTheDocument()
})
it('reads the stay a set leave time makes, and says until when', () => {
render(<VisitControls stay={{ minutes: 240, until: '14:00', onEdit: vi.fn() }} />)
expect(screen.getByRole('button', { name: 'roadtrip.stop.stayShort 4 h roadtrip.stay.until' })).toBeEnabled()
expect(screen.getByRole('button', { name: /^4 h\s*roadtrip\.stay\.until$/ })).toBeEnabled()
})
it('says only until when while the drive has not timed the arrival', () => {
render(<VisitControls stay={{ minutes: null, until: '14:00' }} />)
expect(screen.getByRole('button', { name: 'roadtrip.stop.stayShort roadtrip.stay.until' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'roadtrip.stay.until' })).toBeDisabled()
expect(screen.queryByText('+')).not.toBeInTheDocument()
})
@@ -14,7 +14,7 @@ export interface RoadtripStayControl {
}
/** The white field box the inspector's other sections (rating, hours, files) use. */
export const VISIT_BOX = 'flex min-h-[38px] min-w-0 items-center justify-between gap-2 rounded-[10px] border border-edge-faint bg-surface-card px-3 py-2'
const VISIT_BOX = 'flex min-h-[38px] min-w-0 items-center justify-between gap-2 rounded-[10px] border border-edge-faint bg-surface-card px-3 py-2'
/**
* A stop's road trip controls in the place inspector: how long it takes and whether the