import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import ImagePreviewer from './index' // Mock fetch const mockFetch = vi.fn() globalThis.fetch = mockFetch // Mock URL methods const mockRevokeObjectURL = vi.fn() const mockCreateObjectURL = vi.fn(() => 'blob:mock-url') globalThis.URL.revokeObjectURL = mockRevokeObjectURL globalThis.URL.createObjectURL = mockCreateObjectURL // Mock Image class MockImage { onload: (() => void) | null = null onerror: (() => void) | null = null _src = '' get src() { return this._src } set src(value: string) { this._src = value // Trigger onload after a microtask setTimeout(() => { if (this.onload) this.onload() }, 0) } naturalWidth = 800 naturalHeight = 600 } ;(globalThis as unknown as { Image: typeof MockImage }).Image = MockImage const createMockImages = () => [ { url: 'https://example.com/image1.png', name: 'image1.png', size: 1024 }, { url: 'https://example.com/image2.png', name: 'image2.png', size: 2048 }, { url: 'https://example.com/image3.png', name: 'image3.png', size: 3072 }, ] describe('ImagePreviewer', () => { beforeEach(() => { vi.clearAllMocks() // Default successful fetch mock mockFetch.mockResolvedValue({ ok: true, blob: () => Promise.resolve(new Blob(['test'], { type: 'image/png' })), }) }) afterEach(() => { vi.restoreAllMocks() }) describe('Rendering', () => { it('should render without crashing', async () => { const onClose = vi.fn() const images = createMockImages() await act(async () => { render() }) // Should render in portal expect(document.body.querySelector('.image-previewer')).toBeInTheDocument() }) it('should render close button', async () => { const onClose = vi.fn() const images = createMockImages() await act(async () => { render() }) // Esc text should be visible expect(screen.getByText('Esc')).toBeInTheDocument() }) it('should show loading state initially', async () => { const onClose = vi.fn() const images = createMockImages() // Delay fetch to see loading state mockFetch.mockImplementation(() => new Promise(() => {})) await act(async () => { render() }) // Loading component should be visible expect(document.body.querySelector('.image-previewer')).toBeInTheDocument() }) }) describe('Props', () => { it('should start at initialIndex', async () => { const onClose = vi.fn() const images = createMockImages() await act(async () => { render() }) await waitFor(() => { // Should start at second image expect(screen.getByText('image2.png')).toBeInTheDocument() }) }) it('should default initialIndex to 0', async () => { const onClose = vi.fn() const images = createMockImages() await act(async () => { render() }) await waitFor(() => { expect(screen.getByText('image1.png')).toBeInTheDocument() }) }) }) describe('User Interactions', () => { it('should call onClose when close button is clicked', async () => { const onClose = vi.fn() const images = createMockImages() await act(async () => { render() }) // Find and click close button (the one with RiCloseLine icon) const closeButton = document.querySelector('.absolute.right-6 button') if (closeButton) { fireEvent.click(closeButton) expect(onClose).toHaveBeenCalledTimes(1) } }) it('should navigate to next image when next button is clicked', async () => { const onClose = vi.fn() const images = createMockImages() await act(async () => { render() }) await waitFor(() => { expect(screen.getByText('image1.png')).toBeInTheDocument() }) // Find and click next button (right arrow) const buttons = document.querySelectorAll('button') const nextButton = Array.from(buttons).find(btn => btn.className.includes('right-8'), ) if (nextButton) { await act(async () => { fireEvent.click(nextButton) }) await waitFor(() => { expect(screen.getByText('image2.png')).toBeInTheDocument() }) } }) it('should navigate to previous image when prev button is clicked', async () => { const onClose = vi.fn() const images = createMockImages() await act(async () => { render() }) await waitFor(() => { expect(screen.getByText('image2.png')).toBeInTheDocument() }) // Find and click prev button (left arrow) const buttons = document.querySelectorAll('button') const prevButton = Array.from(buttons).find(btn => btn.className.includes('left-8'), ) if (prevButton) { await act(async () => { fireEvent.click(prevButton) }) await waitFor(() => { expect(screen.getByText('image1.png')).toBeInTheDocument() }) } }) it('should disable prev button at first image', async () => { const onClose = vi.fn() const images = createMockImages() await act(async () => { render() }) const buttons = document.querySelectorAll('button') const prevButton = Array.from(buttons).find(btn => btn.className.includes('left-8'), ) expect(prevButton).toBeDisabled() }) it('should disable next button at last image', async () => { const onClose = vi.fn() const images = createMockImages() await act(async () => { render() }) const buttons = document.querySelectorAll('button') const nextButton = Array.from(buttons).find(btn => btn.className.includes('right-8'), ) expect(nextButton).toBeDisabled() }) }) describe('Image Loading', () => { it('should fetch images on mount', async () => { const onClose = vi.fn() const images = createMockImages() await act(async () => { render() }) await waitFor(() => { expect(mockFetch).toHaveBeenCalled() }) }) it('should show error state when fetch fails', async () => { const onClose = vi.fn() const images = createMockImages() mockFetch.mockRejectedValue(new Error('Network error')) await act(async () => { render() }) await waitFor(() => { expect(screen.getByText(/Failed to load image/)).toBeInTheDocument() }) }) it('should show retry button on error', async () => { const onClose = vi.fn() const images = createMockImages() mockFetch.mockRejectedValue(new Error('Network error')) await act(async () => { render() }) await waitFor(() => { // Retry button should be visible const retryButton = document.querySelector('button.rounded-full') expect(retryButton).toBeInTheDocument() }) }) }) describe('Navigation Boundary Cases', () => { it('should not navigate past first image when prevImage is called at index 0', async () => { const onClose = vi.fn() const images = createMockImages() await act(async () => { render() }) await waitFor(() => { expect(screen.getByText('image1.png')).toBeInTheDocument() }) // Click prev button multiple times - should stay at first image const buttons = document.querySelectorAll('button') const prevButton = Array.from(buttons).find(btn => btn.className.includes('left-8'), ) if (prevButton) { await act(async () => { fireEvent.click(prevButton) fireEvent.click(prevButton) }) // Should still be at first image await waitFor(() => { expect(screen.getByText('image1.png')).toBeInTheDocument() }) } }) it('should not navigate past last image when nextImage is called at last index', async () => { const onClose = vi.fn() const images = createMockImages() await act(async () => { render() }) await waitFor(() => { expect(screen.getByText('image3.png')).toBeInTheDocument() }) // Click next button multiple times - should stay at last image const buttons = document.querySelectorAll('button') const nextButton = Array.from(buttons).find(btn => btn.className.includes('right-8'), ) if (nextButton) { await act(async () => { fireEvent.click(nextButton) fireEvent.click(nextButton) }) // Should still be at last image await waitFor(() => { expect(screen.getByText('image3.png')).toBeInTheDocument() }) } }) }) describe('Retry Functionality', () => { it('should retry image load when retry button is clicked', async () => { const onClose = vi.fn() const images = createMockImages() // First fail, then succeed let callCount = 0 mockFetch.mockImplementation(() => { callCount++ if (callCount === 1) { return Promise.reject(new Error('Network error')) } return Promise.resolve({ ok: true, blob: () => Promise.resolve(new Blob(['test'], { type: 'image/png' })), }) }) await act(async () => { render() }) // Wait for error state await waitFor(() => { expect(screen.getByText(/Failed to load image/)).toBeInTheDocument() }) // Click retry button const retryButton = document.querySelector('button.rounded-full') if (retryButton) { await act(async () => { fireEvent.click(retryButton) }) // Should refetch the image await waitFor(() => { expect(mockFetch).toHaveBeenCalledTimes(4) // 3 initial + 1 retry }) } }) it('should show retry button and call retryImage when clicked', async () => { const onClose = vi.fn() const images = createMockImages() mockFetch.mockRejectedValue(new Error('Network error')) await act(async () => { render() }) await waitFor(() => { expect(screen.getByText(/Failed to load image/)).toBeInTheDocument() }) // Find and click the retry button (not the nav buttons) const allButtons = document.querySelectorAll('button') const retryButton = Array.from(allButtons).find(btn => btn.className.includes('rounded-full') && !btn.className.includes('left-8') && !btn.className.includes('right-8'), ) expect(retryButton).toBeInTheDocument() if (retryButton) { mockFetch.mockClear() mockFetch.mockResolvedValue({ ok: true, blob: () => Promise.resolve(new Blob(['test'], { type: 'image/png' })), }) await act(async () => { fireEvent.click(retryButton) }) await waitFor(() => { expect(mockFetch).toHaveBeenCalled() }) } }) }) describe('Image Cache', () => { it('should clean up blob URLs on unmount', async () => { const onClose = vi.fn() const images = createMockImages() // First render to populate cache const { unmount } = await act(async () => { const result = render() return result }) await waitFor(() => { expect(mockFetch).toHaveBeenCalled() }) // Store the call count for verification const _firstCallCount = mockFetch.mock.calls.length unmount() // Note: The imageCache is cleared on unmount, so this test verifies // the cleanup behavior rather than caching across mounts expect(mockRevokeObjectURL).toHaveBeenCalled() }) }) describe('Edge Cases', () => { it('should handle single image', async () => { const onClose = vi.fn() const images = [createMockImages()[0]] await act(async () => { render() }) // Both navigation buttons should be disabled const buttons = document.querySelectorAll('button') const prevButton = Array.from(buttons).find(btn => btn.className.includes('left-8'), ) const nextButton = Array.from(buttons).find(btn => btn.className.includes('right-8'), ) expect(prevButton).toBeDisabled() expect(nextButton).toBeDisabled() }) it('should stop event propagation on container click', async () => { const onClose = vi.fn() const parentClick = vi.fn() const images = createMockImages() await act(async () => { render(
, ) }) const container = document.querySelector('.image-previewer') if (container) { fireEvent.click(container) expect(parentClick).not.toHaveBeenCalled() } }) it('should display image dimensions when loaded', async () => { const onClose = vi.fn() const images = createMockImages() await act(async () => { render() }) await waitFor(() => { // Should display dimensions (800 × 600 from MockImage) expect(screen.getByText(/800.*600/)).toBeInTheDocument() }) }) it('should display file size', async () => { const onClose = vi.fn() const images = createMockImages() await act(async () => { render() }) await waitFor(() => { // Should display formatted file size expect(screen.getByText('image1.png')).toBeInTheDocument() }) }) }) })