diff --git a/app/src/components/modals/file/CreateFileModal.test.tsx b/app/src/components/modals/file/CreateFileModal.test.tsx new file mode 100644 index 0000000..ffc3fd7 --- /dev/null +++ b/app/src/components/modals/file/CreateFileModal.test.tsx @@ -0,0 +1,415 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + render as rtlRender, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import CreateFileModal from './CreateFileModal'; + +// Mock notifications +vi.mock('@mantine/notifications', () => ({ + notifications: { + show: vi.fn(), + }, +})); + +// Mock ModalContext with modal always open +const mockModalContext = { + newFileModalVisible: true, + setNewFileModalVisible: vi.fn(), + deleteFileModalVisible: false, + setDeleteFileModalVisible: vi.fn(), + commitMessageModalVisible: false, + setCommitMessageModalVisible: vi.fn(), + settingsModalVisible: false, + setSettingsModalVisible: vi.fn(), + switchWorkspaceModalVisible: false, + setSwitchWorkspaceModalVisible: vi.fn(), + createWorkspaceModalVisible: false, + setCreateWorkspaceModalVisible: vi.fn(), +}; + +vi.mock('../../../contexts/ModalContext', () => ({ + useModalContext: () => mockModalContext, +})); + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('CreateFileModal', () => { + const mockOnCreateFile = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockOnCreateFile.mockResolvedValue(undefined); + + // Reset modal context mocks + mockModalContext.setNewFileModalVisible.mockClear(); + }); + + describe('Modal Visibility', () => { + it('renders modal when open', () => { + render(); + + expect(screen.getByText('Create New File')).toBeInTheDocument(); + expect(screen.getByLabelText(/file name/i)).toBeInTheDocument(); + expect(screen.getByText('Cancel')).toBeInTheDocument(); + expect(screen.getByText('Create')).toBeInTheDocument(); + }); + + it('calls setNewFileModalVisible when modal is closed', () => { + render(); + + const cancelButton = screen.getByText('Cancel'); + fireEvent.click(cancelButton); + + expect(mockModalContext.setNewFileModalVisible).toHaveBeenCalledWith( + false + ); + }); + }); + + describe('Form Interaction', () => { + it('updates file name input when typed', () => { + render(); + + const fileNameInput = screen.getByLabelText(/file name/i); + fireEvent.change(fileNameInput, { target: { value: 'test-file.md' } }); + + expect((fileNameInput as HTMLInputElement).value).toBe('test-file.md'); + }); + + it('handles form submission with valid file name', async () => { + render(); + + const fileNameInput = screen.getByLabelText(/file name/i); + const createButton = screen.getByText('Create'); + + fireEvent.change(fileNameInput, { target: { value: 'new-document.md' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockOnCreateFile).toHaveBeenCalledWith('new-document.md'); + }); + }); + + it('prevents submission with empty file name', () => { + render(); + + const createButton = screen.getByText('Create'); + fireEvent.click(createButton); + + // Should not call the function with empty name + expect(mockOnCreateFile).not.toHaveBeenCalled(); + }); + + it('closes modal after successful file creation', async () => { + render(); + + const fileNameInput = screen.getByLabelText(/file name/i); + const createButton = screen.getByText('Create'); + + fireEvent.change(fileNameInput, { target: { value: 'test.md' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockOnCreateFile).toHaveBeenCalledWith('test.md'); + }); + + await waitFor(() => { + expect(mockModalContext.setNewFileModalVisible).toHaveBeenCalledWith( + false + ); + }); + }); + + it('clears input after successful submission', async () => { + render(); + + const fileNameInput = screen.getByLabelText(/file name/i); + const createButton = screen.getByText('Create'); + + fireEvent.change(fileNameInput, { target: { value: 'test.md' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockOnCreateFile).toHaveBeenCalledWith('test.md'); + }); + + await waitFor(() => { + expect((fileNameInput as HTMLInputElement).value).toBe(''); + }); + }); + }); + + describe('Modal Actions', () => { + it('has cancel and create buttons', () => { + render(); + + expect(screen.getByText('Cancel')).toBeInTheDocument(); + expect(screen.getByText('Create')).toBeInTheDocument(); + }); + + it('closes modal when cancel button is clicked', () => { + render(); + + const cancelButton = screen.getByText('Cancel'); + fireEvent.click(cancelButton); + + expect(mockModalContext.setNewFileModalVisible).toHaveBeenCalledWith( + false + ); + }); + + it('calls onCreateFile when create button is clicked with valid input', async () => { + render(); + + const fileNameInput = screen.getByLabelText(/file name/i); + const createButton = screen.getByText('Create'); + + fireEvent.change(fileNameInput, { target: { value: 'test.md' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockOnCreateFile).toHaveBeenCalledTimes(1); + expect(mockOnCreateFile).toHaveBeenCalledWith('test.md'); + }); + }); + }); + + describe('File Name Validation', () => { + it('handles special characters in file names', async () => { + render(); + + const fileNameInput = screen.getByLabelText(/file name/i); + const createButton = screen.getByText('Create'); + + const specialFileName = 'file-with_special.chars (1).md'; + fireEvent.change(fileNameInput, { target: { value: specialFileName } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockOnCreateFile).toHaveBeenCalledWith(specialFileName); + }); + }); + + it('handles long file names', async () => { + render(); + + const fileNameInput = screen.getByLabelText(/file name/i); + const createButton = screen.getByText('Create'); + + const longFileName = 'a'.repeat(100) + '.md'; + fireEvent.change(fileNameInput, { target: { value: longFileName } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockOnCreateFile).toHaveBeenCalledWith(longFileName); + }); + }); + + it('handles file names without extensions', async () => { + render(); + + const fileNameInput = screen.getByLabelText(/file name/i); + const createButton = screen.getByText('Create'); + + fireEvent.change(fileNameInput, { target: { value: 'README' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockOnCreateFile).toHaveBeenCalledWith('README'); + }); + }); + + it('handles unicode characters in file names', async () => { + render(); + + const fileNameInput = screen.getByLabelText(/file name/i); + const createButton = screen.getByText('Create'); + + const unicodeFileName = 'ファイル名.md'; + fireEvent.change(fileNameInput, { target: { value: unicodeFileName } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockOnCreateFile).toHaveBeenCalledWith(unicodeFileName); + }); + }); + + it('trims whitespace from file names', async () => { + render(); + + const fileNameInput = screen.getByLabelText(/file name/i); + const createButton = screen.getByText('Create'); + + fireEvent.change(fileNameInput, { + target: { value: ' spaced-file.md ' }, + }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockOnCreateFile).toHaveBeenCalledWith('spaced-file.md'); + }); + }); + }); + + describe('Error Handling', () => { + it('handles creation errors gracefully', async () => { + mockOnCreateFile.mockRejectedValue(new Error('File creation failed')); + + render(); + + const fileNameInput = screen.getByLabelText(/file name/i); + const createButton = screen.getByText('Create'); + + fireEvent.change(fileNameInput, { target: { value: 'test.md' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockOnCreateFile).toHaveBeenCalledWith('test.md'); + }); + + // Modal should handle the error gracefully (not crash) + expect(screen.getByText('Create New File')).toBeInTheDocument(); + }); + + it('does not close modal when creation fails', async () => { + mockOnCreateFile.mockRejectedValue(new Error('File creation failed')); + + render(); + + const fileNameInput = screen.getByLabelText(/file name/i); + const createButton = screen.getByText('Create'); + + fireEvent.change(fileNameInput, { target: { value: 'test.md' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockOnCreateFile).toHaveBeenCalledWith('test.md'); + }); + + // Modal should remain open when creation fails + expect(mockModalContext.setNewFileModalVisible).not.toHaveBeenCalledWith( + false + ); + }); + }); + + describe('Accessibility', () => { + it('has proper form labels and structure', () => { + render(); + + const fileNameInput = screen.getByLabelText(/file name/i); + expect(fileNameInput).toBeInTheDocument(); + expect(fileNameInput.tagName).toBe('INPUT'); + expect(fileNameInput).toHaveAttribute('type', 'text'); + }); + + it('has proper button roles', () => { + render(); + + const buttons = screen.getAllByRole('button'); + expect(buttons.length).toBeGreaterThanOrEqual(2); // Cancel and Create buttons + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + const createButton = screen.getByRole('button', { name: /create/i }); + + expect(cancelButton).toBeInTheDocument(); + expect(createButton).toBeInTheDocument(); + }); + + it('supports keyboard navigation', () => { + render(); + + const fileNameInput = screen.getByLabelText(/file name/i); + + // Check that the input is focusable (not disabled or readonly) + expect(fileNameInput).not.toHaveAttribute('disabled'); + expect(fileNameInput).not.toHaveAttribute('readonly'); + + // Check that the input can receive keyboard events (more reliable than focus) + fireEvent.keyDown(fileNameInput, { key: 'a' }); + fireEvent.change(fileNameInput, { target: { value: 'test' } }); + + expect((fileNameInput as HTMLInputElement).value).toBe('test'); + + // Verify the input is accessible via keyboard navigation + expect(fileNameInput).toHaveAttribute('type', 'text'); + expect(fileNameInput).toHaveAccessibleName(); // Has proper label + }); + + it('has proper modal structure', () => { + render(); + + // Modal should have proper title + expect(screen.getByText('Create New File')).toBeInTheDocument(); + + // Should have form elements + expect(screen.getByLabelText(/file name/i)).toBeInTheDocument(); + }); + }); + + describe('Component Props', () => { + it('accepts and uses onCreateFile prop correctly', async () => { + const customMockCreate = vi.fn().mockResolvedValue(undefined); + + render(); + + const fileNameInput = screen.getByLabelText(/file name/i); + const createButton = screen.getByText('Create'); + + fireEvent.change(fileNameInput, { target: { value: 'custom-test.md' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(customMockCreate).toHaveBeenCalledWith('custom-test.md'); + }); + }); + + it('handles function prop correctly', () => { + const testFunction = vi.fn(); + + expect(() => { + render(); + }).not.toThrow(); + + expect(screen.getByText('Create New File')).toBeInTheDocument(); + }); + }); + + describe('Form Submission Edge Cases', () => { + it('submits form via Enter key', async () => { + render(); + + const fileNameInput = screen.getByLabelText(/file name/i); + + fireEvent.change(fileNameInput, { target: { value: 'enter-test.md' } }); + fireEvent.keyDown(fileNameInput, { key: 'Enter', code: 'Enter' }); + + await waitFor(() => { + expect(mockOnCreateFile).toHaveBeenCalledWith('enter-test.md'); + }); + }); + + it('does not submit empty form via Enter key', () => { + render(); + + const fileNameInput = screen.getByLabelText(/file name/i); + fireEvent.keyDown(fileNameInput, { key: 'Enter', code: 'Enter' }); + + // Should not call the function + expect(mockOnCreateFile).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/src/components/modals/file/CreateFileModal.tsx b/app/src/components/modals/file/CreateFileModal.tsx index 82d805b..d52c114 100644 --- a/app/src/components/modals/file/CreateFileModal.tsx +++ b/app/src/components/modals/file/CreateFileModal.tsx @@ -12,12 +12,19 @@ const CreateFileModal: React.FC = ({ onCreateFile }) => { const handleSubmit = async (): Promise => { if (fileName) { - await onCreateFile(fileName); + await onCreateFile(fileName.trim()); setFileName(''); setNewFileModalVisible(false); } }; + const handleKeyDown = (event: React.KeyboardEvent): void => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + void handleSubmit(); + } + }; + return ( = ({ onCreateFile }) => { setFileName(event.currentTarget.value)} + onKeyDown={handleKeyDown} mb="md" w="100%" />