Files
TREK/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts
T
Maurice a910c2a837 fix: follow-ups from the review of the price, Immich, moving day and import fixes
Saved place price (#2471)
- A price above zero that would be left without a currency is refused
  with a 400, over REST and through update_collection_place alike. A list
  has no base currency, so such an amount was read in each viewer's own
  default currency and went into a trip as the trip's. A price sent alone
  keeps the stored currency, and a zero price or clearing both fields
  still goes through. The check sits in the service because the MCP tool
  spreads the contract's shape, where a refine would never arrive.
- An older row that holds an amount without a currency shows it as a
  bare number instead of in the viewer's own currency.
- The Hungarian hint uses the informal register of its file, which is
  now prettier clean.

Immich certificate switch (#2475)
- The switch trusts one server. Saving a different URL without sending
  it turns it off; saving the same URL keeps it as before. The wiki says
  so.

Moving day (#2476)
- Google Maps and CoMaps are left out only when there is nothing to hand
  over. A day with a single stop opens it as a pin again, on the desktop
  and on the phone, as it did before.
- Desktop: a moving day whose flight or train has no stations shows no
  route tools, since there is nothing left to draw. With stations they
  stay.
- Phone: the drive between two stays on a day without stops or bookings
  shows once at the top instead of again at the bottom.
- The export helpers take the carrier flags as one object instead of two
  booleans in a row.

Import card (#2477)
- The success and error marks use the theme tokens.
- The e2e test names its upload with a made-up booking number.

Tests: COLLECTIONS-SVC-132 and 133, COLLECTIONS-E2E-088,
update_collection_place with a price and no currency, FE-COMP-COLEXTRAS-013
(011 adjusted), IMMICH-TLS-010, IMMICH-103 extended,
FE-PLANNER-DAYPLAN-222 adjusted and 224, FE-MOB-PLTL-051, FE-MOB-PTLM-047,
FE-W4BGT-023 and 024 check the mark colours.
2026-09-23 17:41:26 +02:00

701 lines
36 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-051
// 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-051: a day with a single stop still offers the hand-offs, which open that stop as a pin', async () => {
// No stay in TREK: one sight is the whole export. The #2476 gate only drops a day
// with nothing to hand over, not one that opens a pin to navigate to.
const { result } = await renderTimeline(makePlanner({ assignments: { '2': [A_MUSEUM] } }))
expect(result.current.canExportRoute).toBe(true)
act(() => { result.current.exportGoogleMaps() })
expect(window.open).toHaveBeenCalledWith(
'https://www.google.com/maps/search/?api=1&query=48,16.1', '_blank', 'noopener,noreferrer',
)
const vague = buildAssignment({
id: 15, day_id: 2, order_index: 0, place_id: 105,
place: buildPlace({ id: 105, name: 'Idea', lat: null, lng: null }),
})
const nothing = await renderTimeline(makePlanner({ assignments: { '2': [vague] } }))
expect(nothing.result.current.canExportRoute).toBe(false)
})
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'))
})
})