mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
765 lines
33 KiB
TypeScript
765 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(),
|
|
...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-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 })
|
|
|
|
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 })
|
|
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)
|
|
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 })
|
|
})
|
|
})
|
|
})
|