mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
9f0caf8539
"Public transit" on the connector between two city walks in the day plan, on the desktop and on the phone, now opens the existing search from where the first walk really ends to where the next one starts, at the coordinates the day's route draws, named after the first walk's last stop and the next walk's first stop. A loop ends at its first stop again. A stop without a name takes the name of the trip place it stands for, and a walk whose stops cannot be read on this device names that end "End of" or "Start of" the walk. The search opens on the walks' day and departs when the first walk ends: its start that day, the time on the day's entry before the place's own, plus its planned total, a total entered by hand before walking and stays. The assignment's own end time is not used. Above the search a note says when the first walk ends, "at the earliest" where a stay or the walking time is not known yet, and when the next walk starts. Without a start or a total no time is made up: the time field stays empty and Search waits until one is chosen. A walk that only ends after midnight says so, and the time field stays empty as well. On any other day, or for any other leg, the search opens as before, with 09:00 as its default. A connection that arrives after the next walk starts says so in its card, before its legs are opened and before it is taken. Nothing is saved until "Add to day", which keeps the connection as its own journey in the plan as before; both walks stay two walks on foot and nothing is moved. A journey saved before a walk that now arrives after the walk starts says so on its row in the plan, on the desktop and the phone, while the plan is looked at. Nothing is rewritten. The desktop no longer draws a connector of nothing between a stop and a booking at the same spot, as the phone already did, so a journey saved at the walks' ends adds no walk on either side. A leg leaving a loop tour no longer departs at the tour's start time. The leg is told by the walk it leaves, so two walks of a day that end at one spot, such as two loops from the hotel, each search from their own end and time, and a stop between two walks keeps the leg an ordinary one. Only the leg asked for last opens the search, and none opens once the plan is left while the walks' stops are still being read. Both views run on one shared hook, the walk's stops are read through one helper the stop list uses as well, and the walk's start on a day is worked out in one place for the times shown in the detail and here.
773 lines
33 KiB
TypeScript
773 lines
33 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
|
|
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
|
import type { PluginDaySchedule } from '../../../../src/components/Plugins/PluginDaySchedule'
|
|
import type { MPlanTimelineController } from '../../../../src/mobile/screens/trip/plan/useMPlanTimeline'
|
|
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
|
import type { PlanRow, TransitMeta, TransportEntry } from '../../../../src/mobile/screens/trip/plan/planTimelineModel'
|
|
import type { MergedItem } from '../../../../src/utils/dayMerge'
|
|
import type { Assignment, Day, DayNote, Place, RouteSegment } from '../../../../src/types'
|
|
import MPlanTimeline from '../../../../src/mobile/screens/trip/plan/MPlanTimeline'
|
|
|
|
// FE-MOB-PLTL-001 to FE-MOB-PLTL-049
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
tl: {} as Record<string, unknown>,
|
|
schedule: {} as PluginDaySchedule,
|
|
}))
|
|
|
|
vi.mock('../../../../src/mobile/screens/trip/plan/useMPlanTimeline', () => ({
|
|
useMPlanTimeline: () => mocks.tl,
|
|
}))
|
|
|
|
vi.mock('../../../../src/components/Plugins/PluginDaySchedule', async importOriginal => {
|
|
const actual = await importOriginal<typeof import('../../../../src/components/Plugins/PluginDaySchedule')>()
|
|
return { ...actual, usePluginDaySchedule: () => mocks.schedule }
|
|
})
|
|
|
|
const DAY = { id: 2, trip_id: 1, day_number: 2, date: '2026-05-02', title: 'Tokyo → Kyoto' } as unknown as Day
|
|
|
|
const MUSEUM = {
|
|
id: 11, day_id: 2, place_id: 101, order_index: 0,
|
|
place: { id: 101, name: 'Museum', place_time: '09:30', address: 'Museum Rd 1', lat: 35.71, lng: 139.79 },
|
|
} as unknown as Assignment
|
|
|
|
const PARK = {
|
|
id: 12, day_id: 2, place_id: 102, order_index: 1,
|
|
place: { id: 102, name: 'Ueno Park', lat: 35.72, lng: 139.77 },
|
|
} as unknown as Assignment
|
|
|
|
const NOTE = {
|
|
id: 41, day_id: 2, text: 'Buy museum tickets', time: '08:00', icon: 'Ticket', sort_order: 0,
|
|
} as unknown as DayNote
|
|
|
|
const FLIGHT = {
|
|
id: 21, type: 'flight', title: 'LH 714 to Tokyo', day_id: 2,
|
|
reservation_time: '2026-05-02T08:15',
|
|
} as unknown as TransportEntry
|
|
|
|
const TRANSIT_RES = {
|
|
id: 22, type: 'transit', title: 'Shibuya → Asakusa', day_id: 2,
|
|
reservation_time: '2026-05-02T10:05',
|
|
} as unknown as TransportEntry
|
|
|
|
const TRANSIT: TransitMeta = {
|
|
legs: [{
|
|
mode: 'SUBWAY', line: 'G', line_color: '#FF9500', duration: 900,
|
|
from: { name: 'Shibuya Sta.' }, to: { name: 'Asakusa Sta.' },
|
|
}],
|
|
}
|
|
|
|
const SEG: RouteSegment = {
|
|
mid: [35.7, 139.7], from: [35.71, 139.79], to: [35.72, 139.77],
|
|
distance: 2100, duration: 600,
|
|
walkingText: '26 min', drivingText: '7 min', distanceText: '2.1 km',
|
|
}
|
|
|
|
const M_PLACE: MergedItem = { type: 'place', sortKey: 0, data: MUSEUM }
|
|
const M_FLIGHT: MergedItem = { type: 'transport', sortKey: 1, data: FLIGHT }
|
|
const M_TRANSIT: MergedItem = { type: 'transport', sortKey: 2, data: TRANSIT_RES }
|
|
const M_NOTE: MergedItem = { type: 'note', sortKey: 3, data: NOTE }
|
|
const M_PLACE2: MergedItem = { type: 'place', sortKey: 4, data: PARK }
|
|
|
|
const MERGED = [M_PLACE, M_FLIGHT, M_TRANSIT, M_NOTE, M_PLACE2]
|
|
|
|
const ROWS: PlanRow[] = [
|
|
{ key: 'pl-11', kind: 'place', item: M_PLACE, assignment: MUSEUM, linkedReservations: [] },
|
|
{ key: 'conn-pl-11', kind: 'conn', seg: SEG, assignmentId: 11 },
|
|
{ key: 'tr-21', kind: 'transport', item: M_FLIGHT, res: FLIGHT },
|
|
{ key: 'tr-22', kind: 'transit', item: M_TRANSIT, res: TRANSIT_RES, transit: TRANSIT },
|
|
{ key: 'note-41', kind: 'note', item: M_NOTE, note: NOTE },
|
|
{ key: 'pl-12', kind: 'place', item: M_PLACE2, assignment: PARK, linkedReservations: [] },
|
|
{ key: 'conn-orphan', kind: 'conn', seg: { ...SEG, mode: 'walking' } },
|
|
]
|
|
|
|
const EMPTY_SCHEDULE: PluginDaySchedule = {
|
|
byAssignment: {}, byReservation: {}, byPosition: {}, minutesByDay: {},
|
|
}
|
|
|
|
function buildTl(over: Record<string, unknown> = {}): MPlanTimelineController {
|
|
return {
|
|
day: DAY,
|
|
rows: ROWS,
|
|
merged: MERGED,
|
|
hotelLegs: { top: null, bottom: null },
|
|
hotelChips: [],
|
|
weather: null,
|
|
weatherTemp: null,
|
|
weatherPlaceName: null,
|
|
upNext: { assignment: MUSEUM, minutesUntil: 45 },
|
|
language: 'en',
|
|
timeFormat: '24h',
|
|
openTransitKeys: new Set<string>(),
|
|
toggleTransit: vi.fn(),
|
|
moveRow: vi.fn(),
|
|
removeAssignment: vi.fn(),
|
|
editAssignment: vi.fn(),
|
|
editTransport: vi.fn(),
|
|
openTransitJourney: vi.fn(),
|
|
addPlace: vi.fn(),
|
|
addBooking: vi.fn(),
|
|
addTransport: vi.fn(),
|
|
optimize: vi.fn(async () => undefined),
|
|
canExportRoute: true,
|
|
exportGoogleMaps: vi.fn(),
|
|
exportCoMaps: vi.fn(),
|
|
renameDay: vi.fn(),
|
|
fullPlaceOf: vi.fn(() => undefined as Place | undefined),
|
|
routeModeOptions: [
|
|
{ key: 'driving', label: 'Driving' },
|
|
{ key: 'walking', label: 'Walking' },
|
|
{ key: 'plugin:ev/fastest', label: 'EV fastest' },
|
|
],
|
|
setLegMode: vi.fn(),
|
|
transitLegFor: vi.fn(() => null),
|
|
planTransitLeg: vi.fn(),
|
|
journeyLate: vi.fn(() => null),
|
|
...over,
|
|
} as unknown as MPlanTimelineController
|
|
}
|
|
|
|
/** The scrollable timeline card — its inline top offset encodes the layout mode. */
|
|
const card = (container: HTMLElement) => container.querySelector('.overflow-y-auto') as HTMLElement
|
|
|
|
function renderTimeline(
|
|
tlOver: Record<string, unknown> = {},
|
|
plannerOver: Partial<TripPlanner> = {},
|
|
shellOver: Partial<MTripShellApi> = {},
|
|
) {
|
|
mocks.tl = buildTl(tlOver) as unknown as Record<string, unknown>
|
|
const planner = buildPlanner(plannerOver)
|
|
const shell = buildShell(shellOver)
|
|
return { planner, shell, ...render(<MPlanTimeline planner={planner} shell={shell} />) }
|
|
}
|
|
|
|
describe('MPlanTimeline', () => {
|
|
beforeEach(() => {
|
|
mocks.schedule = EMPTY_SCHEDULE
|
|
})
|
|
|
|
describe('go mode — up next card', () => {
|
|
it('FE-MOB-PLTL-001: shows the next stop with its time, subtitle and countdown', () => {
|
|
renderTimeline()
|
|
|
|
expect(screen.getByText('mobileTrip.upNext')).toBeInTheDocument()
|
|
expect(screen.getByText('mobileTrip.inCountdown:transit.min:45')).toBeInTheDocument()
|
|
expect(screen.getAllByText('Museum').length).toBeGreaterThan(0)
|
|
expect(screen.getAllByText('09:30').length).toBeGreaterThan(0)
|
|
expect(screen.getAllByText('Museum Rd 1').length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('FE-MOB-PLTL-002: tapping the card selects the place and its assignment', () => {
|
|
const { planner } = renderTimeline()
|
|
|
|
fireEvent.click(screen.getByText('mobileTrip.upNext'))
|
|
|
|
expect(planner.handlePlaceClick).toHaveBeenCalledWith(101, 11)
|
|
})
|
|
|
|
it('FE-MOB-PLTL-003: hides the countdown, time chip and subtitle when the stop has none', () => {
|
|
const bare = { ...MUSEUM, place: { id: 101, name: 'Museum' } } as unknown as Assignment
|
|
renderTimeline({ upNext: { assignment: bare, minutesUntil: null }, rows: [], merged: [] })
|
|
|
|
expect(screen.queryByText(/mobileTrip.inCountdown/)).not.toBeInTheDocument()
|
|
expect(screen.queryByText('09:30')).not.toBeInTheDocument()
|
|
expect(screen.queryByText('Museum Rd 1')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PLTL-004: collapses the reserved space when the day has no up next', () => {
|
|
const { container, unmount } = renderTimeline()
|
|
expect(card(container).style.top).toContain('216px')
|
|
unmount()
|
|
|
|
const empty = renderTimeline({ upNext: null, rows: [], merged: [] })
|
|
expect(screen.queryByText('mobileTrip.upNext')).not.toBeInTheDocument()
|
|
expect(card(empty.container).style.top).toContain('102px')
|
|
})
|
|
})
|
|
|
|
describe('timeline header', () => {
|
|
it('FE-MOB-PLTL-005: renders the accommodation chips and the weather chip', () => {
|
|
renderTimeline({
|
|
hotelChips: [
|
|
{ key: 'out-1', variant: 'checkout', name: 'Hotel Sacher', time: '11:00:00' },
|
|
{ key: 'in-2', variant: 'checkin', name: 'Ryokan Kyoto', time: '15:00:00' },
|
|
{ key: 'stay-3', variant: 'stay', name: 'Capsule Tokyo', time: null },
|
|
],
|
|
weather: { main: 'Rain', temp: 17 },
|
|
weatherTemp: 17,
|
|
})
|
|
|
|
expect(screen.getByText(/Hotel Sacher/)).toHaveTextContent('Hotel Sacher · 11:00')
|
|
expect(screen.getByText(/Ryokan Kyoto/)).toHaveTextContent('Ryokan Kyoto · 15:00')
|
|
expect(screen.getByText('Capsule Tokyo')).toBeInTheDocument()
|
|
expect(screen.getByText('17°')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PLTL-005b: the weather chip names its anchor place in the accessible label (#2167)', () => {
|
|
renderTimeline({
|
|
weather: { main: 'Rain', temp: 17 },
|
|
weatherTemp: 17,
|
|
weatherPlaceName: 'Shibuya',
|
|
})
|
|
|
|
// echoT renders t('day.weatherFor', { name }) as 'day.weatherFor:Shibuya'.
|
|
expect(screen.getByRole('button', { name: 'day.overview · day.weatherFor:Shibuya' })).toBeInTheDocument()
|
|
expect(screen.getByTitle('Shibuya')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PLTL-006: a stay chip opens the stay editor for members who may edit days (#2210)', () => {
|
|
const { shell } = renderTimeline({
|
|
hotelChips: [{ key: 'in-3', variant: 'checkin', name: 'Capsule Tokyo', time: '15:00:00', accId: 3, placeId: 301 }],
|
|
})
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'day.checkIn · Capsule Tokyo · 15:00' }))
|
|
|
|
expect(shell.openSheet).toHaveBeenCalledWith('accommodation', { dayId: 2, accId: 3, from: 'timeline' })
|
|
})
|
|
|
|
it('FE-MOB-PLTL-006b: without day_edit the chip opens the hotel place instead', () => {
|
|
const { planner, shell } = renderTimeline(
|
|
{ hotelChips: [{ key: 'stay-3', variant: 'stay', name: 'Capsule Tokyo', time: null, accId: 3, placeId: 301 }] },
|
|
{ can: vi.fn(() => false) },
|
|
)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.stay · Capsule Tokyo' }))
|
|
|
|
expect(planner.handlePlaceClick).toHaveBeenCalledWith(301)
|
|
expect(shell.openSheet).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-MOB-PLTL-006c: a stay without a place still leads a read-only member to the day sheet', () => {
|
|
const { planner, shell } = renderTimeline(
|
|
{ hotelChips: [{ key: 'out-3', variant: 'checkout', name: 'Capsule Tokyo', time: '11:00', accId: 3, placeId: null }] },
|
|
{ can: vi.fn(() => false) },
|
|
)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'day.checkOut · Capsule Tokyo · 11:00' }))
|
|
|
|
expect(shell.openSheet).toHaveBeenCalledWith('day', { dayId: 2 })
|
|
expect(planner.handlePlaceClick).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-MOB-PLTL-007: still renders, with the day pill, without chips or weather (#2004)', () => {
|
|
const { container } = renderTimeline()
|
|
|
|
// The pill is the only way into the day sheet, so it may not depend on the
|
|
// day happening to have an accommodation or a weather reading.
|
|
expect(container.querySelector('.border-b')).not.toBeNull()
|
|
expect(screen.getByRole('button', { name: 'day.overview' })).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PLTL-008: draws the hotel bookend legs above and below the rows', () => {
|
|
renderTimeline({
|
|
hotelLegs: {
|
|
top: { seg: SEG, name: 'Hotel Sacher' },
|
|
bottom: { seg: SEG, name: 'Ryokan Kyoto' },
|
|
},
|
|
})
|
|
|
|
expect(screen.getByText('Hotel Sacher')).toBeInTheDocument()
|
|
expect(screen.getByText('Ryokan Kyoto')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('rows', () => {
|
|
it('FE-MOB-PLTL-009: renders one row per kind', () => {
|
|
renderTimeline()
|
|
|
|
expect(screen.getAllByText('Museum').length).toBeGreaterThan(0)
|
|
expect(screen.getByText('Ueno Park')).toBeInTheDocument()
|
|
expect(screen.getByText('LH 714 to Tokyo')).toBeInTheDocument()
|
|
expect(screen.getByText('Shibuya → Asakusa')).toBeInTheDocument()
|
|
expect(screen.getByText('Buy museum tickets')).toBeInTheDocument()
|
|
expect(screen.getByText('7 min')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PLTL-010: a place row selects its place', () => {
|
|
const { planner } = renderTimeline()
|
|
|
|
fireEvent.click(screen.getByText('Ueno Park'))
|
|
|
|
expect(planner.handlePlaceClick).toHaveBeenCalledWith(102, 12)
|
|
})
|
|
|
|
it('FE-MOB-PLTL-011: a place row without a place still reports the assignment', () => {
|
|
const orphan = { id: 13, day_id: 2, place_id: 103, order_index: 2, place: null } as unknown as Assignment
|
|
const rows: PlanRow[] = [{ key: 'pl-13', kind: 'place', item: M_PLACE, assignment: orphan, linkedReservations: [] }]
|
|
const { planner, container } = renderTimeline({ rows })
|
|
|
|
fireEvent.click(container.querySelector('.cursor-pointer.items-center') as HTMLElement)
|
|
|
|
expect(planner.handlePlaceClick).toHaveBeenCalledWith(null, 13)
|
|
})
|
|
|
|
it('FE-MOB-PLTL-012: a transport row opens the transport sheet in go mode', () => {
|
|
const { shell } = renderTimeline()
|
|
|
|
fireEvent.click(screen.getByText('LH 714 to Tokyo'))
|
|
|
|
expect(shell.openSheet).toHaveBeenCalledWith('transport', { reservationId: 21 })
|
|
})
|
|
|
|
it('FE-MOB-PLTL-013: a transport row opens the editor in edit mode', () => {
|
|
const { shell } = renderTimeline({}, {}, { mode: 'edit' })
|
|
|
|
fireEvent.click(screen.getByText('LH 714 to Tokyo'))
|
|
|
|
expect(mocks.tl.editTransport).toHaveBeenCalledWith(FLIGHT)
|
|
expect(shell.openSheet).not.toHaveBeenCalledWith('transport', { reservationId: 21 })
|
|
})
|
|
|
|
it('FE-MOB-PLTL-014: a transit row toggles by its row key and renders its legs when open', () => {
|
|
const { unmount } = renderTimeline()
|
|
fireEvent.click(screen.getByText('Shibuya → Asakusa'))
|
|
expect(mocks.tl.toggleTransit).toHaveBeenCalledWith('tr-22')
|
|
expect(screen.queryByText('Shibuya Sta. → Asakusa Sta.')).not.toBeInTheDocument()
|
|
unmount()
|
|
|
|
renderTimeline({ openTransitKeys: new Set(['tr-22']) })
|
|
|
|
expect(screen.getByText('Shibuya Sta. → Asakusa Sta.')).toBeInTheDocument()
|
|
expect(screen.getByText('transit.min:15')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PLTL-014b: a transit row says when it now arrives after the next city walk starts (CW-08)', () => {
|
|
renderTimeline({ journeyLate: vi.fn(() => ({ walk: 'Harbour', time: '13:00' })) })
|
|
|
|
expect(mocks.tl.journeyLate).toHaveBeenCalledWith(expect.objectContaining({ type: 'transport' }))
|
|
expect(screen.getByTestId('transit-walk-late')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PLTL-015: a note row opens the note sheet with its note in edit mode', () => {
|
|
const { shell } = renderTimeline({}, {}, { mode: 'edit' })
|
|
|
|
fireEvent.click(screen.getByText('Buy museum tickets'))
|
|
|
|
expect(shell.openSheet).toHaveBeenCalledWith('note', { dayId: 2, note: NOTE })
|
|
})
|
|
|
|
it('FE-MOB-PLTL-016: the reorder arrows move the merged item they belong to', () => {
|
|
renderTimeline({}, {}, { mode: 'edit' })
|
|
|
|
const ups = screen.getAllByLabelText('dayplan.moveUp')
|
|
const downs = screen.getAllByLabelText('dayplan.moveDown')
|
|
|
|
// one stack per reorderable row: place, transport, transit, note, place
|
|
expect(ups).toHaveLength(5)
|
|
expect(ups[0]).toBeDisabled()
|
|
expect(downs[4]).toBeDisabled()
|
|
|
|
fireEvent.click(downs[0])
|
|
fireEvent.click(ups[4])
|
|
|
|
expect(mocks.tl.moveRow).toHaveBeenNthCalledWith(1, M_PLACE, 'down')
|
|
expect(mocks.tl.moveRow).toHaveBeenNthCalledWith(2, M_PLACE2, 'up')
|
|
})
|
|
})
|
|
|
|
describe('Tours read-only timeline integration', () => {
|
|
const tour = { ...MUSEUM, id: 91, place_id: 901,
|
|
place: { ...MUSEUM.place, id: 901, name: 'Assigned Tour' }, tour_place_id: 901 } as Assignment
|
|
const item: MergedItem = { type: 'place', sortKey: 0, data: tour }
|
|
const row: PlanRow = { key: 'pl-91', kind: 'place', item, assignment: tour, linkedReservations: [], invalidTour: true }
|
|
|
|
it('shows one localized invalid-endpoint warning and selects the Tour through the shared Place path', () => {
|
|
const { planner } = renderTimeline({ rows: [row], merged: [item], upNext: null }, { toursEnabled: true, tours: [] })
|
|
|
|
expect(screen.getAllByText('tours.dayRoute.endpointUnknown')).toHaveLength(1)
|
|
fireEvent.click(screen.getByText('Assigned Tour'))
|
|
expect(planner.handlePlaceClick).toHaveBeenCalledWith(901, 91)
|
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
|
expect(screen.queryByText('tour-planner')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('does not show the warning for valid status or with Tours disabled', () => {
|
|
const validRow: PlanRow = { ...row, invalidTour: false }
|
|
const first = renderTimeline({ rows: [validRow], merged: [item], upNext: null }, { toursEnabled: true, tours: [] })
|
|
expect(screen.queryByText('tours.dayRoute.endpointUnknown')).not.toBeInTheDocument()
|
|
first.unmount()
|
|
|
|
renderTimeline({ rows: [row], merged: [item], upNext: null }, { toursEnabled: false })
|
|
expect(screen.queryByText('tours.dayRoute.endpointUnknown')).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('per-segment travel mode', () => {
|
|
/** The timeline only offers the mode menu in edit mode, where many buttons exist. */
|
|
const renderEditing = () => renderTimeline({}, {}, { mode: 'edit' })
|
|
const connector = () => screen.getByText('7 min').closest('button') as HTMLElement
|
|
|
|
it('FE-MOB-PLTL-017: a connector with an origin assignment opens the mode menu', () => {
|
|
renderEditing()
|
|
|
|
fireEvent.click(connector())
|
|
|
|
expect(screen.getByText('Driving')).toBeInTheDocument()
|
|
expect(screen.getByText('Walking')).toBeInTheDocument()
|
|
expect(screen.getByText('EV fastest')).toBeInTheDocument()
|
|
expect(screen.getByText('dayplan.transportMode.useDefault')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PLTL-018: picking a mode sets it on the origin assignment', () => {
|
|
renderEditing()
|
|
fireEvent.click(connector())
|
|
|
|
fireEvent.click(screen.getByText('Walking'))
|
|
|
|
expect(mocks.tl.setLegMode).toHaveBeenCalledWith(11, 'walking')
|
|
})
|
|
|
|
it('FE-MOB-PLTL-019: the default entry clears the override', () => {
|
|
renderEditing()
|
|
fireEvent.click(connector())
|
|
|
|
fireEvent.click(screen.getByText('dayplan.transportMode.useDefault'))
|
|
|
|
expect(mocks.tl.setLegMode).toHaveBeenCalledWith(11, null)
|
|
})
|
|
|
|
it('FE-MOB-PLTL-046: a leg with a transit search behind it offers Public transit and opens it (#2398)', () => {
|
|
const leg = { from: { name: 'Museum', lat: 35.71, lng: 139.79 }, to: { name: 'Ueno Park', lat: 35.72, lng: 139.77 }, time: '09:30' }
|
|
renderTimeline({ transitLegFor: vi.fn(() => leg) }, {}, { mode: 'edit' })
|
|
fireEvent.click(connector())
|
|
|
|
expect(mocks.tl.transitLegFor).toHaveBeenCalledWith(SEG)
|
|
fireEvent.click(screen.getByText('transit.title'))
|
|
|
|
expect(mocks.tl.planTransitLeg).toHaveBeenCalledWith(leg, 11)
|
|
expect(mocks.tl.setLegMode).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-MOB-PLTL-047: without a transit search for the leg the menu keeps to the road profiles', () => {
|
|
renderEditing()
|
|
fireEvent.click(connector())
|
|
|
|
expect(screen.getByText('Driving')).toBeInTheDocument()
|
|
expect(screen.queryByText('transit.title')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PLTL-020: read-only members get no tappable connectors', () => {
|
|
renderTimeline({}, { can: vi.fn(() => false) }, { mode: 'edit' })
|
|
|
|
expect(screen.getByText('7 min').closest('button')).toBeNull()
|
|
})
|
|
|
|
it('FE-MOB-PLTL-020b: go mode keeps the connectors read-only even with edit rights', () => {
|
|
renderTimeline()
|
|
|
|
expect(screen.getByText('7 min').closest('button')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('plugin day schedule', () => {
|
|
it('FE-MOB-PLTL-021: slots contributions under their anchors and at the day edges', () => {
|
|
mocks.schedule = {
|
|
byAssignment: { 2: { 11: [{ pluginId: 'ev', id: 'a1', dayId: 2, assignmentId: 11, minutes: 35, label: 'Charging stop', tone: 'success' }] } },
|
|
byReservation: {
|
|
2: {
|
|
21: [{ pluginId: 'ev', id: 'r1', dayId: 2, reservationId: 21, minutes: 45, label: 'Security', tone: 'warn' }],
|
|
22: [{ pluginId: 'ev', id: 'r2', dayId: 2, reservationId: 22, label: 'Ticket check', tone: 'default' }],
|
|
},
|
|
},
|
|
byPosition: {
|
|
2: {
|
|
start: [{ pluginId: 'ev', id: 's1', dayId: 2, position: 'start', minutes: 10, label: 'Morning prep', tone: 'default' }],
|
|
end: [{ pluginId: 'ev', id: 'e1', dayId: 2, position: 'end', minutes: 20, label: 'Evening wrap', tone: 'danger' }],
|
|
},
|
|
},
|
|
minutesByDay: { 2: 110 },
|
|
}
|
|
renderTimeline()
|
|
|
|
expect(screen.getByText('Charging stop')).toBeInTheDocument()
|
|
expect(screen.getByText('Security')).toBeInTheDocument()
|
|
expect(screen.getByText('Ticket check')).toBeInTheDocument()
|
|
expect(screen.getByText('Morning prep')).toBeInTheDocument()
|
|
expect(screen.getByText('Evening wrap')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PLTL-022: contributes nothing while no day is selected', () => {
|
|
mocks.schedule = {
|
|
byAssignment: { 2: { 11: [{ pluginId: 'ev', id: 'a1', dayId: 2, assignmentId: 11, minutes: 35, label: 'Charging stop', tone: 'success' }] } },
|
|
byReservation: {},
|
|
byPosition: { 2: { start: [{ pluginId: 'ev', id: 's1', dayId: 2, position: 'start', label: 'Morning prep', tone: 'default' }], end: [] } },
|
|
minutesByDay: { 2: 35 },
|
|
}
|
|
const rows: PlanRow[] = [{ key: 'pl-11', kind: 'place', item: M_PLACE, assignment: MUSEUM, linkedReservations: [] }]
|
|
renderTimeline({ day: undefined, rows })
|
|
|
|
expect(screen.queryByText('Charging stop')).not.toBeInTheDocument()
|
|
expect(screen.queryByText('Morning prep')).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('empty day', () => {
|
|
it('FE-MOB-PLTL-023: shows the mascot empty state in go mode', () => {
|
|
const { container } = renderTimeline({ rows: [], merged: [], upNext: null })
|
|
|
|
expect(screen.getByText('dayplan.emptyDay')).toBeInTheDocument()
|
|
expect(container.querySelector('.trek--guide')).toBeTruthy()
|
|
})
|
|
|
|
it('FE-MOB-PLTL-024: replaces it with the action grid in edit mode', () => {
|
|
renderTimeline({ rows: [], merged: [], upNext: null }, {}, { mode: 'edit' })
|
|
|
|
expect(screen.queryByText('dayplan.emptyDay')).not.toBeInTheDocument()
|
|
expect(screen.getByText('mobileTrip.addPlaceShort')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('edit mode', () => {
|
|
it('FE-MOB-PLTL-025: reserves the header space and hides the up-next card', () => {
|
|
const { container } = renderTimeline({}, {}, { mode: 'edit' })
|
|
|
|
expect(screen.queryByText('mobileTrip.upNext')).not.toBeInTheDocument()
|
|
expect(card(container).style.top).toContain('140px')
|
|
})
|
|
|
|
it('FE-MOB-PLTL-026: splits the day title into city pills', () => {
|
|
renderTimeline({}, {}, { mode: 'edit' })
|
|
|
|
expect(screen.getByText('Tokyo')).toBeInTheDocument()
|
|
expect(screen.getByText('Kyoto')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PLTL-027: falls back to the day number when the day has no title', () => {
|
|
renderTimeline({ day: { ...DAY, title: null } }, {}, { mode: 'edit' })
|
|
|
|
expect(screen.getByText('planner.dayN:2')).toBeInTheDocument()
|
|
// …and the day pill next to it stays icon-only so the name is not doubled.
|
|
expect(screen.getByRole('button', { name: 'day.overview' })).toHaveTextContent('')
|
|
})
|
|
|
|
it('FE-MOB-PLTL-028: the pencil renames the day and Enter commits the change', () => {
|
|
renderTimeline({}, {}, { mode: 'edit' })
|
|
|
|
fireEvent.click(screen.getByLabelText('mobileTrip.renameDay'))
|
|
const input = screen.getByPlaceholderText('mobileTrip.dayTitlePlaceholder') as HTMLInputElement
|
|
expect(input.value).toBe('Tokyo → Kyoto')
|
|
|
|
fireEvent.change(input, { target: { value: 'Osaka' } })
|
|
fireEvent.keyDown(input, { key: 'Enter' })
|
|
|
|
expect(mocks.tl.renameDay).toHaveBeenCalledWith('Osaka')
|
|
// committing closes the field again — the pills come back from the store
|
|
expect(screen.queryByPlaceholderText('mobileTrip.dayTitlePlaceholder')).not.toBeInTheDocument()
|
|
expect(screen.getByText('Tokyo')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PLTL-029: Escape leaves the title untouched', () => {
|
|
renderTimeline({}, {}, { mode: 'edit' })
|
|
|
|
fireEvent.click(screen.getByLabelText('mobileTrip.renameDay'))
|
|
const input = screen.getByPlaceholderText('mobileTrip.dayTitlePlaceholder')
|
|
fireEvent.change(input, { target: { value: 'Osaka' } })
|
|
fireEvent.keyDown(input, { key: 'Escape' })
|
|
|
|
expect(mocks.tl.renameDay).not.toHaveBeenCalled()
|
|
expect(screen.getByText('Tokyo')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PLTL-030: blurring an unchanged draft does not rename', () => {
|
|
renderTimeline({}, {}, { mode: 'edit' })
|
|
|
|
fireEvent.click(screen.getByLabelText('mobileTrip.renameDay'))
|
|
fireEvent.blur(screen.getByPlaceholderText('mobileTrip.dayTitlePlaceholder'))
|
|
|
|
expect(mocks.tl.renameDay).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-MOB-PLTL-031: the calendar button opens the day reorder sheet', () => {
|
|
const { shell } = renderTimeline({}, {}, { mode: 'edit' })
|
|
|
|
fireEvent.click(screen.getByLabelText('dayplan.reorderDays'))
|
|
|
|
expect(shell.openSheet).toHaveBeenCalledWith('days')
|
|
})
|
|
|
|
it('FE-MOB-PLTL-032: undo is disabled until there is something to undo', () => {
|
|
const { unmount } = renderTimeline({}, {}, { mode: 'edit' })
|
|
expect(screen.getByText('undo.button').closest('button')).toBeDisabled()
|
|
unmount()
|
|
|
|
const { planner } = renderTimeline(
|
|
{}, { canUndo: true, lastActionLabel: 'undo.reorder' }, { mode: 'edit' },
|
|
)
|
|
const button = screen.getByText('undo.button').closest('button') as HTMLButtonElement
|
|
expect(button).toHaveAttribute('title', 'undo.tooltip:undo.reorder')
|
|
|
|
fireEvent.click(button)
|
|
|
|
expect(planner.handleUndo).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('FE-MOB-PLTL-033: every action tile fires its planner action', () => {
|
|
const { shell } = renderTimeline({}, {}, { mode: 'edit' })
|
|
|
|
fireEvent.click(screen.getByText('mobileTrip.addPlaceShort'))
|
|
fireEvent.click(screen.getByText('mobileTrip.addNoteShort'))
|
|
fireEvent.click(screen.getByText('mobileTrip.addBookingShort'))
|
|
fireEvent.click(screen.getByText('mobileTrip.addTransportShort'))
|
|
fireEvent.click(screen.getByText('dayplan.optimize'))
|
|
fireEvent.click(screen.getByText('mobileTrip.googleMaps'))
|
|
|
|
expect(mocks.tl.addPlace).toHaveBeenCalledTimes(1)
|
|
expect(shell.openSheet).toHaveBeenCalledWith('note', { dayId: 2 })
|
|
expect(mocks.tl.addBooking).toHaveBeenCalledTimes(1)
|
|
expect(mocks.tl.addTransport).toHaveBeenCalledTimes(1)
|
|
expect(mocks.tl.optimize).toHaveBeenCalledTimes(1)
|
|
expect(mocks.tl.exportGoogleMaps).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('FE-MOB-PLTL-049: a day with no route to hand over offers no Google Maps or CoMaps tile (#2476)', () => {
|
|
// A moving day whose only content is the flight: the export would be a drive
|
|
// from one hotel to the other, so the two tiles are left out, not left dead.
|
|
renderTimeline({ canExportRoute: false }, {}, { mode: 'edit' })
|
|
|
|
expect(screen.queryByText('mobileTrip.googleMaps')).not.toBeInTheDocument()
|
|
expect(screen.queryByText('mobileTrip.coMaps')).not.toBeInTheDocument()
|
|
expect(screen.getByText('dayplan.optimize')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PLTL-034: the note tile is inert while no day is selected', () => {
|
|
const { shell } = renderTimeline({ day: undefined, rows: [], merged: [] }, {}, { mode: 'edit' })
|
|
|
|
fireEvent.click(screen.getByText('mobileTrip.addNoteShort'))
|
|
|
|
expect(shell.openSheet).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-MOB-PLTL-038: renaming starts from an empty draft while no day is loaded', () => {
|
|
renderTimeline({ day: undefined, rows: [], merged: [] }, {}, { mode: 'edit' })
|
|
|
|
fireEvent.click(screen.getByLabelText('mobileTrip.renameDay'))
|
|
const input = screen.getByPlaceholderText('mobileTrip.dayTitlePlaceholder') as HTMLInputElement
|
|
expect(input.value).toBe('')
|
|
|
|
fireEvent.change(input, { target: { value: 'Nara' } })
|
|
fireEvent.blur(input)
|
|
|
|
expect(mocks.tl.renameDay).toHaveBeenCalledWith('Nara')
|
|
})
|
|
|
|
it('FE-MOB-PLTL-035: read-only members never enter edit mode', () => {
|
|
renderTimeline({}, { can: vi.fn(() => false) }, { mode: 'edit' })
|
|
|
|
expect(screen.queryByText('mobileTrip.addPlaceShort')).not.toBeInTheDocument()
|
|
expect(screen.getByText('mobileTrip.upNext')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PLTL-036: place rows expose edit and remove in edit mode', () => {
|
|
renderTimeline({}, {}, { mode: 'edit' })
|
|
|
|
const edits = screen.getAllByLabelText('common.edit')
|
|
fireEvent.click(edits[0])
|
|
fireEvent.click(screen.getAllByLabelText('planner.removeFromDay')[0])
|
|
|
|
expect(mocks.tl.editAssignment).toHaveBeenCalledWith(MUSEUM)
|
|
expect(mocks.tl.removeAssignment).toHaveBeenCalledWith(MUSEUM)
|
|
})
|
|
|
|
it('FE-MOB-PLTL-048: without place_edit a place row keeps remove and reorder but loses its edit circle (#2446)', () => {
|
|
renderTimeline({}, { can: vi.fn((action: string) => action !== 'place_edit') as TripPlanner['can'] }, { mode: 'edit' })
|
|
|
|
// the place row keeps its remove circle and loses the edit one; the
|
|
// transport and note rows keep theirs, editing those is a day right
|
|
const row = screen.getByText('Museum').closest('[role="button"]') as HTMLElement
|
|
expect(within(row).getByLabelText('planner.removeFromDay')).toBeInTheDocument()
|
|
expect(within(row).queryByLabelText('common.edit')).not.toBeInTheDocument()
|
|
fireEvent.click(within(row).getByLabelText('planner.removeFromDay'))
|
|
expect(mocks.tl.removeAssignment).toHaveBeenCalledWith(MUSEUM)
|
|
expect(mocks.tl.editAssignment).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-MOB-PLTL-037: the transit row opens the journey view from its edit circle', () => {
|
|
renderTimeline({}, {}, { mode: 'edit' })
|
|
|
|
const transitCard = screen.getByText('Shibuya → Asakusa').closest('.mt-1\\.5') as HTMLElement
|
|
fireEvent.click(within(transitCard).getByLabelText('common.edit'))
|
|
|
|
expect(mocks.tl.openTransitJourney).toHaveBeenCalledWith(TRANSIT_RES)
|
|
})
|
|
})
|
|
describe('day swipe (#2051)', () => {
|
|
const DAYS = [
|
|
{ id: 1, trip_id: 1, day_number: 1 }, DAY, { id: 3, trip_id: 1, day_number: 3 },
|
|
] as unknown as Day[]
|
|
|
|
const panel = (container: HTMLElement) => container.firstElementChild as HTMLElement
|
|
|
|
/** A committing left swipe across the whole panel. */
|
|
function swipe(el: HTMLElement, from = 300, to = 180) {
|
|
fireEvent.touchStart(el, { touches: [{ clientX: from, clientY: 300 }] })
|
|
fireEvent.touchMove(el, { touches: [{ clientX: from - 20, clientY: 300 }] })
|
|
fireEvent.touchMove(el, { touches: [{ clientX: to, clientY: 300 }] })
|
|
fireEvent.touchEnd(el, { touches: [], changedTouches: [{ clientX: to, clientY: 300 }] })
|
|
}
|
|
|
|
it('FE-MOB-PLTL-039: swiping the panel selects the next day without re-framing the map', async () => {
|
|
const { container, planner } = renderTimeline({}, { days: DAYS, selectedDayId: 2 })
|
|
|
|
swipe(panel(container))
|
|
|
|
// skipFit must be true: the map stays mounted under the timeline, and
|
|
// re-fitting it here would move it somewhere nobody asked for.
|
|
await waitFor(() => expect(planner.handleSelectDay).toHaveBeenCalledWith(3, true))
|
|
})
|
|
|
|
it('FE-MOB-PLTL-040: the header chip strip is excluded from the swipe zone', () => {
|
|
const { container } = renderTimeline({}, { days: DAYS, selectedDayId: 2 })
|
|
|
|
expect(container.querySelector('[data-hswipe-ignore]')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-MOB-PLTL-041: the live region is present and silent until a swipe lands', () => {
|
|
const { container } = renderTimeline({}, { days: DAYS, selectedDayId: 2 })
|
|
|
|
const live = container.querySelector('[role="status"]') as HTMLElement
|
|
expect(live).toHaveAttribute('aria-live', 'polite')
|
|
expect(live).toHaveAttribute('aria-atomic', 'true')
|
|
expect(live).toHaveTextContent('')
|
|
})
|
|
|
|
it('FE-MOB-PLTL-042: a committed swipe announces the day it reached', async () => {
|
|
const { container } = renderTimeline({}, { days: DAYS, selectedDayId: 2 })
|
|
|
|
swipe(panel(container))
|
|
|
|
await waitFor(() => expect(container.querySelector('[role="status"]'))
|
|
.toHaveTextContent('mobileTrip.dayAnnounce:3,3'))
|
|
})
|
|
|
|
it('FE-MOB-PLTL-043: edit mode keeps both the swipe and the long-press reorder', async () => {
|
|
const { container, planner } = renderTimeline({}, { days: DAYS, selectedDayId: 2 }, { mode: 'edit' })
|
|
|
|
swipe(panel(container))
|
|
|
|
await waitFor(() => expect(planner.handleSelectDay).toHaveBeenCalledWith(3, true))
|
|
expect(card(container)).toHaveAttribute('data-touch-drag')
|
|
})
|
|
|
|
it('FE-MOB-PLTL-044: a plain tap on a place row still opens it', () => {
|
|
const { planner } = renderTimeline({}, { days: DAYS, selectedDayId: 2 })
|
|
|
|
fireEvent.click(screen.getByText('Ueno Park'))
|
|
|
|
expect(planner.handlePlaceClick).toHaveBeenCalledWith(102, 12)
|
|
})
|
|
|
|
it('FE-MOB-PLTL-045: nothing narrows touch-action, and the excluded strip stays tappable', () => {
|
|
const { container, shell } = renderTimeline({}, { days: DAYS, selectedDayId: 2 })
|
|
|
|
// touch-action intersects down the tree, so a pan-y here would take the
|
|
// header strip's own horizontal pan with it.
|
|
expect(panel(container).style.touchAction).toBe('')
|
|
expect(card(container).style.touchAction).toBe('')
|
|
|
|
fireEvent.click(screen.getByLabelText('day.overview'))
|
|
expect(shell.openSheet).toHaveBeenCalledWith('day', { dayId: 2 })
|
|
})
|
|
})
|
|
})
|