From a78b903f5a747e589cfb2b49053ba68550efe587 Mon Sep 17 00:00:00 2001 From: Felipe Cardoso Date: Thu, 1 Jan 2026 17:20:51 +0100 Subject: [PATCH] test(frontend): add unit tests for Projects list components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive test coverage for projects list components: - ProjectCard.test.tsx: Card rendering, status badges, actions menu - ProjectFilters.test.tsx: Search, filters, view mode toggle - ProjectsGrid.test.tsx: Grid/list layout, loading, empty states 30 tests covering rendering, interactions, and edge cases. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../components/projects/ProjectCard.test.tsx | 105 +++++++++++++++++ .../projects/ProjectFilters.test.tsx | 98 ++++++++++++++++ .../components/projects/ProjectsGrid.test.tsx | 107 ++++++++++++++++++ 3 files changed, 310 insertions(+) create mode 100644 frontend/tests/components/projects/ProjectCard.test.tsx create mode 100644 frontend/tests/components/projects/ProjectFilters.test.tsx create mode 100644 frontend/tests/components/projects/ProjectsGrid.test.tsx diff --git a/frontend/tests/components/projects/ProjectCard.test.tsx b/frontend/tests/components/projects/ProjectCard.test.tsx new file mode 100644 index 0000000..cc524b1 --- /dev/null +++ b/frontend/tests/components/projects/ProjectCard.test.tsx @@ -0,0 +1,105 @@ +/** + * ProjectCard Component Tests + */ + +import { render, screen, fireEvent } from '@testing-library/react'; +import { ProjectCard, ProjectCardSkeleton } from '@/components/projects/ProjectCard'; +import type { ProjectListItem } from '@/lib/api/hooks/useProjects'; + +describe('ProjectCard', () => { + const mockProject: ProjectListItem = { + id: 'proj-1', + name: 'Test Project', + description: 'This is a test project description', + status: 'active', + complexity: 'medium', + progress: 65, + openIssues: 5, + activeAgents: 3, + currentSprint: 'Sprint 2', + lastActivity: '5 minutes ago', + createdAt: '2025-01-01T00:00:00Z', + owner: { id: 'user-1', name: 'Test User' }, + tags: ['frontend', 'react', 'typescript'], + }; + + it('renders project name', () => { + render(); + expect(screen.getByText('Test Project')).toBeInTheDocument(); + }); + + it('renders project description', () => { + render(); + expect(screen.getByText('This is a test project description')).toBeInTheDocument(); + }); + + it('renders project status badge', () => { + render(); + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + + it('renders current sprint', () => { + render(); + // Sprint not shown in current card design, but progress is + expect(screen.getByText('65%')).toBeInTheDocument(); + }); + + it('renders project metrics', () => { + render(); + expect(screen.getByText('3')).toBeInTheDocument(); // agents + expect(screen.getByText('5')).toBeInTheDocument(); // issues + }); + + it('renders last activity time', () => { + render(); + expect(screen.getByText('5 minutes ago')).toBeInTheDocument(); + }); + + it('renders project tags', () => { + render(); + expect(screen.getByText('frontend')).toBeInTheDocument(); + expect(screen.getByText('react')).toBeInTheDocument(); + expect(screen.getByText('typescript')).toBeInTheDocument(); + }); + + it('calls onClick when card is clicked', () => { + const onClick = jest.fn(); + render(); + + // Click the card (first button, which is the card itself) + const buttons = screen.getAllByRole('button'); + fireEvent.click(buttons[0]); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('renders action menu when onAction is provided', () => { + const onAction = jest.fn(); + render(); + + // Menu button should exist with sr-only text + const menuButtons = screen.getAllByRole('button'); + const menuButton = menuButtons.find(btn => btn.querySelector('.sr-only')); + expect(menuButton).toBeDefined(); + expect(menuButton!.querySelector('.sr-only')).toHaveTextContent('Project actions'); + }); + + it('does not render action menu when onAction is not provided', () => { + render(); + + // Only the card itself should be a button + const buttons = screen.getAllByRole('button'); + expect(buttons.length).toBe(1); + }); + + it('applies custom className', () => { + const { container } = render(); + expect(container.querySelector('.custom-class')).toBeInTheDocument(); + }); +}); + +describe('ProjectCardSkeleton', () => { + it('renders skeleton elements', () => { + const { container } = render(); + expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0); + }); +}); diff --git a/frontend/tests/components/projects/ProjectFilters.test.tsx b/frontend/tests/components/projects/ProjectFilters.test.tsx new file mode 100644 index 0000000..94c5ac4 --- /dev/null +++ b/frontend/tests/components/projects/ProjectFilters.test.tsx @@ -0,0 +1,98 @@ +/** + * ProjectFilters Component Tests + */ + +import { render, screen, fireEvent } from '@testing-library/react'; +import { ProjectFilters } from '@/components/projects/ProjectFilters'; + +describe('ProjectFilters', () => { + const defaultProps = { + searchQuery: '', + onSearchChange: jest.fn(), + statusFilter: 'all' as const, + onStatusFilterChange: jest.fn(), + complexityFilter: 'all' as const, + onComplexityFilterChange: jest.fn(), + sortBy: 'recent' as const, + onSortByChange: jest.fn(), + sortOrder: 'desc' as const, + onSortOrderChange: jest.fn(), + viewMode: 'grid' as const, + onViewModeChange: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders search input', () => { + render(); + expect(screen.getByPlaceholderText('Search projects...')).toBeInTheDocument(); + }); + + it('calls onSearchChange when typing in search', () => { + render(); + + const searchInput = screen.getByPlaceholderText('Search projects...'); + fireEvent.change(searchInput, { target: { value: 'test query' } }); + + expect(defaultProps.onSearchChange).toHaveBeenCalledWith('test query'); + }); + + it('renders status filter dropdown', () => { + render(); + expect(screen.getByRole('combobox', { name: /Filter by status/i })).toBeInTheDocument(); + }); + + it('renders filters button', () => { + render(); + expect(screen.getByRole('button', { name: /Filters/i })).toBeInTheDocument(); + }); + + it('renders view mode toggle buttons', () => { + render(); + expect(screen.getByRole('button', { name: /Grid view/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /List view/i })).toBeInTheDocument(); + }); + + it('calls onViewModeChange when clicking view toggle', () => { + render(); + + const listButton = screen.getByRole('button', { name: /List view/i }); + fireEvent.click(listButton); + + expect(defaultProps.onViewModeChange).toHaveBeenCalledWith('list'); + }); + + it('shows extended filters when Filters button is clicked', () => { + render(); + + const filtersButton = screen.getByRole('button', { name: /Filters/i }); + fireEvent.click(filtersButton); + + expect(screen.getByText('Complexity')).toBeInTheDocument(); + expect(screen.getByText('Sort By')).toBeInTheDocument(); + expect(screen.getByText('Order')).toBeInTheDocument(); + }); + + it('shows active filter count badge when filters are active', () => { + render(); + + expect(screen.getByText('1')).toBeInTheDocument(); + }); + + it('shows Clear Filters button when filters are active and extended is open', () => { + render(); + + // Open extended filters + const filtersButton = screen.getByRole('button', { name: /Filters/i }); + fireEvent.click(filtersButton); + + expect(screen.getByRole('button', { name: /Clear Filters/i })).toBeInTheDocument(); + }); + + it('applies custom className', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('custom-class'); + }); +}); diff --git a/frontend/tests/components/projects/ProjectsGrid.test.tsx b/frontend/tests/components/projects/ProjectsGrid.test.tsx new file mode 100644 index 0000000..1d8d56d --- /dev/null +++ b/frontend/tests/components/projects/ProjectsGrid.test.tsx @@ -0,0 +1,107 @@ +/** + * ProjectsGrid Component Tests + */ + +import { render, screen, fireEvent } from '@testing-library/react'; +import { ProjectsGrid } from '@/components/projects/ProjectsGrid'; +import type { ProjectListItem } from '@/lib/api/hooks/useProjects'; + +// Mock next-intl navigation +jest.mock('@/lib/i18n/routing', () => ({ + Link: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})); + +describe('ProjectsGrid', () => { + const mockProjects: ProjectListItem[] = [ + { + id: 'proj-1', + name: 'Project One', + description: 'First project', + status: 'active', + complexity: 'medium', + progress: 50, + openIssues: 5, + activeAgents: 2, + lastActivity: '5 min ago', + createdAt: '2025-01-01T00:00:00Z', + owner: { id: 'user-1', name: 'User One' }, + }, + { + id: 'proj-2', + name: 'Project Two', + description: 'Second project', + status: 'paused', + complexity: 'high', + progress: 75, + openIssues: 3, + activeAgents: 0, + lastActivity: '1 day ago', + createdAt: '2025-01-02T00:00:00Z', + owner: { id: 'user-2', name: 'User Two' }, + }, + ]; + + it('renders project cards', () => { + render(); + + expect(screen.getByText('Project One')).toBeInTheDocument(); + expect(screen.getByText('Project Two')).toBeInTheDocument(); + }); + + it('renders in grid layout by default', () => { + const { container } = render(); + + // Should have grid classes + expect(container.firstChild).toHaveClass('grid'); + }); + + it('renders in list layout when viewMode is list', () => { + const { container } = render(); + + // Should have space-y-4 class for list view + expect(container.firstChild).toHaveClass('space-y-4'); + }); + + it('shows loading skeletons when isLoading is true', () => { + const { container } = render(); + + // Should render skeleton cards + expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0); + }); + + it('shows empty state when no projects and no filters', () => { + render(); + + expect(screen.getByText('No projects found')).toBeInTheDocument(); + expect(screen.getByText('Get started by creating your first project')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Create Project/i })).toBeInTheDocument(); + }); + + it('shows filter-adjusted empty state when no projects with filters', () => { + render(); + + expect(screen.getByText('No projects found')).toBeInTheDocument(); + expect(screen.getByText('Try adjusting your filters or search query')).toBeInTheDocument(); + }); + + it('calls onProjectClick when project card is clicked', () => { + const onProjectClick = jest.fn(); + render(); + + // Click on the first project card + const projectCards = screen.getAllByRole('button'); + fireEvent.click(projectCards[0]); + + expect(onProjectClick).toHaveBeenCalledWith(mockProjects[0]); + }); + + it('applies custom className', () => { + const { container } = render( + + ); + + expect(container.firstChild).toHaveClass('custom-class'); + }); +});