feat(roadtrip): switch a stop between a pause and a destination from the phone chain

The desktop rail turns a place on the drive into a service stop by its own disc: click
the 3, pick the pump, and the 3 becomes an orange disc while everything below it
renumbers. The phone chain drew the same disc but it did nothing, so the only way to
mark a petrol stop was to open the trip on a computer.

The disc is now the control here too, for the same reason it is there: it is exactly
what changes, so it is its own preview. It opens 'rtkind', a sheet rather than the
desktop's popover under the disc, because a palette aimed at an anchor would land under
the thumb that opened it. The kinds stay discs rather than seven rows of words, each
named for a screen reader. A second tap on the kind a stop already is takes it back to
a destination, and once there is something to undo the sheet says so in words too.

It writes through planner.setRoadtripStopKind, the very call the rail makes, so the
chain redraws off the store with no second code path. Without place_edit there is no
handler and the disc is a plain disc, not a disabled-looking control.

A leg pill with no figures in it is fixed on the way past: a segment that reaches the
chain before its routing round has landed carries metres and seconds but empty texts,
and reading only the texts printed the template with both slots empty, so the pill read
" in " and said nothing. The numbers are formatted when the texts are missing, which is
what the rail does with the same segment, and with neither the leg says it is pending.
This commit is contained in:
Maurice
2026-09-17 13:10:28 +02:00
parent a250fa6339
commit 0b1ca1c98a
7 changed files with 466 additions and 19 deletions
@@ -9,7 +9,7 @@ import type { TranslationFn } from '../../../../src/types'
import type { RefuelSearch } from '../../../../src/components/Roadtrip/useRefuelSearch'
import type { RefuelCandidate } from '../../../../src/components/Roadtrip/refuelSuggestion'
// FE-MOB-RTROW-001 to FE-MOB-RTROW-040
// FE-MOB-RTROW-001 to FE-MOB-RTROW-046
// Same echo strategy as tests/helpers/mobileTrip: assertions stay on keys, not copy.
const t: TranslationFn = (key, params) =>
@@ -168,6 +168,52 @@ describe('RtStopRow', () => {
expect(screen.getByText('35 min')).toBeInTheDocument()
})
// The disc as the control that switches a stop between a destination and a pause, the
// way the desktop rail's own disc does.
it('FE-MOB-RTROW-041: the disc is a button that asks for the kind, and the tap stops there', () => {
const onOpen = vi.fn()
const onPickKind = vi.fn()
render(<RtStopRow row={stopRow()} chrome={chrome} onOpen={onOpen} onPickKind={onPickKind} />)
const disc = screen.getByRole('button', { name: 'roadtrip.stop.makeService' })
expect(disc).toHaveTextContent('2')
fireEvent.click(disc)
expect(onPickKind).toHaveBeenCalledTimes(1)
// The row opens the stop; a tap meant for the disc must not do both.
expect(onOpen).not.toHaveBeenCalled()
})
it('FE-MOB-RTROW-042: the disc of a service stop asks the same question, named for what it already is', () => {
const onPickKind = vi.fn()
render(
<RtStopRow
row={stopRow({ number: null, service: true, stop: { ...stopRow().stop, stopType: 'fuel' } })}
chrome={chrome}
onOpen={vi.fn()}
onPickKind={onPickKind}
/>,
)
const disc = screen.getByRole('button', { name: 'roadtrip.stop.kind' })
// Still the kind's own symbol: the control is its own preview of what it changes.
expect(disc.querySelector('.lucide-fuel')).not.toBeNull()
fireEvent.click(disc)
expect(onPickKind).toHaveBeenCalledTimes(1)
})
it('FE-MOB-RTROW-043: without the handler the disc is not a button at all', () => {
// A traveller who may not edit places gets no handler, and a disabled-looking control
// for something they cannot do is worse than no control.
render(<RtStopRow row={stopRow()} chrome={chrome} onOpen={vi.fn()} />)
expect(screen.queryByRole('button', { name: 'roadtrip.stop.makeService' })).toBeNull()
expect(screen.queryByRole('button', { name: 'roadtrip.stop.kind' })).toBeNull()
expect(screen.getByText('2')).toBeInTheDocument()
})
it('FE-MOB-RTROW-013: writes an overnight warning as a word, since it has no figure', () => {
const warning: ScheduleWarning = { index: 1, code: 'overnight' }
const { container } = render(<RtStopRow row={stopRow({ warning })} chrome={chrome} onOpen={vi.fn()} />)
@@ -204,6 +250,49 @@ describe('RtLegRow', () => {
expect(screen.getByText('roadtrip.leg.pending')).toBeInTheDocument()
})
it('FE-MOB-RTROW-044: a segment with no texts yet is read off its numbers, the way the desktop rail reads it', () => {
// A segment can reach the chain before its routing round has landed: metres and
// seconds are there, the pre-formatted texts are not.
render(
<RtLegRow
seg={{ ...SEG, distanceText: '', durationText: undefined, drivingText: '' }}
mode="driving"
chrome={chrome}
/>,
)
expect(screen.getByText('roadtrip.leg.driveText:210 km,2 h 40 min')).toBeInTheDocument()
})
it('FE-MOB-RTROW-045: a segment with neither texts nor numbers says pending, never a bare separator', () => {
// This printed the template with both slots empty, so the pill read " in " and said
// nothing at all. A leg that is not routed says so.
render(
<RtLegRow
seg={{ ...SEG, distance: 0, duration: 0, distanceText: '', durationText: undefined, drivingText: '' }}
mode="driving"
chrome={chrome}
/>,
)
expect(screen.getByText('roadtrip.leg.pending')).toBeInTheDocument()
expect(screen.queryByText(/driveText/)).toBeNull()
})
it('FE-MOB-RTROW-046: half a segment is pending too, rather than a figure with a hole beside it', () => {
// A distance with no time would read "210 km in", which is worse than saying nothing:
// it looks like a sentence that was cut off.
render(
<RtLegRow
seg={{ ...SEG, duration: 0, durationText: undefined, drivingText: '' }}
mode="driving"
chrome={chrome}
/>,
)
expect(screen.getByText('roadtrip.leg.pending')).toBeInTheDocument()
})
it('FE-MOB-RTROW-017: swaps the icon for a walked leg and for a plugin mode', () => {
const walk = render(<RtLegRow seg={SEG} mode="walking" chrome={chrome} />)
expect(walk.container.querySelector('.lucide-footprints')).not.toBeNull()
@@ -230,6 +230,28 @@ describe('MRoadtripTab', () => {
expect(shell.openSheet).toHaveBeenCalledWith('rtstop', { dayId: 2, assignmentId: 503 })
})
it('FE-MOB-RTTAB-051: the disc asks which kind of stop it is, without opening the stop', () => {
const { shell } = renderTab()
// Kyoto Station is a destination, so its disc offers to make it a stop on the way.
fireEvent.click(screen.getAllByRole('button', { name: 'roadtrip.stop.makeService' })[1])
expect(shell.openSheet).toHaveBeenCalledWith('rtkind', {
placeId: 103,
stopType: null,
name: 'Kyoto Station',
})
// The row's own tap opens the stop; the disc's must not do both.
expect(shell.openSheet).toHaveBeenCalledTimes(1)
})
it('FE-MOB-RTTAB-052: a traveller who may not edit places gets a disc that is not a control', () => {
renderTab(planner({ can: () => false }))
expect(screen.queryByRole('button', { name: 'roadtrip.stop.makeService' })).toBeNull()
expect(screen.queryByRole('button', { name: 'roadtrip.stop.kind' })).toBeNull()
})
it('FE-MOB-RTTAB-006: writes the day warning out with the sentence the desktop hides in a tooltip', () => {
const over = stage({ dayWarning: { code: 'dayDriving', minutes: 620, limitMinutes: 540 } })
renderTab(planner({ roadtripRoutes: routes({ days: [over] }) }))
@@ -0,0 +1,122 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MRtKindSheet from '../../../../src/mobile/screens/trip/roadtrip/MRtKindSheet'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { resetAllStores } from '../../../helpers/store'
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-RTKIND-001 to FE-MOB-RTKIND-008
//
// The sheet renders inside the real TranslationProvider, so the copy is asserted in
// English. It writes through the very call the desktop rail makes, so what these hold is
// the phone's half: which stop, which kind, and that a second tap undoes it.
function makePlanner(overrides: Record<string, unknown> = {}) {
return buildPlanner({
tripId: 4,
setRoadtripStopKind: vi.fn(async () => undefined),
...overrides,
} as unknown as Partial<TripPlanner>)
}
function makeShell(overrides: Record<string, unknown> = {}) {
return buildShell({
sheet: { id: 'rtkind', payload: { placeId: 202, stopType: null, name: 'Bremen Marktplatz' } },
...overrides,
} as unknown as Partial<MTripShellApi>)
}
function renderSheet(plannerOverrides: Record<string, unknown> = {}, shellOverrides: Record<string, unknown> = {}) {
const planner = makePlanner(plannerOverrides)
const shell = makeShell(shellOverrides)
render(<MRtKindSheet planner={planner} shell={shell} />)
return { planner, shell }
}
describe('MRtKindSheet', () => {
beforeEach(() => {
resetAllStores()
})
it('FE-MOB-RTKIND-001: stays closed while another sheet id is active', () => {
renderSheet({}, { sheet: { id: 'rtstop', payload: { placeId: 202 } } })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-RTKIND-002: opens on the stop it was asked about, with every kind offered', () => {
renderSheet()
expect(screen.getByRole('dialog', { name: 'Kind of stop' })).toBeInTheDocument()
expect(screen.getByText('Bremen Marktplatz')).toBeInTheDocument()
// The seven the desktop palette offers, each named for a screen reader although the
// disc itself is the label on screen.
for (const name of ['Accommodation', 'Fuel', 'Charging', 'Rest area', 'Campsite', 'Food', 'Sights']) {
expect(screen.getByRole('button', { name, pressed: false })).toBeInTheDocument()
}
})
it('FE-MOB-RTKIND-003: picking a kind writes it to the place and closes', async () => {
const { planner, shell } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'Fuel' }))
await waitFor(() => expect(planner.setRoadtripStopKind).toHaveBeenCalledWith(202, 'fuel'))
await waitFor(() => expect(shell.closeSheet).toHaveBeenCalledTimes(1))
})
it('FE-MOB-RTKIND-004: the kind a stop already is shows as chosen, and tapping it again takes it back', async () => {
const { planner } = renderSheet({}, {
sheet: { id: 'rtkind', payload: { placeId: 202, stopType: 'fuel', name: 'Bremen Marktplatz' } },
})
expect(screen.getByRole('button', { name: 'Fuel', pressed: true })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Fuel' }))
// Null is what turns a pause back into a destination, and it is the same write.
await waitFor(() => expect(planner.setRoadtripStopKind).toHaveBeenCalledWith(202, null))
})
it('FE-MOB-RTKIND-005: the way back is offered only once there is something to undo', async () => {
renderSheet()
// On a destination it would be a button saying "leave everything as it is".
expect(screen.queryByRole('button', { name: 'Back to a destination' })).toBeNull()
const { planner } = renderSheet({}, {
sheet: { id: 'rtkind', payload: { placeId: 202, stopType: 'rest_area', name: 'Bremen Marktplatz' } },
})
fireEvent.click(screen.getAllByRole('button', { name: 'Back to a destination' })[0])
await waitFor(() => expect(planner.setRoadtripStopKind).toHaveBeenCalledWith(202, null))
})
it('FE-MOB-RTKIND-006: a traveller who may not edit places can open it but change nothing', () => {
const { planner } = renderSheet({ can: () => false })
for (const name of ['Fuel', 'Accommodation']) {
expect(screen.getByRole('button', { name })).toBeDisabled()
}
fireEvent.click(screen.getByRole('button', { name: 'Fuel' }))
expect(planner.setRoadtripStopKind).not.toHaveBeenCalled()
})
it('FE-MOB-RTKIND-007: a payload without a place is not a sheet', () => {
renderSheet({}, { sheet: { id: 'rtkind', payload: {} } })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-RTKIND-008: a failing write leaves the sheet open rather than claiming it saved', async () => {
const { shell } = renderSheet({
setRoadtripStopKind: vi.fn(async () => { throw new Error('offline') }),
})
fireEvent.click(screen.getByRole('button', { name: 'Fuel' }))
// The planner's own call reports the failure; what matters here is that the sheet does
// not close on a write that never landed.
await waitFor(() => expect(screen.getByRole('dialog', { name: 'Kind of stop' })).toBeInTheDocument())
expect(shell.closeSheet).not.toHaveBeenCalled()
})
})