mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
252 lines
12 KiB
TypeScript
252 lines
12 KiB
TypeScript
import { act, fireEvent, render, screen, waitFor, within } from '../../../tests/helpers/render'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { TourListItem } from '@trek/shared'
|
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
|
import { useSettingsStore } from '../../store/settingsStore'
|
|
import { useTripStore } from '../../store/tripStore'
|
|
import { toursApi } from '../../api/client'
|
|
import ToursSidebar from './ToursSidebar'
|
|
|
|
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }))
|
|
|
|
vi.mock('../shared/Toast', () => ({ useToast: () => toast }))
|
|
|
|
vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') })
|
|
vi.mock('../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in client tests') })
|
|
vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden in client tests') })
|
|
|
|
const tour: TourListItem = {
|
|
place_id: 42,
|
|
name: 'Selected ridge walk',
|
|
tour_type: 'hike',
|
|
distance: 4,
|
|
elevation_gain: 100,
|
|
elevation_loss: 80,
|
|
duration: null,
|
|
difficulty: null,
|
|
wanderer_ref: null,
|
|
match_confidence: null,
|
|
max_hiking_difficulty: 2,
|
|
planned: false,
|
|
caution: false,
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetAllStores()
|
|
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } })
|
|
})
|
|
|
|
describe('ToursSidebar', () => {
|
|
it('uses the compact counted dropdown for All, Unplanned and Planned', () => {
|
|
render(<ToursSidebar tripId={1} days={[]} tours={[tour, { ...tour, place_id: 43, name: 'Planned walk', planned: true }]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
|
|
const trigger = screen.getByRole('button', { name: 'Show' })
|
|
expect(trigger).toHaveTextContent('All')
|
|
expect(trigger).toHaveTextContent('2')
|
|
fireEvent.click(trigger)
|
|
const choices = screen.getByRole('group', { name: 'Show' })
|
|
expect(within(choices).getByRole('button', { name: /All\s*2/ })).toHaveAttribute('aria-pressed', 'true')
|
|
expect(within(choices).getByRole('button', { name: /Unplanned\s*1/ })).toBeInTheDocument()
|
|
expect(within(choices).getByRole('button', { name: /Planned\s*1/ })).toBeInTheDocument()
|
|
})
|
|
|
|
it('uses the place selection id for the same selected background as a place row', () => {
|
|
render(<ToursSidebar tripId={1} days={[]} tours={[tour]} selectedPlaceId={42} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
|
|
const row = screen.getByRole('option', { name: /Selected ridge walk/i })
|
|
expect(row).toHaveAttribute('aria-selected', 'true')
|
|
expect(row).toHaveClass('bg-surface-selected')
|
|
})
|
|
|
|
it('RS-01: previews the Tour description and classified information-link host', () => {
|
|
const listed = { ...tour, description: 'A quiet path above the lake', website: 'https://www.komoot.com/tour/42', planned_duration_minutes: 95 }
|
|
render(<ToursSidebar tripId={1} days={[]} tours={[listed]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
|
|
const row = screen.getByRole('option', { name: /Selected ridge walk/i })
|
|
expect(row).toHaveTextContent('A quiet path above the lake')
|
|
expect(row).toHaveTextContent('Komoot · komoot.com')
|
|
expect(row).toHaveTextContent('1 h 35 min')
|
|
expect(row.querySelector('[data-testid="tour-description-preview"]')).toHaveStyle({ WebkitLineClamp: '2' })
|
|
})
|
|
|
|
it('selects the tour from keyboard activation', () => {
|
|
const onSelectTour = vi.fn()
|
|
render(<ToursSidebar tripId={1} days={[]} tours={[tour]} selectedPlaceId={null} onAssignToDay={vi.fn()} onSelectTour={onSelectTour} />)
|
|
const row = screen.getByRole('option', { name: /Selected ridge walk/i })
|
|
fireEvent.keyDown(row, { key: 'Enter' })
|
|
expect(onSelectTour).toHaveBeenCalledWith(tour, row)
|
|
})
|
|
|
|
it.each([
|
|
['en', 'A deliberately long English hiking tour name that should wrap without pushing its metrics aside'],
|
|
['de', 'Eine außergewöhnlich lange deutsche Wanderung mit ausführlicher Bezeichnung für schmale Seitenleisten'],
|
|
])('keeps %s title, fact pills and the day action in one place-style row', (language, name) => {
|
|
seedStore(useSettingsStore, { settings: { language, distance_unit: 'metric' } })
|
|
const onAssignToDay = vi.fn()
|
|
const { container } = render(<ToursSidebar
|
|
canEdit={true}
|
|
canAssign={true}
|
|
tripId={1}
|
|
days={[{ id: 7, trip_id: 1, day_number: 1, title: 'Day one', date: null } as never]}
|
|
tours={[{ ...tour, name, distance: 1.9, elevation_gain: 13, elevation_loss: 11, has_waypoints: true }]}
|
|
selectedPlaceId={42}
|
|
onAssignToDay={onAssignToDay}
|
|
onSelectTour={vi.fn()}
|
|
/>)
|
|
|
|
const row = screen.getByRole('option', { name: new RegExp(name) })
|
|
expect(row).toHaveAttribute('aria-selected', 'true')
|
|
expect(row).toHaveTextContent(name)
|
|
const metrics = row.querySelector('[data-testid="tour-metrics"]')!
|
|
expect(metrics).toHaveTextContent('1.9 km')
|
|
expect(metrics).toHaveTextContent('13 m')
|
|
expect(metrics).toHaveTextContent('11 m')
|
|
const labels = [...metrics.querySelectorAll('[aria-label]')].map(element => element.getAttribute('aria-label'))
|
|
expect(labels[0]).toContain('1.9 km')
|
|
expect(labels[1]).toContain('13 m')
|
|
expect(labels[2]).toContain('11 m')
|
|
expect(within(row).queryByText('TREK')).not.toBeInTheDocument()
|
|
expect(screen.getByLabelText(/Maximum trail difficulty|Maximale Wanderschwierigkeit|T2/)).toBeInTheDocument()
|
|
const actionRow = row.querySelector('[data-testid="tour-action-row"]')!
|
|
expect(actionRow.querySelector('button')).toHaveAttribute('aria-haspopup', 'menu')
|
|
expect(container.querySelector('[data-testid="tour-metrics"]')).toBeTruthy()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /Add to day|Zum Tag hinzufügen/ }))
|
|
expect(screen.getByRole('button', { name: /Day one/ })).toBeInTheDocument()
|
|
})
|
|
|
|
it('shows no T-badge for an imported tour that carries no difficulty', () => {
|
|
const imported = { ...tour, name: 'Imported GPX walk', has_waypoints: false, max_hiking_difficulty: null }
|
|
render(<ToursSidebar tripId={1} days={[]} tours={[imported]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
|
|
|
|
const metrics = within(screen.getByRole('option', { name: /Imported GPX walk/ })).getByTestId('tour-metrics')
|
|
expect(metrics).not.toHaveTextContent(/T\d/)
|
|
expect(metrics).not.toHaveTextContent('Tnull')
|
|
expect(within(metrics).queryByLabelText(/^T\d/)).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('leaves the post-write refresh to the shared assignment handler', async () => {
|
|
let resolveAssignment!: () => void
|
|
const assignment = new Promise<void>(resolve => { resolveAssignment = resolve })
|
|
const onAssignToDay = vi.fn(() => assignment)
|
|
const onToursChanged = vi.fn()
|
|
render(<ToursSidebar
|
|
canEdit={true}
|
|
canAssign={true}
|
|
tripId={1}
|
|
days={[{ id: 7, trip_id: 1, day_number: 1, title: 'Day one', date: null } as never]}
|
|
tours={[tour]}
|
|
onAssignToDay={onAssignToDay}
|
|
onToursChanged={onToursChanged}
|
|
onSelectTour={vi.fn()}
|
|
/>)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /Add to day|Zum Tag hinzufügen/ }))
|
|
fireEvent.click(screen.getByRole('button', { name: /Day one/ }))
|
|
expect(onAssignToDay).toHaveBeenCalledWith(42, 7)
|
|
expect(onToursChanged).not.toHaveBeenCalled()
|
|
|
|
await act(async () => { resolveAssignment(); await assignment })
|
|
expect(onToursChanged).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('disables already-assigned days and refreshes availability when the picker is reopened', async () => {
|
|
const dayOne = { id: 7, trip_id: 1, day_number: 1, title: 'Day one', date: null } as never
|
|
const dayTwo = { id: 8, trip_id: 1, day_number: 2, title: 'Day two', date: null } as never
|
|
const dayThree = { id: 9, trip_id: 1, day_number: 3, title: 'Day three', date: null } as never
|
|
useTripStore.setState({ assignments: { '7': [{ id: 1, day_id: 7, place_id: tour.place_id }] } as never })
|
|
const onAssignToDay = vi.fn((placeId: number, dayId: number) => {
|
|
useTripStore.setState(state => ({
|
|
assignments: { ...state.assignments, [String(dayId)]: [{ id: 2, day_id: dayId, place_id: placeId }] } as never,
|
|
}))
|
|
return true
|
|
})
|
|
render(<ToursSidebar canAssign tripId={1} days={[dayOne, dayTwo, dayThree]} tours={[tour]} onAssignToDay={onAssignToDay} onSelectTour={vi.fn()} />)
|
|
|
|
const openPicker = () => fireEvent.click(screen.getByRole('button', { name: /Add to day|Zum Tag hinzufügen/ }))
|
|
openPicker()
|
|
expect(screen.getByRole('button', { name: /Day one/ })).toBeDisabled()
|
|
expect(screen.getByRole('button', { name: /Day two/ })).toBeEnabled()
|
|
fireEvent.click(screen.getByRole('button', { name: /Day two/ }))
|
|
expect(onAssignToDay).toHaveBeenCalledTimes(1)
|
|
expect(onAssignToDay).toHaveBeenCalledWith(tour.place_id, 8)
|
|
|
|
openPicker()
|
|
expect(screen.getByRole('button', { name: /Day one/ })).toBeDisabled()
|
|
expect(screen.getByRole('button', { name: /Day two/ })).toBeDisabled()
|
|
expect(screen.getByRole('button', { name: /Day three/ })).toBeEnabled()
|
|
fireEvent.click(screen.getByRole('button', { name: /Day three/ }))
|
|
expect(onAssignToDay).toHaveBeenCalledTimes(2)
|
|
expect(onAssignToDay).toHaveBeenLastCalledWith(tour.place_id, 9)
|
|
})
|
|
|
|
it('neither refreshes nor reports success when assignment fails', async () => {
|
|
toast.success.mockClear()
|
|
const onToursChanged = vi.fn()
|
|
render(<ToursSidebar
|
|
canAssign={true}
|
|
tripId={1}
|
|
days={[{ id: 7, trip_id: 1, day_number: 1, title: 'Day one', date: null } as never]}
|
|
tours={[tour]}
|
|
onAssignToDay={vi.fn().mockResolvedValue(false)}
|
|
onToursChanged={onToursChanged}
|
|
onSelectTour={vi.fn()}
|
|
/>)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /Add to day|Zum Tag hinzufügen/ }))
|
|
fireEvent.click(screen.getByRole('button', { name: /Day one/ }))
|
|
|
|
await act(async () => {})
|
|
expect(toast.success).not.toHaveBeenCalled()
|
|
expect(onToursChanged).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('quarantines returned Tour ids before loading the imported Places', async () => {
|
|
const importCall = vi.spyOn(toursApi, 'importGpx').mockResolvedValue({
|
|
tours: [tour], caution: false, skipped: 0,
|
|
})
|
|
const onToursChanged = vi.fn()
|
|
const loadTrip = vi.spyOn(useTripStore.getState(), 'loadTrip').mockResolvedValue(undefined)
|
|
const { container } = render(<ToursSidebar
|
|
tripId={1}
|
|
days={[]}
|
|
tours={[]}
|
|
canEdit={true}
|
|
onAssignToDay={vi.fn()}
|
|
onToursChanged={onToursChanged}
|
|
onSelectTour={vi.fn()}
|
|
/>)
|
|
|
|
const input = container.querySelector('input[type="file"]')!
|
|
const file = new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })
|
|
fireEvent.change(input, { target: { files: [file] } })
|
|
|
|
await waitFor(() => expect(loadTrip).toHaveBeenCalledWith(1))
|
|
expect(onToursChanged).toHaveBeenCalledWith([42])
|
|
expect(onToursChanged.mock.invocationCallOrder[0]).toBeLessThan(loadTrip.mock.invocationCallOrder[0])
|
|
expect(importCall).toHaveBeenCalledOnce()
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('reports duplicate-only GPX success without refetching or invalidating unchanged data', async () => {
|
|
vi.spyOn(toursApi, 'importGpx').mockResolvedValue({ tours: [], caution: false, skipped: 3 })
|
|
const onToursChanged = vi.fn()
|
|
const loadTrip = vi.spyOn(useTripStore.getState(), 'loadTrip').mockResolvedValue(undefined)
|
|
const { container } = render(<ToursSidebar
|
|
tripId={1}
|
|
days={[]}
|
|
tours={[]}
|
|
canEdit={true}
|
|
onAssignToDay={vi.fn()}
|
|
onToursChanged={onToursChanged}
|
|
onSelectTour={vi.fn()}
|
|
/>)
|
|
|
|
fireEvent.change(container.querySelector('input[type=file]')!, {
|
|
target: { files: [new File(['<gpx/>'], 'already-imported.gpx', { type: 'application/gpx+xml' })] },
|
|
})
|
|
|
|
await waitFor(() => expect(toast.warning).toHaveBeenCalledWith('All places were already in the trip.'))
|
|
expect(onToursChanged).not.toHaveBeenCalled()
|
|
expect(loadTrip).not.toHaveBeenCalled()
|
|
vi.restoreAllMocks()
|
|
})
|
|
})
|