agent-ecosystem/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx

691 lines
20 KiB
TypeScript

/* eslint-disable @typescript-eslint/naming-convention -- Component mocks mirror PascalCase exports. */
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
const unreadBadgeMock = vi.hoisted(() => ({
props: [] as { unreadCount: number; totalCount: number; pulseKey?: number }[],
}));
const unreadCommentCountMock = vi.hoisted(() => ({
value: 0,
calls: 0,
}));
vi.mock('@renderer/components/team/MemberBadge', () => ({
MemberBadge: ({ name }: { name: string }) => React.createElement('span', null, name),
}));
vi.mock('@renderer/components/team/UnreadCommentsBadge', () => ({
UnreadCommentsBadge: (props: { unreadCount: number; totalCount: number; pulseKey?: number }) => {
unreadBadgeMock.props.push(props);
return React.createElement('span', {
className: (props.pulseKey ?? 0) > 0 ? 'kanban-comment-badge-pulse' : '',
'data-pulse-key': props.pulseKey ?? 0,
'data-testid': 'unread-comments-badge',
});
},
}));
vi.mock('@renderer/components/ui/button', () => ({
Button: ({
children,
className,
onClick,
disabled,
'aria-label': ariaLabel,
}: {
children: React.ReactNode;
className?: string;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
disabled?: boolean;
'aria-label'?: string;
}) =>
React.createElement(
'button',
{ className, onClick, disabled, 'aria-label': ariaLabel, type: 'button' },
children
),
}));
vi.mock('@renderer/components/ui/popover', () => ({
Popover: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
PopoverTrigger: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
PopoverContent: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
}));
vi.mock('@renderer/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
TooltipContent: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
}));
vi.mock('@renderer/hooks/useTheme', () => ({
useTheme: () => ({ isLight: false }),
}));
vi.mock('@renderer/hooks/useUnreadCommentCount', () => ({
useUnreadCommentCount: () => {
unreadCommentCountMock.calls += 1;
return unreadCommentCountMock.value;
},
}));
/* eslint-enable @typescript-eslint/naming-convention -- Re-enable after component mocks. */
import { KanbanTaskCard } from './KanbanTaskCard';
import type { TaskComment, TeamTaskWithKanban } from '@shared/types/team';
const baseTask: TeamTaskWithKanban = {
id: 'task-1',
displayId: 'abcd1234',
subject: 'Implement safer onboarding flow',
owner: 'alice',
reviewer: '',
status: 'in_progress',
changePresence: 'unknown',
comments: [],
blockedBy: [],
blocks: [],
workIntervals: [],
historyEvents: [],
createdAt: '2026-04-18T10:00:00.000Z',
updatedAt: '2026-04-18T10:10:00.000Z',
} as unknown as TeamTaskWithKanban;
const noop = (): void => undefined;
function createComment(id: string, author = 'teammate'): TaskComment {
return {
id,
author,
text: `Comment ${id}`,
createdAt: '2026-04-18T10:20:00.000Z',
type: 'regular',
};
}
function createTaskCardElement(
props: Partial<React.ComponentProps<typeof KanbanTaskCard>> = {}
): React.ReactElement {
return React.createElement(KanbanTaskCard, {
task: baseTask,
teamName: 'my-team',
columnId: 'in_progress',
hasReviewers: true,
compact: false,
taskMap: new Map(),
memberColorMap: new Map([['alice', 'blue']]),
onRequestReview: noop,
onApprove: noop,
onRequestChanges: noop,
onMoveBackToDone: noop,
onStartTask: noop,
onCompleteTask: noop,
onCancelTask: noop,
onViewChanges: noop,
...props,
});
}
function getLastUnreadBadgeProps(): { unreadCount: number; totalCount: number; pulseKey?: number } {
const props = unreadBadgeMock.props[unreadBadgeMock.props.length - 1];
if (!props) throw new Error('UnreadCommentsBadge was not rendered');
return props;
}
async function flushReact(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
async function rerenderTaskCard(
root: ReturnType<typeof createRoot>,
props: Partial<React.ComponentProps<typeof KanbanTaskCard>> = {}
): Promise<void> {
await act(async () => {
root.render(createTaskCardElement(props));
await flushReact();
});
}
function createStrictTaskCardElement(
props: Partial<React.ComponentProps<typeof KanbanTaskCard>> = {}
): React.ReactElement {
return React.createElement(React.StrictMode, null, createTaskCardElement(props));
}
async function renderStrictTaskCard(
props: Partial<React.ComponentProps<typeof KanbanTaskCard>> = {}
): Promise<{ host: HTMLDivElement; root: ReturnType<typeof createRoot> }> {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(createStrictTaskCardElement(props));
await flushReact();
});
return { host, root };
}
async function rerenderStrictTaskCard(
root: ReturnType<typeof createRoot>,
props: Partial<React.ComponentProps<typeof KanbanTaskCard>> = {}
): Promise<void> {
await act(async () => {
root.render(createStrictTaskCardElement(props));
await flushReact();
});
}
afterEach(() => {
unreadBadgeMock.props.length = 0;
unreadCommentCountMock.value = 0;
unreadCommentCountMock.calls = 0;
});
async function renderTaskCard(
props: Partial<React.ComponentProps<typeof KanbanTaskCard>> = {}
): Promise<{ host: HTMLDivElement; root: ReturnType<typeof createRoot> }> {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(createTaskCardElement(props));
await flushReact();
});
return { host, root };
}
describe('KanbanTaskCard comment badge pulse', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('skips rerender when refreshed task objects keep the same snapshot', async () => {
const taskMap = new Map();
const memberColorMap = new Map([['alice', 'blue']]);
const { root } = await renderTaskCard({
task: { ...baseTask, comments: [] },
taskMap,
memberColorMap,
});
expect(unreadCommentCountMock.calls).toBeGreaterThan(0);
unreadCommentCountMock.calls = 0;
await rerenderTaskCard(root, {
task: { ...baseTask, comments: [] },
taskMap,
memberColorMap,
});
expect(unreadCommentCountMock.calls).toBe(0);
await act(async () => {
root.unmount();
await flushReact();
});
});
it('skips rerender when an unrelated taskMap entry changes', async () => {
const memberColorMap = new Map([['alice', 'blue']]);
const { root } = await renderTaskCard({
task: { ...baseTask, blockedBy: [], blocks: [], comments: [] },
taskMap: new Map([['other-task', { ...baseTask, id: 'other-task', subject: 'Other task' }]]),
memberColorMap,
});
unreadCommentCountMock.calls = 0;
await rerenderTaskCard(root, {
task: { ...baseTask, blockedBy: [], blocks: [], comments: [] },
taskMap: new Map([
['other-task', { ...baseTask, id: 'other-task', subject: 'Updated unrelated task' }],
]),
memberColorMap,
});
expect(unreadCommentCountMock.calls).toBe(0);
await act(async () => {
root.unmount();
await flushReact();
});
});
it('rerenders when a displayed dependency task changes', async () => {
const memberColorMap = new Map([['alice', 'blue']]);
const blockedTask = { ...baseTask, id: 'dep-1', displayId: 'dep1', subject: 'Dependency A' };
const { root } = await renderTaskCard({
task: { ...baseTask, blockedBy: ['dep-1'], blocks: [], comments: [] },
taskMap: new Map([['dep-1', blockedTask]]),
memberColorMap,
});
unreadCommentCountMock.calls = 0;
await rerenderTaskCard(root, {
task: { ...baseTask, blockedBy: ['dep-1'], blocks: [], comments: [] },
taskMap: new Map([['dep-1', { ...blockedTask, subject: 'Dependency B', status: 'done' }]]),
memberColorMap,
});
expect(unreadCommentCountMock.calls).toBeGreaterThan(0);
await act(async () => {
root.unmount();
await flushReact();
});
});
it('rerenders when a hidden task field changes so click handlers stay current', async () => {
const taskMap = new Map();
const memberColorMap = new Map([['alice', 'blue']]);
const { root } = await renderTaskCard({
task: { ...baseTask, comments: [] },
taskMap,
memberColorMap,
});
unreadCommentCountMock.calls = 0;
await rerenderTaskCard(root, {
task: { ...baseTask, comments: [], description: 'Updated hidden details' },
taskMap,
memberColorMap,
});
expect(unreadCommentCountMock.calls).toBeGreaterThan(0);
await act(async () => {
root.unmount();
await flushReact();
});
});
it('does not pulse on initial render with existing comments', async () => {
const { host, root } = await renderTaskCard({
task: { ...baseTask, comments: [createComment('comment-1')] },
});
expect(getLastUnreadBadgeProps().pulseKey ?? 0).toBe(0);
expect(host.querySelector('.kanban-comment-badge-pulse')).toBeNull();
await act(async () => {
root.unmount();
await flushReact();
});
});
it('pulses when a new non-user comment arrives', async () => {
const firstComment = createComment('comment-1');
const { host, root } = await renderTaskCard({
task: { ...baseTask, comments: [firstComment] },
});
unreadBadgeMock.props.length = 0;
await rerenderTaskCard(root, {
task: { ...baseTask, comments: [firstComment, createComment('comment-2')] },
});
expect(getLastUnreadBadgeProps().pulseKey).toBe(1);
expect(host.querySelector('.kanban-comment-badge-pulse')).not.toBeNull();
await act(async () => {
root.unmount();
await flushReact();
});
});
it('pulses when the first non-user comment arrives', async () => {
const { host, root } = await renderTaskCard({
task: { ...baseTask, comments: [] },
});
unreadBadgeMock.props.length = 0;
await rerenderTaskCard(root, {
task: { ...baseTask, comments: [createComment('comment-1')] },
});
expect(getLastUnreadBadgeProps().pulseKey).toBe(1);
expect(host.querySelector('.kanban-comment-badge-pulse')).not.toBeNull();
await act(async () => {
root.unmount();
await flushReact();
});
});
it('does not double-pulse under React StrictMode', async () => {
const firstComment = createComment('comment-1');
const { root } = await renderStrictTaskCard({
task: { ...baseTask, comments: [firstComment] },
});
unreadBadgeMock.props.length = 0;
await rerenderStrictTaskCard(root, {
task: { ...baseTask, comments: [firstComment, createComment('comment-2')] },
});
expect(getLastUnreadBadgeProps().pulseKey).toBe(1);
await act(async () => {
root.unmount();
await flushReact();
});
});
it('restarts the pulse when another non-user comment arrives', async () => {
const firstComment = createComment('comment-1');
const secondComment = createComment('comment-2');
const { root } = await renderTaskCard({
task: { ...baseTask, comments: [firstComment] },
});
await rerenderTaskCard(root, {
task: { ...baseTask, comments: [firstComment, secondComment] },
});
expect(getLastUnreadBadgeProps().pulseKey).toBe(1);
unreadBadgeMock.props.length = 0;
await rerenderTaskCard(root, {
task: {
...baseTask,
comments: [firstComment, secondComment, createComment('comment-3')],
},
});
expect(getLastUnreadBadgeProps().pulseKey).toBe(2);
await act(async () => {
root.unmount();
await flushReact();
});
});
it('does not pulse when the new comment belongs to the user', async () => {
const firstComment = createComment('comment-1');
const { host, root } = await renderTaskCard({
task: { ...baseTask, comments: [firstComment] },
});
unreadBadgeMock.props.length = 0;
await rerenderTaskCard(root, {
task: { ...baseTask, comments: [firstComment, createComment('comment-2', 'user')] },
});
expect(getLastUnreadBadgeProps().pulseKey ?? 0).toBe(0);
expect(host.querySelector('.kanban-comment-badge-pulse')).toBeNull();
await act(async () => {
root.unmount();
await flushReact();
});
});
it('does not pulse when only the unread count changes', async () => {
const taskWithComment = { ...baseTask, comments: [createComment('comment-1')] };
const { root } = await renderTaskCard({ task: taskWithComment });
unreadBadgeMock.props.length = 0;
unreadCommentCountMock.value = 1;
await rerenderTaskCard(root, { task: taskWithComment });
const props = getLastUnreadBadgeProps();
expect(props.unreadCount).toBe(1);
expect(props.pulseKey ?? 0).toBe(0);
await act(async () => {
root.unmount();
await flushReact();
});
});
it('does not reuse an old pulse when the card instance switches tasks', async () => {
const firstComment = createComment('comment-1');
const secondComment = createComment('comment-2');
const taskWithPulse = { ...baseTask, comments: [firstComment] };
const { root } = await renderTaskCard({ task: taskWithPulse });
await rerenderTaskCard(root, {
task: { ...baseTask, comments: [firstComment, secondComment] },
});
expect(getLastUnreadBadgeProps().pulseKey).toBe(1);
unreadBadgeMock.props.length = 0;
await rerenderTaskCard(root, {
task: {
...baseTask,
id: 'task-2',
displayId: 'efgh5678',
comments: [createComment('task-2-comment')],
},
});
expect(getLastUnreadBadgeProps().pulseKey ?? 0).toBe(0);
unreadBadgeMock.props.length = 0;
await rerenderTaskCard(root, {
task: { ...baseTask, comments: [firstComment, secondComment] },
});
expect(getLastUnreadBadgeProps().pulseKey ?? 0).toBe(0);
await act(async () => {
root.unmount();
await flushReact();
});
});
});
describe('KanbanTaskCard change badge', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('does not render a No changes badge when changePresence is no_changes', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(KanbanTaskCard, {
task: { ...baseTask, changePresence: 'no_changes' },
teamName: 'my-team',
columnId: 'in_progress',
hasReviewers: true,
compact: false,
taskMap: new Map(),
memberColorMap: new Map([['alice', 'blue']]),
onRequestReview: noop,
onApprove: noop,
onRequestChanges: noop,
onMoveBackToDone: noop,
onStartTask: noop,
onCompleteTask: noop,
onCancelTask: noop,
onViewChanges: noop,
})
);
await Promise.resolve();
});
expect(host.textContent).not.toContain('No changes');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('still renders the Changes action when changePresence is has_changes', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(KanbanTaskCard, {
task: { ...baseTask, changePresence: 'has_changes' },
teamName: 'my-team',
columnId: 'in_progress',
hasReviewers: true,
compact: false,
taskMap: new Map(),
memberColorMap: new Map([['alice', 'blue']]),
onRequestReview: noop,
onApprove: noop,
onRequestChanges: noop,
onMoveBackToDone: noop,
onStartTask: noop,
onCompleteTask: noop,
onCancelTask: noop,
onViewChanges: noop,
})
);
await Promise.resolve();
});
expect(host.querySelector('[aria-label="Changes"]')).not.toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('renders a Changes attention action when changePresence needs attention', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(KanbanTaskCard, {
task: { ...baseTask, changePresence: 'needs_attention' },
teamName: 'my-team',
columnId: 'in_progress',
hasReviewers: true,
compact: false,
taskMap: new Map(),
memberColorMap: new Map([['alice', 'blue']]),
onRequestReview: noop,
onApprove: noop,
onRequestChanges: noop,
onMoveBackToDone: noop,
onStartTask: noop,
onCompleteTask: noop,
onCancelTask: noop,
onViewChanges: noop,
})
);
await Promise.resolve();
});
expect(host.querySelector('[aria-label="Changes need attention"]')).not.toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});
describe('KanbanTaskCard blocked border', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('highlights blocked tasks outside final columns', async () => {
const { host, root } = await renderTaskCard({
task: { ...baseTask, blockedBy: ['task-2'] },
columnId: 'in_progress',
});
const card = host.querySelector('[data-task-id="task-1"]');
expect(card?.className).toContain('kanban-task-card');
expect(card?.className).toContain('border-yellow-500/30');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it.each(['done', 'approved'] as const)(
'does not highlight blocked tasks in %s',
async (columnId) => {
const { host, root } = await renderTaskCard({
task: { ...baseTask, blockedBy: ['task-2'] },
columnId,
});
const card = host.querySelector('[data-task-id="task-1"]');
expect(card?.className).not.toContain('border-yellow-500/30');
expect(card?.className).toContain('border-[var(--color-border)]');
expect(host.textContent).toContain('Blocked by');
await act(async () => {
root.unmount();
await Promise.resolve();
});
}
);
});
describe('KanbanTaskCard live log indicator', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('shows the live log indicator only when task log activity is active', async () => {
const { host, root } = await renderTaskCard({ hasLiveTaskLogs: true });
expect(host.querySelector('[aria-label="Task logs active"]')).not.toBeNull();
await act(async () => {
root.render(
React.createElement(KanbanTaskCard, {
task: baseTask,
teamName: 'my-team',
columnId: 'in_progress',
hasReviewers: true,
compact: false,
taskMap: new Map(),
memberColorMap: new Map([['alice', 'blue']]),
onRequestReview: noop,
onApprove: noop,
onRequestChanges: noop,
onMoveBackToDone: noop,
onStartTask: noop,
onCompleteTask: noop,
onCancelTask: noop,
onViewChanges: noop,
hasLiveTaskLogs: false,
})
);
await Promise.resolve();
});
expect(host.querySelector('[aria-label="Task logs active"]')).toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});