Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions web-ui/src/__tests__/components/tasks/TaskCard.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<a href={href}>{children}</a>
);
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 }) => (
<div role="tooltip">{children}</div>
),
}));

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(<TaskCard {...defaultProps} />);
expect(screen.getByText('Backlog')).toBeInTheDocument();
});

it('renders tooltip with BACKLOG meaning', () => {
render(<TaskCard {...defaultProps} />);
expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.BACKLOG.meaning);
});

it('renders tooltip with BACKLOG next steps', () => {
render(<TaskCard {...defaultProps} />);
expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.BACKLOG.nextSteps);
});

it('renders tooltip with READY meaning', () => {
const task = { ...baseTask, status: 'READY' as const };
render(<TaskCard {...defaultProps} task={task} />);
expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.READY.meaning);
});

it('renders tooltip with FAILED meaning', () => {
const task = { ...baseTask, status: 'FAILED' as const };
render(<TaskCard {...defaultProps} task={task} onReset={jest.fn()} />);
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(<TaskCard {...defaultProps} task={task} onStop={jest.fn()} />);
expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.IN_PROGRESS.meaning);
});

it('renders tooltip with DONE meaning', () => {
const task = { ...baseTask, status: 'DONE' as const };
render(<TaskCard {...defaultProps} task={task} />);
expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.DONE.meaning);
});

it('renders tooltip with BLOCKED meaning', () => {
const task = { ...baseTask, status: 'BLOCKED' as const };
render(<TaskCard {...defaultProps} task={task} />);
expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.BLOCKED.meaning);
});

it('renders tooltip with MERGED meaning', () => {
const task = { ...baseTask, status: 'MERGED' as const };
render(<TaskCard {...defaultProps} task={task} />);
expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.MERGED.meaning);
});
});
161 changes: 161 additions & 0 deletions web-ui/src/__tests__/components/tasks/TaskDetailModal.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<div role="tooltip">{children}</div>
),
}));

// ── 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 }) => (
<a href={href}>{children}</a>
);
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> = {}): 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<Task> = {}) {
const task = makeTask(taskOverrides);
(tasksApi.getOne as jest.Mock).mockResolvedValue(task);
return render(<TaskDetailModal {...defaultProps} />);
}

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();
});
});
44 changes: 26 additions & 18 deletions web-ui/src/components/tasks/TaskCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -80,6 +81,8 @@ export function TaskCard({
aria-label={`View details for ${task.title}`}
>
<CardContent className="p-3">
{/* Single TooltipProvider for the entire card to avoid per-tooltip provider overhead */}
<TooltipProvider>
{/* Top row: checkbox (if selection mode) + status badge */}
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
Expand All @@ -91,26 +94,30 @@ export function TaskCard({
aria-label={`Select ${task.title}`}
/>
)}
<Badge
variant={STATUS_BADGE_VARIANT[task.status] as never}
>
{STATUS_LABEL[task.status]}
</Badge>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant={STATUS_BADGE_VARIANT[task.status] as never}>
{STATUS_LABEL[task.status]}
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-[220px] space-y-1">
<p className="text-xs font-medium">{STATUS_INFO[task.status].meaning}</p>
<p className="text-xs text-muted-foreground">{STATUS_INFO[task.status].nextSteps}</p>
</TooltipContent>
</Tooltip>
</div>
{task.depends_on.length > 0 && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex cursor-default items-center gap-1 text-xs text-muted-foreground">
<LinkCircleIcon className="h-3.5 w-3.5" />
{task.depends_on.length}
</span>
</TooltipTrigger>
<TooltipContent>
Depends on {task.depends_on.length} task{task.depends_on.length !== 1 ? 's' : ''}. This task will become READY when all dependencies complete.
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex cursor-default items-center gap-1 text-xs text-muted-foreground">
<LinkCircleIcon className="h-3.5 w-3.5" />
{task.depends_on.length}
</span>
</TooltipTrigger>
<TooltipContent>
Depends on {task.depends_on.length} task{task.depends_on.length !== 1 ? 's' : ''}. This task will become READY when all dependencies complete.
</TooltipContent>
</Tooltip>
)}
</div>

Expand Down Expand Up @@ -215,6 +222,7 @@ export function TaskCard({
)}
</div>
)}
</TooltipProvider>
</CardContent>
</Card>
);
Expand Down
Loading
Loading