Files
TREK/client/src/components/Files/FileManagerImageLightbox.test.tsx
T
Maurice 0dd07befa7 feat(planner): bring the plan tab and every trip dialog into the new look
The Plan tab was the last trip tab still in the old design. It now reads
like Bookings, Costs, Lists, Files and Collab, without losing a function:

- Day plan: each day is a card with a head band (day tile with weather,
  stay and rental pills, a "+" menu for place, stay, transport, transit
  and note), rows with "..." menus, white badges and a white route bar.
- Places column: add, search and filters in three rows, a show dropdown,
  category and rating filters, and a selection bar at the foot.
- Place inspector, day details and the stay editor on the shared dialog
  frame; the place form in the editor look with the name in the head band.
- Clicking a booking or transport anywhere on the plan opens the booking
  detail popup first; Edit leads to the editor. The popup is one host
  shared with the Bookings and Transports tabs.
- Files and Collab dialogs, notes, polls, links and chat moved to the same
  frame; shared DialogShell and dialogParts hold the building blocks.
- The map hover card shows the place photo and follows the theme.

Every new and reworded string is translated into all locales, and a few
older English leftovers (help center, cost split, import hints) with them.
Phone screens keep their own look.
2026-09-27 21:00:19 +02:00

267 lines
12 KiB
TypeScript

// FE-W4LBX-001 to FE-W4LBX-020
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { TripFile } from '../../types'
import { render, screen, fireEvent, waitFor, act } from '../../../tests/helpers/render'
const getAuthUrl = vi.fn(async (url: string, _kind: string) => `${url}?token=abc`)
const openFile = vi.fn(async (_url: string, _name: string) => {})
const downloadFile = vi.fn(async (_url: string, _name: string) => {})
vi.mock('../../api/authUrl', () => ({ getAuthUrl: (url: string, kind: string) => getAuthUrl(url, kind) }))
vi.mock('../../utils/fileDownload', () => ({
openFile: (url: string, name: string) => openFile(url, name),
downloadFile: (url: string, name: string) => downloadFile(url, name),
}))
vi.mock('../Journey/VideoPlayer', () => ({
default: ({ src }: { src: string }) => <div data-testid="video" data-src={src} />,
}))
import { ImageLightbox } from './FileManagerImageLightbox'
type LightboxFile = TripFile & { url: string }
function file(overrides: Partial<LightboxFile> = {}): LightboxFile {
return { id: 1, original_name: 'beach.jpg', mime_type: 'image/jpeg', url: '/uploads/files/beach.jpg', ...overrides } as unknown as LightboxFile
}
const IMAGES = [
file({ id: 1, original_name: 'a.jpg', url: '/f/a.jpg' }),
file({ id: 2, original_name: 'b.jpg', url: '/f/b.jpg' }),
file({ id: 3, original_name: 'c.jpg', url: '/f/c.jpg' }),
]
beforeEach(() => {
getAuthUrl.mockReset()
getAuthUrl.mockImplementation(async (url: string) => `${url}?token=abc`)
openFile.mockReset()
openFile.mockResolvedValue(undefined)
downloadFile.mockReset()
downloadFile.mockResolvedValue(undefined)
})
describe('ImageLightbox', () => {
it('FE-W4LBX-001: renders nothing when the index points past the list', () => {
const { baseElement } = render(<ImageLightbox files={[]} initialIndex={0} onClose={() => {}} />)
expect(baseElement.querySelector('[role="presentation"]')).toBeNull()
})
it('FE-W4LBX-002: shows the file name and its position in the gallery', () => {
render(<ImageLightbox files={IMAGES} initialIndex={1} onClose={() => {}} />)
expect(screen.getByText('b.jpg', { exact: false })).toBeInTheDocument()
expect(screen.getByText('2 / 3')).toBeInTheDocument()
})
it('FE-W4LBX-003: mints a signed download url for the shown image', async () => {
const { baseElement } = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
await waitFor(() => expect(baseElement.querySelector('img[alt="a.jpg"]')).not.toBeNull())
expect(getAuthUrl).toHaveBeenCalledWith('/f/a.jpg', 'download')
expect(baseElement.querySelector('img[alt="a.jpg"]')).toHaveAttribute('src', '/f/a.jpg?token=abc')
})
it('FE-W4LBX-004: hides the previous arrow on the first and the next arrow on the last file', () => {
const first = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
// header (3) + next arrow + 3 thumbnails
expect(first.baseElement.querySelectorAll('.lucide-chevron-left')).toHaveLength(0)
expect(first.baseElement.querySelectorAll('.lucide-chevron-right')).toHaveLength(1)
first.unmount()
const last = render(<ImageLightbox files={IMAGES} initialIndex={2} onClose={() => {}} />)
expect(last.baseElement.querySelectorAll('.lucide-chevron-left')).toHaveLength(1)
expect(last.baseElement.querySelectorAll('.lucide-chevron-right')).toHaveLength(0)
})
it('FE-W4LBX-005: the arrows page through the gallery', () => {
const { baseElement } = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
fireEvent.click(baseElement.querySelector('.lucide-chevron-right')!.closest('button')!)
expect(screen.getByText('2 / 3')).toBeInTheDocument()
fireEvent.click(baseElement.querySelector('.lucide-chevron-left')!.closest('button')!)
expect(screen.getByText('1 / 3')).toBeInTheDocument()
})
it('FE-W4LBX-006: the arrow keys page and Escape closes', () => {
const onClose = vi.fn()
render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={onClose} />)
fireEvent.keyDown(window, { key: 'ArrowRight' })
expect(screen.getByText('2 / 3')).toBeInTheDocument()
fireEvent.keyDown(window, { key: 'ArrowLeft' })
expect(screen.getByText('1 / 3')).toBeInTheDocument()
fireEvent.keyDown(window, { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce()
})
it('FE-W4LBX-007: paging never runs past either end', () => {
render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
fireEvent.keyDown(window, { key: 'ArrowLeft' })
expect(screen.getByText('1 / 3')).toBeInTheDocument()
fireEvent.keyDown(window, { key: 'ArrowRight' })
fireEvent.keyDown(window, { key: 'ArrowRight' })
fireEvent.keyDown(window, { key: 'ArrowRight' })
expect(screen.getByText('3 / 3')).toBeInTheDocument()
})
it('FE-W4LBX-008: a swipe pages in the swiped direction', () => {
const { baseElement } = render(<ImageLightbox files={IMAGES} initialIndex={1} onClose={() => {}} />)
const root = baseElement.querySelector('[role="presentation"]') as HTMLElement
fireEvent.touchStart(root, { touches: [{ clientX: 200 }] })
fireEvent.touchEnd(root, { changedTouches: [{ clientX: 100 }] })
expect(screen.getByText('3 / 3')).toBeInTheDocument()
fireEvent.touchStart(root, { touches: [{ clientX: 100 }] })
fireEvent.touchEnd(root, { changedTouches: [{ clientX: 220 }] })
expect(screen.getByText('2 / 3')).toBeInTheDocument()
})
it('FE-W4LBX-009: a short swipe is ignored', () => {
const { baseElement } = render(<ImageLightbox files={IMAGES} initialIndex={1} onClose={() => {}} />)
const root = baseElement.querySelector('[role="presentation"]') as HTMLElement
fireEvent.touchStart(root, { touches: [{ clientX: 200 }] })
fireEvent.touchEnd(root, { changedTouches: [{ clientX: 180 }] })
expect(screen.getByText('2 / 3')).toBeInTheDocument()
})
it('FE-W4LBX-010: a touch end without a start is ignored', () => {
const { baseElement } = render(<ImageLightbox files={IMAGES} initialIndex={1} onClose={() => {}} />)
fireEvent.touchEnd(baseElement.querySelector('[role="presentation"]')!, { changedTouches: [{ clientX: 0 }] })
expect(screen.getByText('2 / 3')).toBeInTheDocument()
})
it('FE-W4LBX-011: the header buttons open, download and close', () => {
const onClose = vi.fn()
render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={onClose} />)
fireEvent.click(screen.getByRole('button', { name: /open/i }))
expect(openFile).toHaveBeenCalledWith('/f/a.jpg', 'a.jpg')
fireEvent.click(screen.getByRole('button', { name: /download/i }))
expect(downloadFile).toHaveBeenCalledWith('/f/a.jpg', 'a.jpg')
fireEvent.click(screen.getAllByRole('button')[2])
expect(onClose).toHaveBeenCalledOnce()
})
it('FE-W4LBX-012: clicking the backdrop closes but clicking the image does not', async () => {
const onClose = vi.fn()
const { baseElement } = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={onClose} />)
await waitFor(() => expect(baseElement.querySelector('img[alt="a.jpg"]')).not.toBeNull())
fireEvent.click(baseElement.querySelector('img[alt="a.jpg"]')!)
expect(onClose).not.toHaveBeenCalled()
fireEvent.click(baseElement.querySelector('[role="presentation"]')!)
expect(onClose).toHaveBeenCalledOnce()
})
it('FE-W4LBX-013: the thumbnail strip jumps to the picked file', async () => {
render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
// header: open + download + close, then the next arrow, then three thumbs.
const thumbs = screen.getAllByRole('button').slice(-3)
fireEvent.click(thumbs[2])
expect(screen.getByText('3 / 3')).toBeInTheDocument()
await waitFor(() => expect(getAuthUrl).toHaveBeenCalledWith('/f/c.jpg', 'download'))
})
it('FE-W4LBX-014: hides the strip for a single file', () => {
render(<ImageLightbox files={[IMAGES[0]]} initialIndex={0} onClose={() => {}} />)
expect(screen.getAllByRole('button')).toHaveLength(3)
})
it('FE-W4LBX-015: a video plays in the player and never mints a download token', async () => {
const video = file({ id: 9, original_name: 'clip.mp4', mime_type: 'video/mp4', url: '/f/clip.mp4' })
const { baseElement } = render(<ImageLightbox files={[video]} initialIndex={0} onClose={() => {}} />)
// The player loads on demand now — plyr no longer ships with the file manager.
expect(await screen.findByTestId('video')).toHaveAttribute('data-src', '/f/clip.mp4')
expect(getAuthUrl).not.toHaveBeenCalled()
expect(baseElement.querySelector('img')).toBeNull()
})
it('FE-W4LBX-016: a video thumbnail is a play glyph, not an image request', async () => {
const files = [IMAGES[0], file({ id: 9, original_name: 'clip.mp4', mime_type: 'video/mp4', url: '/f/clip.mp4' })]
const { baseElement } = render(<ImageLightbox files={files} initialIndex={0} onClose={() => {}} />)
await waitFor(() => expect(getAuthUrl).toHaveBeenCalledWith('/f/a.jpg', 'download'))
expect(getAuthUrl).not.toHaveBeenCalledWith('/f/clip.mp4', 'download')
expect(baseElement.querySelector('.lucide-play')).not.toBeNull()
})
it('FE-W4LBX-017: clicking the video wrapper does not close the lightbox', () => {
const onClose = vi.fn()
render(<ImageLightbox files={[file({ mime_type: 'video/mp4' })]} initialIndex={0} onClose={onClose} />)
fireEvent.click(screen.getByTestId('video').parentElement!)
expect(onClose).not.toHaveBeenCalled()
})
it('FE-W4LBX-018: Escape reaches the current onClose after a re-render', () => {
const first = vi.fn()
const second = vi.fn()
const { rerender } = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={first} />)
rerender(<ImageLightbox files={IMAGES} initialIndex={0} onClose={second} />)
fireEvent.keyDown(window, { key: 'Escape' })
expect(second).toHaveBeenCalledOnce()
expect(first).not.toHaveBeenCalled()
})
it('FE-W4LBX-020: a mint that lands after the gallery moved on does not paint', async () => {
const pending: Record<string, (url: string) => void> = {}
getAuthUrl.mockImplementation((url: string) => new Promise<string>(resolve => { pending[url] = resolve }))
const { baseElement } = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
await waitFor(() => expect(getAuthUrl).toHaveBeenCalledWith('/f/a.jpg', 'download'))
fireEvent.keyDown(window, { key: 'ArrowRight' })
await waitFor(() => expect(getAuthUrl).toHaveBeenCalledWith('/f/b.jpg', 'download'))
// The token for the image we already left resolves last.
await act(async () => {
pending['/f/b.jpg']('/f/b.jpg?token=b')
pending['/f/a.jpg']('/f/a.jpg?token=a')
})
expect(baseElement.querySelector('img[alt="b.jpg"]')).toHaveAttribute('src', '/f/b.jpg?token=b')
})
it('FE-W4LBX-019: the strip mints thumbnail tokens only for thumbs in view', async () => {
// Every thumbnail costs its own one-shot token, so an off-screen thumb must
// stay quiet. The default observer stub never intersects.
const { unmount } = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
await waitFor(() => expect(getAuthUrl).toHaveBeenCalledWith('/f/a.jpg', 'download'))
expect(getAuthUrl).toHaveBeenCalledTimes(1)
unmount()
getAuthUrl.mockClear()
const original = globalThis.IntersectionObserver
globalThis.IntersectionObserver = class {
constructor(private cb: IntersectionObserverCallback) {}
observe() { this.cb([{ isIntersecting: true } as IntersectionObserverEntry], this as unknown as IntersectionObserver) }
disconnect() {}
unobserve() {}
takeRecords() { return [] }
} as unknown as typeof IntersectionObserver
try {
render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
await waitFor(() => expect(getAuthUrl).toHaveBeenCalledWith('/f/c.jpg', 'download'))
} finally {
globalThis.IntersectionObserver = original
}
})
})