feat(roadtrip): other ways to drive a leg, on the phone

The desk offers alternative routes from the drive band between two stops;
the phone had nothing. A leg in the phone chain now carries one round
button that asks the router for other ways and switches to the map, where a
bar takes the stage bar's place: the ways as chips with their colour, time,
distance and what sets them apart, and a confirm button. On glass there is
no hover, so a tap on a chip or on a line only previews that road; confirm
writes it. The button shows only where the desk offers it (a routed leg
between chosen stops), for someone who may edit the day, and not offline,
since vias are written online only.

The picker could not have stayed open on a phone at all: the planner closed
it whenever road trip mode was off, and that mode is off on a phone by
design, so the next render shut it. The close now follows where the picker
can be seen (road trip mode on the desk, the road trip tab on a phone), and
useRouteAlternatives returns a memoised object.

The rules both shells need are shared: legReroutable in the row model,
alternativesPhase and alternativeSubline next to the overlays, and the desk
bar and sidebar read them too with no visible change. Both map engines take
an optional fitPadding, so the phone frames the leg between the day chips
and the bar instead of under them.
This commit is contained in:
Maurice
2026-09-17 00:10:51 +00:00
parent 9b415c8ab9
commit 6a6c7f1882
25 changed files with 1572 additions and 57 deletions
+117 -2
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, render, screen } from '../../../helpers/render'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { buildPlace } from '../../../helpers/factories'
@@ -10,8 +10,11 @@ import { useSettingsStore } from '../../../../src/store/settingsStore'
import { useTripStore } from '../../../../src/store/tripStore'
import { seedStore } from '../../../helpers/store'
import type { Place } from '../../../../src/types'
import type { AlternativeOverlay } from '../../../../src/components/Roadtrip/alternativeOverlays'
import type { LegAlternatives } from '../../../../src/components/Roadtrip/useRouteAlternatives'
import { RT_ALT_BAR_LIFT } from '../../../../src/mobile/screens/trip/roadtrip/useMRtAlternatives'
// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-027
// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-032
//
// The stage's pins come out of the trip store rather than the planner's map list, so the
// stage fixtures seed the store and leave `mapPlaces` to stand for what the plan tab shows.
@@ -132,6 +135,37 @@ const tapPin = (placeId?: number) => (mocks.props.onMarkerClick as (id?: number)
/** What the area handed the renderer that a fresh copy would make it draw again. */
const DRAWN = ['places', 'route', 'routeColors', 'accessLines', 'focusPoints'] as const
/** A picker open on leg 0 of day 3, with or without roads back yet. */
function withPicker(overlays: AlternativeOverlay[], over: Partial<TripPlanner> = {}): Partial<TripPlanner> {
const base = buildPlanner()
const open: LegAlternatives = { dayId: 3, index: 0, routes: [], loading: overlays.length === 0, error: false }
return {
routeAlternatives: { ...base.routeAlternatives, open },
alternativeOverlays: overlays,
...over,
} as Partial<TripPlanner>
}
/** Two offered roads, as far as the map area reads them. */
const OFFERS = [
{ index: 0, coordinates: [[53.55, 9.99], [53.87, 10.69]], color: 'blue', label: '1 h', note: 'Current' },
{ index: 1, coordinates: [[53.55, 9.99], [53.7, 10.2], [53.87, 10.69]], color: 'pale', label: '1 h 10 min', note: '' },
] as unknown as AlternativeOverlay[]
/** The probe the area reads the safe area off, answering like a phone with a notch. */
function notchedPhone(top: string, bottom: string) {
const real = window.getComputedStyle.bind(window)
return vi.spyOn(window, 'getComputedStyle').mockImplementation((el: Element, pseudo?: string | null) => (
el.className.toString().includes('safe-area-inset-top')
? ({ paddingTop: top, paddingBottom: bottom } as CSSStyleDeclaration)
: real(el, pseudo)
))
}
afterEach(() => {
vi.restoreAllMocks()
})
beforeEach(() => {
mocks.glMap = COMPASS
mocks.prefs = {}
@@ -510,6 +544,87 @@ describe('MMapArea', () => {
expect(stageLayer.style.getPropertyValue('--m-stage-lift')).toBe('76px')
})
it('FE-MOB-MAPAREA-028: on the stage the map draws the offered roads, and a tap on one only lights it', () => {
const { planner } = renderArea(
{ trTab: 'roadtrip', rtView: 'map' },
withPicker(OFFERS, { highlightedAlternative: 1 }),
)
expect(mocks.props.alternativeRoutes).toBe(planner.alternativeOverlays)
expect(mocks.props.activeAlternative).toBe(1)
;(mocks.props.onChooseAlternative as (index: number) => void)(0)
// Glass has no hover to preview with, so the desk's click that takes a road would save a
// via on the first touch. The bar's confirm is what takes it here.
expect(planner.setHighlightedAlternative).toHaveBeenCalledWith(0)
expect(planner.chooseRouteAlternative).not.toHaveBeenCalled()
// A touch fires emulated enter and leave events, and the leave would put the road out.
expect(mocks.props.onHighlightAlternative).toBeUndefined()
})
it('FE-MOB-MAPAREA-029: the plan tab draws no offered roads and frames nothing for them', () => {
renderArea({ trTab: 'plan' }, withPicker(OFFERS, { highlightedAlternative: 1 }))
expect(mocks.props.alternativeRoutes).toBeUndefined()
expect(mocks.props.activeAlternative).toBeUndefined()
expect(mocks.props.onChooseAlternative).toBeUndefined()
expect(mocks.props.fitPadding).toBeUndefined()
})
it('FE-MOB-MAPAREA-030: with the picker open the floor clears its taller bar, and the compass is still the one inline --bottom-nav-h', () => {
const { container, unmount } = renderArea({ trTab: 'roadtrip', mapFront: true }, withPicker([]))
const layer = container.firstElementChild as HTMLElement
// 174px of bar and the stage bar's 15px gap, as soon as the question is asked.
expect(layer.style.getPropertyValue('--m-stage-lift')).toBe(`${RT_ALT_BAR_LIFT}px`)
expect(layer.style.getPropertyValue('--m-stage-lift')).toBe('189px')
expect(layer.getAttribute('style')).not.toContain('--bottom-nav-h')
expect(container.querySelectorAll('[style*="--bottom-nav-h"]')).toHaveLength(1)
expect(compassBand(container)).not.toBeNull()
unmount()
// Behind the chain the bar is not on screen, so nothing lifts.
const behind = renderArea({ trTab: 'roadtrip', mapFront: false }, withPicker([]))
expect((behind.container.firstElementChild as HTMLElement).style.getPropertyValue('--m-stage-lift')).toBe('0px')
})
it('FE-MOB-MAPAREA-031: the offered roads are framed between the day chips and the bar, once they are there', () => {
const shell = stageShell()
const asking = buildPlanner(withPicker([]))
const { rerender } = render(<MMapArea planner={asking} shell={shell} />)
// Nothing to frame while the router is asked: a padding handed over now would refit
// the stage into a smaller frame just before the answer moves the camera again.
expect(mocks.props.fitPadding).toBeUndefined()
const answered = { ...asking, ...withPicker(OFFERS) } as TripPlanner
rerender(<MMapArea planner={answered} shell={shell} />)
// Top: 12 of --m-safe-top, the rail's 50 offset and 42 height, a 12 gap. Bottom: the
// dock's 74, the bar's lift and the 38 credit row. The sides keep the phone's 20.
expect(mocks.props.fitPadding).toEqual({ top: 116, right: 20, bottom: 74 + RT_ALT_BAR_LIFT + 38, left: 20 })
const first = mocks.props.fitPadding
// Kept between renders, for the reason the stage data is.
rerender(<MMapArea planner={{ ...answered }} shell={shell} />)
expect(mocks.props.fitPadding).toBe(first)
// Closed, the stage frames itself with the engines' own margins again.
rerender(<MMapArea planner={{ ...answered, ...withPicker([]), routeAlternatives: buildPlanner().routeAlternatives }} shell={shell} />)
expect(mocks.props.fitPadding).toBeUndefined()
})
it('FE-MOB-MAPAREA-032: on a phone with a notch the frame moves in by the safe area, and follows a turn of the phone', () => {
const style = notchedPhone('59px', '34px')
renderArea({ trTab: 'roadtrip', rtView: 'map' }, withPicker(OFFERS))
expect(mocks.props.fitPadding).toEqual({ top: 59 + 116, right: 20, bottom: 34 + 301, left: 20 })
// Turned sideways the insets go to the edges, and the frame lets go of them.
style.mockRestore()
notchedPhone('0px', '21px')
act(() => { window.dispatchEvent(new Event('resize')) })
expect(mocks.props.fitPadding).toEqual({ top: 116, right: 20, bottom: 21 + 301, left: 20 })
})
it('FE-MOB-MAPAREA-027: the layer sets only the lift inline, so the compass stays the one inline --bottom-nav-h', () => {
for (const shellOver of [{ trTab: 'plan' }, { trTab: 'roadtrip', mapFront: true }] as Partial<MTripShellApi>[]) {
const { container, unmount } = renderArea(shellOver)
@@ -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-037
// FE-MOB-RTROW-001 to FE-MOB-RTROW-040
// Same echo strategy as tests/helpers/mobileTrip: assertions stay on keys, not copy.
const t: TranslationFn = (key, params) =>
@@ -178,7 +178,7 @@ describe('RtStopRow', () => {
})
describe('RtLegRow', () => {
it('FE-MOB-RTROW-014: is not a control, no button, no role, nothing focusable', () => {
it('FE-MOB-RTROW-014: without a way to ask for other roads the leg is not a control, no button, no role, nothing focusable', () => {
const { container } = render(<RtLegRow seg={SEG} mode="driving" chrome={chrome} />)
expect(screen.queryByRole('button')).toBeNull()
@@ -212,6 +212,53 @@ describe('RtLegRow', () => {
const plugin = render(<RtLegRow seg={SEG} mode="plugin:rail" chrome={chrome} />)
expect(plugin.container.querySelector('.lucide-zap')).not.toBeNull()
})
it('FE-MOB-RTROW-038: other ways are one round button beside the pill, and the pill stays text', () => {
const onAlternatives = vi.fn()
const { container } = render(<RtLegRow seg={SEG} mode="driving" chrome={chrome} onAlternatives={onAlternatives} />)
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(1)
const ask = screen.getByRole('button', { name: 'roadtrip.alt.ask' })
expect(ask.querySelector('.lucide-shuffle')).not.toBeNull()
// A separate 40px target: a tap that stops a scroll on the pill asks the router nothing.
expect(ask.className).toContain('h-10')
expect(ask.className).toContain('w-10')
const drive = screen.getByText('roadtrip.leg.driveText:210 km,2 h 40 min')
expect(ask.contains(drive)).toBe(false)
expect(drive.closest('[role]')).toBeNull()
fireEvent.click(drive)
expect(onAlternatives).not.toHaveBeenCalled()
fireEvent.click(ask)
expect(onAlternatives).toHaveBeenCalledTimes(1)
expect(container.querySelectorAll('[tabindex]')).toHaveLength(0)
})
it('FE-MOB-RTROW-039: shows pressed while its leg is open, in full ink rather than a filled button', () => {
const closed = render(<RtLegRow seg={SEG} mode="driving" chrome={chrome} onAlternatives={vi.fn()} />)
const idle = screen.getByRole('button', { name: 'roadtrip.alt.ask' })
expect(idle).toHaveAttribute('aria-pressed', 'false')
expect(idle.className).toContain('text-m-muted')
closed.unmount()
render(<RtLegRow seg={SEG} mode="driving" chrome={chrome} onAlternatives={vi.fn()} alternativesOpen />)
const open = screen.getByRole('button', { name: 'roadtrip.alt.ask' })
expect(open).toHaveAttribute('aria-pressed', 'true')
expect(open.className).toContain('text-m-ink')
// A filled chip in a column of quiet rows reads as a button pressed and stuck.
expect(open.className).not.toContain('bg-m-act')
})
it('FE-MOB-RTROW-040: offline the button keeps its place but does nothing', () => {
const onAlternatives = vi.fn()
render(<RtLegRow seg={SEG} mode="driving" chrome={chrome} onAlternatives={onAlternatives} alternativesDisabled />)
const ask = screen.getByRole('button', { name: 'roadtrip.alt.ask' })
expect(ask).toBeDisabled()
fireEvent.click(ask)
expect(onAlternatives).not.toHaveBeenCalled()
})
})
describe('RtDryRow', () => {
@@ -7,8 +7,9 @@ import MRoadtripTab from '../../../../src/mobile/screens/trip/roadtrip/MRoadtrip
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import type { Day, Place } from '../../../../src/types'
import type { RoadtripDay, RoadtripRoutes, RouteSegment } from '@trek/shared/roadtrip'
import type { LegAlternatives } from '../../../../src/components/Roadtrip/useRouteAlternatives'
// FE-MOB-RTTAB-001 to FE-MOB-RTTAB-044
// FE-MOB-RTTAB-001 to FE-MOB-RTTAB-050
//
// The stage bar pictures the place its day ends at. It reads that place out of the trip
// store rather than the planner, the unfiltered list, so the picture tests seed the store.
@@ -745,6 +746,122 @@ describe('MRoadtripTab', () => {
})
})
describe('other ways of driving a leg', () => {
const askButtons = () => screen.queryAllByRole('button', { name: 'roadtrip.alt.ask' })
/** The planner with a picker open on one leg, still asking the router. */
function withPicker(open: Partial<LegAlternatives>, over: Partial<TripPlanner> = {}): TripPlanner {
const base = buildPlanner()
return planner({
routeAlternatives: { ...base.routeAlternatives, open: { dayId: 2, index: 0, routes: [], loading: true, error: false, ...open } },
...over,
})
}
it('FE-MOB-RTTAB-045: offers the question on the legs the desk rail offers it on, and only to an editor', () => {
const full = renderTab()
// Two routed legs between real stops. The third runs into the automatic day end,
// which is the shell's own marker and not a road anyone can reshape.
expect(askButtons()).toHaveLength(2)
const kyotoLeg = screen.getByText('roadtrip.leg.driveText:210 km,2 h 40 min').closest('.grid') as HTMLElement
expect(within(kyotoLeg).getByRole('button', { name: 'roadtrip.alt.ask' })).toBeInTheDocument()
full.unmount()
// Routed or not, that last leg stays a line of text: the fixture leaves it unrouted
// too, so a routed one pins that the day end, not the missing route, is the reason.
const intoNight = renderTab(planner({
roadtripRoutes: routes({ days: [stage({ legs: [seg('62 km', '1 h'), seg('210 km', '2 h 40 min'), seg('12 km', '10 min')] })] }),
}))
expect(askButtons()).toHaveLength(2)
const nightLeg = screen.getByText('roadtrip.leg.driveText:12 km,10 min').closest('.grid') as HTMLElement
expect(within(nightLeg).queryByRole('button')).toBeNull()
intoNight.unmount()
// A leg the routing has not answered for has nothing to weigh an offer against.
const unrouted = renderTab(planner({ roadtripRoutes: routes({ days: [stage({ legs: [seg('62 km', '1 h'), undefined, undefined] })] }) }))
expect(askButtons()).toHaveLength(1)
// Both unanswered legs are drawn, only neither of them asks.
expect(screen.getAllByText('roadtrip.leg.pending')).toHaveLength(2)
unrouted.unmount()
renderTab(planner({ can: vi.fn(() => false) }))
expect(askButtons()).toHaveLength(0)
})
it('FE-MOB-RTTAB-046: asks with the card\'s day, clears the old pick and the fuel search, and goes to the map', () => {
const p = planner()
const { shell } = renderTab(p)
fireEvent.click(askButtons()[0])
expect(p.setHighlightedAlternative).toHaveBeenCalledWith(null)
expect(p.refuel.close).toHaveBeenCalledTimes(1)
// Day 2, the card: the leg's first stop is stored on day 1, and the planner works
// that out from the card itself, the way the desk rail asks.
expect(p.askRouteAlternatives).toHaveBeenCalledWith(2, 0)
expect(p.askRouteAlternatives).not.toHaveBeenCalledWith(1, expect.anything())
expect(shell.toggleRtView).toHaveBeenCalledTimes(1)
})
it('FE-MOB-RTTAB-047: the leg with the picker open shows pressed, and tapping it goes back to the answer without asking again', () => {
const p = withPicker({ index: 0 })
const { shell } = renderTab(p)
const [open, other] = askButtons()
expect(open).toHaveAttribute('aria-pressed', 'true')
expect(other).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(open)
// The planner toggles an open leg shut, and the traveller came back for its answer.
expect(p.askRouteAlternatives).not.toHaveBeenCalled()
expect(p.routeAlternatives.close).not.toHaveBeenCalled()
expect(shell.toggleRtView).toHaveBeenCalledTimes(1)
})
it('FE-MOB-RTTAB-048: on the map the picker takes the stage bar\'s slot and the search bar steps away', () => {
const shell = buildShell({ rtView: 'map' })
const open = renderTab(withPicker({}), shell)
const bar = screen.getByRole('region', { name: 'roadtrip.alt.title' })
expect(within(bar).getByText('roadtrip.alt.loading')).toBeInTheDocument()
// In the very wrapper the stage bar sits in, just above the dock.
expect((bar.parentElement as HTMLElement).className).toContain('bottom-[calc(var(--bottom-nav-h,84px)+4px)]')
expect(screen.queryByRole('button', { name: /Kyoto Station/ })).toBeNull()
expect(screen.queryByText('roadtrip.poi.title')).toBeNull()
open.unmount()
renderTab(planner(), shell)
expect(screen.queryByRole('region', { name: 'roadtrip.alt.title' })).toBeNull()
expect(screen.getByRole('button', { name: /Kyoto Station/ })).toBeInTheDocument()
expect(screen.getByText('roadtrip.poi.title')).toBeInTheDocument()
})
it('FE-MOB-RTTAB-049: offline the leg buttons stay where they are and do nothing', () => {
const base = buildPlanner()
const p = planner({ roadtripVias: { ...base.roadtripVias, editable: false } })
renderTab(p)
expect(askButtons()).toHaveLength(2)
for (const button of askButtons()) expect(button).toBeDisabled()
fireEvent.click(askButtons()[0])
expect(p.askRouteAlternatives).not.toHaveBeenCalled()
})
it('FE-MOB-RTTAB-050: a picker left open on another stage is closed, and one on this stage survives the list', () => {
const elsewhere = withPicker({ dayId: 1 })
const first = renderTab(elsewhere)
expect(elsewhere.routeAlternatives.close).toHaveBeenCalledTimes(1)
first.unmount()
// The chain is a look back at the stage, not a way out of the question.
const here = withPicker({ dayId: 2, index: 1 })
renderTab(here)
expect(here.routeAlternatives.close).not.toHaveBeenCalled()
expect(askButtons()[1]).toHaveAttribute('aria-pressed', 'true')
})
})
describe('day swipe', () => {
it('FE-MOB-RTTAB-027: steps the stage without refitting the map, and announces the day it landed on', () => {
const p = planner()
@@ -0,0 +1,204 @@
import { describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen, within } from '../../../helpers/render'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import MRtAlternativesBar from '../../../../src/mobile/screens/trip/roadtrip/MRtAlternativesBar'
import { RT_ALT_BAR_HEIGHT, useMRtAlternatives } from '../../../../src/mobile/screens/trip/roadtrip/useMRtAlternatives'
import { buildAlternativeOverlays } from '../../../../src/components/Roadtrip/alternativeOverlays'
import type { LegAlternatives, OfferedRoute } from '../../../../src/components/Roadtrip/useRouteAlternatives'
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
// FE-MOB-RTALT-001 to FE-MOB-RTALT-011
//
// Rendered through the real controller hook, so a tap is checked all the way to what the
// planner is asked to do, and the pending state is the transition's own.
/**
* Three ways of driving one leg: the road already driven, a slower offer, and a road
* without the motorway that the avoidance router priced.
*/
const ROUTES: OfferedRoute[] = [
{ coordinates: [[35.7, 139.8], [35.4, 138.6], [34.98, 135.75]], distance: 210_000, duration: 9_600, divergence: null, current: true },
{ coordinates: [[35.7, 139.8], [35.9, 138.2], [34.98, 135.75]], distance: 198_000, duration: 10_320, divergence: { lat: 35.9, lng: 138.2 } },
{ coordinates: [[35.7, 139.8], [35.2, 138.9], [34.98, 135.75]], distance: 236_000, duration: 11_400, divergence: { lat: 35.2, lng: 138.9 }, avoids: 'motorway', engine: 'valhalla' },
]
function overlays(routes: OfferedRoute[] = ROUTES) {
return buildAlternativeOverlays(routes, {
fastest: 'Fastest', current: 'Current', noMotorway: 'No motorway', noToll: 'No tolls', noFerry: 'No ferry',
})
}
function planner(open: Partial<LegAlternatives> | null, over: Partial<TripPlanner> = {}): TripPlanner {
const base = buildPlanner()
const leg = open ? { dayId: 2, index: 0, routes: ROUTES, loading: false, error: false, ...open } : null
return buildPlanner({
selectedDayId: 2,
routeAlternatives: { ...base.routeAlternatives, open: leg },
alternativeOverlays: leg ? overlays(leg.routes) : [],
...over,
} as Partial<TripPlanner>)
}
function Harness({ planner: p }: { planner: TripPlanner }) {
const alts = useMRtAlternatives(p, buildShell({ rtView: 'map' }))
return <MRtAlternativesBar planner={p} alts={alts} />
}
function renderBar(p: TripPlanner) {
return { planner: p, ...render(<Harness planner={p} />) }
}
const confirmButton = () => screen.getByRole('button', { name: 'common.confirm' })
const chips = () => screen.queryAllByRole('button', { pressed: false }).concat(screen.queryAllByRole('button', { pressed: true }))
describe('MRtAlternativesBar', () => {
it('FE-MOB-RTALT-001: while the router is asked it says so, offers nothing and confirms nothing', () => {
renderBar(planner({ loading: true, routes: [] }))
const bar = screen.getByRole('region', { name: 'roadtrip.alt.title' })
expect(within(bar).getByRole('status')).toHaveTextContent('roadtrip.alt.loading')
expect(chips()).toHaveLength(0)
expect(confirmButton()).toBeDisabled()
})
it('FE-MOB-RTALT-002: a router that did not answer is a failure, never a leg with one way', () => {
renderBar(planner({ error: true, routes: [] }))
expect(screen.getByRole('status')).toHaveTextContent('roadtrip.alt.failed')
expect(screen.queryByText('roadtrip.alt.onlyOne')).toBeNull()
expect(chips()).toHaveLength(0)
expect(confirmButton()).toBeDisabled()
})
it('FE-MOB-RTALT-003: one road back is the only sensible way, with nothing to pick', () => {
renderBar(planner({ routes: [ROUTES[0]] }))
expect(screen.getByRole('status')).toHaveTextContent('roadtrip.alt.onlyOne')
expect(chips()).toHaveLength(0)
expect(confirmButton()).toBeDisabled()
})
it('FE-MOB-RTALT-004: one chip per road, its drive time first, then its length and what sets it apart', () => {
const p = planner({})
renderBar(p)
const offered = chips()
expect(offered).toHaveLength(3)
const [driven, slower, noMotorway] = p.alternativeOverlays
const second = screen.getByRole('button', { name: `${slower.label}, 198 km, roadtrip.alt.slower:12 min` })
// The time pill's text leads, because that text is what ties the chip to its line.
expect(second.textContent).toBe(`${slower.label}198 kmroadtrip.alt.slower:12 min`)
// The length is a figure in a badge, in its unit's own case.
const badge = within(second).getByText('198 km')
expect(badge.className).toContain('rounded-full')
expect(badge.className).not.toContain('uppercase')
// The swatch is painted in the very colour the road is drawn in.
const swatch = second.querySelector('[aria-hidden="true"]') as HTMLElement
expect(swatch).toHaveStyle({ background: slower.color })
// Its own note wins over the difference: the road driven, the road without the motorway.
expect(screen.getByRole('button', { name: `${driven.label}, 210 km, Current` })).toHaveAttribute('aria-pressed', 'false')
expect(screen.getByRole('button', { name: `${noMotorway.label}, 236 km, No motorway` })).toBeInTheDocument()
})
it('FE-MOB-RTALT-005: a tap on a chip lights its road and writes nothing', () => {
const p = planner({})
renderBar(p)
fireEvent.click(chips()[1])
expect(p.setHighlightedAlternative).toHaveBeenCalledWith(1)
expect(p.chooseRouteAlternative).not.toHaveBeenCalled()
})
it('FE-MOB-RTALT-006: confirm waits for a road other than the one driven, then takes it once', async () => {
const none = renderBar(planner({}))
expect(confirmButton()).toBeDisabled()
none.unmount()
const driven = renderBar(planner({}, { highlightedAlternative: 0 }))
expect(screen.getByRole('button', { pressed: true })).toHaveAccessibleName(/Current/)
expect(confirmButton()).toBeDisabled()
driven.unmount()
const { planner: p } = renderBar(planner({}, { highlightedAlternative: 1 }))
expect(confirmButton()).toBeEnabled()
// Awaited so the write the tap starts has landed before the test ends.
await act(async () => { fireEvent.click(confirmButton()) })
expect(p.chooseRouteAlternative).toHaveBeenCalledTimes(1)
expect(p.chooseRouteAlternative).toHaveBeenCalledWith(1)
})
it('FE-MOB-RTALT-007: while the choice is saved nothing can be tapped twice, and it all comes back after', async () => {
let land: () => void = () => {}
const p = planner({}, {
highlightedAlternative: 1,
chooseRouteAlternative: vi.fn(() => new Promise<void>(resolve => { land = resolve })),
})
renderBar(p)
fireEvent.click(confirmButton())
const busy = confirmButton()
expect(busy).toBeDisabled()
expect(busy).toHaveAttribute('aria-busy', 'true')
expect(busy.querySelector('.animate-spin')).not.toBeNull()
// Still filled: the button is busy with the choice, not unavailable.
expect(busy.className).not.toContain('disabled:bg-')
for (const chip of chips()) expect(chip).toBeDisabled()
fireEvent.click(busy)
expect(p.chooseRouteAlternative).toHaveBeenCalledTimes(1)
await act(async () => { land() })
expect(confirmButton()).toBeEnabled()
expect(confirmButton().querySelector('.animate-spin')).toBeNull()
for (const chip of chips()) expect(chip).toBeEnabled()
})
it('FE-MOB-RTALT-008: the close button closes the picker', () => {
const p = planner({})
renderBar(p)
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
expect(p.routeAlternatives.close).toHaveBeenCalledTimes(1)
})
it('FE-MOB-RTALT-009: a road timed by the other router says so beside the button, and only then', () => {
const other = renderBar(planner({}, { highlightedAlternative: 2 }))
const note = screen.getByText('roadtrip.alt.otherEngine')
expect(note.className).toContain('line-clamp-2')
// Beside the button it qualifies, which gives up its full width for the sentence.
expect(note.parentElement).toBe(confirmButton().parentElement)
expect(confirmButton().className).toContain('flex-none')
other.unmount()
renderBar(planner({}, { highlightedAlternative: 1 }))
expect(screen.queryByText('roadtrip.alt.otherEngine')).toBeNull()
expect(confirmButton().className).toContain('flex-1')
})
it('FE-MOB-RTALT-010: offline a valid pick still cannot be confirmed', () => {
const base = buildPlanner()
renderBar(planner({}, { highlightedAlternative: 1, roadtripVias: { ...base.roadtripVias, editable: false } }))
expect(screen.getByRole('button', { pressed: true })).toBeInTheDocument()
expect(confirmButton()).toBeDisabled()
})
it('FE-MOB-RTALT-011: the bar stands at the height the map lifts by, and says nothing once closed', () => {
const open = renderBar(planner({}))
const bar = screen.getByRole('region', { name: 'roadtrip.alt.title' })
expect(bar).toHaveStyle({ height: `${RT_ALT_BAR_HEIGHT}px` })
// A fixed height cannot take a second line, so the title truncates rather than wraps.
expect(within(bar).getByText('roadtrip.alt.title').className).toContain('truncate')
open.unmount()
// The render the picker closes in: no leg, so no sentence about one.
renderBar(planner(null))
expect(screen.queryByRole('status')).toBeNull()
expect(chips()).toHaveLength(0)
})
})
@@ -0,0 +1,203 @@
import { describe, expect, it, vi } from 'vitest'
import { act, renderHook } from '@testing-library/react'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import {
RT_ALT_BAR_HEIGHT, RT_ALT_BAR_LIFT, useMRtAlternatives,
} from '../../../../src/mobile/screens/trip/roadtrip/useMRtAlternatives'
import { buildAlternativeOverlays } from '../../../../src/components/Roadtrip/alternativeOverlays'
import type { LegAlternatives, OfferedRoute } from '../../../../src/components/Roadtrip/useRouteAlternatives'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
// FE-MOB-RTALTH-001 to FE-MOB-RTALTH-009
//
// The hook holds no picker state, so every case is a planner shape and what the hook
// reads out of it or asks of it.
/** Three ways of driving one leg: the road already driven first, then two offers. */
const ROUTES: OfferedRoute[] = [
{ coordinates: [[35.7, 139.8], [35.4, 138.6], [34.98, 135.75]], distance: 210_000, duration: 9_600, divergence: null, current: true },
{ coordinates: [[35.7, 139.8], [35.9, 138.2], [34.98, 135.75]], distance: 198_000, duration: 10_320, divergence: { lat: 35.9, lng: 138.2 } },
{ coordinates: [[35.7, 139.8], [35.2, 138.9], [34.98, 135.75]], distance: 236_000, duration: 11_400, divergence: { lat: 35.2, lng: 138.9 }, avoids: 'motorway' },
]
const OVERLAYS = buildAlternativeOverlays(ROUTES, {
fastest: 'Fastest', current: 'Current', noMotorway: 'No motorway', noToll: 'No tolls', noFerry: 'No ferry',
})
function leg(over: Partial<LegAlternatives> = {}): LegAlternatives {
return { dayId: 2, index: 1, routes: ROUTES, loading: false, error: false, ...over }
}
/** A planner with the picker open on leg 1 of day 2, answered with three roads. */
function planner(over: Partial<TripPlanner> = {}, open: LegAlternatives | null = leg()): TripPlanner {
const base = buildPlanner()
return buildPlanner({
selectedDayId: 2,
routeAlternatives: { ...base.routeAlternatives, open },
alternativeOverlays: open ? OVERLAYS : [],
...over,
} as Partial<TripPlanner>)
}
function setup(p: TripPlanner = planner(), shell: MTripShellApi = buildShell()) {
const view = renderHook(({ pl, sh }) => useMRtAlternatives(pl, sh), { initialProps: { pl: p, sh: shell } })
return { ...view, planner: p, shell }
}
describe('useMRtAlternatives', () => {
it('FE-MOB-RTALTH-001: a leg is open only when both its day and its position match', () => {
const { result } = setup()
expect(result.current.isOpenFor(2, 1)).toBe(true)
// The same position on another card, and another leg of the same card.
expect(result.current.isOpenFor(1, 1)).toBe(false)
expect(result.current.isOpenFor(2, 0)).toBe(false)
const closed = setup(planner({}, null))
expect(closed.result.current.isOpenFor(2, 1)).toBe(false)
})
it('FE-MOB-RTALTH-002: reads the phase and the picked road off the planner, and has neither while closed', () => {
const closed = setup(planner({}, null))
expect(closed.result.current.open).toBeNull()
expect(closed.result.current.phase).toBeNull()
expect(closed.result.current.picked).toBeNull()
const asking = setup(planner({ alternativeOverlays: [] }, leg({ loading: true, routes: [] })))
expect(asking.result.current.phase).toBe('loading')
const picked = setup(planner({ highlightedAlternative: 2 }))
expect(picked.result.current.phase).toBe('choose')
expect(picked.result.current.overlays).toBe(OVERLAYS)
expect(picked.result.current.picked).toBe(OVERLAYS[2])
// A pick the overlays do not hold is no pick, rather than a road nobody is shown.
const stray = setup(planner({ highlightedAlternative: 7 }))
expect(stray.result.current.picked).toBeNull()
})
it('FE-MOB-RTALTH-003: confirm needs a road other than the one driven, a connection and a choice to make', () => {
expect(setup(planner()).result.current.canConfirm).toBe(false)
// The road already driven writes nothing, so there is nothing to confirm.
expect(setup(planner({ highlightedAlternative: 0 })).result.current.canConfirm).toBe(false)
expect(setup(planner({ highlightedAlternative: 1 })).result.current.canConfirm).toBe(true)
const base = buildPlanner()
const offline = setup(planner({ highlightedAlternative: 1, roadtripVias: { ...base.roadtripVias, editable: false } }))
expect(offline.result.current.editable).toBe(false)
expect(offline.result.current.canConfirm).toBe(false)
// A pick left over from the answer before is not a choice while the router is asked again.
const asking = setup(planner({ highlightedAlternative: 1 }, leg({ loading: true })))
expect(asking.result.current.canConfirm).toBe(false)
})
it('FE-MOB-RTALTH-004: asking a new leg clears the old pick and the fuel search, then goes to the map', () => {
const p = planner({}, null)
const shell = buildShell({ rtView: 'list' })
const { result } = setup(p, shell)
act(() => { result.current.ask(2, 0) })
expect(p.setHighlightedAlternative).toHaveBeenCalledWith(null)
expect(p.refuel.close).toHaveBeenCalledTimes(1)
expect(p.askRouteAlternatives).toHaveBeenCalledWith(2, 0)
// The pick is cleared before the new question goes out.
const cleared = vi.mocked(p.setHighlightedAlternative).mock.invocationCallOrder[0]
expect(cleared).toBeLessThan(vi.mocked(p.askRouteAlternatives).mock.invocationCallOrder[0])
expect(shell.toggleRtView).toHaveBeenCalledTimes(1)
// Already on the map, the switch would take the traveller back to the chain.
const onMap = buildShell({ rtView: 'map' })
const second = setup(planner({}, null), onMap)
act(() => { second.result.current.ask(2, 0) })
expect(second.planner.askRouteAlternatives).toHaveBeenCalledWith(2, 0)
expect(onMap.toggleRtView).not.toHaveBeenCalled()
})
it('FE-MOB-RTALTH-005: asking the leg already open only goes back to its answer', () => {
const p = planner({ highlightedAlternative: 1 })
const shell = buildShell({ rtView: 'list' })
const { result } = setup(p, shell)
act(() => { result.current.ask(2, 1) })
// The planner's ask toggles an open leg shut, and the pick on it is still wanted.
expect(p.askRouteAlternatives).not.toHaveBeenCalled()
expect(p.setHighlightedAlternative).not.toHaveBeenCalled()
expect(p.refuel.close).not.toHaveBeenCalled()
expect(shell.toggleRtView).toHaveBeenCalledTimes(1)
})
it('FE-MOB-RTALTH-006: a stage change closes the picker, and nothing else does', () => {
const p = planner()
const { rerender, shell } = setup(p)
expect(p.routeAlternatives.close).not.toHaveBeenCalled()
// Re-rendered on the same stage, as the shell does on every store write.
rerender({ pl: { ...p }, sh: shell })
expect(p.routeAlternatives.close).not.toHaveBeenCalled()
rerender({ pl: { ...p, selectedDayId: 3 }, sh: shell })
expect(p.routeAlternatives.close).toHaveBeenCalledTimes(1)
// The all days view has no stage at all, so no leg of one either.
const allDays = planner({ selectedDayId: null })
setup(allDays)
expect(allDays.routeAlternatives.close).toHaveBeenCalledTimes(1)
// With nothing open there is nothing to close.
const closed = planner({ selectedDayId: 3 }, null)
setup(closed)
expect(closed.routeAlternatives.close).not.toHaveBeenCalled()
})
it('FE-MOB-RTALTH-007: confirm writes the picked road once and is pending until the write lands', async () => {
let land: () => void = () => {}
const p = planner({
highlightedAlternative: 2,
chooseRouteAlternative: vi.fn(() => new Promise<void>(resolve => { land = resolve })),
})
const { result } = setup(p)
act(() => { result.current.confirm() })
expect(p.chooseRouteAlternative).toHaveBeenCalledWith(2)
expect(result.current.saving).toBe(true)
expect(result.current.canConfirm).toBe(false)
// A second tap while the first is in flight writes nothing more.
act(() => { result.current.confirm() })
expect(p.chooseRouteAlternative).toHaveBeenCalledTimes(1)
await act(async () => { land() })
expect(result.current.saving).toBe(false)
expect(result.current.canConfirm).toBe(true)
})
it('FE-MOB-RTALTH-008: a pick only lights a road, cancel closes, and the question needs day edit rights', () => {
const p = planner()
const { result } = setup(p)
act(() => { result.current.pick(1) })
expect(p.setHighlightedAlternative).toHaveBeenCalledWith(1)
expect(p.chooseRouteAlternative).not.toHaveBeenCalled()
act(() => { result.current.confirm() })
// Nothing picked in this planner, so the confirm stays a no-op.
expect(p.chooseRouteAlternative).not.toHaveBeenCalled()
act(() => { result.current.cancel() })
expect(p.routeAlternatives.close).toHaveBeenCalledTimes(1)
expect(result.current.canAsk).toBe(true)
expect(p.can).toHaveBeenCalledWith('day_edit', p.trip)
const viewer = setup(planner({ can: vi.fn(() => false) }))
expect(viewer.result.current.canAsk).toBe(false)
})
it('FE-MOB-RTALTH-009: the map lifts its floor by the bar and the gap the stage bar keeps', () => {
// 61px stage bar plus 15 is the 76px stage lift; the same 15 between the dock and this bar.
expect(RT_ALT_BAR_HEIGHT).toBe(174)
expect(RT_ALT_BAR_LIFT).toBe(189)
})
})