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