Files
TREK/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts
T
Maurice 8d78c988c3 fix(dayplan): a moving day with a flight draws no drive from one stay to the next
A day that checks out of one hotel and into another, with nothing else
planned, falls back to a drive from the first hotel to the second
(#1297). A flight, train, ferry or coach booked on that day is the move
itself, but one saved without its stations leaves no waypoint behind, so
the day map, the trip overview and the phone plan all drew the full road
between the two hotels, and the phone showed it above and below the
flight (#2476). With its stations the day was already right.

- The fallback drive between the two hotels is no longer drawn when any
  such booking is on the day, located or not. A day without a booking
  keeps it. No line beats a wrong one.
- The phone plan takes the evening hotel leg from the last drive that
  reaches the hotel, not the first, so an earlier drive to the same spot
  no longer stands in for the one that closes the day.
- Google Maps and CoMaps exports of such a day, which could only ever
  describe that same drive, return nothing, and on the desktop and the
  phone the two buttons are left out whenever fewer than two stops
  remain, instead of doing nothing on a tap.

A hotel stop planned by hand ahead of the flight already sorts behind
it through the ride seating of #2474; a test now pins that for the days
view as well.
2026-09-23 17:41:25 +02:00

683 lines
35 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WeatherResult } from '@trek/shared'
import { assignmentsApi, reservationsApi, weatherApi } from '../../../../src/api/client'
import { useMPlanTimeline } from '../../../../src/mobile/screens/trip/plan/useMPlanTimeline'
import type { TransportEntry } from '../../../../src/mobile/screens/trip/plan/planTimelineModel'
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { usePluginStore, type ActivePlugin } from '../../../../src/store/pluginStore'
import { useTripStore } from '../../../../src/store/tripStore'
import type { Accommodation, Day, RouteSegment } from '../../../../src/types'
import { buildAssignment, buildDayNote, buildPlace, buildReservation } from '../../../helpers/factories'
import { buildPlanner, buildTripActions } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { act, renderHook, waitFor } from '../../../helpers/render'
// FE-MOB-PLTL-001 to FE-MOB-PLTL-050
// The connector calculation is its own hook with real OSRM calls — stubbed here so
// the timeline sees exactly the legs a test wants to match against.
const routeCalc = vi.hoisted(() => ({ segments: [] as unknown[] }))
vi.mock('../../../../src/hooks/useRouteCalculation', () => ({
useRouteCalculation: () => ({ routeSegments: routeCalc.segments }),
}))
const DAYS = [
{ id: 1, trip_id: 1, day_number: 1, date: '2026-05-01', title: null },
{ id: 2, trip_id: 1, day_number: 2, date: '2026-05-02', title: 'Old Town' },
{ id: 3, trip_id: 1, day_number: 3, date: '2026-05-03', title: null },
] as unknown as Day[]
const MUSEUM = buildPlace({ id: 101, name: 'Museum', lat: 48, lng: 16.1 })
const PARK = buildPlace({ id: 102, name: 'Park', lat: 48, lng: 16.3 })
const CAFE = buildPlace({ id: 103, name: 'Cafe', lat: 48, lng: 16.2 })
const A_MUSEUM = buildAssignment({ id: 11, day_id: 2, order_index: 0, place_id: 101, place: MUSEUM })
const A_PARK = buildAssignment({ id: 12, day_id: 2, order_index: 1, place_id: 102, place: PARK })
const A_CAFE = buildAssignment({ id: 13, day_id: 2, order_index: 2, place_id: 103, place: CAFE })
// Timed stops without coordinates — the up-next pick needs times, not a map.
const TIMED_EARLY = buildAssignment({
id: 21, day_id: 2, order_index: 0, place_id: 201,
place: buildPlace({ id: 201, name: 'Breakfast', lat: null, lng: null, place_time: '09:00' }),
})
const TIMED_LATE = buildAssignment({
id: 22, day_id: 2, order_index: 1, place_id: 202,
place: buildPlace({ id: 202, name: 'Museum', lat: null, lng: null, place_time: '11:00' }),
})
const HOTEL = {
id: 71, trip_id: 1, start_day_id: 1, end_day_id: 3,
place_name: 'Hotel Sacher', place_lat: 48, place_lng: 16.05,
} as unknown as Accommodation
const FORECAST = {
temp: 20.4, temp_max: 24, temp_min: 12, main: 'Rain', description: 'light rain', type: 'forecast',
} as WeatherResult
function seg(from: [number, number], to: [number, number]): RouteSegment {
return {
mid: [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2],
from, to, distance: 1200, duration: 600,
walkingText: '15 min', drivingText: '3 min', distanceText: '1.2 km',
}
}
const BASE_SETTINGS = {
time_format: '24h', date_format: 'DD.MM.YYYY', default_currency: 'EUR', distance_unit: 'km',
}
function makePlanner(overrides: Record<string, unknown> = {}): TripPlanner {
return buildPlanner({
tripId: 1,
days: DAYS,
selectedDayId: 2,
assignments: { '2': [A_CAFE, A_MUSEUM, A_PARK] },
places: [MUSEUM, PARK, CAFE],
reservations: [],
tripAccommodations: [],
settings: BASE_SETTINGS,
...overrides,
} as unknown as Partial<TripPlanner>)
}
/** Renders and settles the weather request so no state update lands outside act(). */
async function renderTimeline(planner: TripPlanner) {
const view = renderHook(() => useMPlanTimeline(planner))
await act(async () => { await Promise.resolve(); await Promise.resolve() })
return { ...view, planner }
}
describe('useMPlanTimeline', () => {
beforeEach(() => {
resetAllStores()
usePluginStore.setState({ plugins: [] })
routeCalc.segments = []
vi.spyOn(weatherApi, 'get').mockResolvedValue(FORECAST)
vi.spyOn(assignmentsApi, 'updateTransport').mockResolvedValue({})
vi.spyOn(reservationsApi, 'updatePositions').mockResolvedValue({})
vi.spyOn(window, 'open').mockImplementation(() => null)
})
// The plugin store is reset in beforeEach, not here: a setState while the hook
// is still mounted (cleanup runs after this) would re-render outside act().
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
it('FE-MOB-PLTL-001: resolves the selected day and sorts its stops by order index', async () => {
const { result } = await renderTimeline(makePlanner())
expect(result.current.day?.id).toBe(2)
expect(result.current.rows.map(r => r.key)).toEqual(['pl-11', 'pl-12', 'pl-13'])
expect(result.current.timeFormat).toBe('24h')
expect(result.current.language).toBe('en')
})
it('FE-MOB-PLTL-002: folds day notes from the store into the timeline', async () => {
seedStore(useTripStore, { dayNotes: { '2': [buildDayNote({ id: 41, day_id: 2, sort_order: 0.5 })] } })
const { result } = await renderTimeline(makePlanner({ assignments: { '2': [A_MUSEUM, A_PARK] } }))
expect(result.current.rows.map(r => r.key)).toEqual(['pl-11', 'note-41', 'pl-12'])
})
it('FE-MOB-PLTL-003: slots the calculated legs in as connectors', async () => {
routeCalc.segments = [seg([48, 16.1], [48, 16.3]), seg([48, 16.3], [48, 16.2])]
const { result } = await renderTimeline(makePlanner())
expect(result.current.rows.map(r => r.kind)).toEqual(['place', 'conn', 'place', 'conn', 'place'])
})
it('FE-MOB-PLTL-004: exposes the hotel bookend legs and header chips', async () => {
const out = seg([48, 16.05], [48, 16.1])
const back = seg([48, 16.2], [48, 16.05])
routeCalc.segments = [out, back]
const { result } = await renderTimeline(makePlanner({ tripAccommodations: [HOTEL] }))
expect(result.current.hotelLegs.top?.seg).toBe(out)
expect(result.current.hotelLegs.bottom?.seg).toBe(back)
expect(result.current.hotelChips).toEqual([
{ key: 'stay-71', variant: 'stay', name: 'Hotel Sacher', time: null, accId: 71, placeId: null },
])
})
it('FE-MOB-PLTL-005: derives nothing while no day is selected', async () => {
const { result } = await renderTimeline(makePlanner({ selectedDayId: null }))
expect(result.current.day).toBeUndefined()
expect(result.current.rows).toEqual([])
expect(result.current.merged).toEqual([])
expect(result.current.hotelChips).toEqual([])
expect(result.current.hotelLegs).toEqual({ top: null, bottom: null })
expect(result.current.upNext).toBeNull()
expect(weatherApi.get).not.toHaveBeenCalled()
})
it('FE-MOB-PLTL-006: keeps every day-scoped action inert without a selected day', async () => {
const planner = makePlanner({ selectedDayId: null })
const { result } = await renderTimeline(planner)
act(() => {
result.current.moveRow({ type: 'place', sortKey: 0, data: A_MUSEUM }, 'down')
result.current.removeAssignment(A_MUSEUM)
result.current.renameDay('Nope')
result.current.exportGoogleMaps()
result.current.setLegMode(11, 'walking')
result.current.addTransport()
})
await act(async () => { await result.current.optimize() })
expect(planner.handleRemoveAssignment).not.toHaveBeenCalled()
expect(planner.handleUpdateDayTitle).not.toHaveBeenCalled()
expect(window.open).not.toHaveBeenCalled()
expect(assignmentsApi.updateTransport).not.toHaveBeenCalled()
expect(planner.tripActions.reorderAssignments).not.toHaveBeenCalled()
// The transport sheet still opens — it just has no day to attach to.
expect(planner.setTransportModalDayId).toHaveBeenCalledWith(null)
})
it('FE-MOB-PLTL-007: requests the forecast for the first located stop', async () => {
const { result } = await renderTimeline(makePlanner())
await waitFor(() => expect(result.current.weather).not.toBeNull())
expect(weatherApi.get).toHaveBeenCalledWith(48, 16.1, '2026-05-02', 'en')
expect(result.current.weatherTemp).toBe(20)
// #2167 — the anchor's name rides along so the chip can say where this is for.
expect(result.current.weatherPlaceName).toBe('Museum')
})
it('FE-MOB-PLTL-008: converts the forecast to Fahrenheit on request', async () => {
const planner = makePlanner({ settings: { ...BASE_SETTINGS, temperature_unit: 'fahrenheit' } })
const { result } = await renderTimeline(planner)
await waitFor(() => expect(result.current.weatherTemp).toBe(69))
})
it('FE-MOB-PLTL-009: drops a forecast the service flagged as an error', async () => {
vi.mocked(weatherApi.get).mockResolvedValue({ ...FORECAST, error: 'no_forecast' })
const { result } = await renderTimeline(makePlanner())
expect(result.current.weather).toBeNull()
expect(result.current.weatherTemp).toBeNull()
})
it('FE-MOB-PLTL-010: survives a rejected forecast request', async () => {
vi.mocked(weatherApi.get).mockRejectedValue(new Error('offline'))
const { result } = await renderTimeline(makePlanner())
expect(result.current.weather).toBeNull()
})
it('FE-MOB-PLTL-011: anchors the forecast to the day hotel when no stop has coordinates', async () => {
const vague = buildAssignment({
id: 14, day_id: 2, order_index: 0, place_id: 104,
place: buildPlace({ id: 104, name: 'Idea', lat: null, lng: null }),
})
const { result } = await renderTimeline(makePlanner({ assignments: { '2': [vague] }, tripAccommodations: [HOTEL] }))
expect(weatherApi.get).toHaveBeenCalledWith(48, 16.05, '2026-05-02', 'en')
expect(result.current.weatherPlaceName).toBe('Hotel Sacher')
})
it('FE-MOB-PLTL-012: skips the forecast without any coordinates at all', async () => {
const vague = buildAssignment({
id: 14, day_id: 2, order_index: 0, place_id: 104,
place: buildPlace({ id: 104, name: 'Idea', lat: null, lng: null }),
})
const { result } = await renderTimeline(makePlanner({ assignments: { '2': [vague] } }))
expect(weatherApi.get).not.toHaveBeenCalled()
expect(result.current.weatherPlaceName).toBeNull()
})
it('FE-MOB-PLTL-045: drops a forecast that settles after the timeline is gone', async () => {
let resolveWeather: (w: WeatherResult) => void = () => {}
vi.mocked(weatherApi.get).mockReturnValueOnce(new Promise(res => { resolveWeather = res }))
const first = renderHook(() => useMPlanTimeline(makePlanner()))
first.unmount()
await act(async () => { resolveWeather(FORECAST); await Promise.resolve() })
expect(first.result.current.weather).toBeNull()
let failWeather: (e: Error) => void = () => {}
vi.mocked(weatherApi.get).mockReturnValueOnce(new Promise((_res, rej) => { failWeather = rej }))
const second = renderHook(() => useMPlanTimeline(makePlanner()))
second.unmount()
await act(async () => { failWeather(new Error('offline')); await Promise.resolve() })
expect(second.result.current.weather).toBeNull()
})
it('FE-MOB-PLTL-013: toggles an expanded transit row on and off', async () => {
const { result } = await renderTimeline(makePlanner())
act(() => { result.current.toggleTransit('tr-61') })
expect(result.current.openTransitKeys.has('tr-61')).toBe(true)
act(() => { result.current.toggleTransit('tr-62') })
expect([...result.current.openTransitKeys].sort()).toEqual(['tr-61', 'tr-62'])
act(() => { result.current.toggleTransit('tr-61') })
expect(result.current.openTransitKeys.has('tr-61')).toBe(false)
})
it('FE-MOB-PLTL-014: counts down on today and shows nothing for a day gone by', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 2, 10, 15))
const today = makePlanner({ assignments: { '2': [TIMED_EARLY, TIMED_LATE] } })
const { result } = renderHook(() => useMPlanTimeline(today))
expect(result.current.upNext).toEqual({ assignment: TIMED_LATE, minutesUntil: 45 })
const past = makePlanner({
assignments: { '2': [TIMED_EARLY, TIMED_LATE] },
days: DAYS.map(d => (d.id === 2 ? { ...d, date: '2026-04-30' } : d)),
})
const other = renderHook(() => useMPlanTimeline(past))
expect(other.result.current.upNext).toBeNull()
})
it('FE-MOB-PLTL-015: re-evaluates the countdown on the half-minute tick', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 2, 10, 0))
const planner = makePlanner({ assignments: { '2': [TIMED_EARLY, TIMED_LATE] } })
const { result } = renderHook(() => useMPlanTimeline(planner))
expect(result.current.upNext).toEqual({ assignment: TIMED_LATE, minutesUntil: 60 })
vi.setSystemTime(new Date(2026, 4, 2, 11, 30))
act(() => { vi.advanceTimersByTime(30_000) })
expect(result.current.upNext).toBeNull()
})
it('FE-MOB-PLTL-016: reorders the day when a stop is moved down', async () => {
const planner = makePlanner()
const { result } = await renderTimeline(planner)
await act(async () => { result.current.moveRow(result.current.merged[0], 'down') })
await waitFor(() => expect(planner.tripActions.reorderAssignments).toHaveBeenCalledWith(1, 2, [12, 11, 13]))
expect(planner.pushUndo).toHaveBeenCalledWith('undo.reorder', expect.any(Function))
expect(planner.updateRouteForDay).toHaveBeenCalledWith(2)
})
it('FE-MOB-PLTL-017: restores the previous order through the undo entry', async () => {
const planner = makePlanner()
const { result } = await renderTimeline(planner)
await act(async () => { result.current.moveRow(result.current.merged[2], 'up') })
await waitFor(() => expect(planner.pushUndo).toHaveBeenCalled())
const undo = vi.mocked(planner.pushUndo).mock.calls[0][1] as () => Promise<void>
await undo()
expect(planner.tripActions.reorderAssignments).toHaveBeenLastCalledWith(1, 2, [11, 12, 13])
})
it('FE-MOB-PLTL-018: ignores a move past either end of the timeline', async () => {
const planner = makePlanner()
const { result } = await renderTimeline(planner)
await act(async () => {
result.current.moveRow(result.current.merged[0], 'up')
result.current.moveRow(result.current.merged[2], 'down')
result.current.moveRow({ type: 'place', sortKey: 9, data: A_MUSEUM }, 'down')
})
expect(planner.tripActions.reorderAssignments).not.toHaveBeenCalled()
})
it('FE-MOB-PLTL-019: refuses to move a timed stop past another timed stop', async () => {
const nine = buildAssignment({
id: 21, day_id: 2, order_index: 0, place_id: 201,
place: buildPlace({ id: 201, name: 'Breakfast', lat: 48, lng: 16.1, place_time: '09:00' }),
})
const eleven = buildAssignment({
id: 22, day_id: 2, order_index: 1, place_id: 202,
place: buildPlace({ id: 202, name: 'Museum', lat: 48, lng: 16.2, place_time: '11:00' }),
})
const planner = makePlanner({ assignments: { '2': [nine, eleven] } })
const { result } = await renderTimeline(planner)
await act(async () => { result.current.moveRow(result.current.merged[0], 'down') })
expect(planner.toast.info).toHaveBeenCalledWith('dayplan.cannotBreakChronology')
expect(planner.tripActions.reorderAssignments).not.toHaveBeenCalled()
})
it('FE-MOB-PLTL-020: lets an untimed stop move between timed ones', async () => {
const nine = buildAssignment({
id: 21, day_id: 2, order_index: 0, place_id: 201,
place: buildPlace({ id: 201, name: 'Breakfast', lat: 48, lng: 16.1, place_time: '09:00' }),
})
const free = buildAssignment({
id: 22, day_id: 2, order_index: 1, place_id: 202,
place: buildPlace({ id: 202, name: 'Park', lat: 48, lng: 16.2 }),
})
const eleven = buildAssignment({
id: 23, day_id: 2, order_index: 2, place_id: 203,
place: buildPlace({ id: 203, name: 'Museum', lat: 48, lng: 16.3, place_time: '11:00' }),
})
const planner = makePlanner({ assignments: { '2': [nine, free, eleven] } })
const { result } = await renderTimeline(planner)
await act(async () => { result.current.moveRow(result.current.merged[1], 'up') })
await waitFor(() => expect(planner.tripActions.reorderAssignments).toHaveBeenCalledWith(1, 2, [22, 21, 23]))
expect(planner.toast.info).not.toHaveBeenCalled()
})
it('FE-MOB-PLTL-021: persists note and transport positions alongside the reorder', async () => {
const bus = buildReservation({
id: 51, type: 'bus', title: 'Bus 13A', day_id: 2, day_positions: { 2: 1.5 },
})
seedStore(useTripStore, {
dayNotes: { '2': [buildDayNote({ id: 41, day_id: 2, sort_order: 0.5 })] },
reservations: [bus],
})
const planner = makePlanner({ assignments: { '2': [A_MUSEUM, A_PARK] }, reservations: [bus] })
const { result } = await renderTimeline(planner)
expect(result.current.merged.map(m => m.type)).toEqual(['place', 'note', 'place', 'transport'])
await act(async () => { result.current.moveRow(result.current.merged[3], 'up') })
await waitFor(() => expect(reservationsApi.updatePositions).toHaveBeenCalledWith(
1, [{ id: 51, day_plan_position: 2 / 3 }], 2,
))
expect(planner.tripActions.reorderAssignments).toHaveBeenCalledWith(1, 2, [11, 12])
expect(planner.tripActions.updateDayNote).toHaveBeenCalledWith(1, 2, 41, { sort_order: 1 / 3 })
// The optimistic store write lands before the round trip so the merge stays stable.
expect(useTripStore.getState().reservations[0]).toMatchObject({
id: 51, day_plan_position: 2 / 3, day_positions: { 2: 2 / 3 },
})
})
it('FE-MOB-PLTL-022: writes per-leg positions of a multi-leg flight back as an object', async () => {
const flight = buildReservation({
id: 60, type: 'flight', title: 'FRA → CTS', day_id: 2,
metadata: JSON.stringify({
// The third leg departs on day 3, so it never reaches this day's timeline.
legs: [{ from: 'FRA', to: 'IST' }, { from: 'IST', to: 'NRT' }, { dep_day_id: 3, from: 'NRT', to: 'CTS' }],
}),
})
const dinner = buildReservation({ id: 70, type: 'restaurant', title: 'Dinner', day_id: 3 })
seedStore(useTripStore, { reservations: [flight, dinner] })
const planner = makePlanner({ assignments: {}, reservations: [flight] })
const { result } = await renderTimeline(planner)
expect(result.current.rows.map(r => r.key)).toEqual(['tr-60-leg0', 'tr-60-leg1'])
await act(async () => { result.current.moveRow(result.current.merged[1], 'up') })
await waitFor(() => expect(planner.tripActions.updateReservation).toHaveBeenCalled())
const [tripId, resId, payload] = vi.mocked(planner.tripActions.updateReservation).mock.calls[0]
expect([tripId, resId]).toEqual([1, 60])
const meta = (payload as unknown as { metadata: { legs: Array<{ day_positions?: Record<string, number> }> } }).metadata
// Positions are base + (idx + 1) / (group + 1) with base -1: the whole group sits before slot 0.
expect(meta.legs[0].day_positions).toEqual({ 2: -1 + 2 / 3 })
expect(meta.legs[1].day_positions).toEqual({ 2: -1 + 1 / 3 })
expect(meta.legs[2].day_positions).toBeUndefined()
// Metadata must land in the store as an object, and only this booking is touched.
expect(typeof useTripStore.getState().reservations[0].metadata).toBe('object')
expect(useTripStore.getState().reservations[1]).toBe(dinner)
// No assignments on the day — nothing to undo, and no reorder call.
expect(planner.pushUndo).not.toHaveBeenCalled()
expect(planner.tripActions.reorderAssignments).not.toHaveBeenCalled()
expect(planner.updateRouteForDay).toHaveBeenCalledWith(2)
// A second move now reads the object metadata back out of the store.
await act(async () => { result.current.moveRow(result.current.merged[1], 'up') })
await waitFor(() => expect(planner.tripActions.updateReservation).toHaveBeenCalledTimes(2))
const second = vi.mocked(planner.tripActions.updateReservation).mock.calls[1][2] as unknown as
{ metadata: { legs: Array<{ day_positions?: Record<string, number> }> } }
expect(second.metadata.legs[0].day_positions).toEqual({ 2: -1 + 2 / 3 })
})
it('FE-MOB-PLTL-023: skips leg positions when the booking or its metadata is unusable', async () => {
const flight = buildReservation({
id: 60, type: 'flight', title: 'FRA → NRT', day_id: 2,
metadata: JSON.stringify({ legs: [{ from: 'FRA', to: 'IST' }, { from: 'IST', to: 'NRT' }] }),
})
const planner = makePlanner({ assignments: {}, reservations: [flight] })
const { result } = await renderTimeline(planner)
// Booking missing from the store entirely.
await act(async () => { result.current.moveRow(result.current.merged[1], 'up') })
expect(planner.tripActions.updateReservation).not.toHaveBeenCalled()
// Present, but with metadata that no longer parses into legs.
seedStore(useTripStore, { reservations: [{ ...flight, metadata: '{not json' }] })
await act(async () => { result.current.moveRow(result.current.merged[1], 'up') })
expect(planner.tripActions.updateReservation).not.toHaveBeenCalled()
expect(planner.updateRouteForDay).toHaveBeenCalledWith(2)
})
it('FE-MOB-PLTL-044: slots a timed transport that carries no saved position', async () => {
const bus = buildReservation({
id: 51, type: 'bus', title: 'Bus 13A', day_id: 2, reservation_time: '2026-05-02T08:00',
})
const dinner = buildReservation({ id: 52, type: 'restaurant', title: 'Dinner', day_id: 3 })
seedStore(useTripStore, { reservations: [bus, dinner] })
const planner = makePlanner({ assignments: { '2': [A_MUSEUM, A_PARK] }, reservations: [bus, dinner] })
const { result } = await renderTimeline(planner)
expect(result.current.merged.map(m => m.type)).toEqual(['place', 'place', 'transport'])
await act(async () => { result.current.moveRow(result.current.merged[2], 'up') })
await waitFor(() => expect(reservationsApi.updatePositions).toHaveBeenCalledWith(
1, [{ id: 51, day_plan_position: 0.5 }], 2,
))
// Only the moved booking gains a per-day position; the other day's booking is untouched.
expect(useTripStore.getState().reservations.map(r => r.day_positions)).toEqual([{ 2: 0.5 }, undefined])
})
it('FE-MOB-PLTL-024: surfaces a failed reorder as an error toast and refetches the day', async () => {
const actions = buildTripActions()
actions.reorderAssignments.mockRejectedValue(new Error('offline'))
const planner = makePlanner({ tripActions: actions })
const { result } = await renderTimeline(planner)
await act(async () => { result.current.moveRow(result.current.merged[0], 'down') })
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('offline'))
expect(planner.updateRouteForDay).not.toHaveBeenCalled()
// The optimistic positions are dropped by refetching, not undone one by one.
expect(actions.refreshDays).toHaveBeenCalledWith(1)
expect(actions.loadReservations).toHaveBeenCalledWith(1)
})
it('FE-MOB-PLTL-025: falls back to the generic message for a non-Error failure', async () => {
const actions = buildTripActions()
actions.reorderAssignments.mockRejectedValue('nope')
const planner = makePlanner({ tripActions: actions })
const { result } = await renderTimeline(planner)
await act(async () => { result.current.moveRow(result.current.merged[0], 'down') })
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('trip.toast.reorderError'))
})
it('FE-MOB-PLTL-026: removes and edits a stop through the planner', async () => {
const planner = makePlanner()
const { result } = await renderTimeline(planner)
act(() => {
result.current.removeAssignment(A_PARK)
result.current.editAssignment(A_PARK)
})
expect(planner.handleRemoveAssignment).toHaveBeenCalledWith(2, 12)
expect(planner.openPlaceEditor).toHaveBeenCalledWith(PARK, 12)
})
it('FE-MOB-PLTL-027: does not open the editor for a stop missing from the pool', async () => {
const planner = makePlanner({ places: [] })
const { result } = await renderTimeline(planner)
act(() => { result.current.editAssignment(A_PARK) })
expect(planner.openPlaceEditor).not.toHaveBeenCalled()
expect(result.current.fullPlaceOf(A_PARK)).toBeUndefined()
})
it('FE-MOB-PLTL-028: resolves the full pool entry behind a timeline stop', async () => {
const { result } = await renderTimeline(makePlanner())
expect(result.current.fullPlaceOf(A_CAFE)).toBe(CAFE)
})
it('FE-MOB-PLTL-029: opens a blank place form from the add bar', async () => {
const planner = makePlanner()
const { result } = await renderTimeline(planner)
act(() => { result.current.addPlace() })
expect(planner.setEditingPlace).toHaveBeenCalledWith(null)
expect(planner.setEditingAssignmentId).toHaveBeenCalledWith(null)
expect(planner.setPrefillCoords).toHaveBeenCalledWith(null)
expect(planner.setShowPlaceForm).toHaveBeenCalledWith(true)
})
it('FE-MOB-PLTL-030: opens blank booking and transport forms from the add bar', async () => {
const planner = makePlanner()
const { result } = await renderTimeline(planner)
act(() => {
result.current.addBooking()
result.current.addTransport()
})
expect(planner.setEditingReservation).toHaveBeenCalledWith(null)
expect(planner.setBookingForAssignmentId).toHaveBeenCalledWith(null)
expect(planner.setShowReservationModal).toHaveBeenCalledWith(true)
expect(planner.setEditingTransport).toHaveBeenCalledWith(null)
expect(planner.setTransitPrefill).toHaveBeenCalledWith(null)
expect(planner.setTransportModalAutomated).toHaveBeenCalledWith(false)
expect(planner.setTransportModalDayId).toHaveBeenCalledWith(2)
expect(planner.setShowTransportModal).toHaveBeenCalledWith(true)
})
it('FE-MOB-PLTL-031: edits a transport row through the transport modal and anything else through the booking modal', async () => {
const bus = buildReservation({ id: 51, type: 'bus', title: 'Bus 13A', day_id: 2 })
const dinner = buildReservation({ id: 52, type: 'restaurant', title: 'Dinner', day_id: 2 })
const planner = makePlanner({ reservations: [bus, dinner] })
const { result } = await renderTimeline(planner)
// The row carries a leg-expanded copy; the full reservation is looked up by id.
act(() => { result.current.editTransport({ ...bus, __leg: { index: 0, total: 2, from: null, to: null } } as TransportEntry) })
expect(planner.setEditingTransport).toHaveBeenCalledWith(bus)
expect(planner.setShowTransportModal).toHaveBeenCalledWith(true)
act(() => { result.current.editTransport(dinner as TransportEntry) })
expect(planner.setEditingReservation).toHaveBeenCalledWith(dinner)
expect(planner.setShowReservationModal).toHaveBeenCalledWith(true)
// A row whose booking is no longer in the pool edits the row itself.
const orphan = buildReservation({ id: 99, type: 'taxi', title: 'Taxi', day_id: 2 }) as TransportEntry
act(() => { result.current.editTransport(orphan) })
expect(planner.setEditingTransport).toHaveBeenLastCalledWith(orphan)
})
it('FE-MOB-PLTL-032: opens the journey view for a transit row, falling back to the passed row', async () => {
const transit = buildReservation({ id: 61, type: 'transit', title: 'U2', day_id: 2 })
const planner = makePlanner({ reservations: [transit] })
const { result } = await renderTimeline(planner)
act(() => { result.current.openTransitJourney({ ...transit, title: 'stale' } as TransportEntry) })
expect(planner.setTransitJourney).toHaveBeenCalledWith(transit)
const orphan = buildReservation({ id: 99, type: 'transit', title: 'Gone', day_id: 2 }) as TransportEntry
act(() => { result.current.openTransitJourney(orphan) })
expect(planner.setTransitJourney).toHaveBeenLastCalledWith(orphan)
})
it('FE-MOB-PLTL-033: optimizes the day order and offers an undo', async () => {
const planner = makePlanner()
const { result } = await renderTimeline(planner)
await act(async () => { await result.current.optimize() })
expect(planner.tripActions.reorderAssignments).toHaveBeenCalledWith(1, 2, [11, 13, 12])
expect(planner.pushUndo).toHaveBeenCalledWith('undo.optimize', expect.any(Function))
expect(planner.updateRouteForDay).toHaveBeenCalledWith(2)
expect(planner.toast.success).toHaveBeenCalledWith('dayplan.toast.routeOptimized')
const undo = vi.mocked(planner.pushUndo).mock.calls[0][1] as () => Promise<void>
await undo()
expect(planner.tripActions.reorderAssignments).toHaveBeenLastCalledWith(1, 2, [11, 12, 13])
})
it('FE-MOB-PLTL-034: reports when the accommodation anchored the optimized route', async () => {
const planner = makePlanner({ tripAccommodations: [HOTEL] })
const { result } = await renderTimeline(planner)
await act(async () => { await result.current.optimize() })
expect(planner.toast.success).toHaveBeenCalledWith('dayplan.toast.routeOptimizedFromHotel')
})
it('FE-MOB-PLTL-035: refuses to optimize fewer than two movable stops', async () => {
const planner = makePlanner({ assignments: { '2': [A_MUSEUM] } })
const { result } = await renderTimeline(planner)
await act(async () => { await result.current.optimize() })
expect(planner.toast.info).toHaveBeenCalledWith('dayplan.toast.needTwoPlaces')
expect(planner.tripActions.reorderAssignments).not.toHaveBeenCalled()
})
it('FE-MOB-PLTL-036: reports a failed optimize as an error toast', async () => {
const actions = buildTripActions()
actions.reorderAssignments.mockRejectedValueOnce(new Error('server down'))
const planner = makePlanner({ tripActions: actions })
const { result } = await renderTimeline(planner)
await act(async () => { await result.current.optimize() })
expect(planner.toast.error).toHaveBeenCalledWith('server down')
expect(planner.toast.success).not.toHaveBeenCalled()
actions.reorderAssignments.mockRejectedValue('nope')
await act(async () => { await result.current.optimize() })
expect(planner.toast.error).toHaveBeenLastCalledWith('trip.toast.reorderError')
})
it('FE-MOB-PLTL-037: exports the day as a Google Maps route', async () => {
const { result } = await renderTimeline(makePlanner())
act(() => { result.current.exportGoogleMaps() })
expect(window.open).toHaveBeenCalledWith(
'https://www.google.com/maps/dir/48,16.1/48,16.3/48,16.2', '_blank', 'noopener,noreferrer',
)
})
it('FE-MOB-PLTL-038: opens nothing when the day has no located stops', async () => {
const vague = buildAssignment({
id: 14, day_id: 2, order_index: 0, place_id: 104,
place: buildPlace({ id: 104, name: 'Idea', lat: null, lng: null }),
})
const { result } = await renderTimeline(makePlanner({ assignments: { '2': [vague] } }))
act(() => { result.current.exportGoogleMaps() })
expect(window.open).not.toHaveBeenCalled()
})
it('FE-MOB-PLTL-050: a moving day that only holds its flight has no route to export (#2476)', async () => {
// Day 2 checks out of one hotel and into another, the flight in between saved
// without its airports. The exports used to open a drive between the two hotels.
const stays = [
{ ...HOTEL, id: 72, start_day_id: 1, end_day_id: 2, place_name: 'Hotel A' },
{ ...HOTEL, id: 73, start_day_id: 2, end_day_id: 3, place_name: 'Hotel B', place_lat: 53.55, place_lng: 9.99 },
] as Accommodation[]
const flight = buildReservation({ id: 77, type: 'flight', title: 'LH 2078', day_id: 2, end_day_id: 2, reservation_time: '2026-05-02T15:15' })
const { result } = await renderTimeline(makePlanner({ assignments: {}, reservations: [flight], tripAccommodations: stays }))
expect(result.current.canExportRoute).toBe(false)
act(() => { result.current.exportGoogleMaps() })
act(() => { result.current.exportCoMaps() })
expect(window.open).not.toHaveBeenCalled()
// The same day without the booking is a drive from one hotel to the other.
const byRoad = await renderTimeline(makePlanner({ assignments: {}, reservations: [], tripAccommodations: stays }))
expect(byRoad.result.current.canExportRoute).toBe(true)
act(() => { byRoad.result.current.exportGoogleMaps() })
expect(window.open).toHaveBeenCalledWith(
'https://www.google.com/maps/dir/48,16.05/53.55,9.99', '_blank', 'noopener,noreferrer',
)
})
it('FE-MOB-PLTL-039: renames the day with a trimmed title', async () => {
const planner = makePlanner()
const { result } = await renderTimeline(planner)
act(() => { result.current.renameDay(' Old Town ') })
expect(planner.handleUpdateDayTitle).toHaveBeenCalledWith(2, 'Old Town')
})
// The two built-in modes were the only labels on this menu written in English
// instead of looked up, so a German reader got "Driving"/"Walking" between
// translated rows — and next to plugin profiles carrying their own label.
it('FE-MOB-PLTL-040: offers the built-in travel modes plus every plugin profile', async () => {
usePluginStore.setState({
plugins: [
{ id: 'ev', name: 'EV', type: 'integration', icon: null, routeProfiles: [{ id: 'eco', label: 'Eco' }, { id: 'fast', label: 'Fast' }] },
{ id: 'plain', name: 'Plain', type: 'integration', icon: null },
] as ActivePlugin[],
})
const { result } = await renderTimeline(makePlanner())
expect(result.current.routeModeOptions).toEqual([
{ key: 'driving', label: 'mobileTrip.profileDriving' },
{ key: 'walking', label: 'mobileTrip.profileWalking' },
{ key: 'plugin:ev/eco', label: 'Eco' },
{ key: 'plugin:ev/fast', label: 'Fast' },
])
})
it('FE-MOB-PLTL-041: sets a leg mode optimistically and persists it', async () => {
seedStore(useTripStore, { assignments: { '2': [A_MUSEUM, A_PARK] } })
const { result } = await renderTimeline(makePlanner())
await act(async () => { result.current.setLegMode(11, 'walking') })
expect(useTripStore.getState().assignments['2'].map(a => a.leg_transport_mode)).toEqual(['walking', undefined])
expect(assignmentsApi.updateTransport).toHaveBeenCalledWith(1, 11, 'walking')
})
it('FE-MOB-PLTL-042: reverts to the server state when the leg mode fails to save', async () => {
seedStore(useTripStore, { assignments: { '2': [A_MUSEUM] } })
vi.mocked(assignmentsApi.updateTransport).mockRejectedValue(new Error('conflict'))
const planner = makePlanner()
const { result } = await renderTimeline(planner)
await act(async () => { result.current.setLegMode(11, null) })
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('conflict'))
expect(planner.tripActions.refreshDays).toHaveBeenCalledWith(1)
})
// The store may hold no assignments for the day yet (nothing loaded) — the call still goes out.
it('FE-MOB-PLTL-043: uses the generic message for a non-Error leg-mode failure', async () => {
vi.mocked(assignmentsApi.updateTransport).mockRejectedValue('boom')
const planner = makePlanner()
const { result } = await renderTimeline(planner)
await act(async () => { result.current.setLegMode(11, 'driving') })
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.unknownError'))
})
})