import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import MFileLightbox from '../../../../src/mobile/screens/trip/tabs/MFileLightbox' import { getAuthUrl } from '../../../../src/api/authUrl' import { downloadFile, openFile } from '../../../../src/utils/fileDownload' import type { TranslationFn, TripFile } from '../../../../src/types' import { fireEvent, render, screen, waitFor } from '../../../helpers/render' // FE-MOB-FLBOX-001 to FE-MOB-FLBOX-017 vi.mock('../../../../src/api/authUrl', () => ({ getAuthUrl: vi.fn(async (url: string) => `${url}?token=t1`), })) vi.mock('../../../../src/utils/fileDownload', () => ({ downloadFile: vi.fn(async () => undefined), openFile: vi.fn(async () => undefined), })) vi.mock('../../../../src/components/Journey/VideoPlayer', () => ({ default: ({ src }: { src: string }) =>
, })) const t: TranslationFn = key => key function file(id: number, name: string, mime = 'image/jpeg'): TripFile { return { id, trip_id: 1, filename: name, original_name: name, mime_type: mime, url: `/api/trips/1/files/${id}/download`, created_at: '2026-05-01T10:00:00.000Z', } as unknown as TripFile } const FILES = [file(1, 'beach.jpg'), file(2, 'castle.png', 'image/png'), file(3, 'clip.mp4', 'video/mp4')] function renderBox(index: number, overrides: { files?: TripFile[] } = {}) { const onIndexChange = vi.fn() const onClose = vi.fn() const view = render( , ) return { ...view, onIndexChange, onClose } } describe('MFileLightbox', () => { beforeEach(() => { vi.clearAllMocks() }) afterEach(() => { document.body.style.overflow = '' document.getElementById('m-sheet-root')?.remove() }) it('FE-MOB-FLBOX-001: shows the file name, the position counter and the signed image url', async () => { renderBox(0) expect(screen.getByText('beach.jpg')).toBeInTheDocument() expect(screen.getByText('1 / 3')).toBeInTheDocument() expect(getAuthUrl).toHaveBeenCalledWith('/api/trips/1/files/1/download', 'download') await waitFor(() => expect(screen.getByAltText('beach.jpg')).toHaveAttribute('src', '/api/trips/1/files/1/download?token=t1'), ) }) it('FE-MOB-FLBOX-002: renders nothing when the index points past the list', () => { const { container } = renderBox(5) expect(container).toBeEmptyDOMElement() expect(document.querySelector('.m-root')).toBeNull() expect(getAuthUrl).not.toHaveBeenCalled() }) it('FE-MOB-FLBOX-003: hides the prev arrow on the first file and the next arrow on the last', () => { renderBox(0) expect(screen.queryByRole('button', { name: 'mobileTrip.filesPrev' })).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'mobileTrip.filesNext' })).toBeInTheDocument() }) it('FE-MOB-FLBOX-004: steps forward and back without bubbling the close click', () => { const { onIndexChange, onClose } = renderBox(1) fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.filesNext' })) expect(onIndexChange).toHaveBeenCalledWith(2) fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.filesPrev' })) expect(onIndexChange).toHaveBeenCalledWith(0) expect(onClose).not.toHaveBeenCalled() }) it('FE-MOB-FLBOX-005: arrow keys navigate and Escape closes', () => { const { onIndexChange, onClose } = renderBox(1) fireEvent.keyDown(window, { key: 'ArrowRight' }) expect(onIndexChange).toHaveBeenCalledWith(2) fireEvent.keyDown(window, { key: 'ArrowLeft' }) expect(onIndexChange).toHaveBeenCalledWith(0) fireEvent.keyDown(window, { key: 'Escape' }) expect(onClose).toHaveBeenCalledTimes(1) }) it('FE-MOB-FLBOX-006: arrow keys stop at both ends', () => { const { onIndexChange, unmount } = renderBox(0) fireEvent.keyDown(window, { key: 'ArrowLeft' }) expect(onIndexChange).not.toHaveBeenCalled() unmount() const last = renderBox(FILES.length - 1) fireEvent.keyDown(window, { key: 'ArrowRight' }) expect(last.onIndexChange).not.toHaveBeenCalled() }) it('FE-MOB-FLBOX-007: unregisters the key handler on unmount', () => { const { onClose, unmount } = renderBox(0) unmount() fireEvent.keyDown(window, { key: 'Escape' }) expect(onClose).not.toHaveBeenCalled() }) it('FE-MOB-FLBOX-008: a swipe to the right goes back, a swipe to the left goes forward', () => { const { onIndexChange } = renderBox(1) const overlay = document.querySelector('.m-root') as HTMLElement fireEvent.touchStart(overlay, { touches: [{ clientX: 200 }] }) fireEvent.touchEnd(overlay, { changedTouches: [{ clientX: 300 }] }) expect(onIndexChange).toHaveBeenLastCalledWith(0) fireEvent.touchStart(overlay, { touches: [{ clientX: 300 }] }) fireEvent.touchEnd(overlay, { changedTouches: [{ clientX: 200 }] }) expect(onIndexChange).toHaveBeenLastCalledWith(2) }) it('FE-MOB-FLBOX-009: ignores a short drag and a touchend without a start', () => { const { onIndexChange } = renderBox(1) const overlay = document.querySelector('.m-root') as HTMLElement fireEvent.touchEnd(overlay, { changedTouches: [{ clientX: 900 }] }) expect(onIndexChange).not.toHaveBeenCalled() fireEvent.touchStart(overlay, { touches: [{ clientX: 200 }] }) fireEvent.touchEnd(overlay, { changedTouches: [{ clientX: 210 }] }) expect(onIndexChange).not.toHaveBeenCalled() }) it('FE-MOB-FLBOX-010: tapping the backdrop closes, tapping the header does not', () => { const { onClose } = renderBox(0) const overlay = document.querySelector('.m-root') as HTMLElement fireEvent.click(screen.getByText('beach.jpg')) expect(onClose).not.toHaveBeenCalled() fireEvent.click(overlay) expect(onClose).toHaveBeenCalledTimes(1) }) it('FE-MOB-FLBOX-011: tapping the empty media area closes but tapping the image does not', async () => { const { onClose } = renderBox(0) const img = await screen.findByAltText('beach.jpg') fireEvent.click(img) expect(onClose).not.toHaveBeenCalled() // The picture now sits in a wrapper that swallows the tap for it — the // itself carries no handler — so the empty area is one level up. fireEvent.click(img.parentElement?.parentElement as HTMLElement) expect(onClose).toHaveBeenCalledTimes(1) }) it('FE-MOB-FLBOX-012: the close button closes', () => { const { onClose } = renderBox(0) fireEvent.click(screen.getByRole('button', { name: 'common.close' })) expect(onClose).toHaveBeenCalledTimes(1) }) it('FE-MOB-FLBOX-013: opens and downloads through the protected file helpers', () => { renderBox(1) fireEvent.click(screen.getByRole('button', { name: 'files.openTab' })) expect(openFile).toHaveBeenCalledWith('/api/trips/1/files/2/download', 'castle.png') fireEvent.click(screen.getByRole('button', { name: 'files.download' })) expect(downloadFile).toHaveBeenCalledWith('/api/trips/1/files/2/download', 'castle.png') }) it('FE-MOB-FLBOX-014: swallows a failing open or download', async () => { vi.mocked(openFile).mockRejectedValueOnce(new Error('offline')) vi.mocked(downloadFile).mockRejectedValueOnce(new Error('offline')) renderBox(0) fireEvent.click(screen.getByRole('button', { name: 'files.openTab' })) fireEvent.click(screen.getByRole('button', { name: 'files.download' })) await waitFor(() => expect(downloadFile).toHaveBeenCalled()) expect(screen.getByText('beach.jpg')).toBeInTheDocument() }) it('FE-MOB-FLBOX-015: a video plays in the player instead of resolving a signed image url', () => { renderBox(2) expect(screen.getByTestId('video-player')).toHaveAttribute('data-src', '/api/trips/1/files/3/download') expect(screen.queryByAltText('clip.mp4')).not.toBeInTheDocument() expect(getAuthUrl).not.toHaveBeenCalled() }) it('FE-MOB-FLBOX-016: locks the body scroll while open and mounts into the sheet root', () => { const root = document.createElement('div') root.id = 'm-sheet-root' document.body.appendChild(root) document.body.style.overflow = 'auto' const { unmount } = renderBox(0) expect(document.body.style.overflow).toBe('hidden') expect(root.querySelector('.m-root')).not.toBeNull() unmount() expect(document.body.style.overflow).toBe('auto') }) it('FE-MOB-FLBOX-017: tapping the player does not dismiss the viewer', () => { const { onClose } = renderBox(2) fireEvent.click(screen.getByTestId('video-player').parentElement as HTMLElement) expect(onClose).not.toHaveBeenCalled() }) // Swiping is quicker than the token round trip, so the first file's url must not // land on the second one. it('FE-MOB-FLBOX-018: a token that resolves after a swipe does not overwrite the new image', async () => { const release: Array<(url: string) => void> = [] vi.mocked(getAuthUrl).mockImplementation( (url: string) => new Promise(resolve => { release.push(() => resolve(`${url}?token=t1`)) }), ) const { rerender, onIndexChange, onClose } = renderBox(0) rerender( , ) await waitFor(() => expect(release).toHaveLength(2)) // The first file resolves last. release[1]('') release[0]('') await waitFor(() => expect(screen.getByAltText('castle.png')).toHaveAttribute('src', '/api/trips/1/files/2/download?token=t1'), ) }) })