This commit is contained in:
Danilo Reyes
2025-11-02 01:01:38 -06:00
parent b0e22af242
commit 48020b6f42
8 changed files with 2473 additions and 17 deletions

View File

@@ -0,0 +1,536 @@
/**
* Component tests for board components
* Tests BoardCard, CreateBoardModal, and DeleteConfirmModal
*/
import { render, fireEvent, screen, waitFor } from '@testing-library/svelte';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { goto } from '$app/navigation';
import BoardCard from '$lib/components/boards/BoardCard.svelte';
import CreateBoardModal from '$lib/components/boards/CreateBoardModal.svelte';
import DeleteConfirmModal from '$lib/components/common/DeleteConfirmModal.svelte';
import type { BoardSummary } from '$lib/types/boards';
// Mock $app/navigation
vi.mock('$app/navigation', () => ({
goto: vi.fn(),
}));
describe('BoardCard', () => {
const mockBoard: BoardSummary = {
id: '123e4567-e89b-12d3-a456-426614174000',
title: 'Test Board',
description: 'Test description',
image_count: 5,
thumbnail_url: 'https://example.com/thumb.jpg',
created_at: '2025-11-01T10:00:00Z',
updated_at: '2025-11-02T15:30:00Z',
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('renders board title', () => {
render(BoardCard, { props: { board: mockBoard } });
expect(screen.getByText('Test Board')).toBeInTheDocument();
});
it('renders board description', () => {
render(BoardCard, { props: { board: mockBoard } });
expect(screen.getByText('Test description')).toBeInTheDocument();
});
it('renders image count', () => {
render(BoardCard, { props: { board: mockBoard } });
expect(screen.getByText('5 images')).toBeInTheDocument();
});
it('renders singular "image" when count is 1', () => {
const singleImageBoard = { ...mockBoard, image_count: 1 };
render(BoardCard, { props: { board: singleImageBoard } });
expect(screen.getByText('1 image')).toBeInTheDocument();
});
it('renders thumbnail image when URL provided', () => {
render(BoardCard, { props: { board: mockBoard } });
const img = screen.getByAltText('Test Board');
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute('src', 'https://example.com/thumb.jpg');
});
it('renders placeholder when no thumbnail', () => {
const noThumbBoard = { ...mockBoard, thumbnail_url: null };
render(BoardCard, { props: { board: noThumbBoard } });
expect(screen.getByText('🖼️')).toBeInTheDocument();
});
it('renders formatted update date', () => {
render(BoardCard, { props: { board: mockBoard } });
// Should show "Updated Nov 2, 2025" or similar
expect(screen.getByText(/updated/i)).toBeInTheDocument();
});
it('renders without description when null', () => {
const noDescBoard = { ...mockBoard, description: null };
render(BoardCard, { props: { board: noDescBoard } });
expect(screen.queryByText('Test description')).not.toBeInTheDocument();
});
});
describe('Interactions', () => {
it('navigates to board on click', async () => {
render(BoardCard, { props: { board: mockBoard } });
const card = screen.getByRole('button');
await fireEvent.click(card);
expect(goto).toHaveBeenCalledWith('/boards/123e4567-e89b-12d3-a456-426614174000');
});
it('navigates to board on Enter key', async () => {
render(BoardCard, { props: { board: mockBoard } });
const card = screen.getByRole('button');
await fireEvent.keyDown(card, { key: 'Enter' });
expect(goto).toHaveBeenCalledWith('/boards/123e4567-e89b-12d3-a456-426614174000');
});
it('dispatches delete event when delete button clicked', async () => {
const { component } = render(BoardCard, { props: { board: mockBoard } });
const deleteHandler = vi.fn();
component.$on('delete', deleteHandler);
const deleteBtn = screen.getByLabelText('Delete board');
await fireEvent.click(deleteBtn);
expect(deleteHandler).toHaveBeenCalledTimes(1);
});
it('delete button click stops propagation', async () => {
render(BoardCard, { props: { board: mockBoard } });
const deleteBtn = screen.getByLabelText('Delete board');
await fireEvent.click(deleteBtn);
// Card click should not have been triggered (goto should not be called)
expect(goto).not.toHaveBeenCalled();
});
it('has proper accessibility attributes', () => {
render(BoardCard, { props: { board: mockBoard } });
const card = screen.getByRole('button');
expect(card).toHaveAttribute('tabindex', '0');
});
});
});
describe('CreateBoardModal', () => {
describe('Rendering', () => {
it('renders modal with title', () => {
render(CreateBoardModal);
expect(screen.getByText('Create New Board')).toBeInTheDocument();
});
it('renders all form fields', () => {
render(CreateBoardModal);
expect(screen.getByLabelText(/board title/i)).toBeInTheDocument();
expect(screen.getByLabelText(/description/i)).toBeInTheDocument();
});
it('renders create and cancel buttons', () => {
render(CreateBoardModal);
expect(screen.getByRole('button', { name: /create board/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
it('populates initial values when provided', () => {
render(CreateBoardModal, {
props: { initialTitle: 'My Board', initialDescription: 'My Description' },
});
const titleInput = screen.getByLabelText(/board title/i) as HTMLInputElement;
const descInput = screen.getByLabelText(/description/i) as HTMLTextAreaElement;
expect(titleInput.value).toBe('My Board');
expect(descInput.value).toBe('My Description');
});
});
describe('Validation', () => {
it('shows error when title is empty', async () => {
render(CreateBoardModal);
const submitBtn = screen.getByRole('button', { name: /create board/i });
await fireEvent.click(submitBtn);
expect(await screen.findByText(/title is required/i)).toBeInTheDocument();
});
it('shows error when title is too long', async () => {
render(CreateBoardModal);
const titleInput = screen.getByLabelText(/board title/i);
await fireEvent.input(titleInput, { target: { value: 'a'.repeat(256) } });
const submitBtn = screen.getByRole('button', { name: /create board/i });
await fireEvent.click(submitBtn);
expect(await screen.findByText(/255 characters or less/i)).toBeInTheDocument();
});
it('shows error when description is too long', async () => {
render(CreateBoardModal);
const titleInput = screen.getByLabelText(/board title/i);
await fireEvent.input(titleInput, { target: { value: 'Valid Title' } });
const descInput = screen.getByLabelText(/description/i);
await fireEvent.input(descInput, { target: { value: 'a'.repeat(1001) } });
const submitBtn = screen.getByRole('button', { name: /create board/i });
await fireEvent.click(submitBtn);
expect(await screen.findByText(/1000 characters or less/i)).toBeInTheDocument();
});
it('accepts valid input', async () => {
const { component } = render(CreateBoardModal);
const createHandler = vi.fn();
component.$on('create', createHandler);
const titleInput = screen.getByLabelText(/board title/i);
await fireEvent.input(titleInput, { target: { value: 'Valid Board Title' } });
const descInput = screen.getByLabelText(/description/i);
await fireEvent.input(descInput, { target: { value: 'Valid description' } });
const submitBtn = screen.getByRole('button', { name: /create board/i });
await fireEvent.click(submitBtn);
expect(createHandler).toHaveBeenCalledTimes(1);
});
it('shows character count for title', async () => {
render(CreateBoardModal);
const titleInput = screen.getByLabelText(/board title/i);
await fireEvent.input(titleInput, { target: { value: 'Test' } });
expect(screen.getByText(/4\/255 characters/i)).toBeInTheDocument();
});
it('shows character count for description', async () => {
render(CreateBoardModal);
const descInput = screen.getByLabelText(/description/i);
await fireEvent.input(descInput, { target: { value: 'Testing' } });
expect(screen.getByText(/7\/1000 characters/i)).toBeInTheDocument();
});
});
describe('Submission', () => {
it('dispatches create event with correct data', async () => {
const { component } = render(CreateBoardModal);
const createHandler = vi.fn();
component.$on('create', createHandler);
const titleInput = screen.getByLabelText(/board title/i);
await fireEvent.input(titleInput, { target: { value: 'My Board' } });
const descInput = screen.getByLabelText(/description/i);
await fireEvent.input(descInput, { target: { value: 'My Description' } });
const submitBtn = screen.getByRole('button', { name: /create board/i });
await fireEvent.click(submitBtn);
await waitFor(() => {
expect(createHandler).toHaveBeenCalledTimes(1);
});
const event = createHandler.mock.calls[0][0];
expect(event.detail).toEqual({
title: 'My Board',
description: 'My Description',
});
});
it('omits description when empty', async () => {
const { component } = render(CreateBoardModal);
const createHandler = vi.fn();
component.$on('create', createHandler);
const titleInput = screen.getByLabelText(/board title/i);
await fireEvent.input(titleInput, { target: { value: 'My Board' } });
const submitBtn = screen.getByRole('button', { name: /create board/i });
await fireEvent.click(submitBtn);
await waitFor(() => {
expect(createHandler).toHaveBeenCalled();
});
const event = createHandler.mock.calls[0][0];
expect(event.detail.description).toBeUndefined();
});
it('trims whitespace from inputs', async () => {
const { component } = render(CreateBoardModal);
const createHandler = vi.fn();
component.$on('create', createHandler);
const titleInput = screen.getByLabelText(/board title/i);
await fireEvent.input(titleInput, { target: { value: ' My Board ' } });
const descInput = screen.getByLabelText(/description/i);
await fireEvent.input(descInput, { target: { value: ' My Description ' } });
const submitBtn = screen.getByRole('button', { name: /create board/i });
await fireEvent.click(submitBtn);
await waitFor(() => {
expect(createHandler).toHaveBeenCalled();
});
const event = createHandler.mock.calls[0][0];
expect(event.detail.title).toBe('My Board');
expect(event.detail.description).toBe('My Description');
});
});
describe('Modal Behavior', () => {
it('dispatches close event when cancel clicked', async () => {
const { component } = render(CreateBoardModal);
const closeHandler = vi.fn();
component.$on('close', closeHandler);
const cancelBtn = screen.getByRole('button', { name: /cancel/i });
await fireEvent.click(cancelBtn);
expect(closeHandler).toHaveBeenCalledTimes(1);
});
it('dispatches close event when X button clicked', async () => {
const { component } = render(CreateBoardModal);
const closeHandler = vi.fn();
component.$on('close', closeHandler);
const closeBtn = screen.getByLabelText(/close/i);
await fireEvent.click(closeBtn);
expect(closeHandler).toHaveBeenCalledTimes(1);
});
it('dispatches close event when backdrop clicked', async () => {
const { component } = render(CreateBoardModal);
const closeHandler = vi.fn();
component.$on('close', closeHandler);
const backdrop = screen.getByRole('dialog');
await fireEvent.click(backdrop);
expect(closeHandler).toHaveBeenCalledTimes(1);
});
it('does not close when modal content clicked', async () => {
const { component } = render(CreateBoardModal);
const closeHandler = vi.fn();
component.$on('close', closeHandler);
const modalContent = screen.getByText('Create New Board').closest('.modal-content');
if (modalContent) {
await fireEvent.click(modalContent);
}
expect(closeHandler).not.toHaveBeenCalled();
});
});
});
describe('DeleteConfirmModal', () => {
const defaultProps = {
title: 'Delete Item',
message: 'Are you sure?',
itemName: 'Test Item',
};
describe('Rendering', () => {
it('renders with provided title', () => {
render(DeleteConfirmModal, { props: defaultProps });
expect(screen.getByText('Delete Item')).toBeInTheDocument();
});
it('renders with provided message', () => {
render(DeleteConfirmModal, { props: defaultProps });
expect(screen.getByText('Are you sure?')).toBeInTheDocument();
});
it('renders item name when provided', () => {
render(DeleteConfirmModal, { props: defaultProps });
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
it('renders without item name when not provided', () => {
const props = { ...defaultProps, itemName: '' };
render(DeleteConfirmModal, { props });
expect(screen.queryByRole('strong')).not.toBeInTheDocument();
});
it('renders destructive warning icon by default', () => {
render(DeleteConfirmModal, { props: defaultProps });
expect(screen.getByText('⚠️')).toBeInTheDocument();
});
it('renders info icon when not destructive', () => {
const props = { ...defaultProps, isDestructive: false };
render(DeleteConfirmModal, { props });
expect(screen.getByText('')).toBeInTheDocument();
});
it('renders custom button text', () => {
const props = {
...defaultProps,
confirmText: 'Remove',
cancelText: 'Keep',
};
render(DeleteConfirmModal, { props });
expect(screen.getByRole('button', { name: /remove/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /keep/i })).toBeInTheDocument();
});
});
describe('Interactions', () => {
it('dispatches confirm event when confirm button clicked', async () => {
const { component } = render(DeleteConfirmModal, { props: defaultProps });
const confirmHandler = vi.fn();
component.$on('confirm', confirmHandler);
const confirmBtn = screen.getByRole('button', { name: /delete/i });
await fireEvent.click(confirmBtn);
expect(confirmHandler).toHaveBeenCalledTimes(1);
});
it('dispatches cancel event when cancel button clicked', async () => {
const { component } = render(DeleteConfirmModal, { props: defaultProps });
const cancelHandler = vi.fn();
component.$on('cancel', cancelHandler);
const cancelBtn = screen.getByRole('button', { name: /cancel/i });
await fireEvent.click(cancelBtn);
expect(cancelHandler).toHaveBeenCalledTimes(1);
});
it('dispatches cancel when backdrop clicked', async () => {
const { component } = render(DeleteConfirmModal, { props: defaultProps });
const cancelHandler = vi.fn();
component.$on('cancel', cancelHandler);
const backdrop = screen.getByRole('dialog');
await fireEvent.click(backdrop);
expect(cancelHandler).toHaveBeenCalledTimes(1);
});
it('disables buttons when processing', () => {
const props = { ...defaultProps, isProcessing: true };
render(DeleteConfirmModal, { props });
const confirmBtn = screen.getByRole('button', { name: /processing/i });
const cancelBtn = screen.getByRole('button', { name: /cancel/i });
expect(confirmBtn).toBeDisabled();
expect(cancelBtn).toBeDisabled();
});
it('shows processing state on confirm button', () => {
const props = { ...defaultProps, isProcessing: true };
render(DeleteConfirmModal, { props });
expect(screen.getByText(/processing/i)).toBeInTheDocument();
});
it('does not close on backdrop click when processing', async () => {
const props = { ...defaultProps, isProcessing: true };
const { component } = render(DeleteConfirmModal, { props });
const cancelHandler = vi.fn();
component.$on('cancel', cancelHandler);
const backdrop = screen.getByRole('dialog');
await fireEvent.click(backdrop);
expect(cancelHandler).not.toHaveBeenCalled();
});
});
describe('Styling', () => {
it('applies destructive styling to confirm button when isDestructive', () => {
render(DeleteConfirmModal, { props: defaultProps });
const confirmBtn = screen.getByRole('button', { name: /delete/i });
expect(confirmBtn).toHaveClass('destructive');
});
it('does not apply destructive styling when not destructive', () => {
const props = { ...defaultProps, isDestructive: false };
render(DeleteConfirmModal, { props });
const confirmBtn = screen.getByRole('button', { name: /delete/i });
expect(confirmBtn).not.toHaveClass('destructive');
});
});
describe('Accessibility', () => {
it('has proper ARIA attributes', () => {
render(DeleteConfirmModal, { props: defaultProps });
const dialog = screen.getByRole('dialog');
expect(dialog).toHaveAttribute('aria-modal', 'true');
expect(dialog).toHaveAttribute('aria-labelledby', 'modal-title');
});
it('modal title has correct ID for aria-labelledby', () => {
render(DeleteConfirmModal, { props: defaultProps });
const title = screen.getByText('Delete Item');
expect(title).toHaveAttribute('id', 'modal-title');
});
});
});