import { fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { FileInput } from './FileInput'; // Helper function to create mock File objects function createMockFile(name: string, _size: number, type: string): File { const blob = new Blob([''], { type }); return new File([blob], name, { type, lastModified: Date.now() }); } // Helper function to get the hidden file input element // Note: File inputs are typically hidden and controlled via file trigger UI function getFileInput(): HTMLInputElement { const input = document.querySelector('input[type="file"]'); if (!input) { throw new Error('File input not found'); } return input as HTMLInputElement; } describe('FileInput', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('uncontrolled mode', () => { it('should manage files internally when value prop is not provided', async () => { const onChange = vi.fn(); render(); const file = createMockFile('test.txt', 1024, 'text/plain'); const input = getFileInput(); // Simulate file selection fireEvent.change(input, { target: { files: [file] } }); // In uncontrolled mode, onChange should still be called expect(onChange).toHaveBeenCalledWith([file]); // The file should be displayed expect(screen.getByText('test.txt')).toBeTruthy(); }); it('should clear files internally when clear button is clicked in uncontrolled mode', async () => { const onChange = vi.fn(); render(); const file = createMockFile('test.txt', 1024, 'text/plain'); const input = getFileInput(); fireEvent.change(input, { target: { files: [file] } }); expect(screen.getByText('test.txt')).toBeTruthy(); // Click clear button const clearButton = screen.getByText('Clear all'); await userEvent.click(clearButton); // onChange should be called with null expect(onChange).toHaveBeenCalledWith(null); // File should no longer be displayed expect(screen.queryByText('test.txt')).toBeNull(); }); }); describe('controlled mode', () => { it('should display files from value prop in controlled mode', () => { const file1 = createMockFile('document.pdf', 2048, 'application/pdf'); const file2 = createMockFile('image.png', 4096, 'image/png'); render( , ); expect(screen.getByText('document.pdf')).toBeTruthy(); expect(screen.getByText('image.png')).toBeTruthy(); expect(screen.getByText('2 files selected')).toBeTruthy(); }); it('should display no files when value is null in controlled mode', () => { render(); expect(screen.queryByText(/files? selected/)).toBeNull(); }); it('should display no files when value is empty array in controlled mode', () => { render(); expect(screen.queryByText(/files? selected/)).toBeNull(); }); it('should call onChange with new files when files are selected in controlled mode', async () => { const onChange = vi.fn(); render(); const file = createMockFile('new-file.txt', 1024, 'text/plain'); const input = getFileInput(); fireEvent.change(input, { target: { files: [file] } }); expect(onChange).toHaveBeenCalledWith([file]); }); it('should not update internal state when in controlled mode (parent controls value)', async () => { const onChange = vi.fn(); const initialFile = createMockFile('initial.txt', 1024, 'text/plain'); // Render with initial value const { rerender } = render( , ); expect(screen.getByText('initial.txt')).toBeTruthy(); // Simulate file selection const newFile = createMockFile('new-file.txt', 2048, 'text/plain'); const input = getFileInput(); fireEvent.change(input, { target: { files: [newFile] } }); // onChange should be called, but in controlled mode the displayed file // should still be the initial file until parent updates the value prop expect(onChange).toHaveBeenCalledWith([newFile]); // Without parent updating value, the display should still show original file expect(screen.getByText('initial.txt')).toBeTruthy(); expect(screen.queryByText('new-file.txt')).toBeNull(); // Now parent updates the value rerender( , ); // Now the new file should be displayed expect(screen.getByText('new-file.txt')).toBeTruthy(); expect(screen.queryByText('initial.txt')).toBeNull(); }); it('should call onChange with null when clear button is clicked in controlled mode', async () => { const onChange = vi.fn(); const file = createMockFile('test.pdf', 1024, 'application/pdf'); render( , ); expect(screen.getByText('test.pdf')).toBeTruthy(); const clearButton = screen.getByText('Clear all'); await userEvent.click(clearButton); expect(onChange).toHaveBeenCalledWith(null); }); it('should call onChange when individual file is removed in controlled mode', async () => { const onChange = vi.fn(); const file1 = createMockFile('file1.txt', 1024, 'text/plain'); const file2 = createMockFile('file2.txt', 2048, 'text/plain'); render( , ); // Find and click the remove button for the first file const removeButtons = screen.getAllByRole('button', { name: /Remove/ }); const firstRemoveButton = removeButtons[0]; expect(firstRemoveButton).toBeTruthy(); await userEvent.click(firstRemoveButton!); // Should be called with remaining file expect(onChange).toHaveBeenCalledWith([file2]); }); it('should call onChange with null when last file is removed in controlled mode', async () => { const onChange = vi.fn(); const file = createMockFile('only-file.txt', 1024, 'text/plain'); render( , ); const removeButton = screen.getByRole('button', { name: /Remove/ }); await userEvent.click(removeButton); expect(onChange).toHaveBeenCalledWith(null); }); }); describe('controlled vs uncontrolled detection', () => { it('should be controlled when value prop is provided (including null)', () => { const onChange = vi.fn(); // With value={null}, component is controlled render( , ); const file = createMockFile('test.txt', 1024, 'text/plain'); const input = getFileInput(); fireEvent.change(input, { target: { files: [file] } }); // onChange should be called but file should not be displayed (controlled mode) expect(onChange).toHaveBeenCalledWith([file]); // In controlled mode with value={null}, the file won't show until parent updates expect(screen.queryByText('test.txt')).toBeNull(); }); it('should be uncontrolled when value prop is undefined', async () => { const onChange = vi.fn(); render(); const file = createMockFile('test.txt', 1024, 'text/plain'); const input = getFileInput(); fireEvent.change(input, { target: { files: [file] } }); // In uncontrolled mode, file should be displayed immediately expect(onChange).toHaveBeenCalledWith([file]); expect(screen.getByText('test.txt')).toBeTruthy(); }); }); describe('onChange callback', () => { it('should receive File[] when files are selected', () => { const onChange = vi.fn(); render(); const file = createMockFile('test.txt', 1024, 'text/plain'); const input = getFileInput(); fireEvent.change(input, { target: { files: [file] } }); expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith([file]); expect(Array.isArray(onChange.mock.calls[0]?.[0])).toBe(true); }); it('should receive null when files are cleared', async () => { const onChange = vi.fn(); render(); // First add a file const file = createMockFile('test.txt', 1024, 'text/plain'); const input = getFileInput(); fireEvent.change(input, { target: { files: [file] } }); // Clear files const clearButton = screen.getByText('Clear all'); await userEvent.click(clearButton); expect(onChange).toHaveBeenLastCalledWith(null); }); it('should handle multiple files when allowsMultiple is true', () => { const onChange = vi.fn(); render( , ); const file1 = createMockFile('file1.txt', 1024, 'text/plain'); const file2 = createMockFile('file2.txt', 2048, 'text/plain'); const input = getFileInput(); fireEvent.change(input, { target: { files: [file1, file2] } }); expect(onChange).toHaveBeenCalledWith([file1, file2]); }); }); });