/* 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; 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.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 { await Promise.resolve(); await Promise.resolve(); } async function rerenderTaskCard( root: ReturnType, props: Partial> = {} ): Promise { await act(async () => { root.render(createTaskCardElement(props)); await flushReact(); }); } function createStrictTaskCardElement( props: Partial> = {} ): React.ReactElement { return React.createElement(React.StrictMode, null, createTaskCardElement(props)); } async function renderStrictTaskCard( props: Partial> = {} ): Promise<{ host: HTMLDivElement; root: ReturnType }> { 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, props: Partial> = {} ): Promise { 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> = {} ): Promise<{ host: HTMLDivElement; root: ReturnType }> { 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(); }); }); });