/** * ImagePreview Component Unit Tests * Tests for full-screen image preview with navigation */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ImagePreview } from './ImagePreview'; import type { TransactionImage } from '../../../types'; describe('ImagePreview', () => { const mockImages: TransactionImage[] = [ { id: 1, transactionId: 100, filePath: '/uploads/image1.jpg', fileName: 'receipt1.jpg', fileSize: 102400, // 100 KB mimeType: 'image/jpeg', createdAt: '2024-01-01T10:00:00Z', }, { id: 2, transactionId: 100, filePath: '/uploads/image2.jpg', fileName: 'receipt2.jpg', fileSize: 204800, // 200 KB mimeType: 'image/jpeg', createdAt: '2024-01-01T10:01:00Z', }, { id: 3, transactionId: 100, filePath: '/uploads/image3.jpg', fileName: 'receipt3.jpg', fileSize: 153600, // 150 KB mimeType: 'image/jpeg', createdAt: '2024-01-01T10:02:00Z', }, ]; const mockOnClose = vi.fn(); beforeEach(() => { mockOnClose.mockClear(); }); afterEach(() => { // Restore body overflow document.body.style.overflow = ''; }); describe('Rendering', () => { it('should not render when open is false', () => { const { container } = render( ); expect(container.firstChild).toBeNull(); }); it('should render when open is true', () => { render( ); expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByLabelText('图片预览')).toBeInTheDocument(); }); it('should not render when images array is empty', () => { const { container } = render( ); expect(container.firstChild).toBeNull(); }); it('should display the correct image at initialIndex', () => { render( ); const image = screen.getByAltText('receipt2.jpg'); expect(image).toBeInTheDocument(); expect(image).toHaveAttribute('src', expect.stringContaining('/images/2')); }); it('should display image counter', () => { render( ); expect(screen.getByText('1 / 3')).toBeInTheDocument(); }); it('should display image filename and filesize', () => { render( ); expect(screen.getByText('receipt1.jpg')).toBeInTheDocument(); expect(screen.getByText('100.0 KB')).toBeInTheDocument(); }); it('should display navigation buttons when multiple images', () => { render( ); expect(screen.getByLabelText('上一张')).toBeInTheDocument(); expect(screen.getByLabelText('下一张')).toBeInTheDocument(); }); it('should not display navigation buttons when single image', () => { render( ); expect(screen.queryByLabelText('上一张')).not.toBeInTheDocument(); expect(screen.queryByLabelText('下一张')).not.toBeInTheDocument(); }); }); describe('Close Functionality', () => { it('should call onClose when close button is clicked', async () => { const user = userEvent.setup(); render( ); const closeButton = screen.getByLabelText('关闭预览'); await user.click(closeButton); expect(mockOnClose).toHaveBeenCalledTimes(1); }); it('should call onClose when overlay is clicked', async () => { const user = userEvent.setup(); render( ); const overlay = screen.getByRole('dialog'); await user.click(overlay); expect(mockOnClose).toHaveBeenCalledTimes(1); }); it('should not call onClose when image container is clicked', async () => { const user = userEvent.setup(); render( ); const image = screen.getByAltText('receipt1.jpg'); await user.click(image); expect(mockOnClose).not.toHaveBeenCalled(); }); it('should call onClose when Escape key is pressed', () => { render( ); fireEvent.keyDown(window, { key: 'Escape' }); expect(mockOnClose).toHaveBeenCalledTimes(1); }); }); describe('Navigation', () => { it('should navigate to next image when next button is clicked', async () => { const user = userEvent.setup(); render( ); expect(screen.getByText('1 / 3')).toBeInTheDocument(); expect(screen.getByAltText('receipt1.jpg')).toBeInTheDocument(); const nextButton = screen.getByLabelText('下一张'); await user.click(nextButton); await waitFor(() => { expect(screen.getByText('2 / 3')).toBeInTheDocument(); expect(screen.getByAltText('receipt2.jpg')).toBeInTheDocument(); }); }); it('should navigate to previous image when previous button is clicked', async () => { const user = userEvent.setup(); render( ); expect(screen.getByText('2 / 3')).toBeInTheDocument(); expect(screen.getByAltText('receipt2.jpg')).toBeInTheDocument(); const prevButton = screen.getByLabelText('上一张'); await user.click(prevButton); await waitFor(() => { expect(screen.getByText('1 / 3')).toBeInTheDocument(); expect(screen.getByAltText('receipt1.jpg')).toBeInTheDocument(); }); }); it('should wrap to last image when clicking previous on first image', async () => { const user = userEvent.setup(); render( ); expect(screen.getByText('1 / 3')).toBeInTheDocument(); const prevButton = screen.getByLabelText('上一张'); await user.click(prevButton); await waitFor(() => { expect(screen.getByText('3 / 3')).toBeInTheDocument(); expect(screen.getByAltText('receipt3.jpg')).toBeInTheDocument(); }); }); it('should wrap to first image when clicking next on last image', async () => { const user = userEvent.setup(); render( ); expect(screen.getByText('3 / 3')).toBeInTheDocument(); const nextButton = screen.getByLabelText('下一张'); await user.click(nextButton); await waitFor(() => { expect(screen.getByText('1 / 3')).toBeInTheDocument(); expect(screen.getByAltText('receipt1.jpg')).toBeInTheDocument(); }); }); it('should navigate to next image when ArrowRight key is pressed', () => { render( ); expect(screen.getByText('1 / 3')).toBeInTheDocument(); fireEvent.keyDown(window, { key: 'ArrowRight' }); waitFor(() => { expect(screen.getByText('2 / 3')).toBeInTheDocument(); }); }); it('should navigate to previous image when ArrowLeft key is pressed', () => { render( ); expect(screen.getByText('2 / 3')).toBeInTheDocument(); fireEvent.keyDown(window, { key: 'ArrowLeft' }); waitFor(() => { expect(screen.getByText('1 / 3')).toBeInTheDocument(); }); }); }); describe('Body Scroll Lock', () => { it('should lock body scroll when modal is open', () => { render( ); expect(document.body.style.overflow).toBe('hidden'); }); it('should restore body scroll when modal is closed', () => { const { rerender } = render( ); expect(document.body.style.overflow).toBe('hidden'); rerender( ); expect(document.body.style.overflow).toBe(''); }); }); describe('Index Reset', () => { it('should reset to initialIndex when it changes', () => { const { rerender } = render( ); expect(screen.getByText('1 / 3')).toBeInTheDocument(); rerender( ); waitFor(() => { expect(screen.getByText('3 / 3')).toBeInTheDocument(); }); }); }); describe('Touch/Swipe Navigation', () => { it('should navigate to next image on left swipe', () => { render( ); const container = screen.getByAltText('receipt1.jpg').parentElement!; // Simulate left swipe (swipe from right to left) fireEvent.touchStart(container, { targetTouches: [{ clientX: 200 }], }); fireEvent.touchMove(container, { targetTouches: [{ clientX: 100 }], }); fireEvent.touchEnd(container); waitFor(() => { expect(screen.getByText('2 / 3')).toBeInTheDocument(); }); }); it('should navigate to previous image on right swipe', () => { render( ); const container = screen.getByAltText('receipt2.jpg').parentElement!; // Simulate right swipe (swipe from left to right) fireEvent.touchStart(container, { targetTouches: [{ clientX: 100 }], }); fireEvent.touchMove(container, { targetTouches: [{ clientX: 200 }], }); fireEvent.touchEnd(container); waitFor(() => { expect(screen.getByText('1 / 3')).toBeInTheDocument(); }); }); it('should not navigate on small swipe distance', () => { render( ); const container = screen.getByAltText('receipt1.jpg').parentElement!; // Simulate small swipe (less than minimum distance) fireEvent.touchStart(container, { targetTouches: [{ clientX: 100 }], }); fireEvent.touchMove(container, { targetTouches: [{ clientX: 120 }], }); fireEvent.touchEnd(container); // Should still be on first image expect(screen.getByText('1 / 3')).toBeInTheDocument(); }); }); describe('Accessibility', () => { it('should have proper ARIA attributes', () => { render( ); const dialog = screen.getByRole('dialog'); expect(dialog).toHaveAttribute('aria-modal', 'true'); expect(dialog).toHaveAttribute('aria-label', '图片预览'); }); it('should have proper button labels', () => { render( ); expect(screen.getByLabelText('关闭预览')).toBeInTheDocument(); expect(screen.getByLabelText('上一张')).toBeInTheDocument(); expect(screen.getByLabelText('下一张')).toBeInTheDocument(); }); it('should have non-draggable image', () => { render( ); const image = screen.getByAltText('receipt1.jpg'); expect(image).toHaveAttribute('draggable', 'false'); }); }); });