diff --git a/web-ui/src/__tests__/components/tasks/TaskCard.test.tsx b/web-ui/src/__tests__/components/tasks/TaskCard.test.tsx new file mode 100644 index 00000000..9e6dde65 --- /dev/null +++ b/web-ui/src/__tests__/components/tasks/TaskCard.test.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { TaskCard } from '@/components/tasks/TaskCard'; +import { STATUS_INFO } from '@/lib/taskStatusInfo'; +import type { Task } from '@/types'; + +jest.mock('next/link', () => { + const MockLink = ({ href, children }: { href: string; children: React.ReactNode }) => ( + {children} + ); + MockLink.displayName = 'MockLink'; + return MockLink; +}); + +// Radix UI tooltips use portals and pointer events that don't work in jsdom. +// Replace with a simple always-visible version to test content. +jest.mock('@/components/ui/tooltip', () => ({ + TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +const baseTask: Task = { + id: 'task-1', + title: 'Test Task', + description: 'A test task description', + status: 'BACKLOG', + priority: 0, + depends_on: [], +}; + +const defaultProps = { + task: baseTask, + selectionMode: false, + selected: false, + onToggleSelect: jest.fn(), + onClick: jest.fn(), + onExecute: jest.fn(), + onMarkReady: jest.fn(), +}; + +describe('TaskCard status badge tooltip', () => { + it('renders the status badge label', () => { + render(); + expect(screen.getByText('Backlog')).toBeInTheDocument(); + }); + + it('renders tooltip with BACKLOG meaning', () => { + render(); + expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.BACKLOG.meaning); + }); + + it('renders tooltip with BACKLOG next steps', () => { + render(); + expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.BACKLOG.nextSteps); + }); + + it('renders tooltip with READY meaning', () => { + const task = { ...baseTask, status: 'READY' as const }; + render(); + expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.READY.meaning); + }); + + it('renders tooltip with FAILED meaning', () => { + const task = { ...baseTask, status: 'FAILED' as const }; + render(); + expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.FAILED.meaning); + }); + + it('renders tooltip with IN_PROGRESS meaning', () => { + const task = { ...baseTask, status: 'IN_PROGRESS' as const }; + render(); + expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.IN_PROGRESS.meaning); + }); + + it('renders tooltip with DONE meaning', () => { + const task = { ...baseTask, status: 'DONE' as const }; + render(); + expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.DONE.meaning); + }); + + it('renders tooltip with BLOCKED meaning', () => { + const task = { ...baseTask, status: 'BLOCKED' as const }; + render(); + expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.BLOCKED.meaning); + }); + + it('renders tooltip with MERGED meaning', () => { + const task = { ...baseTask, status: 'MERGED' as const }; + render(); + expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.MERGED.meaning); + }); +}); diff --git a/web-ui/src/__tests__/components/tasks/TaskDetailModal.test.tsx b/web-ui/src/__tests__/components/tasks/TaskDetailModal.test.tsx new file mode 100644 index 00000000..bcf60518 --- /dev/null +++ b/web-ui/src/__tests__/components/tasks/TaskDetailModal.test.tsx @@ -0,0 +1,161 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { TaskDetailModal } from '@/components/tasks/TaskDetailModal'; +import { STATUS_INFO } from '@/lib/taskStatusInfo'; +import type { Task } from '@/types'; + +// Radix UI tooltips use portals and pointer events that don't work in jsdom. +jest.mock('@/components/ui/tooltip', () => ({ + TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +// ── Mocks ──────────────────────────────────────────────────────────────── + +jest.mock('swr', () => ({ + __esModule: true, + default: jest.fn(() => ({ data: { tasks: [] }, isLoading: false, error: null })), +})); + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: jest.fn() }), +})); + +jest.mock('next/link', () => { + const MockLink = ({ href, children }: { href: string; children: React.ReactNode }) => ( + {children} + ); + MockLink.displayName = 'MockLink'; + return MockLink; +}); + +jest.mock('@/lib/api', () => ({ + tasksApi: { + getOne: jest.fn(), + getAll: jest.fn(), + updateStatus: jest.fn(), + }, +})); + +jest.mock('@/hooks/useRequirementsLookup', () => ({ + useRequirementsLookup: () => ({ requirementsMap: new Map(), isLoading: false }), +})); + +import { tasksApi } from '@/lib/api'; + +const makeTask = (overrides: Partial = {}): Task => ({ + id: 'task-1', + title: 'Test Task', + description: 'A description', + status: 'BACKLOG', + priority: 0, + depends_on: [], + ...overrides, +}); + +const defaultProps = { + taskId: 'task-1', + workspacePath: '/ws', + open: true, + onClose: jest.fn(), + onExecute: jest.fn(), + onStatusChange: jest.fn(), +}; + +function renderModal(taskOverrides: Partial = {}) { + const task = makeTask(taskOverrides); + (tasksApi.getOne as jest.Mock).mockResolvedValue(task); + return render(); +} + +describe('TaskDetailModal status badge tooltip', () => { + it('renders tooltip with BACKLOG meaning', async () => { + renderModal({ status: 'BACKLOG' }); + await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument()); + expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.BACKLOG.meaning); + }); + + it('renders tooltip with DONE meaning', async () => { + renderModal({ status: 'DONE' }); + await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument()); + expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.DONE.meaning); + }); + + it('renders tooltip with FAILED meaning', async () => { + renderModal({ status: 'FAILED' }); + await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument()); + expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.FAILED.meaning); + }); +}); + +describe('TaskDetailModal valid transition guidance', () => { + it('shows "Mark Ready" button for BACKLOG status', async () => { + renderModal({ status: 'BACKLOG' }); + await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument()); + expect(screen.getByRole('button', { name: /mark ready/i })).toBeInTheDocument(); + }); + + it('shows "Execute" button for READY status', async () => { + renderModal({ status: 'READY' }); + await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument()); + expect(screen.getByRole('button', { name: /execute/i })).toBeInTheDocument(); + }); + + it('shows next-step guidance for DONE status (no action button but guidance visible)', async () => { + renderModal({ status: 'DONE' }); + await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument()); + expect(screen.getByTestId('status-next-step')).toBeInTheDocument(); + expect(screen.getByTestId('status-next-step')).toHaveTextContent(STATUS_INFO.DONE.nextSteps); + }); + + it('shows next-step guidance for BLOCKED status', async () => { + renderModal({ status: 'BLOCKED' }); + await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument()); + expect(screen.getByTestId('status-next-step')).toBeInTheDocument(); + expect(screen.getByTestId('status-next-step')).toHaveTextContent(STATUS_INFO.BLOCKED.nextSteps); + }); + + it('shows next-step guidance for MERGED status', async () => { + renderModal({ status: 'MERGED' }); + await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument()); + expect(screen.getByTestId('status-next-step')).toBeInTheDocument(); + expect(screen.getByTestId('status-next-step')).toHaveTextContent(STATUS_INFO.MERGED.nextSteps); + }); + + it('shows next-step guidance for FAILED status via the alert panel', async () => { + renderModal({ status: 'FAILED' }); + await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument()); + expect(screen.getByTestId('status-next-step')).toBeInTheDocument(); + expect(screen.getByTestId('status-next-step')).toHaveTextContent(STATUS_INFO.FAILED.nextSteps); + }); + + it('does not show next-step guidance for BACKLOG (has action button)', async () => { + renderModal({ status: 'BACKLOG' }); + await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument()); + expect(screen.queryByTestId('status-next-step')).not.toBeInTheDocument(); + }); + + it('does not show next-step guidance for READY (has action button)', async () => { + renderModal({ status: 'READY' }); + await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument()); + expect(screen.queryByTestId('status-next-step')).not.toBeInTheDocument(); + }); +}); + +describe('TaskDetailModal last changed timestamp', () => { + it('shows last changed date when updated_at is present', async () => { + renderModal({ status: 'DONE', updated_at: '2026-01-15T10:30:00Z' }); + await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument()); + expect(screen.getByText(/last changed/i)).toBeInTheDocument(); + }); + + it('does not show last changed when updated_at is absent', async () => { + renderModal({ status: 'BACKLOG' }); + await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument()); + expect(screen.queryByText(/last changed/i)).not.toBeInTheDocument(); + }); +}); diff --git a/web-ui/src/components/tasks/TaskCard.tsx b/web-ui/src/components/tasks/TaskCard.tsx index 70b6075a..01d72791 100644 --- a/web-ui/src/components/tasks/TaskCard.tsx +++ b/web-ui/src/components/tasks/TaskCard.tsx @@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'; +import { STATUS_INFO } from '@/lib/taskStatusInfo'; import type { Task, TaskStatus, ProofRequirement } from '@/types'; /** Map backend TaskStatus to badge variant name. */ @@ -80,6 +81,8 @@ export function TaskCard({ aria-label={`View details for ${task.title}`} > + {/* Single TooltipProvider for the entire card to avoid per-tooltip provider overhead */} + {/* Top row: checkbox (if selection mode) + status badge */}
@@ -91,26 +94,30 @@ export function TaskCard({ aria-label={`Select ${task.title}`} /> )} - - {STATUS_LABEL[task.status]} - + + + + {STATUS_LABEL[task.status]} + + + +

{STATUS_INFO[task.status].meaning}

+

{STATUS_INFO[task.status].nextSteps}

+
+
{task.depends_on.length > 0 && ( - - - - - - {task.depends_on.length} - - - - Depends on {task.depends_on.length} task{task.depends_on.length !== 1 ? 's' : ''}. This task will become READY when all dependencies complete. - - - + + + + + {task.depends_on.length} + + + + Depends on {task.depends_on.length} task{task.depends_on.length !== 1 ? 's' : ''}. This task will become READY when all dependencies complete. + + )}
@@ -215,6 +222,7 @@ export function TaskCard({ )} )} +
); diff --git a/web-ui/src/components/tasks/TaskDetailModal.tsx b/web-ui/src/components/tasks/TaskDetailModal.tsx index c64ef7a5..7a6bca62 100644 --- a/web-ui/src/components/tasks/TaskDetailModal.tsx +++ b/web-ui/src/components/tasks/TaskDetailModal.tsx @@ -13,6 +13,7 @@ import { BookOpen01Icon, Alert02Icon, CheckListIcon, + InformationCircleIcon, } from '@hugeicons/react'; import { Dialog, @@ -22,6 +23,8 @@ import { DialogDescription, DialogFooter, } from '@/components/ui/dialog'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { STATUS_INFO } from '@/lib/taskStatusInfo'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import useSWR from 'swr'; @@ -152,9 +155,19 @@ export function TaskDetailModal({ <>
- - {STATUS_LABEL[task.status]} - + + + + + {STATUS_LABEL[task.status]} + + + +

{STATUS_INFO[task.status].meaning}

+

{STATUS_INFO[task.status].nextSteps}

+
+
+
{task.priority > 0 && ( Priority {task.priority} @@ -188,6 +201,12 @@ export function TaskDetailModal({ {task.estimated_hours}h estimated )} + {task.updated_at && ( + + + Last changed: {new Date(task.updated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} + + )}
{/* Dependencies — full list with status highlights and navigation */} @@ -263,24 +282,34 @@ export function TaskDetailModal({ )} - {/* FAILED-state guidance panel */} + {/* FAILED-state guidance panel — testid enables status-next-step checks */} {task.status === 'FAILED' && ( -
+

Task failed during execution

- Check PROOF9 gates to identify which quality requirements need attention. - {(task.requirement_ids ?? []).length === 0 && ( - <> Use the button below to view all gates. - )} + {STATUS_INFO.FAILED.nextSteps}

)} + {/* Next-step guidance for statuses with no action buttons */} + {(task.status === 'DONE' || task.status === 'BLOCKED' || task.status === 'MERGED') && ( +
+
+ +
+

What's next?

+

{STATUS_INFO[task.status].nextSteps}

+
+
+
+ )} + {task.status === 'BACKLOG' && (