mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
85b5756e89
Ride minutes and late marks follow an edited timetable instead of the routing that fetched them; a landing day's join band reads as the ride it is rather than a nine hour drive of no length; the refuel offer is there on the road out of a terminal or a pick-up desk; the phone's stage map draws the ride; the share page numbers its stops the way the planner does; the PDF lists a day the way the day plan lists it, from both shells; a poster-less clip draws its play badge everywhere, not only in the editor; the hero title has room for its descenders and the list card keeps its badge clear; the release notice keeps its close control in reach when it stacks; a link chip's actions are thumb-sized and its delete asks first; the Google retry line and the Google-only switch appear only where Google answers.
259 lines
11 KiB
TypeScript
259 lines
11 KiB
TypeScript
// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-020
|
|
|
|
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
|
import { buildSettings } from '../../../tests/helpers/factories'
|
|
import { useSettingsStore } from '../../store/settingsStore'
|
|
import MobileEntryView from './MobileEntryView'
|
|
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
|
|
|
function buildPhoto(photoId: number): JourneyPhoto {
|
|
return {
|
|
id: photoId * 10,
|
|
entry_id: 1,
|
|
photo_id: photoId,
|
|
caption: null,
|
|
sort_order: photoId,
|
|
shared: 0,
|
|
created_at: 0,
|
|
}
|
|
}
|
|
|
|
function buildEntry(overrides: Record<string, unknown> = {}): JourneyEntry {
|
|
return {
|
|
id: 1,
|
|
journey_id: 1,
|
|
author_id: 1,
|
|
type: 'entry',
|
|
title: 'Sunrise over the fjord',
|
|
story: null,
|
|
entry_date: '2025-06-01',
|
|
entry_time: '07:30:00',
|
|
location_name: 'Geiranger, Norway',
|
|
location_lat: 62.1,
|
|
location_lng: 7.2,
|
|
mood: null,
|
|
weather: null,
|
|
tags: [],
|
|
pros_cons: null,
|
|
visibility: 'private',
|
|
sort_order: 0,
|
|
photos: [],
|
|
created_at: 0,
|
|
updated_at: 0,
|
|
...overrides,
|
|
} as unknown as JourneyEntry
|
|
}
|
|
|
|
interface ViewProps {
|
|
readOnly: boolean
|
|
publicPhotoUrl: (photoId: number) => string
|
|
onClose: () => void
|
|
onEdit: () => void
|
|
onDelete: () => void
|
|
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
|
|
}
|
|
|
|
function renderView(entry: JourneyEntry, props: Partial<ViewProps> = {}) {
|
|
const handlers = {
|
|
onClose: props.onClose ?? vi.fn(),
|
|
onEdit: props.onEdit ?? vi.fn(),
|
|
onDelete: props.onDelete ?? vi.fn(),
|
|
onPhotoClick: props.onPhotoClick ?? vi.fn(),
|
|
}
|
|
const result = render(
|
|
<MobileEntryView
|
|
entry={entry}
|
|
readOnly={props.readOnly}
|
|
publicPhotoUrl={props.publicPhotoUrl}
|
|
{...handlers}
|
|
/>,
|
|
)
|
|
return { ...result, ...handlers }
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetAllStores()
|
|
})
|
|
|
|
describe('MobileEntryView', () => {
|
|
it('FE-COMP-JENTRYVIEW-001: renders the header with the long date, time and location', () => {
|
|
renderView(buildEntry())
|
|
expect(screen.getByText('Sunday, June 1')).toBeInTheDocument()
|
|
expect(screen.getByText('07:30')).toBeInTheDocument()
|
|
expect(screen.getByText('Geiranger, Norway')).toBeInTheDocument()
|
|
expect(screen.getByRole('heading', { name: 'Sunrise over the fjord' })).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-JENTRYVIEW-002: an entry without time, location or title renders none of them', () => {
|
|
const { container } = renderView(buildEntry({ entry_time: null, location_name: null, title: null }))
|
|
expect(container.querySelector('h1')).toBeNull()
|
|
expect(container.querySelector('svg.lucide-clock')).toBeNull()
|
|
expect(container.querySelector('svg.lucide-map-pin')).toBeNull()
|
|
})
|
|
|
|
it('FE-COMP-JENTRYVIEW-003: the close button calls back', () => {
|
|
const { onClose, container } = renderView(buildEntry())
|
|
fireEvent.click(container.querySelector('svg.lucide-x')!.closest('button')!)
|
|
expect(onClose).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('FE-COMP-JENTRYVIEW-004: edit closes the sheet first, then opens the editor', () => {
|
|
const order: string[] = []
|
|
const onClose = vi.fn(() => { order.push('close') })
|
|
const onEdit = vi.fn(() => { order.push('edit') })
|
|
renderView(buildEntry(), { onClose, onEdit })
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /Edit/ }))
|
|
|
|
expect(order).toEqual(['close', 'edit'])
|
|
})
|
|
|
|
it('FE-COMP-JENTRYVIEW-005: delete closes the sheet first, then asks to delete', () => {
|
|
const order: string[] = []
|
|
const onClose = vi.fn(() => { order.push('close') })
|
|
const onDelete = vi.fn(() => { order.push('delete') })
|
|
const { container } = renderView(buildEntry(), { onClose, onDelete })
|
|
|
|
fireEvent.click(container.querySelector('svg.lucide-trash2')!.closest('button')!)
|
|
|
|
expect(order).toEqual(['close', 'delete'])
|
|
})
|
|
|
|
it('FE-COMP-JENTRYVIEW-006: read-only journeys hide the edit and delete actions', () => {
|
|
const { container } = renderView(buildEntry(), { readOnly: true })
|
|
expect(screen.queryByRole('button', { name: /Edit/ })).not.toBeInTheDocument()
|
|
expect(container.querySelector('svg.lucide-trash2')).toBeNull()
|
|
// the close button stays
|
|
expect(container.querySelector('svg.lucide-x')).not.toBeNull()
|
|
})
|
|
|
|
it('FE-COMP-JENTRYVIEW-007: a single photo renders as the hero without a strip or counter', () => {
|
|
const { container } = renderView(buildEntry({ photos: [buildPhoto(4)] }))
|
|
const images = container.querySelectorAll('img')
|
|
expect(images).toHaveLength(1)
|
|
expect(images[0]).toHaveAttribute('src', '/api/photos/4/original')
|
|
expect(container.querySelector('svg.lucide-camera')).toBeNull()
|
|
})
|
|
|
|
it('FE-COMP-JENTRYVIEW-008: several photos add a counter and a thumbnail strip', () => {
|
|
const { container } = renderView(buildEntry({ photos: [buildPhoto(4), buildPhoto(5), buildPhoto(6)] }))
|
|
expect(screen.getByText('3 photos')).toBeInTheDocument()
|
|
const images = container.querySelectorAll('img')
|
|
// hero + one thumbnail per photo
|
|
expect(images).toHaveLength(4)
|
|
expect(images[1]).toHaveAttribute('src', '/api/photos/4/thumbnail')
|
|
})
|
|
|
|
it('FE-COMP-JENTRYVIEW-009: tapping the hero and a thumbnail opens the lightbox at that index', () => {
|
|
const onPhotoClick = vi.fn()
|
|
const photos = [buildPhoto(4), buildPhoto(5)]
|
|
const { container } = renderView(buildEntry({ photos }), { onPhotoClick })
|
|
const images = container.querySelectorAll('img')
|
|
|
|
fireEvent.click(images[0])
|
|
expect(onPhotoClick).toHaveBeenLastCalledWith(photos, 0)
|
|
|
|
fireEvent.click(images[2])
|
|
expect(onPhotoClick).toHaveBeenLastCalledWith(photos, 1)
|
|
})
|
|
|
|
it('FE-COMP-JENTRYVIEW-010: a public journey builds its own photo urls', () => {
|
|
const { container } = renderView(
|
|
buildEntry({ photos: [buildPhoto(4), buildPhoto(5)] }),
|
|
{ publicPhotoUrl: (id: number) => `/public/p/${id}.jpg` },
|
|
)
|
|
const images = container.querySelectorAll('img')
|
|
expect(images[0]).toHaveAttribute('src', '/public/p/4.jpg')
|
|
expect(images[1]).toHaveAttribute('src', '/public/p/4.jpg')
|
|
})
|
|
|
|
it('FE-COMP-JENTRYVIEW-011: mood and weather render as labelled chips', () => {
|
|
const { container } = renderView(buildEntry({ mood: 'rough', weather: 'stormy' }))
|
|
expect(screen.getByText('Rough')).toBeInTheDocument()
|
|
expect(screen.getByText('Stormy')).toBeInTheDocument()
|
|
expect(container.querySelector('svg.lucide-frown')).not.toBeNull()
|
|
expect(container.querySelector('svg.lucide-cloud-lightning')).not.toBeNull()
|
|
})
|
|
|
|
it('FE-COMP-JENTRYVIEW-012: unknown mood and weather values render no chips', () => {
|
|
renderView(buildEntry({ mood: 'ecstatic', weather: 'hail' }))
|
|
expect(screen.queryByText('Rough')).not.toBeInTheDocument()
|
|
expect(screen.queryByText('Stormy')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-JENTRYVIEW-013: the story is rendered as markdown body copy', () => {
|
|
renderView(buildEntry({ story: 'We **walked** to the pier' }))
|
|
expect(screen.getByText('walked').tagName).toBe('STRONG')
|
|
})
|
|
|
|
it('FE-COMP-JENTRYVIEW-014: tags render as chips', () => {
|
|
renderView(buildEntry({ tags: ['hiking', 'fjord'] }))
|
|
expect(screen.getByText('hiking')).toBeInTheDocument()
|
|
expect(screen.getByText('fjord')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-JENTRYVIEW-015: pros and cons render both lists with a divider', () => {
|
|
const { container } = renderView(buildEntry({ pros_cons: { pros: ['great light'], cons: ['very cold'] } }))
|
|
expect(screen.getByText('Pros')).toBeInTheDocument()
|
|
expect(screen.getByText('Cons')).toBeInTheDocument()
|
|
expect(screen.getByText('great light')).toBeInTheDocument()
|
|
expect(screen.getByText('very cold')).toBeInTheDocument()
|
|
expect(container.querySelector('svg.lucide-thumbs-up')).not.toBeNull()
|
|
expect(container.querySelector('svg.lucide-thumbs-down')).not.toBeNull()
|
|
})
|
|
|
|
it('FE-COMP-JENTRYVIEW-016: a cons-only entry skips the pros block', () => {
|
|
renderView(buildEntry({ pros_cons: { pros: [], cons: ['very cold'] } }))
|
|
expect(screen.queryByText('Pros')).not.toBeInTheDocument()
|
|
expect(screen.getByText('Cons')).toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-JENTRYVIEW-017: an empty pros/cons block is not rendered at all', () => {
|
|
renderView(buildEntry({ pros_cons: { pros: [], cons: [] } }))
|
|
expect(screen.queryByText('Pros')).not.toBeInTheDocument()
|
|
expect(screen.queryByText('Cons')).not.toBeInTheDocument()
|
|
})
|
|
|
|
// This view held its own English label tables while the desktop one resolved
|
|
// the same values through i18n, so a German reader got English chips on a
|
|
// phone and translated ones on a tablet (#1846). Which of the two appears is
|
|
// decided by two different breakpoints, which is why it looked random.
|
|
it('FE-COMP-JENTRYVIEW-018: mood, weather and the edit action follow the chosen language', async () => {
|
|
seedStore(useSettingsStore, { settings: buildSettings({ language: 'de' }) })
|
|
renderView(buildEntry({
|
|
mood: 'amazing',
|
|
weather: 'sunny',
|
|
pros_cons: { pros: ['gutes Licht'], cons: ['sehr kalt'] },
|
|
}))
|
|
|
|
// The locale bundle is fetched, so the first paint is still English.
|
|
expect(await screen.findByText('Großartig')).toBeInTheDocument()
|
|
expect(screen.getByText('Sonnig')).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: /Bearbeiten/ })).toBeInTheDocument()
|
|
expect(screen.queryByText('Amazing')).not.toBeInTheDocument()
|
|
expect(screen.queryByText('Sunny')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('FE-COMP-JENTRYVIEW-019: a clip is drawn from its poster, in the hero as in the strip (#2341)', () => {
|
|
// Its original is the video file, which an <img> cannot show, so the hero used
|
|
// to be a broken image for every clip, poster or not.
|
|
const clip = { ...buildPhoto(4), media_type: 'video', provider: 'local', thumbnail_path: 'journey/poster.jpg' }
|
|
const { container } = renderView(buildEntry({ photos: [clip, buildPhoto(5)] }))
|
|
const images = container.querySelectorAll('img')
|
|
expect(images[0]).toHaveAttribute('src', '/api/photos/4/thumbnail')
|
|
expect(images[1]).toHaveAttribute('src', '/api/photos/4/thumbnail')
|
|
expect(container.querySelectorAll('svg.lucide-play')).toHaveLength(2)
|
|
})
|
|
|
|
it('FE-COMP-JENTRYVIEW-020: a clip without a poster keeps the play badge and asks for no image at all', () => {
|
|
const clip = { ...buildPhoto(4), media_type: 'video', provider: 'local', thumbnail_path: null }
|
|
const { container } = renderView(buildEntry({ photos: [clip, buildPhoto(5)] }))
|
|
const images = container.querySelectorAll('img')
|
|
// The second photo's strip thumbnail is the only image left.
|
|
expect(images).toHaveLength(1)
|
|
expect(images[0]).toHaveAttribute('src', '/api/photos/5/thumbnail')
|
|
expect(container.querySelectorAll('svg.lucide-play')).toHaveLength(2)
|
|
})
|
|
})
|