mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(roadtrip): bring the road trip addon to the phone
A road trip tab on the mobile trip screen: the stage as a chain, the same map underneath it, the sheets a passenger needs, and a search along the route. The addon was desktop only because road trip MODE is a data switch rather than a view switch. It rewrites `assignments` and `places` for the whole planner hook, and every mobile sheet is permanently mounted and reads those same two lists, so turning it on there brought back documented regressions. The mode flag is therefore untouched. A second flag, `roadtripFeedActive`, feeds only the routing round and does it off the STORED assignments, so the phone gets the drive without what the mode drags along with it. The map is one instance shared by the plan tab and the road trip tab, hoisted into a single JSX expression so React keeps it in the same position and the WebGL context is never torn down on a tab switch. The corridor search is scoped to what a phone can afford: a window of the day rather than all of it, a smaller tile ceiling, a capped retry pass and a deadline. The standard search is three requests where the desk makes fifteen. The categories are deliberately not narrowed, because they all ride one query per box, so a second one costs nothing. Two gaps found while wiring it up are closed here as well. The fuel search drew its offers nowhere on the phone, so the button did nothing visible; they are in the dry band now, with the detour, the spare range and an accept button. And the map credit opened as a two line ribbon across a screen the map fills: both GL engines now start it collapsed, and Leaflet, which has no collapsed mode of its own, is given one.
This commit is contained in:
@@ -5,7 +5,7 @@ import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/
|
||||
import type { CompassMap } from '../../../../src/components/Map/MapCompassPill'
|
||||
import { useSettingsStore } from '../../../../src/store/settingsStore'
|
||||
|
||||
// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-007
|
||||
// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-014
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
poi: {} as Record<string, unknown>,
|
||||
@@ -39,7 +39,10 @@ const COMPASS: CompassMap = {
|
||||
|
||||
function renderArea(shellOver: Partial<MTripShellApi> = {}, plannerOver: Partial<TripPlanner> = {}) {
|
||||
const planner = buildPlanner(plannerOver)
|
||||
const shell = buildShell({ view: 'map', ...shellOver })
|
||||
// `mapFront` is what the floating chrome keys off since the map became one
|
||||
// instance shared by the plan tab and the road trip tab. `view` alone no longer
|
||||
// says whether the map is the front layer.
|
||||
const shell = buildShell({ view: 'map', mapFront: true, ...shellOver })
|
||||
return { planner, shell, ...render(<MMapArea planner={planner} shell={shell} />) }
|
||||
}
|
||||
|
||||
@@ -78,7 +81,23 @@ describe('MMapArea', () => {
|
||||
|
||||
// The dock is 62px tall at safe-bottom + 12; the controls add their own 12.
|
||||
expect((container.firstElementChild as HTMLElement).className)
|
||||
.toContain('[--bottom-nav-h:calc(env(safe-area-inset-bottom,0px)+74px)]')
|
||||
.toContain('[--bottom-nav-h:calc(env(safe-area-inset-bottom,0px)+74px+var(--m-stage-lift,0px))]')
|
||||
// No lift off the stage: the plan tab has no bar in that band.
|
||||
expect((container.firstElementChild as HTMLElement).style.getPropertyValue('--m-stage-lift')).toBe('0px')
|
||||
})
|
||||
|
||||
it('FE-MOB-MAPAREA-013: on the stage the controls clear the stage bar by one gap', () => {
|
||||
const { container } = renderArea({ trTab: 'roadtrip', mapFront: true })
|
||||
|
||||
// The bar is 61px tall and keeps a gap on both sides, so the round controls land
|
||||
// above it rather than on its top edge.
|
||||
expect((container.firstElementChild as HTMLElement).style.getPropertyValue('--m-stage-lift')).toBe('76px')
|
||||
})
|
||||
|
||||
it('FE-MOB-MAPAREA-014: behind the chain there is no bar to clear, so nothing lifts', () => {
|
||||
const { container } = renderArea({ trTab: 'roadtrip', mapFront: false })
|
||||
|
||||
expect((container.firstElementChild as HTMLElement).style.getPropertyValue('--m-stage-lift')).toBe('0px')
|
||||
})
|
||||
|
||||
it('FE-MOB-MAPAREA-004: a renderer that cannot rotate gets no compass', () => {
|
||||
@@ -98,7 +117,7 @@ describe('MMapArea', () => {
|
||||
})
|
||||
|
||||
it('FE-MOB-MAPAREA-006: no floating chrome while the timeline covers the map', () => {
|
||||
const { container } = renderArea({ view: 'plan' })
|
||||
const { container } = renderArea({ view: 'plan', mapFront: false })
|
||||
|
||||
expect(screen.queryByLabelText('Cafés')).not.toBeInTheDocument()
|
||||
expect(compassBand(container)).toBeNull()
|
||||
@@ -113,4 +132,62 @@ describe('MMapArea', () => {
|
||||
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('transport', { reservationId: 7 })
|
||||
})
|
||||
it('FE-MOB-MAPAREA-008: on the stage the pins are the search and the fuel offers, and nothing else', () => {
|
||||
const corridorHit = { osm_id: 'c1', name: 'Shell', lat: 53.5, lng: 9.8, category: 'fuel' }
|
||||
const offered = { osm_id: 'r1', name: 'Aral', lat: 53.4, lng: 9.7, category: 'fuel' }
|
||||
mocks.poi = { ...mocks.poi, pois: [{ osm_id: 'e1', name: 'Café', lat: 53.3, lng: 9.6, category: 'cafe' }] }
|
||||
const base = buildPlanner()
|
||||
renderArea({ trTab: 'roadtrip' }, {
|
||||
roadtripCorridor: { ...base.roadtripCorridor, visible: [corridorHit] },
|
||||
refuel: { ...base.refuel, offered: [offered] },
|
||||
} as unknown as Partial<TripPlanner>)
|
||||
|
||||
const ids = (mocks.props.pois as Array<{ osm_id: string }>).map(p => p.osm_id)
|
||||
expect(ids).toEqual(expect.arrayContaining(['c1', 'r1']))
|
||||
// "What is around this piece of map" is a different question from "what is on the
|
||||
// way", and its bar is off this tab, so its pins would be pins nobody could clear.
|
||||
expect(ids).not.toContain('e1')
|
||||
expect(screen.queryByLabelText('Cafés')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-MAPAREA-009: off the stage a corridor hit is not drawn at all', () => {
|
||||
const base = buildPlanner()
|
||||
renderArea({ trTab: 'plan' }, {
|
||||
roadtripCorridor: { ...base.roadtripCorridor, visible: [{ osm_id: 'c1', lat: 53.5, lng: 9.8, category: 'fuel' }] },
|
||||
} as unknown as Partial<TripPlanner>)
|
||||
|
||||
// A pin with no route under it cannot explain where on the drive it sits.
|
||||
expect((mocks.props.pois as Array<{ osm_id: string }>).map(p => p.osm_id)).not.toContain('c1')
|
||||
})
|
||||
|
||||
it('FE-MOB-MAPAREA-010: a pin tapped on the stage goes through the road trip door', () => {
|
||||
const { planner } = renderArea({ trTab: 'roadtrip' })
|
||||
|
||||
const marker = { osm_id: 'c1', name: 'Shell', lat: 53.5, lng: 9.8, category: 'fuel', alongKm: 82 }
|
||||
;(mocks.props.onPoiClick as (m: unknown) => void)(marker)
|
||||
|
||||
expect(planner.handlePoiClick).toHaveBeenCalledWith(marker)
|
||||
expect(planner.openAddPlaceFromPoi).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-MAPAREA-011: on the plan tab it stays the plain place form, carrying the day', () => {
|
||||
const { planner } = renderArea({ trTab: 'plan' }, { selectedDayId: 5 })
|
||||
|
||||
const marker = { osm_id: 'e1', name: 'Café', lat: 53.3, lng: 9.6, category: 'cafe' }
|
||||
;(mocks.props.onPoiClick as (m: unknown) => void)(marker)
|
||||
|
||||
expect(planner.openAddPlaceFromPoi).toHaveBeenCalledWith(marker, 5)
|
||||
expect(planner.handlePoiClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-MAPAREA-012: a focused hit takes the camera; with nothing pending the stage frames itself', () => {
|
||||
const focused = renderArea({ trTab: 'roadtrip' }, { mapFocusPoints: [[53.5, 9.8]] })
|
||||
expect(mocks.props.focusPoints).toEqual([[53.5, 9.8]])
|
||||
focused.unmount()
|
||||
|
||||
renderArea({ trTab: 'roadtrip' })
|
||||
// The stage's own points, which is an array either way: going array → undefined →
|
||||
// array is two dependency changes, and the second throws away the traveller's pan.
|
||||
expect(Array.isArray(mocks.props.focusPoints)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,9 +5,9 @@ import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/
|
||||
import type { TripFile, TripMember } from '../../../../src/types'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen } from '../../../helpers/render'
|
||||
import { fireEvent, render, screen, within } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-MEHR-001 to FE-MOB-MEHR-012
|
||||
// FE-MOB-MEHR-001 to FE-MOB-MEHR-014
|
||||
//
|
||||
// The sheet takes its copy from the real TranslationProvider, so the visible
|
||||
// strings are asserted in English.
|
||||
@@ -133,4 +133,46 @@ describe('MMehrSheet', () => {
|
||||
const rows = screen.getByRole('button', { name: 'Share Trip' }).parentElement
|
||||
expect(rows).toHaveClass('mt-2')
|
||||
})
|
||||
|
||||
// Both sides read the same priority list (dockTabs.ts). They used to be two
|
||||
// hand-kept copies, and extending only one showed the same section in the dock AND
|
||||
// as a tile here.
|
||||
describe('the dock overflow', () => {
|
||||
/** The sections with the road trip addon on, in the order the planner builds them. */
|
||||
const WITH_ROADTRIP = [
|
||||
{ id: 'plan', label: 'Plan', icon: FolderOpen },
|
||||
{ id: 'transports', label: 'Transport', icon: FolderOpen },
|
||||
{ id: 'buchungen', label: 'Bookings', icon: FolderOpen },
|
||||
{ id: 'roadtrip', label: 'Road trip', icon: FolderOpen },
|
||||
{ id: 'listen', label: 'Lists', icon: FolderOpen },
|
||||
{ id: 'finanzplan', label: 'Budget', icon: FolderOpen },
|
||||
{ id: 'dateien', label: 'Files', icon: FolderOpen },
|
||||
{ id: 'collab', label: 'Collaboration', icon: Users },
|
||||
]
|
||||
|
||||
it('FE-MOB-MEHR-013: the section the drive pushed out of the dock lands here', () => {
|
||||
renderSheet({ TRIP_TABS: WITH_ROADTRIP as TripPlanner['TRIP_TABS'] })
|
||||
|
||||
// Six sections do not fit the dock, so the packing list gives up its seat.
|
||||
expect(screen.getByRole('button', { name: 'Lists' })).toBeInTheDocument()
|
||||
// The drive itself never overflows: it sits second in the priority list.
|
||||
expect(screen.queryByRole('button', { name: 'Road trip' })).not.toBeInTheDocument()
|
||||
// Exactly the three the dock could not seat, nothing shown twice.
|
||||
const grid = screen.getByRole('button', { name: 'Lists' }).parentElement
|
||||
expect(within(grid as HTMLElement).getAllByRole('button')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('FE-MOB-MEHR-014: a dock with room keeps the packing list, addon or no addon', () => {
|
||||
renderSheet({
|
||||
TRIP_TABS: WITH_ROADTRIP.filter(
|
||||
tab => tab.id !== 'finanzplan' && tab.id !== 'dateien' && tab.id !== 'collab',
|
||||
) as TripPlanner['TRIP_TABS'],
|
||||
})
|
||||
|
||||
// Five sections still fit beside the More button, so nothing overflows and the
|
||||
// sheet is the action rows alone.
|
||||
expect(screen.queryByRole('button', { name: 'Lists' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Share Trip' }).parentElement).not.toHaveClass('mt-2')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '../../../helpers/render'
|
||||
import {
|
||||
RtAutoRow, RtDryRow, RtLegRow, RtSpillRow, RtStopRow, type RowChrome,
|
||||
} from '../../../../src/mobile/screens/trip/roadtrip/MRoadtripRows'
|
||||
import type { StopRow } from '../../../../src/components/Roadtrip/roadtripRowModel'
|
||||
import type { RouteSegment, ScheduleWarning } from '@trek/shared/roadtrip'
|
||||
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-030
|
||||
|
||||
// Same echo strategy as tests/helpers/mobileTrip: assertions stay on keys, not copy.
|
||||
const t: TranslationFn = (key, params) =>
|
||||
params ? `${key}:${Object.values(params).join(',')}` : key
|
||||
|
||||
const chrome: RowChrome = { t, unit: 'metric' }
|
||||
|
||||
function stopRow(over: Partial<StopRow> = {}): StopRow {
|
||||
return {
|
||||
kind: 'stop',
|
||||
stop: {
|
||||
assignmentId: 501, ownerDayId: 2, ownerIndex: 0, placeId: 101,
|
||||
name: 'Kyoto Station', lat: 34.98, lng: 135.75,
|
||||
time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null,
|
||||
},
|
||||
number: 2,
|
||||
service: false,
|
||||
entry: undefined,
|
||||
time: '12:40',
|
||||
pinned: false,
|
||||
warning: null,
|
||||
dwellMinutes: null,
|
||||
offRoadMeters: null,
|
||||
...over,
|
||||
} as StopRow
|
||||
}
|
||||
|
||||
const SEG: RouteSegment = {
|
||||
mid: [35.4, 138.6], from: [35.7, 139.8], to: [34.98, 135.75],
|
||||
distance: 210_000, duration: 9_600,
|
||||
// The two differ on purpose: durationText is the routed time, drivingText the
|
||||
// fallback the older segments carry.
|
||||
walkingText: '42 h', drivingText: '2 h 55 min', distanceText: '210 km', durationText: '2 h 40 min',
|
||||
}
|
||||
|
||||
describe('RtStopRow', () => {
|
||||
it('FE-MOB-RTROW-001: gives a destination its number and no kind icon', () => {
|
||||
const { container } = render(<RtStopRow row={stopRow()} chrome={chrome} onOpen={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('2')).toBeInTheDocument()
|
||||
expect(container.querySelector('.lucide-fuel')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-002: gives a service stop its kind symbol in the kind colour instead of a number', () => {
|
||||
const { container } = render(
|
||||
<RtStopRow
|
||||
row={stopRow({
|
||||
number: null,
|
||||
service: true,
|
||||
stop: { ...stopRow().stop, name: 'Shell Ebina', stopType: 'fuel' },
|
||||
})}
|
||||
chrome={chrome}
|
||||
onOpen={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const disc = container.querySelector('.lucide-fuel')?.parentElement as HTMLElement
|
||||
expect(disc).toBeTruthy()
|
||||
// #E8590C is SERVICE_COLORS.fuel, the colour the stop wears on the map too.
|
||||
expect(disc).toHaveStyle({ background: '#E8590C' })
|
||||
expect(screen.queryByText('2')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-003: keeps at most two marks, a dwell and a warning push the road distance out', () => {
|
||||
const { container } = render(
|
||||
<RtStopRow
|
||||
row={stopRow({
|
||||
dwellMinutes: 45,
|
||||
warning: { index: 1, code: 'late', minutes: 25 },
|
||||
offRoadMeters: 120,
|
||||
})}
|
||||
chrome={chrome}
|
||||
onOpen={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('45 min')).toBeInTheDocument()
|
||||
expect(screen.getByText('+25 min')).toBeInTheDocument()
|
||||
expect(screen.queryByText('120 m')).toBeNull()
|
||||
expect(container.querySelector('.lucide-footprints')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-004: shows the road distance only when the row has neither dwell nor warning', () => {
|
||||
render(<RtStopRow row={stopRow({ offRoadMeters: 120 })} chrome={chrome} onOpen={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('120 m')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-005: gives a pinned time the pin and the full colour', () => {
|
||||
render(<RtStopRow row={stopRow({ pinned: true })} chrome={chrome} onOpen={vi.fn()} />)
|
||||
|
||||
expect(screen.getByLabelText('roadtrip.stop.pinned')).toBeInTheDocument()
|
||||
expect(screen.getByText('12:40').className).toContain('text-m-ink')
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-006: leaves a computed time unpinned and muted', () => {
|
||||
render(<RtStopRow row={stopRow()} chrome={chrome} onOpen={vi.fn()} />)
|
||||
|
||||
expect(screen.queryByLabelText('roadtrip.stop.pinned')).toBeNull()
|
||||
const time = screen.getByText('12:40')
|
||||
expect(time.className).toContain('text-m-faint')
|
||||
expect(time.className).not.toContain('text-m-ink')
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-007: prints no clock for a stop the schedule could not time', () => {
|
||||
render(<RtStopRow row={stopRow({ time: null })} chrome={chrome} onOpen={vi.fn()} />)
|
||||
|
||||
expect(screen.queryByText('12:40')).toBeNull()
|
||||
expect(screen.getByText('Kyoto Station')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-008: opens the stop on a tap and on the keyboard', () => {
|
||||
const onOpen = vi.fn()
|
||||
render(<RtStopRow row={stopRow()} chrome={chrome} onOpen={onOpen} />)
|
||||
|
||||
const row = screen.getByRole('button')
|
||||
fireEvent.click(row)
|
||||
expect(onOpen).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.keyDown(row, { key: 'Enter' })
|
||||
expect(onOpen).toHaveBeenCalledTimes(2)
|
||||
|
||||
fireEvent.keyDown(row, { key: ' ' })
|
||||
expect(onOpen).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-009: ignores a key press that came from something inside the row', () => {
|
||||
const onOpen = vi.fn()
|
||||
render(<RtStopRow row={stopRow()} chrome={chrome} onOpen={onOpen} />)
|
||||
|
||||
fireEvent.keyDown(screen.getByText('Kyoto Station'), { key: 'Enter' })
|
||||
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-010: writes a late warning as the minutes it costs', () => {
|
||||
const warning: ScheduleWarning = { index: 1, code: 'late', minutes: 25 }
|
||||
const { container } = render(<RtStopRow row={stopRow({ warning })} chrome={chrome} onOpen={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('+25 min')).toBeInTheDocument()
|
||||
expect(container.querySelector('.lucide-alert-triangle')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-011: writes a range warning as the distance driven since the last fill', () => {
|
||||
const warning: ScheduleWarning = { index: 1, code: 'range', sinceKm: 520 }
|
||||
const { container } = render(<RtStopRow row={stopRow({ warning })} chrome={chrome} onOpen={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('520 km')).toBeInTheDocument()
|
||||
expect(container.querySelector('.lucide-fuel')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-012: writes a leg warning as the time it runs over', () => {
|
||||
const warning: ScheduleWarning = { index: 1, code: 'leg', overMinutes: 35 }
|
||||
render(<RtStopRow row={stopRow({ warning })} chrome={chrome} onOpen={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('35 min')).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()} />)
|
||||
|
||||
expect(screen.getByText('roadtrip.warn.overnight')).toBeInTheDocument()
|
||||
expect(container.querySelector('.lucide-moon')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RtLegRow', () => {
|
||||
it('FE-MOB-RTROW-014: 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()
|
||||
expect(container.querySelector('button')).toBeNull()
|
||||
expect(container.querySelector('[role]')).toBeNull()
|
||||
expect(container.querySelector('[tabindex]')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-015: prints the drive and the car icon for a routed leg, falling back to the driving text', () => {
|
||||
const bare = render(<RtLegRow seg={{ ...SEG, durationText: undefined }} mode="driving" chrome={chrome} />)
|
||||
expect(screen.getByText('roadtrip.leg.driveText:210 km,2 h 55 min')).toBeInTheDocument()
|
||||
bare.unmount()
|
||||
|
||||
const { container } = render(<RtLegRow seg={SEG} mode="driving" chrome={chrome} />)
|
||||
|
||||
expect(screen.getByText('roadtrip.leg.driveText:210 km,2 h 40 min')).toBeInTheDocument()
|
||||
expect(container.querySelector('.lucide-car-front')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-016: says the leg is pending while no segment has come back', () => {
|
||||
render(<RtLegRow seg={undefined} 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()
|
||||
walk.unmount()
|
||||
|
||||
const plugin = render(<RtLegRow seg={SEG} mode="plugin:rail" chrome={chrome} />)
|
||||
expect(plugin.container.querySelector('.lucide-zap')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RtDryRow', () => {
|
||||
const idle = {
|
||||
openFor: null, loading: false, outcome: null, results: [], offered: [],
|
||||
ask: vi.fn(), close: vi.fn(),
|
||||
} as unknown as RefuelSearch
|
||||
const props = { intoLegKm: 82, chrome, electric: false, offline: false, refuel: idle, dayId: 7, legIndex: 1 }
|
||||
|
||||
it('FE-MOB-RTROW-018: names the fuel wording and the distance into the leg for a combustion car', () => {
|
||||
render(<RtDryRow {...props} onSearch={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('roadtrip.refuel.dry')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.refuel.after:82 km')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'roadtrip.refuel.find' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-019: swaps to the charging wording for an electric car', () => {
|
||||
render(<RtDryRow {...props} electric onSearch={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('roadtrip.refuel.dryElectric')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'roadtrip.refuel.findElectric' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('roadtrip.refuel.dry')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-020: searches from the button', () => {
|
||||
const onSearch = vi.fn()
|
||||
render(<RtDryRow {...props} onSearch={onSearch} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'roadtrip.refuel.find' }))
|
||||
expect(onSearch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-021: disables the search offline and says why underneath it', () => {
|
||||
const onSearch = vi.fn()
|
||||
render(<RtDryRow {...props} offline onSearch={onSearch} />)
|
||||
|
||||
const button = screen.getByRole('button', { name: 'roadtrip.refuel.find' })
|
||||
expect(button).toBeDisabled()
|
||||
expect(screen.getByText('mobileTrip.rtSearchOffline')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(button)
|
||||
expect(onSearch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-022: drops the button entirely without write permission, offline line included', () => {
|
||||
render(<RtDryRow {...props} offline />)
|
||||
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
expect(screen.queryByText('mobileTrip.rtSearchOffline')).toBeNull()
|
||||
// The warning itself stays: it is information, not an action.
|
||||
expect(screen.getByText('roadtrip.refuel.dry')).toBeInTheDocument()
|
||||
})
|
||||
const offer = (over: Record<string, unknown> = {}) => ({
|
||||
osm_id: 'n1', name: 'Shell Ebina', lat: 35.44, lng: 139.39, category: 'fuel',
|
||||
poi_type: 'amenity=fuel', address: null, website: null, phone: null,
|
||||
opening_hours: null, cuisine: null, source: 'openstreetmap',
|
||||
offRouteKm: 0.4, alongKm: 70, spareKm: 22, ...over,
|
||||
}) as unknown as RefuelCandidate
|
||||
|
||||
const answering = (over: Partial<RefuelSearch> = {}) => ({
|
||||
...idle, openFor: '7:1', ...over,
|
||||
} as unknown as RefuelSearch)
|
||||
|
||||
it('FE-MOB-RTROW-027: says it is looking while the search runs', () => {
|
||||
render(<RtDryRow {...props} refuel={answering({ loading: true })} onSearch={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('roadtrip.refuel.looking')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-028: offers what it found, with the detour and what is left in the tank', () => {
|
||||
render(
|
||||
<RtDryRow
|
||||
{...props}
|
||||
refuel={answering({ outcome: 'found', results: [offer()] })}
|
||||
onSearch={vi.fn()}
|
||||
onAccept={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Shell Ebina')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.poi.offRoute:400 m · roadtrip.refuel.spare:22 km')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-029: an offer three legs away does not light up this band', () => {
|
||||
// One search is open at a time and it names the dry point it belongs to.
|
||||
render(
|
||||
<RtDryRow
|
||||
{...props}
|
||||
refuel={answering({ openFor: '7:4', outcome: 'found', results: [offer()] })}
|
||||
onSearch={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('Shell Ebina')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-030: taking an offer goes through the planner, and never without permission', () => {
|
||||
const onAccept = vi.fn()
|
||||
const refuel = answering({ outcome: 'found', results: [offer()] })
|
||||
const view = render(<RtDryRow {...props} refuel={refuel} onSearch={vi.fn()} onAccept={onAccept} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'roadtrip.refuel.add:Shell Ebina' }))
|
||||
expect(onAccept).toHaveBeenCalledWith(expect.objectContaining({ osm_id: 'n1' }))
|
||||
view.unmount()
|
||||
|
||||
render(<RtDryRow {...props} refuel={refuel} />)
|
||||
expect(screen.queryByRole('button', { name: 'roadtrip.refuel.add:Shell Ebina' })).toBeNull()
|
||||
// The offer is still worth seeing: a reader can look up a station even if they
|
||||
// cannot put it on the trip.
|
||||
expect(screen.getByText('Shell Ebina')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-031: three sentences for three different facts, and none of them "nothing here"', () => {
|
||||
const none = render(<RtDryRow {...props} refuel={answering({ outcome: 'none' })} onSearch={vi.fn()} />)
|
||||
expect(screen.getByText('roadtrip.refuel.none')).toBeInTheDocument()
|
||||
none.unmount()
|
||||
|
||||
const cut = render(<RtDryRow {...props} refuel={answering({ outcome: 'incomplete' })} onSearch={vi.fn()} />)
|
||||
expect(screen.getByText('roadtrip.refuel.incomplete')).toBeInTheDocument()
|
||||
cut.unmount()
|
||||
|
||||
render(<RtDryRow {...props} refuel={answering({ outcome: 'failed' })} onSearch={vi.fn()} />)
|
||||
// A request that failed never checked the stretch, so it must not read as empty.
|
||||
expect(screen.getByText('roadtrip.refuel.failed')).toBeInTheDocument()
|
||||
expect(screen.queryByText('roadtrip.refuel.none')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RtAutoRow', () => {
|
||||
it('FE-MOB-RTROW-023: marks the end of a day with the moon and the time it stopped', () => {
|
||||
const { container } = render(<RtAutoRow phase="end" time="22:00" chrome={chrome} />)
|
||||
|
||||
expect(screen.getByText('roadtrip.window.stop')).toBeInTheDocument()
|
||||
expect(screen.getByText('22:00')).toBeInTheDocument()
|
||||
expect(container.querySelector('.lucide-moon')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-024: marks the morning with the sunrise and no clock when there is none', () => {
|
||||
const { container } = render(<RtAutoRow phase="resume" time={null} chrome={chrome} />)
|
||||
|
||||
expect(screen.getByText('roadtrip.window.resume')).toBeInTheDocument()
|
||||
expect(container.querySelector('.lucide-sunrise')).not.toBeNull()
|
||||
expect(screen.queryByText('22:00')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RtSpillRow', () => {
|
||||
it('FE-MOB-RTROW-025: names the day the night drive came from and when it set off', () => {
|
||||
render(
|
||||
<RtSpillRow fromDayNumber={1} departs="23:10" chrome={chrome}>
|
||||
<span>Fuji Viewpoint</span>
|
||||
</RtSpillRow>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('roadtrip.spill.title:1')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.spill.departs:23:10')).toBeInTheDocument()
|
||||
expect(screen.getByText('Fuji Viewpoint')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-026: leaves the departure off when the night drive has no time', () => {
|
||||
render(<RtSpillRow fromDayNumber={1} departs={null} chrome={chrome} />)
|
||||
|
||||
expect(screen.getByText('roadtrip.spill.title:1')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/roadtrip\.spill\.departs/)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,475 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, within } from '../../../helpers/render'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import MRoadtripTab from '../../../../src/mobile/screens/trip/roadtrip/MRoadtripTab'
|
||||
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'
|
||||
|
||||
// FE-MOB-RTTAB-001 to FE-MOB-RTTAB-031
|
||||
|
||||
// The preference store is a zustand slice keyed by user and trip; the tab only ever
|
||||
// reads two flags out of it, so the hook is the smaller seam.
|
||||
const mocks = vi.hoisted(() => ({
|
||||
prefs: {} as Record<string, unknown>,
|
||||
swipe: {} as { onSelectDay: (id: number) => void; describeDay: (i: number, n: number) => string },
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/hooks/useRoadtripSettings', () => ({
|
||||
useRoadtripSettings: (select: (p: Record<string, unknown>) => unknown) => select(mocks.prefs),
|
||||
}))
|
||||
|
||||
// The swipe itself is the plan tab's, tested there; this keeps the real hook and only
|
||||
// records what the road trip tab hands it.
|
||||
vi.mock('../../../../src/mobile/screens/trip/plan/useMPlanDaySwipe', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('../../../../src/mobile/screens/trip/plan/useMPlanDaySwipe')>()
|
||||
return {
|
||||
...actual,
|
||||
useMPlanDaySwipe: (config: Parameters<typeof actual.useMPlanDaySwipe>[0]) => {
|
||||
mocks.swipe = config as unknown as typeof mocks.swipe
|
||||
return actual.useMPlanDaySwipe(config)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const DAYS = [
|
||||
{ id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null },
|
||||
{ id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: null },
|
||||
] as unknown as Day[]
|
||||
|
||||
const PLACES = [
|
||||
{ id: 101, trip_id: 7, name: 'Fuji Viewpoint', lat: 35.36, lng: 138.73 },
|
||||
{ id: 103, trip_id: 7, name: 'Kyoto Station', lat: 34.98, lng: 135.75 },
|
||||
] as unknown as Place[]
|
||||
|
||||
const seg = (distanceText: string, durationText: string): RouteSegment => ({
|
||||
mid: [35.4, 138.6], from: [35.7, 139.8], to: [34.98, 135.75],
|
||||
distance: 210_000, duration: 9_600,
|
||||
walkingText: '42 h', drivingText: durationText, distanceText, durationText,
|
||||
})
|
||||
|
||||
/**
|
||||
* One stage with all five row kinds on it.
|
||||
*
|
||||
* The first stop is stored on day 1: it was reached after midnight, so it is drawn on
|
||||
* this card behind a spill band while still belonging to the day before. That is the
|
||||
* case the sheet id has to survive.
|
||||
*/
|
||||
function stage(over: Partial<RoadtripDay> = {}): RoadtripDay {
|
||||
return {
|
||||
dayId: 2,
|
||||
dayNumber: 2,
|
||||
date: '2026-05-02',
|
||||
title: null,
|
||||
stops: [
|
||||
{
|
||||
assignmentId: 501, ownerDayId: 1, ownerIndex: 3, placeId: 101, name: 'Fuji Viewpoint',
|
||||
lat: 35.36, lng: 138.73, time: null, dwellMinutes: 45,
|
||||
legMode: null, incomingLegMode: null, stopType: null,
|
||||
},
|
||||
{
|
||||
assignmentId: 502, ownerDayId: 2, ownerIndex: 0, placeId: 102, name: 'Shell Ebina',
|
||||
lat: 35.44, lng: 139.39, time: null, dwellMinutes: 10,
|
||||
legMode: null, incomingLegMode: null, stopType: 'fuel',
|
||||
},
|
||||
{
|
||||
assignmentId: 503, ownerDayId: 2, ownerIndex: 1, placeId: 103, name: 'Kyoto Station',
|
||||
lat: 34.98, lng: 135.75, time: null, dwellMinutes: null,
|
||||
legMode: null, incomingLegMode: null, stopType: null,
|
||||
},
|
||||
{
|
||||
assignmentId: 504, ownerDayId: 2, ownerIndex: 2, placeId: 104, name: 'Day end',
|
||||
lat: 34.9, lng: 135.7, time: null, dwellMinutes: null,
|
||||
legMode: null, incomingLegMode: null, stopType: null,
|
||||
automaticNight: { phase: 'end', fromDayNumber: 2 },
|
||||
},
|
||||
],
|
||||
legs: [seg('62 km', '1 h'), seg('210 km', '2 h 40 min'), undefined],
|
||||
schedule: {
|
||||
entries: [
|
||||
{ arrival: '08:30', departure: '09:15', anchored: true, dayOffset: 0 },
|
||||
{ arrival: '10:05', departure: '10:15', anchored: false, dayOffset: 0 },
|
||||
{ arrival: '12:40', departure: null, anchored: false, dayOffset: 0 },
|
||||
{ arrival: '22:00', departure: null, anchored: false, dayOffset: 0 },
|
||||
],
|
||||
warnings: [],
|
||||
},
|
||||
legVias: [[], [], []],
|
||||
geometry: [],
|
||||
distance: 412_000,
|
||||
duration: 18_000,
|
||||
driveWarnings: [{ index: 2, code: 'late', minutes: 25 }],
|
||||
dayWarning: null,
|
||||
spills: [{ at: 0, count: 1, fromDayNumber: 1, departure: '23:10', leg: undefined }],
|
||||
dryPoints: [{ legIndex: 1, intoLegKm: 82, drivenMeters: 82_000, sinceKm: 520, lat: 35.2, lng: 137.5 }],
|
||||
...over,
|
||||
} as unknown as RoadtripDay
|
||||
}
|
||||
|
||||
function routes(over: Partial<RoadtripRoutes> = {}): RoadtripRoutes {
|
||||
return {
|
||||
days: [stage()],
|
||||
quietDays: [], lines: [], lineDays: [], accessLines: [], vias: [], segments: [],
|
||||
totalDistance: 980_000, totalDuration: 54_000, totalStops: 4, loading: false,
|
||||
...over,
|
||||
} as unknown as RoadtripRoutes
|
||||
}
|
||||
|
||||
function planner(over: Partial<TripPlanner> = {}): TripPlanner {
|
||||
return buildPlanner({
|
||||
tripId: 7,
|
||||
days: DAYS,
|
||||
places: PLACES,
|
||||
selectedDayId: 2,
|
||||
roadtripRoutes: routes(),
|
||||
askRefuel: vi.fn(),
|
||||
...over,
|
||||
} as Partial<TripPlanner>)
|
||||
}
|
||||
|
||||
function renderTab(p: TripPlanner = planner(), shell: MTripShellApi = buildShell()) {
|
||||
const view = render(<MRoadtripTab planner={p} shell={shell} tab="roadtrip" />)
|
||||
return { ...view, planner: p, shell }
|
||||
}
|
||||
|
||||
/** Freezes the clock and hands back the stage's own date, whatever the runner's zone. */
|
||||
function freezeAt(hour: number, minute: number): string {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2026, 4, 2, hour, minute))
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function today(date: string): Partial<TripPlanner> {
|
||||
return {
|
||||
days: DAYS.map(d => (d.id === 2 ? { ...d, date } : d)) as unknown as Day[],
|
||||
roadtripRoutes: routes({ days: [stage({ date })] }),
|
||||
}
|
||||
}
|
||||
|
||||
describe('MRoadtripTab', () => {
|
||||
beforeEach(() => {
|
||||
mocks.prefs = {}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('list half', () => {
|
||||
it('FE-MOB-RTTAB-001: heads the stage with its departure, its arrival, the day total and the stop count', () => {
|
||||
renderTab()
|
||||
|
||||
// Die ABFAHRT des ersten Stopps (09:15), nicht seine Ankunft (08:30): die beiden
|
||||
// trennt der Aufenthalt, und an einem Morgen im Hotel die ganze Nacht.
|
||||
const leave = screen.getByText('roadtrip.stay.leave').parentElement as HTMLElement
|
||||
expect(within(leave).getByText('09:15')).toBeInTheDocument()
|
||||
const arrive = screen.getByText('roadtrip.stay.arrive').parentElement as HTMLElement
|
||||
expect(within(arrive).getByText('12:40')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByText('roadtrip.leg.driveText:412 km,5 h')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.day.stopCount:2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-002: dates the head card, and falls back to the day number without a date', () => {
|
||||
renderTab()
|
||||
expect(screen.getByText(/May 2/)).toBeInTheDocument()
|
||||
|
||||
const undated = planner({ days: DAYS.map(d => ({ ...d, date: null })) as unknown as Day[] })
|
||||
const second = render(<MRoadtripTab planner={undated} shell={buildShell()} tab="roadtrip" />)
|
||||
expect(within(second.container).getByText('roadtrip.day:2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-003: draws one row per stop, in the order of the model', () => {
|
||||
renderTab()
|
||||
|
||||
const names = screen.getAllByText(/^(Fuji Viewpoint|Shell Ebina|Kyoto Station)$/).map(n => n.textContent)
|
||||
expect(names).toEqual(['Fuji Viewpoint', 'Shell Ebina', 'Kyoto Station'])
|
||||
// The petrol stop takes no number, so the destination after it is still 2.
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
expect(screen.getByText('2')).toBeInTheDocument()
|
||||
expect(screen.queryByText('3')).toBeNull()
|
||||
// And the automatic night is a band, not a stop.
|
||||
expect(screen.queryByText('Day end')).toBeNull()
|
||||
expect(screen.getByText('roadtrip.window.stop')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-004: opens a stop on the day it is stored on, not the card it is drawn on', () => {
|
||||
const { shell } = renderTab()
|
||||
|
||||
fireEvent.click(screen.getByText('Fuji Viewpoint'))
|
||||
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('rtstop', { dayId: 1, assignmentId: 501 })
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-005: opens a stop of this day with this day', () => {
|
||||
const { shell } = renderTab()
|
||||
|
||||
fireEvent.click(screen.getByText('Kyoto Station'))
|
||||
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('rtstop', { dayId: 2, assignmentId: 503 })
|
||||
})
|
||||
|
||||
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] }) }))
|
||||
|
||||
expect(screen.getByText('roadtrip.limit.dayOver:1 h 20 min')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.limit.hint')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-007: leaves the day warning off a stage that stays inside its limit', () => {
|
||||
renderTab()
|
||||
|
||||
expect(screen.queryByText(/roadtrip\.limit\.dayOver/)).toBeNull()
|
||||
expect(screen.queryByText('roadtrip.limit.hint')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-008: shows the empty stage without a summary card full of zeroes', () => {
|
||||
renderTab(planner({ roadtripRoutes: routes({ days: [] }) }))
|
||||
|
||||
expect(screen.getByText('roadtrip.empty.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('mobileTrip.rtPlanOnDesktop')).toBeInTheDocument()
|
||||
expect(screen.queryByText('roadtrip.stay.leave')).toBeNull()
|
||||
expect(screen.queryByText(/roadtrip\.day\.stopCount/)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-009: shows the partial hint instead of the empty state while the round is still running', () => {
|
||||
renderTab(planner({ roadtripRoutes: routes({ days: [], loading: true }) }))
|
||||
|
||||
expect(screen.getByText('roadtrip.summary.partial')).toBeInTheDocument()
|
||||
expect(screen.queryByText('roadtrip.empty.title')).toBeNull()
|
||||
expect(screen.queryByText('mobileTrip.rtPlanOnDesktop')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-010: paints the day tint only once the day colours are on', () => {
|
||||
const plain = renderTab()
|
||||
expect(plain.container.querySelector('span.h-2.w-2.rounded-full')).toBeNull()
|
||||
plain.unmount()
|
||||
|
||||
mocks.prefs = { roadtrip_day_colors: true }
|
||||
const tinted = renderTab()
|
||||
const dot = tinted.container.querySelector('span.h-2.w-2.rounded-full') as HTMLElement
|
||||
// dayColor(2).line, the second of the eight, see dayColors.ts.
|
||||
expect(dot).toHaveStyle({ background: '#ff9f0a' })
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-011: dashes the two figures for a stage the routing could not time', () => {
|
||||
const untimed = stage({
|
||||
schedule: { entries: [0, 1, 2, 3].map(() => ({ arrival: null, departure: null, anchored: false, dayOffset: 0 })), warnings: [] },
|
||||
} as unknown as Partial<RoadtripDay>)
|
||||
renderTab(planner({ roadtripRoutes: routes({ days: [untimed] }) }))
|
||||
|
||||
const leave = screen.getByText('roadtrip.stay.leave').parentElement as HTMLElement
|
||||
expect(within(leave).getByText('-')).toBeInTheDocument()
|
||||
const arrive = screen.getByText('roadtrip.stay.arrive').parentElement as HTMLElement
|
||||
expect(within(arrive).getByText('-')).toBeInTheDocument()
|
||||
// The chain itself still stands, it just carries no clocks.
|
||||
expect(screen.getByText('Kyoto Station')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-012: says the day total is still partial while the distance is zero', () => {
|
||||
renderTab(planner({ roadtripRoutes: routes({ days: [stage({ distance: 0 })] }) }))
|
||||
|
||||
expect(screen.getByText('roadtrip.summary.partial')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/roadtrip\.leg\.driveText:412/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('up next', () => {
|
||||
it('FE-MOB-RTTAB-013: counts down to the next stop still ahead, on the day it actually is', () => {
|
||||
const date = freezeAt(7, 0)
|
||||
renderTab(planner(today(date)))
|
||||
|
||||
const card = screen.getByText('mobileTrip.upNext').closest('section') as HTMLElement
|
||||
expect(within(card).getByText('Fuji Viewpoint')).toBeInTheDocument()
|
||||
expect(within(card).getByText('mobileTrip.inCountdown:1 h 30 min')).toBeInTheDocument()
|
||||
expect(within(card).getByText('08:30')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-014: shows no countdown on a day that is not today', () => {
|
||||
const date = freezeAt(7, 0)
|
||||
const shifted = new Date(`${date}T00:00:00Z`)
|
||||
shifted.setUTCDate(shifted.getUTCDate() + 7)
|
||||
renderTab(planner(today(shifted.toISOString().slice(0, 10))))
|
||||
|
||||
// Same clock, same stage, a week further on: the countdown is about the date.
|
||||
expect(screen.queryByText('mobileTrip.upNext')).toBeNull()
|
||||
expect(screen.getByText('roadtrip.stay.leave')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-015: shows no countdown once the last stop is behind you', () => {
|
||||
const date = freezeAt(23, 30)
|
||||
renderTab(planner(today(date)))
|
||||
|
||||
expect(screen.queryByText('mobileTrip.upNext')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-016: opens the stop from the countdown card', () => {
|
||||
const date = freezeAt(7, 0)
|
||||
const { shell } = renderTab(planner(today(date)))
|
||||
|
||||
fireEvent.click(screen.getByText('mobileTrip.upNext'))
|
||||
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('rtstop', { dayId: 1, assignmentId: 501 })
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-017: sends the next stop to the map half and switches to it', () => {
|
||||
const date = freezeAt(7, 0)
|
||||
const p = planner(today(date))
|
||||
const { shell } = renderTab(p)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('mobileTrip.showOnMap'))
|
||||
|
||||
expect(p.setSelectedPlaceId).toHaveBeenCalledWith(101)
|
||||
expect(shell.toggleRtView).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-018: leaves the navigate link dead when the stop has no place row behind it', () => {
|
||||
const date = freezeAt(7, 0)
|
||||
const enabled = renderTab(planner(today(date)))
|
||||
expect(screen.getByText('places.navigate').closest('a')).toHaveAttribute('aria-disabled', 'false')
|
||||
enabled.unmount()
|
||||
|
||||
renderTab(planner({ ...today(date), places: [] }))
|
||||
const link = screen.getByText('places.navigate').closest('a') as HTMLElement
|
||||
expect(link).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(link).toHaveAttribute('href', '#')
|
||||
})
|
||||
})
|
||||
|
||||
describe('refuel', () => {
|
||||
it('FE-MOB-RTTAB-019: asks for a refuel with the dry point of the leg the band sits on', () => {
|
||||
const p = planner()
|
||||
renderTab(p)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'roadtrip.refuel.find' }))
|
||||
|
||||
expect(p.askRefuel).toHaveBeenCalledWith(2, expect.objectContaining({ legIndex: 1, intoLegKm: 82 }))
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-020: offers no refuel search to a member who cannot edit the day', () => {
|
||||
renderTab(planner({ can: vi.fn(() => false) }))
|
||||
|
||||
expect(screen.getByText('roadtrip.refuel.dry')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'roadtrip.refuel.find' })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-021: reads the vehicle out of the trip preferences', () => {
|
||||
mocks.prefs = { roadtrip_vehicle: 'electric' }
|
||||
renderTab()
|
||||
|
||||
expect(screen.getByText('roadtrip.refuel.dryElectric')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'roadtrip.refuel.findElectric' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('map half', () => {
|
||||
const mapShell = () => buildShell({ rtView: 'map' })
|
||||
|
||||
it('FE-MOB-RTTAB-022: renders the stage bar and nothing that would cover the map', () => {
|
||||
const { container } = renderTab(planner(), mapShell())
|
||||
|
||||
expect((container.firstChild as HTMLElement).className).toContain('pointer-events-none')
|
||||
expect(screen.queryByText('roadtrip.stay.leave')).toBeNull()
|
||||
expect(screen.queryByText('Shell Ebina')).toBeNull()
|
||||
expect(screen.queryByText('roadtrip.refuel.dry')).toBeNull()
|
||||
expect(screen.queryByText('roadtrip.window.stop')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-023: names the last stop of the day, its count, its arrival and its distance', () => {
|
||||
renderTab(planner(), mapShell())
|
||||
|
||||
const bar = screen.getByRole('button', { name: /Kyoto Station/ })
|
||||
expect(within(bar).getByText('Kyoto Station')).toBeInTheDocument()
|
||||
expect(within(bar).getByText('roadtrip.day.stopCount:2 · 22:00')).toBeInTheDocument()
|
||||
expect(within(bar).getByText('412 km')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-024: drops the arrival from the bar when the day has none, and the pending distance with it', () => {
|
||||
const untimed = stage({
|
||||
distance: 0,
|
||||
schedule: { entries: [0, 1, 2, 3].map(() => ({ arrival: null, departure: null, anchored: false, dayOffset: 0 })), warnings: [] },
|
||||
} as unknown as Partial<RoadtripDay>)
|
||||
renderTab(planner({ roadtripRoutes: routes({ days: [untimed] }) }), mapShell())
|
||||
|
||||
const bar = screen.getByRole('button', { name: /roadtrip.leg.pending/ })
|
||||
expect(within(bar).getByText('roadtrip.day.stopCount:2')).toBeInTheDocument()
|
||||
expect(within(bar).getByText('roadtrip.leg.pending')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-025: switches back to the chain from the bar', () => {
|
||||
const { shell } = renderTab(planner(), mapShell())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Kyoto Station/ }))
|
||||
|
||||
expect(shell.toggleRtView).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-026: falls back to the whole drive when no day is picked, and offers nothing to tap', () => {
|
||||
renderTab(planner({ selectedDayId: null }), mapShell())
|
||||
|
||||
expect(screen.getByText('roadtrip.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('980 km')).toBeInTheDocument()
|
||||
// Not a dead button: with no stage there is nothing for a tap to open, so the
|
||||
// whole-drive line is a plain block. The search bar above it stays a button.
|
||||
expect(screen.getByText('roadtrip.title').closest('button')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the corridor search', () => {
|
||||
const mapShell = () => buildShell({ rtView: 'map' })
|
||||
|
||||
it('FE-MOB-RTTAB-028: the search bar stands in the same band on both halves', () => {
|
||||
const list = renderTab()
|
||||
const inList = screen.getByText('roadtrip.poi.title').closest('div[class*="top-[calc"]')
|
||||
expect(inList).not.toBeNull()
|
||||
list.unmount()
|
||||
|
||||
renderTab(planner(), mapShell())
|
||||
const onMap = screen.getByText('roadtrip.poi.title').closest('div[class*="top-[calc"]')
|
||||
// Literally the same offset: one stage seen two ways, and a control that jumps
|
||||
// between them is a control you have to find twice.
|
||||
expect(onMap?.className).toBe(inList?.className)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-029: it opens the search sheet', () => {
|
||||
const { shell } = renderTab()
|
||||
|
||||
fireEvent.click(screen.getByText('roadtrip.poi.title'))
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('rtsearch')
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-030: the chain starts below the bar rather than under it', () => {
|
||||
const { container } = renderTab()
|
||||
|
||||
const scroller = container.querySelector('[class*="overflow-y-auto"]') as HTMLElement
|
||||
// 96px band plus a 44px bar plus its gap. A padding left at the old 102 would
|
||||
// put the head card's first line behind the glass.
|
||||
expect(scroller.className).toContain('pt-[calc(var(--m-safe-top,12px)+150px)]')
|
||||
})
|
||||
it('FE-MOB-RTTAB-031: no stage, no search bar, and the chain keeps its old top', () => {
|
||||
// Without a stage the corridor falls back to the trip's first routed day, which
|
||||
// is not the one on screen: every distance it answered with would be measured
|
||||
// against a road nobody is looking at.
|
||||
const { container } = renderTab(planner({ selectedDayId: null }))
|
||||
|
||||
expect(screen.queryByText('roadtrip.poi.title')).toBeNull()
|
||||
const scroller = container.querySelector('[class*="overflow-y-auto"]') as HTMLElement
|
||||
expect(scroller.className).toContain('pt-[calc(var(--m-safe-top,12px)+102px)]')
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
renderTab(p)
|
||||
|
||||
mocks.swipe.onSelectDay(1)
|
||||
|
||||
// The second argument is skipFit: the map stays where it is, the stage's own
|
||||
// focus points frame it.
|
||||
expect(p.handleSelectDay).toHaveBeenCalledWith(1, true)
|
||||
expect(mocks.swipe.describeDay(0, 2)).toBe('mobileTrip.dayAnnounce:1,2')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '../../../helpers/render'
|
||||
import { buildPlanner } from '../../../helpers/mobileTrip'
|
||||
import MRtCorridorBar from '../../../../src/mobile/screens/trip/roadtrip/MRtCorridorBar'
|
||||
import type { MRtCorridorController } from '../../../../src/mobile/screens/trip/roadtrip/useMRtCorridor'
|
||||
import type { CorridorPoi } from '../../../../src/components/Roadtrip/useCorridorPois'
|
||||
|
||||
// FE-MOB-RTBAR-001 to FE-MOB-RTBAR-009
|
||||
|
||||
const hits = (count: number) =>
|
||||
Array.from({ length: count }, (_, i) => ({ osm_id: `h${i}` })) as unknown as CorridorPoi[]
|
||||
|
||||
function corridor(over: Partial<MRtCorridorController> = {}): MRtCorridorController {
|
||||
return {
|
||||
categories: ['fuel'], toggleCategory: vi.fn(),
|
||||
reach: 'ahead', setReach: vi.fn(), reachKm: 50, anchored: false,
|
||||
run: vi.fn(), clear: vi.fn(), canSearch: true,
|
||||
loading: false, progress: { done: 0, total: 0 }, hits: [], answered: false,
|
||||
offline: false, error: false, capped: false, failedAreas: 0, truncatedAreas: 0,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function renderBar(over: Partial<MRtCorridorController> = {}) {
|
||||
const c = corridor(over)
|
||||
const onOpen = vi.fn()
|
||||
const view = render(<MRtCorridorBar planner={buildPlanner()} corridor={c} onOpen={onOpen} />)
|
||||
return { ...view, corridor: c, onOpen }
|
||||
}
|
||||
|
||||
describe('MRtCorridorBar', () => {
|
||||
it('FE-MOB-RTBAR-001: idle it names the question and how far the next search reaches', () => {
|
||||
renderBar()
|
||||
|
||||
expect(screen.getByText('roadtrip.poi.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('mobileTrip.rtReachAhead:50 km')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTBAR-002: a whole-stage search says so instead of quoting a distance', () => {
|
||||
renderBar({ reach: 'stage' })
|
||||
|
||||
expect(screen.getByText('roadtrip.poi.wholeDay')).toBeInTheDocument()
|
||||
expect(screen.queryByText('mobileTrip.rtReachAhead:50 km')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTBAR-003: idle there is nothing to clear, so no second button', () => {
|
||||
renderBar()
|
||||
|
||||
// The bar is one target until there is an answer or a run to stop. A permanently
|
||||
// present X would be a control that does nothing most of the time.
|
||||
expect(screen.getAllByRole('button')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTBAR-004: running it counts the boxes and offers to stop', () => {
|
||||
const { corridor: c } = renderBar({ loading: true, progress: { done: 2, total: 5 } })
|
||||
|
||||
expect(screen.getByText('roadtrip.poi.searching:2,5')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
|
||||
expect(c.clear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTBAR-005: answered it counts what is on the way and offers to drop it', () => {
|
||||
const { corridor: c } = renderBar({ answered: true, hits: hits(7) })
|
||||
|
||||
expect(screen.getByText('roadtrip.poi.found:7')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'roadtrip.poi.clearResults' }))
|
||||
expect(c.clear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTBAR-006: an answer of none still counts, rather than looking unasked', () => {
|
||||
renderBar({ answered: true, hits: [] })
|
||||
|
||||
expect(screen.getByText('roadtrip.poi.found:0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTBAR-007: offline it says why instead of pretending it can search', () => {
|
||||
renderBar({ offline: true, canSearch: false })
|
||||
|
||||
expect(screen.getByText('mobileTrip.rtSearchOffline')).toBeInTheDocument()
|
||||
expect(screen.queryByText('roadtrip.poi.title')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTBAR-008: the bar itself opens the sheet', () => {
|
||||
const { onOpen } = renderBar()
|
||||
|
||||
fireEvent.click(screen.getByText('roadtrip.poi.title'))
|
||||
expect(onOpen).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('FE-MOB-RTBAR-009: a failed search is not a count of nothing', () => {
|
||||
renderBar({ answered: true, error: true, hits: [] })
|
||||
|
||||
expect(screen.getByText('roadtrip.poi.failed')).toBeInTheDocument()
|
||||
// "0 on the way" after a search that never checked reads as an empty stretch of road.
|
||||
expect(screen.queryByText('roadtrip.poi.found:0')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,188 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '../../../helpers/render'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import MRtCorridorSheet from '../../../../src/mobile/screens/trip/roadtrip/MRtCorridorSheet'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import type { MRtCorridorController } from '../../../../src/mobile/screens/trip/roadtrip/useMRtCorridor'
|
||||
import type { CorridorPoi } from '../../../../src/components/Roadtrip/useCorridorPois'
|
||||
|
||||
// FE-MOB-RTSRCH-001 to FE-MOB-RTSRCH-015
|
||||
|
||||
// The controller is exercised on its own in useMRtCorridor.test; here the sheet is
|
||||
// the unit, so what it reads is handed to it rather than derived from a planner.
|
||||
const mocks = vi.hoisted(() => ({ corridor: {} as MRtCorridorController }))
|
||||
vi.mock('../../../../src/mobile/screens/trip/roadtrip/useMRtCorridor', () => ({
|
||||
useMRtCorridor: () => mocks.corridor,
|
||||
}))
|
||||
|
||||
const hit = (over: Partial<CorridorPoi> = {}): CorridorPoi => ({
|
||||
osm_id: 'n1', name: 'Shell Ebina', lat: 35.44, lng: 139.39,
|
||||
category: 'fuel', poi_type: 'amenity=fuel',
|
||||
address: null, website: null, phone: null, opening_hours: null, cuisine: null,
|
||||
source: 'openstreetmap', offRouteKm: 0.4, alongKm: 82,
|
||||
...over,
|
||||
} as unknown as CorridorPoi)
|
||||
|
||||
function corridor(over: Partial<MRtCorridorController> = {}): MRtCorridorController {
|
||||
return {
|
||||
categories: ['fuel'], toggleCategory: vi.fn(),
|
||||
reach: 'ahead', setReach: vi.fn(), reachKm: 50, anchored: false,
|
||||
run: vi.fn(), clear: vi.fn(), canSearch: true,
|
||||
loading: false, progress: { done: 0, total: 0 }, hits: [], answered: false,
|
||||
offline: false, error: false, capped: false, failedAreas: 0, truncatedAreas: 0,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function renderSheet(
|
||||
over: Partial<MRtCorridorController> = {},
|
||||
p: Partial<TripPlanner> = {},
|
||||
shellOver: Partial<MTripShellApi> = {},
|
||||
) {
|
||||
mocks.corridor = corridor(over)
|
||||
const planner = buildPlanner({ can: () => true, ...p } as Partial<TripPlanner>)
|
||||
const shell = buildShell({ sheet: { id: 'rtsearch' }, ...shellOver })
|
||||
const view = render(<MRtCorridorSheet planner={planner} shell={shell} />)
|
||||
return { ...view, planner, shell, corridor: mocks.corridor }
|
||||
}
|
||||
|
||||
beforeEach(() => { mocks.corridor = corridor() })
|
||||
|
||||
describe('MRtCorridorSheet', () => {
|
||||
it('FE-MOB-RTSRCH-001: offers every corridor category, with the active ones pressed', () => {
|
||||
renderSheet({ categories: ['fuel', 'charging'] })
|
||||
|
||||
// Several at once, not one at a time: the kinds ride one query per box, so a
|
||||
// second category is free and a picker that forces one is three searches.
|
||||
expect(screen.getByText('roadtrip.poi.fuel')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.poi.charging')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.poi.rest')).toBeInTheDocument()
|
||||
expect(screen.getByText('poi.cat.hotels')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSRCH-002: tapping a category toggles it', () => {
|
||||
const { corridor: c } = renderSheet()
|
||||
|
||||
fireEvent.click(screen.getByText('roadtrip.poi.charging'))
|
||||
expect(c.toggleCategory).toHaveBeenCalledWith('charging')
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSRCH-003: the two reaches are the whole of the narrowing', () => {
|
||||
renderSheet()
|
||||
|
||||
expect(screen.getByText('mobileTrip.rtReach')).toBeInTheDocument()
|
||||
expect(screen.getByText('mobileTrip.rtReachAhead:50 km')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.poi.wholeDay')).toBeInTheDocument()
|
||||
// No corridor width, no section, no plug type, no minimum power: four ways of
|
||||
// narrowing an answer already on screen are a desk activity.
|
||||
expect(screen.queryByText('roadtrip.poi.within')).toBeNull()
|
||||
expect(screen.queryByText('roadtrip.poi.anySocket')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSRCH-004: picking a reach goes through the controller', () => {
|
||||
const { corridor: c } = renderSheet()
|
||||
|
||||
fireEvent.click(screen.getByText('roadtrip.poi.wholeDay'))
|
||||
expect(c.setReach).toHaveBeenCalledWith('stage')
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSRCH-005: says where "ahead" starts, because that is what it means', () => {
|
||||
const fromStart = renderSheet()
|
||||
expect(screen.getByText('mobileTrip.rtFromStart')).toBeInTheDocument()
|
||||
fromStart.unmount()
|
||||
|
||||
renderSheet({ anchored: true })
|
||||
expect(screen.getByText('mobileTrip.rtFromNext')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSRCH-006: the search button asks, and counts the boxes while it runs', () => {
|
||||
const idle = renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: /roadtrip.poi.search/ }))
|
||||
expect(idle.corridor.run).toHaveBeenCalledTimes(1)
|
||||
idle.unmount()
|
||||
|
||||
renderSheet({ loading: true, progress: { done: 1, total: 3 } })
|
||||
expect(screen.getByRole('button', { name: /roadtrip.poi.searching:1,3/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSRCH-007: a search nothing can answer is a dead button, so it is disabled', () => {
|
||||
renderSheet({ canSearch: false, categories: [] })
|
||||
|
||||
expect(screen.getByRole('button', { name: /roadtrip.poi.search/ })).toBeDisabled()
|
||||
expect(screen.getByText('roadtrip.poi.empty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSRCH-008: offline it says so rather than counting an empty answer', () => {
|
||||
renderSheet({ offline: true, canSearch: false, answered: true })
|
||||
|
||||
expect(screen.getByText('mobileTrip.rtSearchOffline')).toBeInTheDocument()
|
||||
expect(screen.queryByText('mobileTrip.rtNoneAhead')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSRCH-009: a failed search is not an empty stretch of road', () => {
|
||||
renderSheet({ error: true, answered: true })
|
||||
|
||||
expect(screen.getByText('roadtrip.poi.failed')).toBeInTheDocument()
|
||||
expect(screen.queryByText('mobileTrip.rtNoneAhead')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSRCH-010: nothing ahead points at the second tap; nothing on the stage does not', () => {
|
||||
const ahead = renderSheet({ answered: true, hits: [] })
|
||||
expect(screen.getByText('mobileTrip.rtNoneAhead')).toBeInTheDocument()
|
||||
ahead.unmount()
|
||||
|
||||
renderSheet({ answered: true, hits: [], reach: 'stage' })
|
||||
expect(screen.getByText('mobileTrip.rtNoneOnStage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSRCH-011: says what the search could not cover, so a short list is not read as a complete one', () => {
|
||||
renderSheet({ answered: true, hits: [hit()], capped: true, failedAreas: 2, truncatedAreas: 1 })
|
||||
|
||||
expect(screen.getByText('roadtrip.poi.capped')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.poi.partial:2')).toBeInTheDocument()
|
||||
// The phone's own wording: the desk's ends in "narrow the corridor", and the
|
||||
// corridor width is a control this screen does not have.
|
||||
expect(screen.getByText('mobileTrip.rtTruncated:1')).toBeInTheDocument()
|
||||
expect(screen.queryByText('roadtrip.poi.truncated:1')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSRCH-012: a hit says what it is and where on the drive it sits', () => {
|
||||
renderSheet({ answered: true, hits: [hit()] })
|
||||
|
||||
expect(screen.getByText('Shell Ebina')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.poi.alongRoute:82 km · roadtrip.poi.offRoute:400 m')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSRCH-013: tapping a hit shows it on the map, which means closing and switching', () => {
|
||||
const { planner, shell } = renderSheet({ answered: true, hits: [hit()] })
|
||||
|
||||
fireEvent.click(screen.getByText('Shell Ebina'))
|
||||
|
||||
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
|
||||
expect(planner.focusRoadtripPoint).toHaveBeenCalledWith(35.44, 139.39)
|
||||
expect(shell.toggleRtView).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSRCH-014: adding a hit goes through the road trip door, and never without permission', () => {
|
||||
const allowed = renderSheet({ answered: true, hits: [hit()] })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'roadtrip.poi.add' }))
|
||||
// The planner's own handler: it reads the distance the hit carries and sends it to
|
||||
// the draft sheet with the stop kind and the position in the chain worked out.
|
||||
expect(allowed.planner.handlePoiClick).toHaveBeenCalledWith(expect.objectContaining({ osm_id: 'n1' }))
|
||||
expect(allowed.shell.closeSheet).toHaveBeenCalledTimes(1)
|
||||
allowed.unmount()
|
||||
|
||||
renderSheet({ answered: true, hits: [hit()] }, { can: () => false })
|
||||
expect(screen.queryByRole('button', { name: 'roadtrip.poi.add' })).toBeNull()
|
||||
})
|
||||
it('FE-MOB-RTSRCH-015: a very long answer stops at sixty rows and says how many there were', () => {
|
||||
const many = Array.from({ length: 84 }, (_, i) => hit({ osm_id: `n${i}`, name: `Stop ${i}` }))
|
||||
renderSheet({ answered: true, hits: many })
|
||||
|
||||
expect(screen.getByText('Stop 59')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Stop 60')).toBeNull()
|
||||
// Silently cutting the list would read as the whole answer; the rest are pins on
|
||||
// the map behind this sheet.
|
||||
expect(screen.getByText('roadtrip.poi.foundFiltered:60,84')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MRtDraftSheet from '../../../../src/mobile/screens/trip/roadtrip/MRtDraftSheet'
|
||||
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import type { RoadtripStopDraft } from '../../../../src/components/Roadtrip/RoadtripStopPopup'
|
||||
import { buildPlanner } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-RTDRAFT-001 to FE-MOB-RTDRAFT-015
|
||||
//
|
||||
// The sheet takes its copy from `planner.t`, which the fixture echoes, so the
|
||||
// assertions are on keys rather than on the English wording. Durations come out
|
||||
// of formatDurationShort and are real text.
|
||||
|
||||
function draftOf(category: string, over: Partial<RoadtripStopDraft> = {}): RoadtripStopDraft {
|
||||
return {
|
||||
poi: {
|
||||
osm_id: `node/${category}`,
|
||||
name: 'Aral Dammtor',
|
||||
lat: 53.5,
|
||||
lng: 9.9,
|
||||
category,
|
||||
poi_type: category,
|
||||
address: 'B4, Hamburg',
|
||||
website: null,
|
||||
phone: null,
|
||||
opening_hours: null,
|
||||
cuisine: null,
|
||||
offRouteKm: 0.2,
|
||||
alongKm: 43,
|
||||
},
|
||||
dayId: 11,
|
||||
dayNumber: 1,
|
||||
position: 2,
|
||||
...over,
|
||||
} as RoadtripStopDraft
|
||||
}
|
||||
|
||||
function renderSheet(overrides: Record<string, unknown> = {}) {
|
||||
const planner = buildPlanner({
|
||||
stopDraft: draftOf('fuel'),
|
||||
setStopDraft: vi.fn(),
|
||||
saveStopDraft: vi.fn(async () => undefined),
|
||||
stopDraftDuplicate: null,
|
||||
...overrides,
|
||||
} as unknown as Partial<TripPlanner>)
|
||||
render(<MRtDraftSheet planner={planner} />)
|
||||
return { planner }
|
||||
}
|
||||
|
||||
const kind = (key: string) => screen.getByRole('button', { name: `roadtrip.poi.${key}` })
|
||||
const dwell = (label: string) => screen.getByRole('button', { name: label })
|
||||
const isActive = (el: HTMLElement) => el.classList.contains('bg-m-act')
|
||||
|
||||
describe('MRtDraftSheet', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTDRAFT-001: renders nothing while no draft is in play', () => {
|
||||
renderSheet({ stopDraft: null })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTDRAFT-002: opens as soon as the planner carries a draft, whoever set it', () => {
|
||||
renderSheet()
|
||||
expect(screen.getByRole('dialog', { name: 'Aral Dammtor' })).toBeInTheDocument()
|
||||
expect(screen.getByText('B4, Hamburg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTDRAFT-003: says which day and which position the stop lands on, counted from one', () => {
|
||||
renderSheet()
|
||||
expect(screen.getByText('roadtrip.stop.landsOn:1,3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTDRAFT-004: preselects the kind from the hit and the stay from that kind', () => {
|
||||
renderSheet()
|
||||
expect(isActive(kind('fuel'))).toBe(true)
|
||||
expect(isActive(dwell('10 min'))).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTDRAFT-005: a charging hit is a charge, not a fuel stop, and takes its own half hour', () => {
|
||||
renderSheet({ stopDraft: draftOf('charging') })
|
||||
expect(isActive(kind('charging'))).toBe(true)
|
||||
expect(isActive(dwell('30 min'))).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTDRAFT-006: an unknown category leaves the kind open and falls back to 30 minutes', () => {
|
||||
renderSheet({ stopDraft: draftOf('pharmacy') })
|
||||
for (const key of ['fuel', 'charging', 'rest', 'food', 'sights']) {
|
||||
expect(isActive(kind(key))).toBe(false)
|
||||
}
|
||||
expect(isActive(dwell('30 min'))).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTDRAFT-007: changing the kind pulls its usual length along', () => {
|
||||
renderSheet()
|
||||
fireEvent.click(kind('rest'))
|
||||
expect(isActive(kind('rest'))).toBe(true)
|
||||
expect(isActive(dwell('20 min'))).toBe(true)
|
||||
expect(isActive(dwell('10 min'))).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTDRAFT-008: tapping the kind already chosen leaves a hand-set length standing', () => {
|
||||
renderSheet()
|
||||
fireEvent.click(kind('charging'))
|
||||
expect(isActive(dwell('30 min'))).toBe(true)
|
||||
fireEvent.click(dwell('45 min'))
|
||||
fireEvent.click(kind('charging'))
|
||||
expect(isActive(dwell('45 min'))).toBe(true)
|
||||
expect(isActive(dwell('30 min'))).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTDRAFT-009: saving hands the kind and the length to the planner', async () => {
|
||||
const { planner } = renderSheet()
|
||||
fireEvent.click(kind('charging'))
|
||||
fireEvent.click(dwell('45 min'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'roadtrip.poi.add' }))
|
||||
await waitFor(() => expect(planner.saveStopDraft).toHaveBeenCalledWith({ stopType: 'charging', dwellMinutes: 45 }))
|
||||
})
|
||||
|
||||
it('FE-MOB-RTDRAFT-010: an edit answers with what the stop already says, and saves rather than adds', async () => {
|
||||
const { planner } = renderSheet({
|
||||
stopDraft: draftOf('fuel', { editing: { placeId: 908, dwellMinutes: 20, stopType: 'rest_area' } }),
|
||||
})
|
||||
expect(isActive(kind('rest'))).toBe(true)
|
||||
expect(isActive(dwell('20 min'))).toBe(true)
|
||||
expect(screen.queryByRole('button', { name: 'roadtrip.poi.add' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.save' }))
|
||||
await waitFor(() => expect(planner.saveStopDraft).toHaveBeenCalledWith({ stopType: 'rest_area', dwellMinutes: 20 }))
|
||||
})
|
||||
|
||||
it('FE-MOB-RTDRAFT-011: warns about a stop this trip already has', () => {
|
||||
renderSheet({ stopDraftDuplicate: 'Aral A1' })
|
||||
expect(screen.getByText('roadtrip.stop.duplicate:Aral A1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTDRAFT-012: a hotel is a night, so it points at the desktop and offers no save', () => {
|
||||
renderSheet({ stopDraft: draftOf('hotel') })
|
||||
expect(screen.getByText('mobileTrip.rtNightDesktopOnly')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'roadtrip.poi.add' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.save' })).not.toBeInTheDocument()
|
||||
// Only the way out and the way to drop it.
|
||||
expect(screen.getAllByRole('button')).toHaveLength(2)
|
||||
expect(screen.getByRole('button', { name: 'common.close' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.discard' })).toBeInTheDocument()
|
||||
// Neither question is asked, because neither answer could be written.
|
||||
expect(screen.queryByText('roadtrip.stop.kind')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('roadtrip.stop.stay')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTDRAFT-013: a campsite is treated the same way, because a night can be booked there too', () => {
|
||||
renderSheet({ stopDraft: draftOf('campsite') })
|
||||
expect(screen.getByText('mobileTrip.rtNightDesktopOnly')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'roadtrip.poi.add' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTDRAFT-014: a draft that already carries an overnight offer is night-only whatever its category', () => {
|
||||
renderSheet({
|
||||
stopDraft: draftOf('fuel', { overnight: { days: [{ id: 11, number: 1, date: '2026-05-01' }], defaultEndDayId: 12 } }),
|
||||
})
|
||||
expect(screen.getByText('mobileTrip.rtNightDesktopOnly')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'roadtrip.poi.add' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTDRAFT-015: discarding clears the draft and writes nothing', () => {
|
||||
const { planner } = renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.discard' }))
|
||||
expect(planner.setStopDraft).toHaveBeenCalledWith(null)
|
||||
expect(planner.saveStopDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import MRtInfoSheet from '../../../../src/mobile/screens/trip/roadtrip/MRtInfoSheet'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { useAuthStore } from '../../../../src/store/authStore'
|
||||
import { useSettingsStore } from '../../../../src/store/settingsStore'
|
||||
import { useRoadtripPreferencesStore } from '../../../../src/store/roadtripPreferencesStore'
|
||||
import type { RoadtripPreferences } from '@trek/shared'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { render, screen } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-RTINFO-001 to FE-MOB-RTINFO-015
|
||||
//
|
||||
// The sheet renders inside the real TranslationProvider, so the copy is asserted
|
||||
// in English. Everything it shows comes out of the roadtrip preferences store,
|
||||
// which is keyed by "<userId>:<tripId>".
|
||||
|
||||
const USER_ID = 7
|
||||
const TRIP_ID = 4
|
||||
|
||||
/** An electric car with every limit filled in, so each row has a value to show. */
|
||||
const FULL: RoadtripPreferences = {
|
||||
roadtrip_vehicle: 'electric',
|
||||
roadtrip_battery_kwh: 77,
|
||||
roadtrip_kwh_per_100: 18,
|
||||
roadtrip_battery_degradation: 8,
|
||||
roadtrip_fill_percent: 80,
|
||||
roadtrip_leg_minutes: 240,
|
||||
roadtrip_day_minutes: 480,
|
||||
roadtrip_day_start: '08:00',
|
||||
roadtrip_day_end: '19:00',
|
||||
roadtrip_avoid: 'toll,ferry',
|
||||
}
|
||||
|
||||
function seedPreferences(preferences: RoadtripPreferences): void {
|
||||
useRoadtripPreferencesStore.setState({ byTrip: { [`${USER_ID}:${TRIP_ID}`]: preferences } })
|
||||
}
|
||||
|
||||
function renderSheet(preferences: RoadtripPreferences = FULL, shellOverrides: Record<string, unknown> = {}) {
|
||||
seedPreferences(preferences)
|
||||
const planner = buildPlanner({ tripId: TRIP_ID } as unknown as Partial<TripPlanner>)
|
||||
const shell = buildShell({ sheet: { id: 'rtinfo' }, ...shellOverrides } as unknown as Partial<MTripShellApi>)
|
||||
render(<MRtInfoSheet planner={planner} shell={shell} />)
|
||||
return { planner, shell }
|
||||
}
|
||||
|
||||
/** The value cell of one limit row, found through its label. */
|
||||
function limitValue(label: string): string {
|
||||
const row = screen.getByText(label).parentElement
|
||||
return row?.lastElementChild?.textContent ?? ''
|
||||
}
|
||||
|
||||
describe('MRtInfoSheet', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
useRoadtripPreferencesStore.setState({ byTrip: {} })
|
||||
seedStore(useAuthStore, { user: { id: USER_ID } as never })
|
||||
})
|
||||
|
||||
it('FE-MOB-RTINFO-001: stays closed while another sheet id is active', () => {
|
||||
renderSheet(FULL, { sheet: { id: 'rtstop' } })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTINFO-002: opens as the driving figures of the trip', () => {
|
||||
renderSheet()
|
||||
expect(screen.getByRole('dialog', { name: 'Driving figures' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Vehicle')).toBeInTheDocument()
|
||||
expect(screen.getByText('Driving settings')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTINFO-003: shows the range the stage plans with, worked out from the battery', () => {
|
||||
renderSheet()
|
||||
// 77 kWh less 8 % wear, at 18 kWh/100 km.
|
||||
expect(screen.getByText('394')).toBeInTheDocument()
|
||||
expect(screen.getByText('km')).toBeInTheDocument()
|
||||
expect(screen.getByText('per charge')).toBeInTheDocument()
|
||||
expect(screen.getByRole('img', { name: '394 km per charge' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTINFO-004: explains the fill, the block size and the battery wear under the bar', () => {
|
||||
renderSheet()
|
||||
expect(screen.getByText('A 80 % stop gives 315.2 km · One block = 50 km · 8 % lost to age')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTINFO-005: reads the range in miles on an imperial account', () => {
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, distance_unit: 'imperial' } })
|
||||
renderSheet()
|
||||
expect(screen.getByText('244.8')).toBeInTheDocument()
|
||||
expect(screen.getByText('mi')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTINFO-006: prints every limit as its own value', () => {
|
||||
renderSheet()
|
||||
expect(limitValue('Longest drive at once')).toBe('4 h')
|
||||
expect(limitValue('Driving per day')).toBe('8 h')
|
||||
expect(limitValue('Daily travel times')).toBe('08:00 · 19:00')
|
||||
expect(limitValue('Fill up to')).toBe('80 %')
|
||||
expect(limitValue('Avoid where possible')).toBe('2 avoided')
|
||||
})
|
||||
|
||||
it('FE-MOB-RTINFO-007: an unset limit reads "off" rather than zero', () => {
|
||||
renderSheet({})
|
||||
expect(limitValue('Longest drive at once')).toBe('off')
|
||||
expect(limitValue('Driving per day')).toBe('off')
|
||||
expect(limitValue('Daily travel times')).toBe('off')
|
||||
expect(limitValue('Avoid where possible')).toBe('off')
|
||||
expect(screen.queryByText('0')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('0 min')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTINFO-008: an unset fill reads "full", because not saying means filling right up', () => {
|
||||
renderSheet({})
|
||||
expect(limitValue('Fill up to')).toBe('full')
|
||||
expect(screen.queryByText('100 %')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTINFO-009: with no range the figure is a dash and the hint says where to set one', () => {
|
||||
renderSheet({})
|
||||
expect(screen.getByText('-')).toBeInTheDocument()
|
||||
expect(screen.getByRole('img', { name: 'No range set' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Type a range, or fill in the figures below and TREK works it out.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTINFO-010: a half-set day window counts as no window at all', () => {
|
||||
renderSheet({ ...FULL, roadtrip_day_end: '' })
|
||||
expect(limitValue('Daily travel times')).toBe('off')
|
||||
})
|
||||
|
||||
it('FE-MOB-RTINFO-011: stored avoidance reads "off" when no second routing engine can honour it', () => {
|
||||
// A self-hosted router and no Valhalla: the classes are stored but nothing applies them.
|
||||
seedStore(useSettingsStore, {
|
||||
settings: { ...useSettingsStore.getState().settings, routing_base_url: 'https://osrm.example', valhalla_base_url: '' },
|
||||
})
|
||||
renderSheet()
|
||||
expect(limitValue('Avoid where possible')).toBe('off')
|
||||
})
|
||||
|
||||
it('FE-MOB-RTINFO-012: carries no control of any kind, only the way out', () => {
|
||||
renderSheet()
|
||||
const dialog = screen.getByRole('dialog', { name: 'Driving figures' })
|
||||
expect(screen.getAllByRole('button')).toHaveLength(1)
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
|
||||
expect(dialog.querySelectorAll('input, select, textarea')).toHaveLength(0)
|
||||
for (const role of ['textbox', 'spinbutton', 'slider', 'switch', 'checkbox', 'combobox', 'radio'] as const) {
|
||||
expect(screen.queryByRole(role)).not.toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-MOB-RTINFO-013: closes with the footnote that names the desktop as the place to change them', () => {
|
||||
renderSheet()
|
||||
expect(screen.getByText(/These figures are set on the desktop/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTINFO-014: a combustion car reads per tank, out of tank size and consumption', () => {
|
||||
renderSheet({ roadtrip_vehicle: 'combustion', roadtrip_tank_litres: 60, roadtrip_litres_per_100: 7 })
|
||||
// 60 litres at 7 L/100 km, with no battery wear to take off it.
|
||||
expect(screen.getByRole('img', { name: '857 km per tank' })).toBeInTheDocument()
|
||||
expect(screen.getByText('One block = 50 km')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/lost to age/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTINFO-015: a range too long to count in blocks gets a solid bar and no note', () => {
|
||||
renderSheet({ roadtrip_range_km: 2000 })
|
||||
expect(screen.getByRole('img', { name: '2000 km per tank' })).toBeInTheDocument()
|
||||
expect(screen.queryByText(/One block/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,214 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MRtStaySheet from '../../../../src/mobile/screens/trip/roadtrip/MRtStaySheet'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { useSettingsStore } from '../../../../src/store/settingsStore'
|
||||
import type { RoadtripDay } from '@trek/shared/roadtrip'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-RTSTAY-001 to FE-MOB-RTSTAY-018
|
||||
//
|
||||
// The sheet renders inside the real TranslationProvider, so the copy is asserted
|
||||
// in English.
|
||||
|
||||
/** Arrives late in the evening, so a long stay has to carry into the next day. */
|
||||
const STAGE = {
|
||||
dayId: 11,
|
||||
dayNumber: 1,
|
||||
date: '2026-05-01',
|
||||
title: null,
|
||||
stops: [
|
||||
{ assignmentId: 101, ownerDayId: 11, ownerIndex: 0, placeId: 201, name: 'Hamburg Hafen', lat: 53.54, lng: 9.98, time: null, dwellMinutes: 30, legMode: null, incomingLegMode: null, stopType: null },
|
||||
{ assignmentId: 102, ownerDayId: 11, ownerIndex: 1, placeId: 202, name: 'Bremen Marktplatz', lat: 53.07, lng: 8.8, time: null, dwellMinutes: 30, legMode: null, incomingLegMode: null, stopType: null },
|
||||
],
|
||||
legs: [undefined, undefined],
|
||||
legVias: [[], []],
|
||||
geometry: [],
|
||||
distance: 0,
|
||||
duration: 0,
|
||||
dayWarning: null,
|
||||
schedule: {
|
||||
entries: [
|
||||
{ arrival: '09:15', departure: '09:45', anchored: false, dayOffset: 0 },
|
||||
{ arrival: '22:30', departure: '23:00', anchored: false, dayOffset: 0 },
|
||||
],
|
||||
warnings: [],
|
||||
},
|
||||
driveWarnings: [],
|
||||
} as unknown as RoadtripDay
|
||||
|
||||
function makePlanner(overrides: Record<string, unknown> = {}) {
|
||||
return buildPlanner({
|
||||
tripId: 4,
|
||||
selectedDayId: 11,
|
||||
roadtripRoutes: { days: [STAGE] },
|
||||
setRoadtripStay: vi.fn(async () => undefined),
|
||||
...overrides,
|
||||
} as unknown as Partial<TripPlanner>)
|
||||
}
|
||||
|
||||
function makeShell(overrides: Record<string, unknown> = {}) {
|
||||
return buildShell({
|
||||
sheet: { id: 'rtstay', payload: { placeId: 202, minutes: 30, 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(<MRtStaySheet planner={planner} shell={shell} />)
|
||||
return { planner, shell }
|
||||
}
|
||||
|
||||
const minus = () => screen.getByRole('button', { name: '5 minutes less' })
|
||||
const plus = () => screen.getByRole('button', { name: '5 minutes more' })
|
||||
const value = () => screen.getByText('Minutes').previousElementSibling
|
||||
|
||||
describe('MRtStaySheet', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTAY-001: stays closed while another sheet id is active', () => {
|
||||
renderSheet({}, { sheet: { id: 'rtstop', payload: { placeId: 202, minutes: 30 } } })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTAY-002: opens on the stay the place carries, named and scoped', () => {
|
||||
renderSheet()
|
||||
expect(screen.getByRole('dialog', { name: 'Add a stay' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Bremen Marktplatz')).toBeInTheDocument()
|
||||
expect(screen.getByText('The stay belongs to the place, so it counts on every day this stop is planned.')).toBeInTheDocument()
|
||||
expect(value()).toHaveTextContent('30')
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTAY-003: plus and minus move the value in five-minute steps', () => {
|
||||
renderSheet()
|
||||
fireEvent.click(plus())
|
||||
expect(value()).toHaveTextContent('35')
|
||||
fireEvent.click(plus())
|
||||
expect(value()).toHaveTextContent('40')
|
||||
fireEvent.click(minus())
|
||||
expect(value()).toHaveTextContent('35')
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTAY-004: minus stops at zero instead of going negative', () => {
|
||||
renderSheet({}, { sheet: { id: 'rtstay', payload: { placeId: 202, minutes: 3, name: 'Bremen Marktplatz' } } })
|
||||
fireEvent.click(minus())
|
||||
expect(value()).toHaveTextContent('0')
|
||||
expect(minus()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTAY-005: plus stops at a full day', () => {
|
||||
renderSheet({}, { sheet: { id: 'rtstay', payload: { placeId: 202, minutes: 1438, name: 'Bremen Marktplatz' } } })
|
||||
fireEvent.click(plus())
|
||||
expect(value()).toHaveTextContent('1440')
|
||||
expect(plus()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTAY-006: a preset sets the value outright rather than adding to it', () => {
|
||||
renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: '2 h' }))
|
||||
expect(value()).toHaveTextContent('120')
|
||||
fireEvent.click(screen.getByRole('button', { name: '45 min' }))
|
||||
expect(value()).toHaveTextContent('45')
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTAY-007: previews the new departure as arrival plus the stay', () => {
|
||||
renderSheet({}, { sheet: { id: 'rtstay', payload: { placeId: 201, minutes: 30, name: 'Hamburg Hafen' } } })
|
||||
expect(screen.getByText('09:15')).toBeInTheDocument()
|
||||
expect(screen.getByText('09:45')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '1 h' }))
|
||||
expect(screen.getByText('10:15')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTAY-008: carries the preview into the next day when the stay runs past midnight', () => {
|
||||
renderSheet()
|
||||
// Arrives 22:30; two hours of it land at 00:30 the following day.
|
||||
fireEvent.click(screen.getByRole('button', { name: '2 h' }))
|
||||
expect(screen.getByText('00:30')).toBeInTheDocument()
|
||||
expect(screen.getByText('+1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Next day')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTAY-009: no carry mark while the departure stays on the same day', () => {
|
||||
renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: '30 min' }))
|
||||
expect(screen.getByText('23:00')).toBeInTheDocument()
|
||||
expect(screen.queryByText('+1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTAY-010: reads the preview clocks in the 12h setting', () => {
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, time_format: '12h' } })
|
||||
renderSheet({}, { sheet: { id: 'rtstay', payload: { placeId: 201, minutes: 30, name: 'Hamburg Hafen' } } })
|
||||
expect(screen.getByText('9:15 AM')).toBeInTheDocument()
|
||||
expect(screen.getByText('9:45 AM')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTAY-011: drops the preview when the stop is not on the stage', () => {
|
||||
renderSheet({ selectedDayId: 99 })
|
||||
expect(screen.queryByText('Arrive')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Leave')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTAY-012: saving writes the place and the minutes, then hands back to the stop', async () => {
|
||||
const { planner, shell } = renderSheet()
|
||||
fireEvent.click(plus())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(planner.setRoadtripStay).toHaveBeenCalledWith(202, 35))
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('rtstop', { placeId: 202, minutes: 35, name: 'Bremen Marktplatz' })
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTAY-013: clearing the stay is a save of zero, not a second write path', async () => {
|
||||
const { planner, shell } = renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'No stay' }))
|
||||
expect(planner.setRoadtripStay).not.toHaveBeenCalled()
|
||||
expect(value()).toHaveTextContent('0')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(planner.setRoadtripStay).toHaveBeenCalledWith(202, 0))
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('rtstop', { placeId: 202, minutes: 0, name: 'Bremen Marktplatz' })
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTAY-014: closing without saving writes nothing and does not reopen the stop', () => {
|
||||
const { planner, shell } = renderSheet()
|
||||
fireEvent.click(plus())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
|
||||
expect(planner.setRoadtripStay).not.toHaveBeenCalled()
|
||||
expect(shell.openSheet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTAY-015: a failed write puts the stored value back and says so', async () => {
|
||||
const { planner, shell } = renderSheet({
|
||||
setRoadtripStay: vi.fn(async () => { throw new Error('offline') }),
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '2 h' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Unknown error'))
|
||||
expect(value()).toHaveTextContent('30')
|
||||
expect(shell.openSheet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTAY-016: without place_edit there is nothing to set the stay with', () => {
|
||||
renderSheet({ can: vi.fn(() => false) })
|
||||
expect(minus()).toBeDisabled()
|
||||
expect(plus()).toBeDisabled()
|
||||
expect(screen.queryByRole('button', { name: '2 h' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'No stay' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTAY-017: a stop that carries no stay yet opens on zero', () => {
|
||||
renderSheet({}, { sheet: { id: 'rtstay', payload: { placeId: 202, name: 'Bremen Marktplatz' } } })
|
||||
expect(value()).toHaveTextContent('0')
|
||||
expect(minus()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTAY-018: a place the stage does not stop at gets no preview either', () => {
|
||||
renderSheet({}, { sheet: { id: 'rtstay', payload: { placeId: 909, minutes: 30, name: 'Elsewhere' } } })
|
||||
expect(screen.getByRole('dialog', { name: 'Add a stay' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Arrive')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,361 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MRtStopSheet from '../../../../src/mobile/screens/trip/roadtrip/MRtStopSheet'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import type { Place } from '../../../../src/types'
|
||||
import type { RoadtripDay, RoadtripStop } from '@trek/shared/roadtrip'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-RTSTOP-001 to FE-MOB-RTSTOP-027
|
||||
//
|
||||
// The sheet renders inside the real TranslationProvider, so the copy is asserted
|
||||
// in English. `planner.t` from the fixture is never consulted here.
|
||||
|
||||
// The charging panel polls a repo of its own every minute; stubbed so the sheet's
|
||||
// wiring can be asserted without a live availability lookup in the background.
|
||||
vi.mock('../../../../src/components/Roadtrip/ChargingInfo', () => ({
|
||||
default: ({ placeId }: { placeId?: number }) => <div data-testid="charging-info">{placeId}</div>,
|
||||
}))
|
||||
|
||||
function stop(over: Partial<RoadtripStop> & Pick<RoadtripStop, 'assignmentId' | 'placeId' | 'name'>): RoadtripStop {
|
||||
return {
|
||||
ownerDayId: 11,
|
||||
ownerIndex: 0,
|
||||
lat: 53.55,
|
||||
lng: 9.99,
|
||||
time: null,
|
||||
dwellMinutes: null,
|
||||
legMode: 'driving',
|
||||
incomingLegMode: 'driving',
|
||||
stopType: null,
|
||||
...over,
|
||||
} as RoadtripStop
|
||||
}
|
||||
|
||||
/** Stop 1 is pinned, stop 2 is a fuel stop off the road, stop 3 collects three findings. */
|
||||
const HAMBURG = stop({ assignmentId: 101, placeId: 201, name: 'Hamburg Hafen', ownerIndex: 0, time: '09:00', dwellMinutes: 60 })
|
||||
const ARAL = stop({ assignmentId: 102, placeId: 202, name: 'Aral Dammtor', ownerIndex: 1, stopType: 'fuel', dwellMinutes: 10, offRoadMeters: 450 })
|
||||
const BREMEN = stop({ assignmentId: 103, placeId: 203, name: 'Bremen Marktplatz', ownerIndex: 2, dwellMinutes: 90 })
|
||||
/** Set off from on day 11, reached on day 12: stored on one card, drawn on the other. */
|
||||
const KASSEL = stop({ assignmentId: 104, placeId: 204, name: 'Kassel Rathaus', ownerDayId: 11, ownerIndex: 3 })
|
||||
|
||||
const DAY_A = {
|
||||
dayId: 11,
|
||||
dayNumber: 1,
|
||||
date: '2026-05-01',
|
||||
title: null,
|
||||
stops: [HAMBURG, ARAL, BREMEN],
|
||||
legs: [undefined, undefined, undefined],
|
||||
legVias: [[], [], []],
|
||||
geometry: [],
|
||||
distance: 0,
|
||||
duration: 0,
|
||||
dayWarning: null,
|
||||
schedule: {
|
||||
entries: [
|
||||
{ arrival: '09:00', departure: '10:00', anchored: true, dayOffset: 0 },
|
||||
{ arrival: '11:30', departure: '11:40', anchored: false, dayOffset: 0 },
|
||||
{ arrival: '13:05', departure: '14:35', anchored: false, dayOffset: 0 },
|
||||
],
|
||||
warnings: [{ index: 2, code: 'late', minutes: 25 }],
|
||||
},
|
||||
driveWarnings: [
|
||||
{ index: 2, code: 'leg', overMinutes: 40 },
|
||||
{ index: 2, code: 'range', sinceKm: 520 },
|
||||
],
|
||||
} as unknown as RoadtripDay
|
||||
|
||||
const DAY_B = {
|
||||
dayId: 12,
|
||||
dayNumber: 2,
|
||||
date: '2026-05-02',
|
||||
title: null,
|
||||
stops: [KASSEL],
|
||||
legs: [undefined],
|
||||
legVias: [[]],
|
||||
geometry: [],
|
||||
distance: 0,
|
||||
duration: 0,
|
||||
dayWarning: null,
|
||||
schedule: {
|
||||
entries: [{ arrival: '08:11', departure: '08:41', anchored: false, dayOffset: 0 }],
|
||||
warnings: [{ index: 0, code: 'late', minutes: 12 }],
|
||||
},
|
||||
driveWarnings: [],
|
||||
} as unknown as RoadtripDay
|
||||
|
||||
const PLACES = [
|
||||
{ id: 201, name: 'Hamburg Hafen', lat: 53.54, lng: 9.98, address: 'Bei den St. Pauli-Landungsbrücken' },
|
||||
{ id: 203, name: 'Bremen Marktplatz', lat: 53.07, lng: 8.8, address: 'Am Markt' },
|
||||
] as unknown as Place[]
|
||||
|
||||
function makePlanner(overrides: Record<string, unknown> = {}) {
|
||||
return buildPlanner({
|
||||
tripId: 4,
|
||||
places: PLACES,
|
||||
dailyTimesActive: true,
|
||||
setRoadtripEndDay: vi.fn(async () => undefined),
|
||||
roadtripRoutes: { days: [DAY_A, DAY_B] },
|
||||
...overrides,
|
||||
} as unknown as Partial<TripPlanner>)
|
||||
}
|
||||
|
||||
function makeShell(overrides: Record<string, unknown> = {}) {
|
||||
return buildShell({
|
||||
sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 103 } },
|
||||
...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(<MRtStopSheet planner={planner} shell={shell} />)
|
||||
return { planner, shell }
|
||||
}
|
||||
|
||||
describe('MRtStopSheet', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTOP-001: stays closed while another sheet id is active', () => {
|
||||
renderSheet({}, { sheet: { id: 'rtstay', payload: { dayId: 11, assignmentId: 103 } } })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTOP-002: stays closed when no routed day carries the assignment', () => {
|
||||
renderSheet({}, { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 999 } } })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTOP-003: shows arrival and departure large, and says the time was set by hand', () => {
|
||||
renderSheet({}, { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 101 } } })
|
||||
expect(screen.getByRole('dialog', { name: 'Hamburg Hafen' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Arrive')).toBeInTheDocument()
|
||||
expect(screen.getByText('09:00')).toBeInTheDocument()
|
||||
expect(screen.getByText('Leave')).toBeInTheDocument()
|
||||
expect(screen.getByText('10:00')).toBeInTheDocument()
|
||||
expect(screen.getByText('Time you set')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Calculated from the drive')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTOP-004: says the clock was worked out when the stop is not anchored', () => {
|
||||
renderSheet()
|
||||
expect(screen.getByText('13:05')).toBeInTheDocument()
|
||||
expect(screen.getByText('14:35')).toBeInTheDocument()
|
||||
expect(screen.getByText('Calculated from the drive')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Time you set')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTOP-005: lists every finding of the stop, not just the one the chain has room for', () => {
|
||||
renderSheet()
|
||||
// The chain picks 'range' out of the drive warnings and shows nothing else.
|
||||
expect(screen.getByText('Arrives 25 min after the time you set')).toBeInTheDocument()
|
||||
expect(screen.getByText('40 min over your longest drive')).toBeInTheDocument()
|
||||
expect(screen.getByText('520 km since the last fill-up')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTOP-006: spells out the stay and how far the stop sits off the road', () => {
|
||||
renderSheet({}, { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 102 } } })
|
||||
expect(screen.getByText('450 m from the road')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('10 min').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTOP-007: finds the stop on the card it is drawn on, not the day it is stored on', () => {
|
||||
// ownerDayId 11 names day A; the stop only exists in day B's chain after a night drive.
|
||||
renderSheet({}, { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 104 } } })
|
||||
expect(screen.getByRole('dialog', { name: 'Kassel Rathaus' })).toBeInTheDocument()
|
||||
// Day B's own schedule and day B's own warning, filed under day B's index.
|
||||
expect(screen.getByText('08:11')).toBeInTheDocument()
|
||||
expect(screen.getByText('Arrives 12 min after the time you set')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTOP-008: keeps the navigation tile without any write permission', () => {
|
||||
renderSheet({ can: vi.fn(() => false) })
|
||||
expect(screen.getByRole('button', { name: 'Navigation' })).toBeInTheDocument()
|
||||
// The stay tile loses its handler and stays a plain box rather than going dead.
|
||||
expect(screen.queryByRole('button', { name: /^Stay/ })).not.toBeInTheDocument()
|
||||
const stayLabels = screen.getAllByText('Stay')
|
||||
expect(stayLabels).toHaveLength(2)
|
||||
expect(stayLabels[1].closest('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTOP-009: offers the map apps built from the stop itself when no place row matches', () => {
|
||||
renderSheet({ places: [] })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Navigation' }))
|
||||
expect(screen.getByRole('menu')).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'Google Maps' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'Waze' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTOP-010: has no delete, no reorder and no stop-kind control at all', () => {
|
||||
renderSheet()
|
||||
// Close, navigation, stay, show-on-map, plus the end-day switch, which is a switch.
|
||||
expect(screen.getAllByRole('button')).toHaveLength(4)
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Navigation' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /^Stay/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Show on map' })).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('switch')).toHaveLength(1)
|
||||
// The desktop-only copy is absent rather than disabled.
|
||||
expect(screen.queryByText('Remove stop')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Kind of stop')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Set how full this stop fills')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTOP-011: the stay tile hands the stop over to the stay sheet', () => {
|
||||
const { shell } = renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Stay/ }))
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('rtstay', { placeId: 203, minutes: 90 })
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTOP-012: ending the day here calls the stop-addressed writer with that very stop', async () => {
|
||||
const { planner } = renderSheet()
|
||||
const toggle = screen.getByRole('switch', { name: 'End the day here' })
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'false')
|
||||
fireEvent.click(toggle)
|
||||
expect(planner.setRoadtripEndDay).toHaveBeenCalledWith(BREMEN)
|
||||
// Reads as switched the moment it is tapped, before the write lands.
|
||||
await waitFor(() => expect(toggle).toHaveAttribute('aria-checked', 'true'))
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTOP-013: rolls the switch back and reports when the write fails', async () => {
|
||||
const { planner } = renderSheet({
|
||||
setRoadtripEndDay: vi.fn(async () => { throw new Error('Day boundary refused') }),
|
||||
})
|
||||
const toggle = screen.getByRole('switch', { name: 'End the day here' })
|
||||
fireEvent.click(toggle)
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Day boundary refused'))
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTOP-014: dropping the day window takes the end-day switch away entirely', () => {
|
||||
renderSheet({ dailyTimesActive: false })
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('End the day here')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTOP-015: showing the stop on the map flips the stage over and selects it', () => {
|
||||
const { planner, shell } = renderSheet({}, { rtView: 'list' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show on map' }))
|
||||
expect(shell.toggleRtView).toHaveBeenCalledTimes(1)
|
||||
expect(planner.setSelectedPlaceId).toHaveBeenCalledWith(203)
|
||||
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTOP-016: leaves the stage alone when the map is already the front layer', () => {
|
||||
const { shell } = renderSheet({}, { rtView: 'map' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show on map' }))
|
||||
expect(shell.toggleRtView).not.toHaveBeenCalled()
|
||||
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTOP-017: the disc counts destinations, so the third stop on the card is number two', () => {
|
||||
// Hamburg is 1, the fuel stop between them takes no number, Bremen is 2.
|
||||
renderSheet()
|
||||
const heading = screen.getByRole('heading', { name: 'Bremen Marktplatz' })
|
||||
expect(heading.previousElementSibling).toHaveTextContent('2')
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTOP-018: a service stop is drawn by its kind, not by a position in the chain', () => {
|
||||
renderSheet({}, { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 102 } } })
|
||||
const disc = screen.getByRole('heading', { name: 'Aral Dammtor' }).previousElementSibling
|
||||
// The kind's own colour, shared with the map marker, rather than a numbered disc.
|
||||
expect(disc).toHaveStyle({ background: '#E8590C' })
|
||||
expect(disc?.querySelector('svg')).not.toBeNull()
|
||||
expect(disc?.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTOP-019: a stop the routing round has no clock for drops the clock card entirely', () => {
|
||||
const day = {
|
||||
...DAY_A,
|
||||
stops: [BREMEN],
|
||||
schedule: { entries: [], warnings: [] },
|
||||
driveWarnings: [],
|
||||
} as unknown as RoadtripDay
|
||||
renderSheet({ roadtripRoutes: { days: [day] } }, { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 103 } } })
|
||||
expect(screen.queryByText('Arrive')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Leave')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Calculated from the drive')).not.toBeInTheDocument()
|
||||
// The stay is still filed, so the sheet is not empty.
|
||||
expect(screen.getAllByText('1 h 30 min').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTOP-020: an arrival without a departure shows one clock, not an empty second one', () => {
|
||||
const day = {
|
||||
...DAY_A,
|
||||
stops: [HAMBURG],
|
||||
schedule: { entries: [{ arrival: '09:00', departure: null, anchored: false, dayOffset: 0 }], warnings: [] },
|
||||
driveWarnings: [],
|
||||
} as unknown as RoadtripDay
|
||||
renderSheet({ roadtripRoutes: { days: [day] } }, { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 101 } } })
|
||||
expect(screen.getByText('Arrive')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Leave')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTOP-021: a stop with no stay says so on the tile and files no stay finding', () => {
|
||||
const day = {
|
||||
...DAY_A,
|
||||
stops: [{ ...BREMEN, dwellMinutes: null }],
|
||||
schedule: { entries: [{ arrival: '13:05', departure: '13:05', anchored: false, dayOffset: 0 }], warnings: [] },
|
||||
driveWarnings: [],
|
||||
} as unknown as RoadtripDay
|
||||
renderSheet({ roadtripRoutes: { days: [day] } }, { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 103 } } })
|
||||
expect(screen.getByText('No stay')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Stay')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTOP-022: one map app means no picker, the tap opens it and names it on the tile', () => {
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
// Name only: no coordinates and no address, so only the OSM search link survives.
|
||||
renderSheet({ places: [{ id: 203, name: 'Bremen Marktplatz', lat: null, lng: null, address: null }] })
|
||||
const tile = screen.getByRole('button', { name: /^Navigation/ })
|
||||
expect(tile).toHaveTextContent('OpenStreetMap')
|
||||
fireEvent.click(tile)
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
'https://www.openstreetmap.org/search?query=Bremen%20Marktplatz',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
open.mockRestore()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTOP-023: no reachable map app at all drops the tile instead of leaving a dead one', () => {
|
||||
renderSheet({ places: [{ id: 203, name: '', lat: null, lng: null, address: null }] })
|
||||
expect(screen.queryByRole('button', { name: /Navigation/ })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /^Stay/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTOP-024: the map-app picker closes again on a tap outside it', () => {
|
||||
renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Navigation' }))
|
||||
expect(screen.getByRole('menu')).toBeInTheDocument()
|
||||
fireEvent.mouseDown(document.body)
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTOP-025: a payload without an assignment names no stop, so nothing opens', () => {
|
||||
renderSheet({}, { sheet: { id: 'rtstop', payload: {} } })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTOP-026: a charger gets the live availability panel, a pump does not', () => {
|
||||
const charger = stop({ assignmentId: 106, placeId: 206, name: 'Ionity Bremen', stopType: 'charging' })
|
||||
const day = {
|
||||
...DAY_A,
|
||||
stops: [charger],
|
||||
schedule: { entries: [{ arrival: '12:00', departure: '12:30', anchored: false, dayOffset: 0 }], warnings: [] },
|
||||
driveWarnings: [],
|
||||
} as unknown as RoadtripDay
|
||||
renderSheet({ roadtripRoutes: { days: [day] } }, { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 106 } } })
|
||||
expect(screen.getByTestId('charging-info')).toHaveTextContent('206')
|
||||
})
|
||||
|
||||
it('FE-MOB-RTSTOP-027: no availability panel on a stop that is not a charger', () => {
|
||||
renderSheet({}, { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 102 } } })
|
||||
expect(screen.queryByTestId('charging-info')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,7 @@ import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-SHOST-001 to FE-MOB-SHOST-027
|
||||
// FE-MOB-SHOST-001 to FE-MOB-SHOST-028
|
||||
//
|
||||
// Every child sheet is stubbed: this file is about the host — which sheet is
|
||||
// mounted for which shell.sheet id, and how the host's own callbacks wire the
|
||||
@@ -34,6 +34,10 @@ vi.mock('../../../../src/mobile/screens/trip/sheets/MTransportSheet', () => ({ d
|
||||
vi.mock('../../../../src/mobile/screens/trip/sheets/MBrowseActionsSheet', () => ({ default: selfRouted('stub-bract') }))
|
||||
vi.mock('../../../../src/mobile/screens/trip/sheets/MMehrSheet', () => ({ default: selfRouted('stub-mehr') }))
|
||||
vi.mock('../../../../src/mobile/screens/trip/sheets/MExportSheet', () => ({ default: selfRouted('stub-export') }))
|
||||
vi.mock('../../../../src/mobile/screens/trip/roadtrip/MRtCorridorSheet', () => ({ default: selfRouted('stub-rtsearch') }))
|
||||
vi.mock('../../../../src/mobile/screens/trip/roadtrip/MRtDraftSheet', () => ({
|
||||
default: ({ planner }: { planner: TripPlanner }) => <div data-testid="stub-rtdraft" data-trip={planner.tripId} />,
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/mobile/screens/trip/sheets/MNoteSheet', () => ({
|
||||
default: ({ open, payload, onClose }: { open: boolean; payload?: { dayId?: number }; onClose: () => void }) => (
|
||||
@@ -432,4 +436,17 @@ describe('MTripSheets', () => {
|
||||
renderHost({ deletePlaceId: 101, showPlaceForm: true })
|
||||
expect(screen.queryByTestId('stub-confirm')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-028: the search sheet is mounted before the draft it opens', () => {
|
||||
renderHost({}, { sheet: null })
|
||||
|
||||
const order = [...document.querySelectorAll('[data-testid]')]
|
||||
.map(el => el.getAttribute('data-testid'))
|
||||
.filter(id => id === 'stub-rtsearch' || id === 'stub-rtdraft')
|
||||
|
||||
// Both sit at the same z. Portal order is what decides which paints on top, and
|
||||
// taking a hit onto the trip has to open the draft OVER the search it came from.
|
||||
expect(order).toEqual(['stub-rtsearch', 'stub-rtdraft'])
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, fireEvent, render, screen } from '../../../helpers/render'
|
||||
import { act, fireEvent, render, screen, within } from '../../../helpers/render'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import type { Day, PackingItem, TodoItem } from '../../../../src/types'
|
||||
|
||||
// FE-MOB-SHELL-001 to FE-MOB-SHELL-051
|
||||
// FE-MOB-SHELL-001 to FE-MOB-SHELL-064
|
||||
|
||||
const mocks = vi.hoisted(() => ({ planner: {} as TripPlanner }))
|
||||
|
||||
@@ -32,6 +32,16 @@ function slot(testId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One identity per slot, created once. Built inside the wrapper's render body instead,
|
||||
* every rerender hands the shell four brand-new component types and React remounts all
|
||||
* of them, which would quietly mask the one thing FE-MOB-SHELL-054 is watching for.
|
||||
*/
|
||||
const PlanTimelineSlot = slot('plan-timeline')
|
||||
const MapAreaSlot = slot('map-area')
|
||||
const PlacesBrowserSlot = slot('places-browser')
|
||||
const SheetsSlot = slot('sheets')
|
||||
|
||||
function TabSlot({ shell, tab }: { planner: TripPlanner; shell: MTripShellApi; tab: string }) {
|
||||
shellApi = shell
|
||||
return <div data-testid="tab-panel">{tab}</div>
|
||||
@@ -52,6 +62,33 @@ const DAYS = [
|
||||
{ id: 12, trip_id: 1, day_number: 2, date: '2026-05-03', title: null },
|
||||
] as unknown as Day[]
|
||||
|
||||
/**
|
||||
* The same sections with the road trip addon on, in the order the planner hook builds
|
||||
* them, which is NOT the dock's order. The dock seats by its own priority list, so a
|
||||
* fixture that mirrors the hook is the only one that can tell the two apart.
|
||||
*/
|
||||
const RT_TABS = [
|
||||
{ id: 'plan', label: 'Plan' },
|
||||
{ id: 'transports', label: 'Transport' },
|
||||
{ id: 'buchungen', label: 'Bookings' },
|
||||
{ id: 'roadtrip', label: 'Road trip' },
|
||||
{ id: 'listen', label: 'Lists' },
|
||||
{ id: 'finanzplan', label: 'Budget' },
|
||||
{ id: 'dateien', label: 'Files' },
|
||||
{ id: 'collab', label: 'Collaboration' },
|
||||
]
|
||||
|
||||
/** One routed stage, as the addon reports it. */
|
||||
const stage = (over: Record<string, unknown> = {}) => ({
|
||||
dayId: 11, dayNumber: 1, date: '2026-05-02', title: null, distance: 123000, duration: 5400,
|
||||
stops: [], legs: [], legVias: [], geometry: [], schedule: { entries: [] }, driveWarnings: [],
|
||||
...over,
|
||||
})
|
||||
|
||||
/** The dock's seats, in order, plus the More button at the end. */
|
||||
const dockLabels = () =>
|
||||
within(screen.getByRole('navigation')).getAllByRole('button').map(b => b.getAttribute('aria-label'))
|
||||
|
||||
function todayIso(): string {
|
||||
const now = new Date()
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
@@ -66,11 +103,11 @@ function renderShell(overrides: Partial<TripPlanner> = {}) {
|
||||
} as Partial<TripPlanner>)
|
||||
const Shell = () => (
|
||||
<MTripShell
|
||||
PlanTimeline={slot('plan-timeline')}
|
||||
MapArea={slot('map-area')}
|
||||
PlacesBrowser={slot('places-browser')}
|
||||
PlanTimeline={PlanTimelineSlot}
|
||||
MapArea={MapAreaSlot}
|
||||
PlacesBrowser={PlacesBrowserSlot}
|
||||
TabPanel={TabSlot}
|
||||
Sheets={slot('sheets')}
|
||||
Sheets={SheetsSlot}
|
||||
/>
|
||||
)
|
||||
const view = render(<Shell />)
|
||||
@@ -573,6 +610,168 @@ describe('MTripShell', () => {
|
||||
expect(planner.handleSelectDay).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// The road trip tab. The drive is a second reading of the same trip on the same map,
|
||||
// so it does not get a map of its own: the shell's single instance now lies under
|
||||
// BOTH tabs, and everything floating over it keys off `mapFront` rather than off
|
||||
// `view`, which only ever meant the plan tab.
|
||||
describe('the road trip tab', () => {
|
||||
/** Commit a tab change the way the planner would, and redraw. */
|
||||
function goToTab(planner: TripPlanner, rerenderShell: () => void, tabId: string) {
|
||||
;(planner as { activeTab: string }).activeTab = tabId
|
||||
rerenderShell()
|
||||
}
|
||||
|
||||
const rtSwitch = () => screen.getByRole('button', { name: 'mobileTrip.mapView' })
|
||||
const backToList = () => screen.getByRole('button', { name: 'mobileTrip.listView' })
|
||||
|
||||
it('FE-MOB-SHELL-052: the drive takes the second dock seat and the packing list loses its own', () => {
|
||||
renderShell({ TRIP_TABS: RT_TABS } as Partial<TripPlanner>)
|
||||
// Second seat, not fourth: the dock orders by its own priority list, so the drive
|
||||
// sits beside the plan however late the hook appends it.
|
||||
expect(dockLabels()).toEqual(['Plan', 'Road trip', 'Transport', 'Bookings', 'Budget', 'mobileTrip.more'])
|
||||
// Six sections would leave 3px between the circles, so the cap cuts the last one
|
||||
// and the More sheet picks it up.
|
||||
expect(screen.queryByRole('button', { name: 'Lists' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-053: without the addon the dock is exactly what it was', () => {
|
||||
renderShell()
|
||||
expect(dockLabels()).toEqual(['Plan', 'Transport', 'Bookings', 'Budget', 'Lists', 'mobileTrip.more'])
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-054: the map survives the move between the plan tab and the drive', () => {
|
||||
const { planner, rerenderShell } = renderShell({ TRIP_TABS: RT_TABS } as Partial<TripPlanner>)
|
||||
const map = screen.getByTestId('map-area')
|
||||
|
||||
goToTab(planner, rerenderShell, 'roadtrip')
|
||||
expect(screen.getByTestId('tab-panel')).toHaveTextContent('roadtrip')
|
||||
// Identity, not presence. Written as two JSX positions the map remounts here,
|
||||
// which tears down the WebGL context and reloads every tile, a flash on the
|
||||
// device that a presence check would happily call a pass.
|
||||
expect(screen.getByTestId('map-area')).toBe(map)
|
||||
|
||||
goToTab(planner, rerenderShell, 'plan')
|
||||
expect(screen.getByTestId('map-area')).toBe(map)
|
||||
expect(screen.getByTestId('plan-timeline')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-055: a tab with no map still tears it down', () => {
|
||||
const { planner, rerenderShell } = renderShell({ TRIP_TABS: RT_TABS, activeTab: 'roadtrip' } as Partial<TripPlanner>)
|
||||
expect(screen.getByTestId('map-area')).toBeInTheDocument()
|
||||
|
||||
goToTab(planner, rerenderShell, 'transports')
|
||||
|
||||
expect(screen.queryByTestId('map-area')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('tab-panel')).toHaveTextContent('transports')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-056: the switch in the drive flips its own half, not the plan view', () => {
|
||||
renderShell({ TRIP_TABS: RT_TABS, activeTab: 'roadtrip' } as Partial<TripPlanner>)
|
||||
expect(shellApi.mapFront).toBe(false)
|
||||
|
||||
fireEvent.click(rtSwitch())
|
||||
expect(shellApi.rtView).toBe('map')
|
||||
expect(shellApi.mapFront).toBe(true)
|
||||
expect(shellApi.view).toBe('plan')
|
||||
|
||||
fireEvent.click(backToList())
|
||||
expect(shellApi.rtView).toBe('list')
|
||||
expect(shellApi.mapFront).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-057: the drive switch draws the route and otherwise leaves the camera alone', () => {
|
||||
const { planner } = renderShell({
|
||||
TRIP_TABS: RT_TABS, activeTab: 'roadtrip', selectedDayId: null,
|
||||
} as Partial<TripPlanner>)
|
||||
|
||||
fireEvent.click(rtSwitch())
|
||||
expect(planner.autoShowRoute).toHaveBeenCalledTimes(1)
|
||||
// Deliberately quieter than the plan tab's toggle: the stage is always one day,
|
||||
// both halves show the same one, and the camera belongs to whoever looked last.
|
||||
expect(planner.handleSelectDay).not.toHaveBeenCalled()
|
||||
expect(planner.setExpandedDayIds).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(backToList())
|
||||
// And no day restore on the way out, which is what the plan tab does here.
|
||||
expect(planner.handleSelectDay).not.toHaveBeenCalled()
|
||||
expect(planner.setExpandedDayIds).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-058: the two halves are separate states, so neither tab drags the other into its own', () => {
|
||||
const { planner, rerenderShell } = renderShell({ TRIP_TABS: RT_TABS } as Partial<TripPlanner>)
|
||||
|
||||
fireEvent.click(rtSwitch())
|
||||
expect(shellApi.view).toBe('map')
|
||||
expect(shellApi.rtView).toBe('list')
|
||||
expect(shellApi.mapFront).toBe(true)
|
||||
|
||||
goToTab(planner, rerenderShell, 'roadtrip')
|
||||
// The plan tab is still on its map; the drive opens on its chain regardless.
|
||||
expect(shellApi.view).toBe('map')
|
||||
expect(shellApi.mapFront).toBe(false)
|
||||
expect(screen.getByRole('button', { name: 'mobileTrip.mapView' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-059: the all-days button follows the map into the drive', () => {
|
||||
const { planner } = renderShell({ TRIP_TABS: RT_TABS, activeTab: 'roadtrip' } as Partial<TripPlanner>)
|
||||
expect(screen.queryByRole('button', { name: 'mobileTrip.allDays' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(rtSwitch())
|
||||
vi.mocked(planner.setExpandedDayIds).mockClear()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.allDays' }))
|
||||
|
||||
// The whole drive in its day colours, off the same handler the plan map uses.
|
||||
expect(planner.handleSelectDay).toHaveBeenLastCalledWith(null, false)
|
||||
expect(planner.setExpandedDayIds).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-060: the stage header opens the driving settings', () => {
|
||||
renderShell({ TRIP_TABS: RT_TABS, activeTab: 'roadtrip' } as Partial<TripPlanner>)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'roadtrip.title' }))
|
||||
expect(shellApi.sheet).toEqual({ id: 'rtinfo', payload: undefined })
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-061: the stage header names the day on screen and what it costs', () => {
|
||||
renderShell({
|
||||
TRIP_TABS: RT_TABS,
|
||||
activeTab: 'roadtrip',
|
||||
roadtripRoutes: { days: [stage()], totalDistance: 240000 },
|
||||
} as unknown as Partial<TripPlanner>)
|
||||
// The stage beats the total: 123 km is today's drive, 240 km is the whole trip.
|
||||
expect(screen.getByRole('button', { name: 'Sat 2 · 123 km' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-062: with the day filter off it reads the whole drive', () => {
|
||||
renderShell({
|
||||
TRIP_TABS: RT_TABS,
|
||||
activeTab: 'roadtrip',
|
||||
selectedDayId: null,
|
||||
roadtripRoutes: { days: [stage()], totalDistance: 240000 },
|
||||
} as unknown as Partial<TripPlanner>)
|
||||
expect(screen.getByRole('button', { name: '240 km' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-063: before anything has routed it falls back to the addon name, and the plan tab never shows it', () => {
|
||||
const { planner, rerenderShell } = renderShell({ TRIP_TABS: RT_TABS, activeTab: 'roadtrip' } as Partial<TripPlanner>)
|
||||
// Never an empty figure: the header is the only way into the driving settings.
|
||||
expect(screen.getByRole('button', { name: 'roadtrip.title' })).toBeInTheDocument()
|
||||
|
||||
goToTab(planner, rerenderShell, 'plan')
|
||||
expect(screen.queryByRole('button', { name: 'roadtrip.title' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-064: a stage whose day the rail has lost still reads as a day number', () => {
|
||||
// A remote day:deleted can retire the day while its legs are still on screen.
|
||||
renderShell({
|
||||
TRIP_TABS: RT_TABS,
|
||||
activeTab: 'roadtrip',
|
||||
selectedDayId: 99,
|
||||
roadtripRoutes: { days: [stage({ dayId: 99, dayNumber: 4, distance: 50000 })], totalDistance: 240000 },
|
||||
} as unknown as Partial<TripPlanner>)
|
||||
expect(screen.getByRole('button', { name: 'planner.dayN:4 · 50 km' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildShell fixture', () => {
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { useMRtCorridor } from '../../../../src/mobile/screens/trip/roadtrip/useMRtCorridor'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import type { RoadtripCorridor } from '../../../../src/components/Roadtrip/useRoadtripCorridor'
|
||||
import type { Day } from '../../../../src/types'
|
||||
import type { RoadtripDay, RoadtripRoutes } from '@trek/shared/roadtrip'
|
||||
|
||||
// FE-MOB-RTCOR-001 to FE-MOB-RTCOR-012
|
||||
|
||||
const mocks = vi.hoisted(() => ({ offline: false }))
|
||||
vi.mock('../../../../src/hooks/useNetworkMode', () => ({
|
||||
useNetworkMode: () => ({ offline: mocks.offline, forced: false, setForced: vi.fn() }),
|
||||
}))
|
||||
|
||||
const DAYS = [
|
||||
{ id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null },
|
||||
{ id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: null },
|
||||
] as unknown as Day[]
|
||||
|
||||
/** Four stops with clocks on them, so "which one is next" has an answer. */
|
||||
function stage(over: Partial<RoadtripDay> = {}): RoadtripDay {
|
||||
return {
|
||||
dayId: 2,
|
||||
dayNumber: 2,
|
||||
date: '2026-05-02',
|
||||
stops: [
|
||||
{ assignmentId: 501, ownerDayId: 2, ownerIndex: 0, placeId: 101, name: 'Start', lat: 53.55, lng: 9.99, time: null, dwellMinutes: 0, legMode: null, incomingLegMode: null, stopType: null },
|
||||
{ assignmentId: 502, ownerDayId: 2, ownerIndex: 1, placeId: 102, name: 'Middle', lat: 53.5, lng: 9.8, time: null, dwellMinutes: 0, legMode: null, incomingLegMode: null, stopType: null },
|
||||
{ assignmentId: 503, ownerDayId: 2, ownerIndex: 2, placeId: 103, name: 'End', lat: 53.45, lng: 9.6, time: null, dwellMinutes: 0, legMode: null, incomingLegMode: null, stopType: null },
|
||||
],
|
||||
legs: [],
|
||||
schedule: {
|
||||
entries: [
|
||||
{ arrival: '08:00', departure: '08:00', anchored: true, dayOffset: 0 },
|
||||
{ arrival: '10:00', departure: '10:00', anchored: false, dayOffset: 0 },
|
||||
{ arrival: '12:00', departure: null, anchored: false, dayOffset: 0 },
|
||||
],
|
||||
warnings: [],
|
||||
},
|
||||
legVias: [], geometry: [], distance: 300_000, duration: 14_400,
|
||||
driveWarnings: [], dayWarning: null, spills: [], dryPoints: [],
|
||||
...over,
|
||||
} as unknown as RoadtripDay
|
||||
}
|
||||
|
||||
function routes(over: Partial<RoadtripRoutes> = {}): RoadtripRoutes {
|
||||
return {
|
||||
days: [stage()],
|
||||
quietDays: [], lines: [], lineDays: [], accessLines: [], vias: [], segments: [],
|
||||
totalDistance: 300_000, totalDuration: 14_400, totalStops: 3, loading: false,
|
||||
...over,
|
||||
} as unknown as RoadtripRoutes
|
||||
}
|
||||
|
||||
function corridor(over: Partial<RoadtripCorridor> = {}): RoadtripCorridor {
|
||||
const base = buildPlanner().roadtripCorridor as RoadtripCorridor
|
||||
return {
|
||||
...base,
|
||||
// Two points is what `canSearch` reads as "there is a drive here".
|
||||
search: { ...base.search, spine: [{ lat: 53.55, lng: 9.99 }, { lat: 53.45, lng: 9.6 }] },
|
||||
stopsAlongKm: [0, 40, 95],
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function planner(over: Partial<TripPlanner> = {}): TripPlanner {
|
||||
return buildPlanner({
|
||||
tripId: 7,
|
||||
days: DAYS,
|
||||
selectedDayId: 2,
|
||||
roadtripRoutes: routes(),
|
||||
roadtripCorridor: corridor(),
|
||||
...over,
|
||||
} as Partial<TripPlanner>)
|
||||
}
|
||||
|
||||
function setup(p: TripPlanner = planner(), shell: MTripShellApi = buildShell()) {
|
||||
const { result, rerender } = renderHook(() => useMRtCorridor(p, shell))
|
||||
return { result, rerender, planner: p, shell }
|
||||
}
|
||||
|
||||
/** Freezes the clock on the stage's own date, whatever zone the runner is in. */
|
||||
function freezeAt(hour: number, minute: number): TripPlanner {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2026, 4, 2, hour, minute))
|
||||
const date = new Date().toISOString().slice(0, 10)
|
||||
return planner({
|
||||
days: DAYS.map(d => (d.id === 2 ? { ...d, date } : d)) as unknown as Day[],
|
||||
roadtripRoutes: routes({ days: [stage({ date })] }),
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => { mocks.offline = false })
|
||||
afterEach(() => { vi.useRealTimers() })
|
||||
|
||||
describe('useMRtCorridor', () => {
|
||||
it('FE-MOB-RTCOR-001: searches the stage the chip rail is on', () => {
|
||||
const p = planner()
|
||||
setup(p)
|
||||
|
||||
expect(p.roadtripCorridor.setDayId).toHaveBeenCalledWith('2')
|
||||
})
|
||||
|
||||
it('FE-MOB-RTCOR-002: a trip with nothing routed names no day at all', () => {
|
||||
const p = planner({ roadtripRoutes: routes({ days: [] }) })
|
||||
setup(p)
|
||||
|
||||
// Not '' and not the first day of the trip: there is no drive to search, and
|
||||
// writing a day id anyway would make the desktop picker jump on the next render.
|
||||
expect(p.roadtripCorridor.setDayId).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTCOR-003: asks about the stretch ahead, measured from the stop already passed', () => {
|
||||
// 11:00 on the stage's own date: the 12:00 stop is next, so the 10:00 one is behind
|
||||
// and the road still to drive starts at its 40 km.
|
||||
const p = freezeAt(11, 0)
|
||||
const { result } = setup(p)
|
||||
|
||||
expect(result.current.anchored).toBe(true)
|
||||
act(() => { result.current.run() })
|
||||
expect(p.roadtripCorridor.search.search).toHaveBeenCalledWith({ fromKm: 40, toKm: 90 })
|
||||
})
|
||||
|
||||
it('FE-MOB-RTCOR-004: on any other day it starts at the beginning of the stage', () => {
|
||||
// No clock reading to go on. A guess dressed up as a position would put the search
|
||||
// in the wrong half of the drive, so it asks about the first fifty kilometres.
|
||||
const p = planner()
|
||||
const { result } = setup(p)
|
||||
|
||||
expect(result.current.anchored).toBe(false)
|
||||
act(() => { result.current.run() })
|
||||
expect(p.roadtripCorridor.search.search).toHaveBeenCalledWith({ fromKm: 0, toKm: 50 })
|
||||
})
|
||||
|
||||
it('FE-MOB-RTCOR-005: before the first stop is behind, ahead is the whole stage from zero', () => {
|
||||
const p = freezeAt(7, 30)
|
||||
const { result } = setup(p)
|
||||
|
||||
expect(result.current.anchored).toBe(false)
|
||||
act(() => { result.current.run() })
|
||||
expect(p.roadtripCorridor.search.search).toHaveBeenCalledWith({ fromKm: 0, toKm: 50 })
|
||||
})
|
||||
|
||||
it('FE-MOB-RTCOR-006: a stage search asks about the whole day, with no window at all', () => {
|
||||
const p = freezeAt(11, 0)
|
||||
const { result } = setup(p, buildShell({ rtReach: 'stage' }))
|
||||
|
||||
act(() => { result.current.run() })
|
||||
expect(p.roadtripCorridor.search.search).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTCOR-007: changing the reach throws the answer away', () => {
|
||||
const p = planner()
|
||||
const shell = buildShell()
|
||||
const { result } = setup(p, shell)
|
||||
|
||||
act(() => { result.current.setReach('stage') })
|
||||
|
||||
expect(shell.setRtReach).toHaveBeenCalledWith('stage')
|
||||
// Hits from the fifty kilometres ahead are not an answer to "the whole stage".
|
||||
expect(p.roadtripCorridor.clear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTCOR-008: picking the reach it is already on changes nothing', () => {
|
||||
const p = planner()
|
||||
const shell = buildShell()
|
||||
const { result } = setup(p, shell)
|
||||
|
||||
act(() => { result.current.setReach('ahead') })
|
||||
|
||||
expect(shell.setRtReach).not.toHaveBeenCalled()
|
||||
expect(p.roadtripCorridor.clear).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTCOR-009: offline it cannot search, and pressing it anyway does nothing', () => {
|
||||
mocks.offline = true
|
||||
const p = planner()
|
||||
const { result } = setup(p)
|
||||
|
||||
expect(result.current.offline).toBe(true)
|
||||
expect(result.current.canSearch).toBe(false)
|
||||
act(() => { result.current.run() })
|
||||
expect(p.roadtripCorridor.search.search).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTCOR-010: without a category or a routed line there is nothing to ask', () => {
|
||||
const noCategory = setup(planner({ roadtripCorridor: corridor({ categories: [] }) }))
|
||||
expect(noCategory.result.current.canSearch).toBe(false)
|
||||
|
||||
const base = buildPlanner().roadtripCorridor as RoadtripCorridor
|
||||
const noLine = setup(planner({ roadtripCorridor: corridor({ search: { ...base.search, spine: [] } }) }))
|
||||
expect(noLine.result.current.canSearch).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTCOR-011: what the map draws is what the sheet lists', () => {
|
||||
const visible = [{ osm_id: 'a' }] as unknown as RoadtripCorridor['visible']
|
||||
const { result } = setup(planner({ roadtripCorridor: corridor({ visible }) }))
|
||||
|
||||
// One array, not two filtered views of one list: the pins and the rows have to be
|
||||
// the same set or a row points at a pin that is not there.
|
||||
expect(result.current.hits).toBe(visible)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTCOR-012: "answered" needs a search to have run, not just an empty list', () => {
|
||||
const base = buildPlanner().roadtripCorridor as RoadtripCorridor
|
||||
const idle = setup(planner())
|
||||
expect(idle.result.current.answered).toBe(false)
|
||||
|
||||
const ran = setup(planner({
|
||||
roadtripCorridor: corridor({ search: { ...base.search, progress: { done: 3, total: 3 } } }),
|
||||
}))
|
||||
// Nothing found is an answer. Reading it as "nothing asked yet" would hide the one
|
||||
// sentence that says so.
|
||||
expect(ran.result.current.answered).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user