phase 4
This commit is contained in:
536
frontend/tests/components/boards.test.ts
Normal file
536
frontend/tests/components/boards.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user