// 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 }) =>
,
}))
import { ImageLightbox } from './FileManagerImageLightbox'
type LightboxFile = TripFile & { url: string }
function file(overrides: Partial = {}): 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( {}} />)
expect(baseElement.querySelector('[role="presentation"]')).toBeNull()
})
it('FE-W4LBX-002: shows the file name and its position in the gallery', () => {
render( {}} />)
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( {}} />)
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( {}} />)
// 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( {}} />)
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( {}} />)
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()
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( {}} />)
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( {}} />)
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( {}} />)
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( {}} />)
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()
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()
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( {}} />)
// 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( {}} />)
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( {}} />)
// 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( {}} />)
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()
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()
rerender()
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 void> = {}
getAuthUrl.mockImplementation((url: string) => new Promise(resolve => { pending[url] = resolve }))
const { baseElement } = render( {}} />)
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( {}} />)
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( {}} />)
await waitFor(() => expect(getAuthUrl).toHaveBeenCalledWith('/f/c.jpg', 'download'))
} finally {
globalThis.IntersectionObserver = original
}
})
})