import type { WorkflowCommentDetail } from '@/service/workflow-comment' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { CommentThread } from './thread' const mockSetCommentPreviewHovering = vi.hoisted(() => vi.fn()) const mockFlowToScreenPosition = vi.hoisted(() => vi.fn(({ x, y }: { x: number, y: number }) => ({ x, y }))) const storeState = vi.hoisted(() => ({ mentionableUsersCache: { 'app-1': [ { id: 'user-1', name: 'Alice', email: 'alice@example.com', avatar_url: 'alice.png' }, { id: 'user-2', name: 'Bob', email: 'bob@example.com', avatar_url: 'bob.png' }, ], } as Record>, setCommentPreviewHovering: (...args: unknown[]) => mockSetCommentPreviewHovering(...args), })) vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, }), })) vi.mock('@/next/navigation', () => ({ useParams: () => ({ appId: 'app-1' }), })) vi.mock('@/hooks/use-format-time-from-now', () => ({ useFormatTimeFromNow: () => ({ formatTimeFromNow: () => 'just now', }), })) vi.mock('@/context/app-context', () => ({ useAppContext: () => ({ userProfile: { id: 'user-1', name: 'Alice', avatar_url: 'alice.png', }, }), })) vi.mock('reactflow', () => ({ useReactFlow: () => ({ flowToScreenPosition: mockFlowToScreenPosition, }), useViewport: () => ({ x: 0, y: 0, zoom: 1 }), })) vi.mock('../store', () => ({ useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState), })) vi.mock('@/app/components/workflow/collaboration/utils/user-color', () => ({ getUserColor: () => '#22c55e', })) vi.mock('@/app/components/base/divider', () => ({ default: () =>
, })) vi.mock('@/app/components/base/inline-delete-confirm', () => ({ default: ({ onConfirm }: { onConfirm: () => void }) => ( ), })) vi.mock('@/app/components/base/ui/avatar', () => ({ Avatar: ({ name }: { name: string }) =>
{name}
, })) vi.mock('@/app/components/base/ui/dropdown-menu', () => ({ DropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, DropdownMenuTrigger: ({ children, ...props }: React.ComponentProps<'button'>) => ( ), DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, })) vi.mock('@/app/components/base/ui/tooltip', () => ({ Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, TooltipTrigger: ({ children, render, ...props }: React.ComponentProps<'button'> & { children?: React.ReactNode, render?: React.ReactNode }) => { if (render) return <>{render} return }, TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}, })) vi.mock('./mention-input', () => ({ MentionInput: ({ placeholder, value, onSubmit, onCancel, }: { placeholder?: string value: string onSubmit: (content: string, mentionedUserIds: string[]) => void onCancel?: () => void }) => (
{onCancel && ( )}
), })) const createComment = (): WorkflowCommentDetail => ({ id: 'comment-1', position_x: 120, position_y: 80, content: '@Alice original comment', created_by: 'user-1', created_by_account: { id: 'user-1', name: 'Alice', email: 'alice@example.com', avatar_url: 'alice.png', }, created_at: 1, updated_at: 2, resolved: false, mentions: [], replies: [{ id: 'reply-1', content: 'first reply', created_by: 'user-1', created_by_account: { id: 'user-1', name: 'Alice', email: 'alice@example.com', avatar_url: 'alice.png', }, created_at: 2, }], }) describe('CommentThread', () => { beforeEach(() => { vi.clearAllMocks() document.body.innerHTML = '' const workflowContainer = document.createElement('div') workflowContainer.id = 'workflow-container' document.body.appendChild(workflowContainer) }) it('triggers header actions and closes on Escape', () => { const onClose = vi.fn() const onDelete = vi.fn() const onResolve = vi.fn() const onPrev = vi.fn() const onNext = vi.fn() render( , ) fireEvent.click(screen.getByLabelText('workflow.comments.aria.deleteComment')) fireEvent.click(screen.getByLabelText('workflow.comments.aria.resolveComment')) fireEvent.click(screen.getByLabelText('workflow.comments.aria.previousComment')) fireEvent.click(screen.getByLabelText('workflow.comments.aria.nextComment')) fireEvent.click(screen.getByLabelText('workflow.comments.aria.closeComment')) fireEvent.keyDown(document, { key: 'Escape' }) expect(onDelete).toHaveBeenCalledTimes(1) expect(onResolve).toHaveBeenCalledTimes(1) expect(onPrev).toHaveBeenCalledTimes(1) expect(onNext).toHaveBeenCalledTimes(1) expect(onClose).toHaveBeenCalledTimes(2) }) it('does not nest header action buttons inside tooltip trigger buttons', () => { const { container } = render( , ) expect(container.querySelector('button button')).toBeNull() }) it('submits reply and updates preview hovering state on mouse enter/leave', async () => { const onReply = vi.fn() const { container } = render( , ) fireEvent.mouseEnter(container.firstElementChild as Element) fireEvent.mouseLeave(container.firstElementChild as Element) expect(mockSetCommentPreviewHovering).toHaveBeenNthCalledWith(1, true) expect(mockSetCommentPreviewHovering).toHaveBeenNthCalledWith(2, false) fireEvent.click(screen.getByText('submit-workflow.comments.placeholder.reply')) await waitFor(() => { expect(onReply).toHaveBeenCalledWith('content:workflow.comments.placeholder.reply', ['user-2']) }) }) it('supports editing and direct deleting an existing reply', async () => { const onReplyEdit = vi.fn() const onReplyDeleteDirect = vi.fn() render( , ) fireEvent.click(screen.getByText('workflow.comments.actions.editReply')) fireEvent.click(screen.getByText('submit-workflow.comments.placeholder.editReply')) await waitFor(() => { expect(onReplyEdit).toHaveBeenCalledWith('reply-1', 'first reply', ['user-2']) }) await waitFor(() => { expect(screen.getByText('workflow.comments.actions.deleteReply')).toBeInTheDocument() }) fireEvent.click(screen.getByText('workflow.comments.actions.deleteReply')) fireEvent.click(screen.getByTestId('confirm-delete-reply')) expect(onReplyDeleteDirect).toHaveBeenCalledWith('reply-1') }) })