mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
151 lines
6.1 KiB
TypeScript
151 lines
6.1 KiB
TypeScript
import React, { useState } from 'react'
|
|
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { TourListItem } from '@trek/shared'
|
|
import { buildPlace } from '../../../tests/helpers/factories'
|
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
|
import { useSettingsStore } from '../../store/settingsStore'
|
|
import TourDetailDialog from './TourDetailDialog'
|
|
|
|
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('../../../../server/src/nest/database/database.service', () => { throw new Error('Database service imports are forbidden in client tests') })
|
|
vi.mock('better-sqlite3', () => { throw new Error('SQLite imports are forbidden in client tests') })
|
|
|
|
const place = buildPlace({
|
|
id: 42,
|
|
name: 'Ridge walk',
|
|
lat: 48,
|
|
lng: 11,
|
|
notes: 'Bring water',
|
|
route_geometry: JSON.stringify([
|
|
[48, 11, 500],
|
|
[48, 11.01, 550],
|
|
[48, 11.03, 520],
|
|
]),
|
|
})
|
|
|
|
const tour: TourListItem = {
|
|
place_id: 42,
|
|
name: 'Ridge walk',
|
|
tour_type: 'hike',
|
|
distance: 999,
|
|
elevation_gain: 999,
|
|
elevation_loss: 999,
|
|
duration: null,
|
|
difficulty: null,
|
|
wanderer_ref: null,
|
|
match_confidence: null,
|
|
tour_group_id: null,
|
|
max_hiking_difficulty: 2,
|
|
planned: false,
|
|
caution: false,
|
|
}
|
|
|
|
const defaultProps = {
|
|
canEdit: true,
|
|
canAssign: true,
|
|
tour,
|
|
place,
|
|
onClose: vi.fn(),
|
|
onUpdatePlace: vi.fn().mockResolvedValue(undefined),
|
|
onAssignToDay: vi.fn(),
|
|
onRemoveAssignment: vi.fn(),
|
|
onDelete: vi.fn(),
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetAllStores()
|
|
vi.clearAllMocks()
|
|
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } })
|
|
})
|
|
|
|
describe('TourDetailDialog', () => {
|
|
it('keeps desktop detail non-modal and returns focus to its Tour row on Escape', async () => {
|
|
const opener = document.createElement('button')
|
|
opener.textContent = 'Open Tour'
|
|
document.body.appendChild(opener)
|
|
const onClose = vi.fn()
|
|
function DesktopDetail() {
|
|
const [open, setOpen] = useState(true)
|
|
return open ? <TourDetailDialog {...defaultProps} desktopNonModal desktopFocusReturnTarget={opener}
|
|
onClose={() => { onClose(); setOpen(false) }} /> : null
|
|
}
|
|
|
|
const view = render(<DesktopDetail />)
|
|
const close = screen.getByRole('button', { name: 'Close' })
|
|
expect(close).toHaveClass('focus-visible:outline')
|
|
expect(document.querySelector('[aria-modal="true"]')).toBeNull()
|
|
expect(document.querySelector('.trek-modal-backdrop')).toBeNull()
|
|
expect(document.body.style.overflow).toBe('')
|
|
const tab = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })
|
|
close.dispatchEvent(tab)
|
|
expect(tab.defaultPrevented).toBe(false)
|
|
|
|
fireEvent.keyDown(document, { key: 'Escape' })
|
|
await waitFor(() => expect(screen.queryByText('Ridge walk')).not.toBeInTheDocument())
|
|
expect(onClose).toHaveBeenCalledTimes(1)
|
|
expect(document.activeElement).toBe(opener)
|
|
view.unmount()
|
|
document.body.removeChild(opener)
|
|
})
|
|
|
|
it('shows only curated tour content and uses live geometry instead of cached tour metrics', () => {
|
|
render(<TourDetailDialog {...defaultProps} />)
|
|
|
|
expect(screen.getByText('Ridge walk')).toBeInTheDocument()
|
|
expect(screen.getByText('Minimum altitude')).toBeInTheDocument()
|
|
expect(screen.getByText('Maximum altitude')).toBeInTheDocument()
|
|
expect(screen.getByText('Ascent')).toBeInTheDocument()
|
|
expect(screen.getByText('Descent')).toBeInTheDocument()
|
|
expect(screen.getByText('50 m')).toBeInTheDocument()
|
|
expect(screen.getByText('30 m')).toBeInTheDocument()
|
|
expect(screen.queryByText('999 m')).not.toBeInTheDocument()
|
|
expect(screen.getByText('Bring water')).toBeInTheDocument()
|
|
expect(screen.queryByText('Opening Hours')).not.toBeInTheDocument()
|
|
expect(screen.queryByText('Participants')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('uses existing assignment callbacks for add and remove actions', () => {
|
|
const { rerender } = render(<TourDetailDialog {...defaultProps} days={[{ id: 7 } as never]} selectedDayId={7} />)
|
|
fireEvent.click(screen.getByRole('button', { name: /Add to Day/i }))
|
|
expect(defaultProps.onAssignToDay).toHaveBeenCalledWith(42)
|
|
|
|
rerender(<TourDetailDialog {...defaultProps} days={[{ id: 7 } as never]} selectedDayId={7} assignments={{ '7': [{ id: 9, place }] as never }} />)
|
|
fireEvent.click(screen.getByRole('button', { name: /Remove from Day/i }))
|
|
expect(defaultProps.onRemoveAssignment).toHaveBeenCalledWith(7, 9)
|
|
})
|
|
|
|
it('requires place-edit permission for deletion while retaining day-edit assignment removal', () => {
|
|
render(<TourDetailDialog
|
|
{...defaultProps}
|
|
canEdit={false}
|
|
canAssign={true}
|
|
days={[{ id: 7 } as never]}
|
|
selectedDayId={7}
|
|
assignments={{ '7': [{ id: 9, place }] as never }}
|
|
/>)
|
|
|
|
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument()
|
|
fireEvent.click(screen.getByRole('button', { name: /Remove from Day/i }))
|
|
expect(defaultProps.onRemoveAssignment).toHaveBeenCalledWith(7, 9)
|
|
expect(defaultProps.onDelete).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('renames through the supplied place update path', async () => {
|
|
render(<TourDetailDialog {...defaultProps} />)
|
|
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
|
const input = screen.getByDisplayValue('Ridge walk')
|
|
fireEvent.change(input, { target: { value: 'New route name' } })
|
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
|
expect(defaultProps.onUpdatePlace).toHaveBeenCalledWith(42, { name: 'New route name' })
|
|
})
|
|
|
|
it('labels GPX imports as view-only planner content', () => {
|
|
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, has_waypoints: false }} />)
|
|
|
|
expect(screen.getByText('GPX import')).toBeInTheDocument()
|
|
expect(screen.getByText('GPX tours are view-only and cannot be edited in the planner yet.')).toBeInTheDocument()
|
|
})
|
|
})
|