537 lines
17 KiB
TypeScript
537 lines
17 KiB
TypeScript
/**
|
||
* 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');
|
||
});
|
||
});
|
||
});
|
||
|