Readur/frontend/src/components/Labels/__tests__/LabelCreateDialog.test.tsx

507 lines
17 KiB
TypeScript

import { describe, test, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import LabelCreateDialog from '../LabelCreateDialog';
import { type LabelData } from '../Label';
const theme = createTheme();
const mockEditingLabel: LabelData = {
id: 'edit-label-1',
name: 'Existing Label',
description: 'An existing label',
color: '#ff0000',
background_color: undefined,
icon: 'star',
is_system: false,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
document_count: 5,
source_count: 2,
};
const renderLabelCreateDialog = (props: Partial<React.ComponentProps<typeof LabelCreateDialog>> = {}) => {
const defaultProps = {
open: true,
onClose: vi.fn(),
onSubmit: vi.fn(),
...props,
};
return render(
<ThemeProvider theme={theme}>
<LabelCreateDialog {...defaultProps} />
</ThemeProvider>
);
};
describe('LabelCreateDialog Component', () => {
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
user = userEvent.setup();
});
describe('Create Mode', () => {
test('should render create dialog title', () => {
renderLabelCreateDialog();
expect(screen.getByText('Create New Label')).toBeInTheDocument();
});
test('should render all form fields', () => {
renderLabelCreateDialog();
expect(screen.getByLabelText(/label name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/description/i)).toBeInTheDocument();
expect(screen.getByLabelText(/custom color/i)).toBeInTheDocument();
expect(screen.getByText('Color')).toBeInTheDocument();
expect(screen.getByText('Icon (optional)')).toBeInTheDocument();
expect(screen.getByText('Preview')).toBeInTheDocument();
});
test('should show prefilled name when provided', () => {
renderLabelCreateDialog({ prefilledName: 'Prefilled Name' });
const nameInput = screen.getByLabelText(/label name/i) as HTMLInputElement;
expect(nameInput.value).toBe('Prefilled Name');
});
test('should have default color', () => {
renderLabelCreateDialog();
const colorInput = screen.getByLabelText(/custom color/i) as HTMLInputElement;
expect(colorInput.value).toBe('#0969da');
});
test('should show create button', () => {
renderLabelCreateDialog();
expect(screen.getByText('Create')).toBeInTheDocument();
});
});
describe('Edit Mode', () => {
test('should render edit dialog title when editing', () => {
renderLabelCreateDialog({ editingLabel: mockEditingLabel });
expect(screen.getByText('Edit Label')).toBeInTheDocument();
});
test('should populate form with existing label data', () => {
renderLabelCreateDialog({ editingLabel: mockEditingLabel });
const nameInput = screen.getByLabelText(/label name/i) as HTMLInputElement;
const descInput = screen.getByLabelText(/description/i) as HTMLInputElement;
const colorInput = screen.getByLabelText(/custom color/i) as HTMLInputElement;
expect(nameInput.value).toBe('Existing Label');
expect(descInput.value).toBe('An existing label');
expect(colorInput.value).toBe('#ff0000');
});
test('should show update button when editing', () => {
renderLabelCreateDialog({ editingLabel: mockEditingLabel });
expect(screen.getByText('Update')).toBeInTheDocument();
});
});
describe('Form Validation', () => {
test('should disable submit button when name is empty', () => {
renderLabelCreateDialog();
const createButton = screen.getByText('Create');
expect(createButton).toBeDisabled();
});
test('should enable submit button when name is provided', async () => {
renderLabelCreateDialog();
const nameInput = screen.getByLabelText(/label name/i);
await user.type(nameInput, 'Test Label');
const createButton = screen.getByText('Create');
expect(createButton).not.toBeDisabled();
});
test('should disable submit button when name is empty', () => {
renderLabelCreateDialog();
const createButton = screen.getByText('Create');
expect(createButton).toBeDisabled();
});
test('should enable submit button when name is entered', async () => {
renderLabelCreateDialog();
// Button should be disabled initially
const createButton = screen.getByText('Create');
expect(createButton).toBeDisabled();
// Enter name
const nameInput = screen.getByLabelText(/label name/i);
await user.type(nameInput, 'Test Label');
// Button should be enabled
expect(createButton).not.toBeDisabled();
});
});
describe('Color Selection', () => {
test('should render predefined color buttons', () => {
renderLabelCreateDialog();
// Should render the Color section and have color input
expect(screen.getByText('Color')).toBeInTheDocument();
expect(screen.getByLabelText(/custom color/i)).toBeInTheDocument();
// The color buttons are rendered as Material-UI IconButtons with backgroundColor styling
// We'll just verify the color input and section exist rather than counting buttons
expect(screen.getByDisplayValue('#0969da')).toBeInTheDocument(); // Default color
});
test('should select color when predefined color is clicked', async () => {
renderLabelCreateDialog();
// Find a specific color button (this is approximate since colors are in styles)
const colorButtons = screen.getAllByRole('button').filter(button =>
button.getAttribute('style')?.includes('rgb(215, 58, 73)') // #d73a49 GitHub red
);
if (colorButtons.length > 0) {
await user.click(colorButtons[0]);
const colorInput = screen.getByLabelText(/custom color/i) as HTMLInputElement;
expect(colorInput.value).toBe('#d73a49');
}
});
test('should allow custom color input', async () => {
renderLabelCreateDialog();
const colorInput = screen.getByLabelText(/custom color/i);
await user.clear(colorInput);
await user.type(colorInput, '#abcdef');
expect((colorInput as HTMLInputElement).value).toBe('#abcdef');
});
});
describe('Icon Selection', () => {
test('should render icon selection buttons', () => {
renderLabelCreateDialog();
// Should show "None" option and various icon buttons
expect(screen.getByText('None')).toBeInTheDocument();
// Should have icon buttons - look for buttons with SVG icons
const allButtons = screen.getAllByRole('button');
const iconButtons = allButtons.filter(button =>
button.querySelector('svg[data-testid$="Icon"]') &&
!button.textContent?.includes('None') &&
!button.textContent?.includes('Create') &&
!button.textContent?.includes('Cancel')
);
expect(iconButtons.length).toBeGreaterThan(5);
});
test('should select None by default', () => {
renderLabelCreateDialog();
const noneButton = screen.getByText('None').closest('button');
expect(noneButton).toHaveStyle({ borderColor: expect.stringContaining('#') });
});
test('should select icon when clicked', async () => {
renderLabelCreateDialog();
// Find star icon button by looking for buttons with StarIcon
const allButtons = screen.getAllByRole('button');
const starButton = allButtons.find(button =>
button.querySelector('svg[data-testid="StarIcon"]')
);
expect(starButton).toBeInTheDocument();
if (starButton) {
await user.click(starButton);
// Visual feedback should show it's selected (border change)
expect(starButton).toHaveStyle({ borderColor: expect.stringContaining('#') });
}
});
test('should deselect icon when None is clicked', async () => {
renderLabelCreateDialog();
// Select an icon first
const allButtons = screen.getAllByRole('button');
const starButton = allButtons.find(button =>
button.querySelector('svg[data-testid="StarIcon"]')
);
if (starButton) {
await user.click(starButton);
}
// Then click None
const noneButton = screen.getByText('None').closest('button');
await user.click(noneButton!);
// None should be selected again
expect(noneButton).toHaveStyle({ borderColor: expect.stringContaining('#') });
});
});
describe('Preview', () => {
test('should show preview labels', () => {
renderLabelCreateDialog({ prefilledName: 'Test Label' });
// Should show both filled and outlined preview variants
const previewLabels = screen.getAllByText('Test Label');
expect(previewLabels.length).toBeGreaterThanOrEqual(2);
});
test('should update preview when name changes', async () => {
renderLabelCreateDialog();
const nameInput = screen.getByLabelText(/label name/i);
await user.type(nameInput, 'Dynamic Preview');
// Preview should update
expect(screen.getAllByText('Dynamic Preview').length).toBeGreaterThanOrEqual(2);
});
test('should show Label Preview when name is empty', () => {
renderLabelCreateDialog();
expect(screen.getAllByText('Label Preview').length).toBeGreaterThanOrEqual(2);
});
});
describe('Form Submission', () => {
test('should call onSubmit with correct data when creating', async () => {
const onSubmit = vi.fn();
renderLabelCreateDialog({ onSubmit });
// Fill form
const nameInput = screen.getByLabelText(/label name/i);
const descInput = screen.getByLabelText(/description/i);
await user.type(nameInput, 'Test Label');
await user.type(descInput, 'Test description');
// Submit
const createButton = screen.getByText('Create');
await user.click(createButton);
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
name: 'Test Label',
description: 'Test description',
color: '#0969da',
background_color: undefined,
icon: undefined,
}));
});
test('should call onSubmit with updated data when editing', async () => {
const onSubmit = vi.fn();
renderLabelCreateDialog({
onSubmit,
editingLabel: mockEditingLabel
});
// Change name
const nameInput = screen.getByLabelText(/label name/i);
await user.clear(nameInput);
await user.type(nameInput, 'Updated Label');
// Submit
const updateButton = screen.getByText('Update');
await user.click(updateButton);
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
name: 'Updated Label',
description: 'An existing label',
color: '#ff0000',
background_color: undefined,
icon: 'star',
}));
});
test('should handle submission with minimal data', async () => {
const onSubmit = vi.fn();
renderLabelCreateDialog({ onSubmit });
// Only fill required name field
const nameInput = screen.getByLabelText(/label name/i);
await user.type(nameInput, 'Minimal Label');
// Submit
const createButton = screen.getByText('Create');
await user.click(createButton);
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
name: 'Minimal Label',
description: undefined,
color: '#0969da',
background_color: undefined,
icon: undefined,
}));
});
test('should trim whitespace from name', async () => {
const onSubmit = vi.fn();
renderLabelCreateDialog({ onSubmit });
const nameInput = screen.getByLabelText(/label name/i);
await user.type(nameInput, ' Trimmed Label ');
const createButton = screen.getByText('Create');
await user.click(createButton);
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Trimmed Label',
})
);
});
});
describe('Loading State', () => {
test('should show loading state during submission', async () => {
const onSubmit = vi.fn().mockImplementation(() => new Promise(resolve => setTimeout(resolve, 500)));
renderLabelCreateDialog({ onSubmit });
const nameInput = screen.getByLabelText(/label name/i);
await user.type(nameInput, 'Test Label');
const createButton = screen.getByText('Create');
// Initially button should show "Create"
expect(createButton).toHaveTextContent('Create');
expect(createButton).not.toBeDisabled();
await user.click(createButton);
// Wait for loading state to appear - the button text should change to "Saving..."
await waitFor(() => {
expect(createButton).toHaveTextContent('Saving...');
}, { timeout: 2000 });
expect(createButton).toBeDisabled();
// Wait for submission to complete
await waitFor(() => {
expect(createButton).not.toHaveTextContent('Saving...');
}, { timeout: 3000 });
});
test('should disable form fields during submission', async () => {
const onSubmit = vi.fn().mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
renderLabelCreateDialog({ onSubmit });
const nameInput = screen.getByLabelText(/label name/i);
await user.type(nameInput, 'Test Label');
const createButton = screen.getByText('Create');
await user.click(createButton);
// Wait for loading state to take effect
await waitFor(() => {
expect(nameInput).toBeDisabled();
});
expect(screen.getByLabelText(/description/i)).toBeDisabled();
// Wait for submission to complete
await waitFor(() => {
expect(nameInput).not.toBeDisabled();
});
});
});
describe('Dialog Controls', () => {
test('should call onClose when cancel button is clicked', async () => {
const onClose = vi.fn();
renderLabelCreateDialog({ onClose });
const cancelButton = screen.getByText('Cancel');
await user.click(cancelButton);
expect(onClose).toHaveBeenCalled();
});
test('should not call onClose during loading', async () => {
const onClose = vi.fn();
const onSubmit = vi.fn().mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
renderLabelCreateDialog({ onClose, onSubmit });
const nameInput = screen.getByLabelText(/label name/i);
await user.type(nameInput, 'Test Label');
const createButton = screen.getByText('Create');
await user.click(createButton);
// Try to close during loading - should be disabled due to pointer-events: none
const cancelButton = screen.getByText('Cancel');
expect(cancelButton).toBeDisabled();
expect(onClose).not.toHaveBeenCalled();
// Wait for submission to complete
await waitFor(() => {
expect(screen.queryByText('Saving...')).not.toBeInTheDocument();
});
});
test('should reset form when dialog is reopened', () => {
const { rerender } = renderLabelCreateDialog({
open: false,
prefilledName: 'Initial Name'
});
// Reopen with different prefilled name
rerender(
<ThemeProvider theme={theme}>
<LabelCreateDialog
open={true}
onClose={vi.fn()}
onSubmit={vi.fn()}
prefilledName="New Name"
/>
</ThemeProvider>
);
const nameInput = screen.getByLabelText(/label name/i) as HTMLInputElement;
expect(nameInput.value).toBe('New Name');
});
});
describe('Accessibility', () => {
test('should have proper dialog role', () => {
renderLabelCreateDialog();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
test('should have proper form structure', () => {
renderLabelCreateDialog();
// All inputs should have proper labels
expect(screen.getByLabelText(/label name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/description/i)).toBeInTheDocument();
expect(screen.getByLabelText(/custom color/i)).toBeInTheDocument();
});
test('should handle form submission via Enter key', async () => {
const onSubmit = vi.fn();
renderLabelCreateDialog({ onSubmit });
const nameInput = screen.getByLabelText(/label name/i);
await user.type(nameInput, 'Test Label');
// Submit via Enter key
await user.keyboard('{Enter}');
expect(onSubmit).toHaveBeenCalled();
});
});
});