/** * 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'); }); }); });