Files
webref/frontend/tests/components/boards.test.ts
Danilo Reyes 48020b6f42 phase 4
2025-11-02 01:01:38 -06:00

537 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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');
});
});
});