diff --git a/web/app/components/workflow/comment/comment-icon.spec.tsx b/web/app/components/workflow/comment/comment-icon.spec.tsx new file mode 100644 index 0000000000..aee8c64fa3 --- /dev/null +++ b/web/app/components/workflow/comment/comment-icon.spec.tsx @@ -0,0 +1,148 @@ +import type { WorkflowCommentList } from '@/service/workflow-comment' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CommentIcon } from './comment-icon' + +type Position = { x: number, y: number } + +let mockUserId = 'user-1' + +const mockFlowToScreenPosition = vi.fn((position: Position) => position) +const mockScreenToFlowPosition = vi.fn((position: Position) => position) + +vi.mock('reactflow', () => ({ + useReactFlow: () => ({ + flowToScreenPosition: mockFlowToScreenPosition, + screenToFlowPosition: mockScreenToFlowPosition, + }), + useViewport: () => ({ + x: 0, + y: 0, + zoom: 1, + }), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + userProfile: { + id: mockUserId, + name: 'User', + avatar_url: 'avatar', + }, + }), +})) + +vi.mock('@/app/components/base/user-avatar-list', () => ({ + UserAvatarList: ({ users }: { users: Array<{ id: string }> }) => ( +
{users.map(user => user.id).join(',')}
+ ), +})) + +vi.mock('./comment-preview', () => ({ + default: ({ onClick }: { onClick?: () => void }) => ( + + ), +})) + +const createComment = (overrides: Partial = {}): WorkflowCommentList => ({ + id: 'comment-1', + position_x: 0, + position_y: 0, + content: 'Hello', + created_by: 'user-1', + created_by_account: { + id: 'user-1', + name: 'Alice', + email: 'alice@example.com', + }, + created_at: 1, + updated_at: 2, + resolved: false, + mention_count: 0, + reply_count: 0, + participants: [], + ...overrides, +}) + +describe('CommentIcon', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUserId = 'user-1' + }) + + it('toggles preview on hover when inactive', () => { + const comment = createComment() + const { container } = render( + , + ) + const marker = container.querySelector('[data-role="comment-marker"]') as HTMLElement + const hoverTarget = marker.firstElementChild as HTMLElement + + fireEvent.mouseEnter(hoverTarget) + expect(screen.getByTestId('comment-preview')).toBeInTheDocument() + + fireEvent.mouseLeave(hoverTarget) + expect(screen.queryByTestId('comment-preview')).not.toBeInTheDocument() + }) + + it('calls onPositionUpdate after dragging by author', () => { + const comment = createComment({ position_x: 0, position_y: 0 }) + const onClick = vi.fn() + const onPositionUpdate = vi.fn() + const { container } = render( + , + ) + const marker = container.querySelector('[data-role="comment-marker"]') as HTMLElement + + fireEvent.pointerDown(marker, { + pointerId: 1, + button: 0, + clientX: 100, + clientY: 100, + }) + fireEvent.pointerMove(marker, { + pointerId: 1, + clientX: 110, + clientY: 110, + }) + fireEvent.pointerUp(marker, { + pointerId: 1, + clientX: 110, + clientY: 110, + }) + + expect(mockScreenToFlowPosition).toHaveBeenCalledWith({ x: 10, y: 10 }) + expect(onPositionUpdate).toHaveBeenCalledWith({ x: 10, y: 10 }) + expect(onClick).not.toHaveBeenCalled() + }) + + it('calls onClick for non-author clicks', () => { + mockUserId = 'user-2' + const comment = createComment() + const onClick = vi.fn() + const { container } = render( + , + ) + const marker = container.querySelector('[data-role="comment-marker"]') as HTMLElement + + fireEvent.pointerDown(marker, { + pointerId: 1, + button: 0, + clientX: 50, + clientY: 60, + }) + fireEvent.pointerUp(marker, { + pointerId: 1, + clientX: 50, + clientY: 60, + }) + + expect(onClick).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/workflow/comment/comment-input.spec.tsx b/web/app/components/workflow/comment/comment-input.spec.tsx new file mode 100644 index 0000000000..aa60b0c8bc --- /dev/null +++ b/web/app/components/workflow/comment/comment-input.spec.tsx @@ -0,0 +1,106 @@ +import type { FC } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CommentInput } from './comment-input' + +type MentionInputProps = { + value: string + onChange: (value: string) => void + onSubmit: (content: string, mentionedUserIds: string[]) => void + placeholder?: string + autoFocus?: boolean + className?: string +} + +const stableT = (key: string, options?: { ns?: string }) => ( + options?.ns ? `${options.ns}.${key}` : key +) + +let mentionInputProps: MentionInputProps | null = null + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: stableT, + }), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + userProfile: { + id: 'user-1', + name: 'Alice', + avatar_url: 'avatar', + }, + }), +})) + +vi.mock('@/app/components/base/avatar', () => ({ + default: ({ name }: { name: string }) =>
{name}
, +})) + +vi.mock('./mention-input', () => ({ + MentionInput: ((props: MentionInputProps) => { + mentionInputProps = props + return ( + + ) + }) as FC, +})) + +describe('CommentInput', () => { + beforeEach(() => { + vi.clearAllMocks() + mentionInputProps = null + }) + + it('passes translated placeholder to mention input', () => { + render( + , + ) + + expect(mentionInputProps?.placeholder).toBe('workflow.comments.placeholder.add') + expect(mentionInputProps?.autoFocus).toBe(true) + }) + + it('calls onCancel when Escape is pressed', () => { + const onCancel = vi.fn() + + render( + , + ) + + fireEvent.keyDown(document, { key: 'Escape' }) + + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it('forwards mention submit to onSubmit', () => { + const onSubmit = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByTestId('mention-input')) + + expect(onSubmit).toHaveBeenCalledWith('Hello', ['user-2']) + }) +}) diff --git a/web/app/components/workflow/comment/comment-preview.spec.tsx b/web/app/components/workflow/comment/comment-preview.spec.tsx new file mode 100644 index 0000000000..d411c67ecd --- /dev/null +++ b/web/app/components/workflow/comment/comment-preview.spec.tsx @@ -0,0 +1,86 @@ +import type { WorkflowCommentList } from '@/service/workflow-comment' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import CommentPreview from './comment-preview' + +type UserProfile = WorkflowCommentList['created_by_account'] + +const mockSetHovering = vi.fn() +let capturedUsers: UserProfile[] = [] + +vi.mock('@/app/components/base/user-avatar-list', () => ({ + UserAvatarList: ({ users }: { users: UserProfile[] }) => { + capturedUsers = users + return
{users.map(user => user.id).join(',')}
+ }, +})) + +vi.mock('@/hooks/use-format-time-from-now', () => ({ + useFormatTimeFromNow: () => ({ + formatTimeFromNow: (value: number) => `time:${value}`, + }), +})) + +vi.mock('../store', () => ({ + useStore: (selector: (state: { setCommentPreviewHovering: (value: boolean) => void }) => unknown) => + selector({ setCommentPreviewHovering: mockSetHovering }), +})) + +const createComment = (overrides: Partial = {}): WorkflowCommentList => { + const author = { id: 'user-1', name: 'Alice', email: 'alice@example.com' } + const participant = { id: 'user-2', name: 'Bob', email: 'bob@example.com' } + + return { + id: 'comment-1', + position_x: 0, + position_y: 0, + content: 'Hello', + created_by: author.id, + created_by_account: author, + created_at: 1, + updated_at: 10, + resolved: false, + mention_count: 0, + reply_count: 0, + participants: [author, participant], + ...overrides, + } +} + +describe('CommentPreview', () => { + beforeEach(() => { + vi.clearAllMocks() + capturedUsers = [] + }) + + it('orders participants with author first and formats time', () => { + const comment = createComment() + + render() + + expect(capturedUsers.map(user => user.id)).toEqual(['user-1', 'user-2']) + expect(screen.getByText('Hello')).toBeInTheDocument() + expect(screen.getByText('time:10000')).toBeInTheDocument() + }) + + it('updates hover state on enter and leave', () => { + const comment = createComment() + const { container } = render() + const root = container.firstElementChild as HTMLElement + + fireEvent.mouseEnter(root) + fireEvent.mouseLeave(root) + + expect(mockSetHovering).toHaveBeenCalledWith(true) + expect(mockSetHovering).toHaveBeenCalledWith(false) + }) + + it('clears hover state on unmount', () => { + const comment = createComment() + const { unmount } = render() + + unmount() + + expect(mockSetHovering).toHaveBeenCalledWith(false) + }) +}) diff --git a/web/app/components/workflow/comment/cursor.spec.tsx b/web/app/components/workflow/comment/cursor.spec.tsx new file mode 100644 index 0000000000..74cf222c1a --- /dev/null +++ b/web/app/components/workflow/comment/cursor.spec.tsx @@ -0,0 +1,45 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ControlMode } from '../types' +import { CommentCursor } from './cursor' + +const mockState = { + controlMode: ControlMode.Pointer, + mousePosition: { + elementX: 10, + elementY: 20, + }, +} + +vi.mock('@/app/components/base/icons/src/public/other', () => ({ + Comment: (props: { className?: string }) => , +})) + +vi.mock('../store', () => ({ + useStore: (selector: (state: typeof mockState) => unknown) => selector(mockState), +})) + +describe('CommentCursor', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders nothing when not in comment mode', () => { + mockState.controlMode = ControlMode.Pointer + + render() + + expect(screen.queryByTestId('comment-icon')).not.toBeInTheDocument() + }) + + it('renders at current mouse position when in comment mode', () => { + mockState.controlMode = ControlMode.Comment + + render() + + const icon = screen.getByTestId('comment-icon') + const container = icon.parentElement as HTMLElement + + expect(container).toHaveStyle({ left: '10px', top: '20px' }) + }) +})