mirror of
https://github.com/langgenius/dify.git
synced 2026-02-24 18:05:11 +00:00
test: add unit tests for chat components (#32367)
This commit is contained in:
114
web/app/components/base/chat/chat/answer/agent-content.spec.tsx
Normal file
114
web/app/components/base/chat/chat/answer/agent-content.spec.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { ChatItem } from '../../types'
|
||||
import type { IThoughtProps } from '@/app/components/base/chat/chat/thought'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { MarkdownProps } from '@/app/components/base/markdown'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import AgentContent from './agent-content'
|
||||
|
||||
// Mock Markdown component used only in tests
|
||||
vi.mock('@/app/components/base/markdown', () => ({
|
||||
Markdown: (props: MarkdownProps & { 'data-testid'?: string }) => (
|
||||
<div data-testid={props['data-testid'] || 'markdown'} data-content={String(props.content)} className={props.className}>
|
||||
{String(props.content)}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Thought
|
||||
vi.mock('@/app/components/base/chat/chat/thought', () => ({
|
||||
default: ({ thought, isFinished }: IThoughtProps) => (
|
||||
<div data-testid="thought-component" data-finished={isFinished}>
|
||||
{thought.thought}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock FileList and Utils
|
||||
vi.mock('@/app/components/base/file-uploader', () => ({
|
||||
FileList: ({ files }: { files: FileEntity[] }) => (
|
||||
<div data-testid="file-list-component">
|
||||
{files.map(f => f.name).join(', ')}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/file-uploader/utils', () => ({
|
||||
getProcessedFilesFromResponse: (files: FileEntity[]) => files.map(f => ({ ...f, name: `processed-${f.id}` })),
|
||||
}))
|
||||
|
||||
describe('AgentContent', () => {
|
||||
const mockItem: ChatItem = {
|
||||
id: '1',
|
||||
content: '',
|
||||
isAnswer: true,
|
||||
}
|
||||
|
||||
it('renders logAnnotation if present', () => {
|
||||
const itemWithAnnotation = {
|
||||
...mockItem,
|
||||
annotation: {
|
||||
logAnnotation: { content: 'Log Annotation Content' },
|
||||
},
|
||||
}
|
||||
render(<AgentContent item={itemWithAnnotation as ChatItem} />)
|
||||
expect(screen.getByTestId('agent-content-markdown')).toHaveTextContent('Log Annotation Content')
|
||||
})
|
||||
|
||||
it('renders content prop if provided and no annotation', () => {
|
||||
render(<AgentContent item={mockItem} content="Direct Content" />)
|
||||
expect(screen.getByTestId('agent-content-markdown')).toHaveTextContent('Direct Content')
|
||||
})
|
||||
|
||||
it('renders agent_thoughts if content is absent', () => {
|
||||
const itemWithThoughts = {
|
||||
...mockItem,
|
||||
agent_thoughts: [
|
||||
{ thought: 'Thought 1', tool: 'tool1' },
|
||||
{ thought: 'Thought 2' },
|
||||
],
|
||||
}
|
||||
render(<AgentContent item={itemWithThoughts as ChatItem} responding={false} />)
|
||||
const items = screen.getAllByTestId('agent-thought-item')
|
||||
expect(items).toHaveLength(2)
|
||||
const thoughtMarkdowns = screen.getAllByTestId('agent-thought-markdown')
|
||||
expect(thoughtMarkdowns[0]).toHaveTextContent('Thought 1')
|
||||
expect(thoughtMarkdowns[1]).toHaveTextContent('Thought 2')
|
||||
expect(screen.getByTestId('thought-component')).toHaveTextContent('Thought 1')
|
||||
})
|
||||
|
||||
it('passes correct isFinished to Thought component', () => {
|
||||
const itemWithThoughts = {
|
||||
...mockItem,
|
||||
agent_thoughts: [
|
||||
{ thought: 'T1', tool: 'tool1', observation: 'obs1' }, // finished by observation
|
||||
{ thought: 'T2', tool: 'tool2' }, // finished by responding=false
|
||||
],
|
||||
}
|
||||
const { rerender } = render(<AgentContent item={itemWithThoughts as ChatItem} responding={true} />)
|
||||
const thoughts = screen.getAllByTestId('thought-component')
|
||||
expect(thoughts[0]).toHaveAttribute('data-finished', 'true')
|
||||
expect(thoughts[1]).toHaveAttribute('data-finished', 'false')
|
||||
|
||||
rerender(<AgentContent item={itemWithThoughts as ChatItem} responding={false} />)
|
||||
expect(screen.getAllByTestId('thought-component')[1]).toHaveAttribute('data-finished', 'true')
|
||||
})
|
||||
|
||||
it('renders FileList if thought has message_files', () => {
|
||||
const itemWithFiles = {
|
||||
...mockItem,
|
||||
agent_thoughts: [
|
||||
{
|
||||
thought: 'T1',
|
||||
message_files: [{ id: 'file1' }, { id: 'file2' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
render(<AgentContent item={itemWithFiles as ChatItem} />)
|
||||
expect(screen.getByTestId('file-list-component')).toHaveTextContent('processed-file1, processed-file2')
|
||||
})
|
||||
|
||||
it('renders nothing if no annotation, content, or thoughts', () => {
|
||||
render(<AgentContent item={mockItem} />)
|
||||
expect(screen.getByTestId('agent-content-container')).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
@@ -23,15 +23,29 @@ const AgentContent: FC<AgentContentProps> = ({
|
||||
agent_thoughts,
|
||||
} = item
|
||||
|
||||
if (annotation?.logAnnotation)
|
||||
return <Markdown content={annotation?.logAnnotation.content || ''} />
|
||||
if (annotation?.logAnnotation) {
|
||||
return (
|
||||
<Markdown
|
||||
content={annotation?.logAnnotation.content || ''}
|
||||
data-testid="agent-content-markdown"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{content ? <Markdown content={content} /> : agent_thoughts?.map((thought, index) => (
|
||||
<div key={index} className="px-2 py-1">
|
||||
<div data-testid="agent-content-container">
|
||||
{content ? (
|
||||
<Markdown
|
||||
content={content}
|
||||
data-testid="agent-content-markdown"
|
||||
/>
|
||||
) : agent_thoughts?.map((thought, index) => (
|
||||
<div key={index} className="px-2 py-1" data-testid="agent-thought-item">
|
||||
{thought.thought && (
|
||||
<Markdown content={thought.thought} />
|
||||
<Markdown
|
||||
content={thought.thought}
|
||||
data-testid="agent-thought-markdown"
|
||||
/>
|
||||
)}
|
||||
{/* {item.tool} */}
|
||||
{/* perhaps not use tool */}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ChatItem } from '../../types'
|
||||
import type { MarkdownProps } from '@/app/components/base/markdown'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import BasicContent from './basic-content'
|
||||
|
||||
// Mock Markdown component used only in tests
|
||||
vi.mock('@/app/components/base/markdown', () => ({
|
||||
Markdown: ({ content, className }: MarkdownProps) => (
|
||||
<div data-testid="basic-content-markdown" data-content={String(content)} className={className}>
|
||||
{String(content)}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('BasicContent', () => {
|
||||
const mockItem = {
|
||||
id: '1',
|
||||
content: 'Hello World',
|
||||
isAnswer: true,
|
||||
}
|
||||
|
||||
it('renders content correctly', () => {
|
||||
render(<BasicContent item={mockItem as ChatItem} />)
|
||||
const markdown = screen.getByTestId('basic-content-markdown')
|
||||
expect(markdown).toHaveAttribute('data-content', 'Hello World')
|
||||
})
|
||||
|
||||
it('renders logAnnotation content if present', () => {
|
||||
const itemWithAnnotation = {
|
||||
...mockItem,
|
||||
annotation: {
|
||||
logAnnotation: {
|
||||
content: 'Annotated Content',
|
||||
},
|
||||
},
|
||||
}
|
||||
render(<BasicContent item={itemWithAnnotation as ChatItem} />)
|
||||
const markdown = screen.getByTestId('basic-content-markdown')
|
||||
expect(markdown).toHaveAttribute('data-content', 'Annotated Content')
|
||||
})
|
||||
|
||||
it('wraps Windows UNC paths in backticks', () => {
|
||||
const itemWithUNC = {
|
||||
...mockItem,
|
||||
content: '\\\\server\\share\\file.txt',
|
||||
}
|
||||
render(<BasicContent item={itemWithUNC as ChatItem} />)
|
||||
const markdown = screen.getByTestId('basic-content-markdown')
|
||||
expect(markdown).toHaveAttribute('data-content', '`\\\\server\\share\\file.txt`')
|
||||
})
|
||||
|
||||
it('does not wrap content in backticks if it already is', () => {
|
||||
const itemWithBackticks = {
|
||||
...mockItem,
|
||||
content: '`\\\\server\\share\\file.txt`',
|
||||
}
|
||||
render(<BasicContent item={itemWithBackticks as ChatItem} />)
|
||||
const markdown = screen.getByTestId('basic-content-markdown')
|
||||
expect(markdown).toHaveAttribute('data-content', '`\\\\server\\share\\file.txt`')
|
||||
})
|
||||
|
||||
it('does not wrap backslash strings that are not UNC paths', () => {
|
||||
const itemWithBackslashes = {
|
||||
...mockItem,
|
||||
content: '\\not-a-unc',
|
||||
}
|
||||
render(<BasicContent item={itemWithBackslashes as ChatItem} />)
|
||||
const markdown = screen.getByTestId('basic-content-markdown')
|
||||
expect(markdown).toHaveAttribute('data-content', '\\not-a-unc')
|
||||
})
|
||||
|
||||
it('applies error class when isError is true', () => {
|
||||
const errorItem = {
|
||||
...mockItem,
|
||||
isError: true,
|
||||
}
|
||||
render(<BasicContent item={errorItem as ChatItem} />)
|
||||
const markdown = screen.getByTestId('basic-content-markdown')
|
||||
expect(markdown).toHaveClass('!text-[#F04438]')
|
||||
})
|
||||
|
||||
it('renders non-string content without attempting to wrap (covers typeof !== "string" branch)', () => {
|
||||
const itemWithNonStringContent = {
|
||||
...mockItem,
|
||||
content: 12345,
|
||||
}
|
||||
render(<BasicContent item={itemWithNonStringContent as unknown as ChatItem} />)
|
||||
const markdown = screen.getByTestId('basic-content-markdown')
|
||||
expect(markdown).toHaveAttribute('data-content', '12345')
|
||||
})
|
||||
})
|
||||
@@ -15,8 +15,14 @@ const BasicContent: FC<BasicContentProps> = ({
|
||||
content,
|
||||
} = item
|
||||
|
||||
if (annotation?.logAnnotation)
|
||||
return <Markdown content={annotation?.logAnnotation.content || ''} />
|
||||
if (annotation?.logAnnotation) {
|
||||
return (
|
||||
<Markdown
|
||||
content={annotation?.logAnnotation.content || ''}
|
||||
data-testid="basic-content-markdown"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Preserve Windows UNC paths and similar backslash-heavy strings by
|
||||
// wrapping them in inline code so Markdown renders backslashes verbatim.
|
||||
@@ -31,6 +37,7 @@ const BasicContent: FC<BasicContentProps> = ({
|
||||
item.isError && '!text-[#F04438]',
|
||||
)}
|
||||
content={displayContent}
|
||||
data-testid="basic-content-markdown"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import ContentItem from './content-item'
|
||||
|
||||
vi.mock('@/app/components/base/markdown', () => ({
|
||||
Markdown: ({ content }: { content: string }) => <div data-testid="mock-markdown">{content}</div>,
|
||||
}))
|
||||
|
||||
describe('ContentItem', () => {
|
||||
const mockOnInputChange = vi.fn()
|
||||
const mockFormInputFields: FormInputItem[] = [
|
||||
{
|
||||
type: 'paragraph',
|
||||
output_variable_name: 'user_bio',
|
||||
default: {
|
||||
type: 'constant',
|
||||
value: '',
|
||||
selector: [],
|
||||
},
|
||||
} as FormInputItem,
|
||||
]
|
||||
const mockInputs = {
|
||||
user_bio: 'Initial bio',
|
||||
}
|
||||
|
||||
it('should render Markdown for literal content', () => {
|
||||
render(
|
||||
<ContentItem
|
||||
content="Hello world"
|
||||
formInputFields={[]}
|
||||
inputs={{}}
|
||||
onInputChange={mockOnInputChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('mock-markdown')).toHaveTextContent('Hello world')
|
||||
expect(screen.queryByTestId('content-item-textarea')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Textarea for valid output variable content', () => {
|
||||
render(
|
||||
<ContentItem
|
||||
content="{{#$output.user_bio#}}"
|
||||
formInputFields={mockFormInputFields}
|
||||
inputs={mockInputs}
|
||||
onInputChange={mockOnInputChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByTestId('content-item-textarea')
|
||||
expect(textarea).toBeInTheDocument()
|
||||
expect(textarea).toHaveValue('Initial bio')
|
||||
expect(screen.queryByTestId('mock-markdown')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onInputChange when textarea value changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<ContentItem
|
||||
content="{{#$output.user_bio#}}"
|
||||
formInputFields={mockFormInputFields}
|
||||
inputs={mockInputs}
|
||||
onInputChange={mockOnInputChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByTestId('content-item-textarea')
|
||||
await user.type(textarea, 'x')
|
||||
|
||||
expect(mockOnInputChange).toHaveBeenCalledWith('user_bio', 'Initial biox')
|
||||
})
|
||||
|
||||
it('should render nothing if field name is valid but not found in formInputFields', () => {
|
||||
const { container } = render(
|
||||
<ContentItem
|
||||
content="{{#$output.unknown_field#}}"
|
||||
formInputFields={mockFormInputFields}
|
||||
inputs={mockInputs}
|
||||
onInputChange={mockOnInputChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should render nothing if input type is not supported', () => {
|
||||
const { container } = render(
|
||||
<ContentItem
|
||||
content="{{#$output.user_bio#}}"
|
||||
formInputFields={[
|
||||
{
|
||||
type: 'text-input',
|
||||
output_variable_name: 'user_bio',
|
||||
default: {
|
||||
type: 'constant',
|
||||
value: '',
|
||||
selector: [],
|
||||
},
|
||||
} as FormInputItem,
|
||||
]}
|
||||
inputs={mockInputs}
|
||||
onInputChange={mockOnInputChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('[data-testid="content-item-textarea"]')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('.py-3')?.textContent).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -45,6 +45,7 @@ const ContentItem = ({
|
||||
className="h-[104px] sm:text-xs"
|
||||
value={inputs[fieldName]}
|
||||
onChange={(e) => { onInputChange(fieldName, e.target.value) }}
|
||||
data-testid="content-item-textarea"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import ContentWrapper from './content-wrapper'
|
||||
|
||||
describe('ContentWrapper', () => {
|
||||
const defaultProps = {
|
||||
nodeTitle: 'Human Input Node',
|
||||
children: <div data-testid="child-content">Child Content</div>,
|
||||
}
|
||||
|
||||
it('should render node title and children by default when not collapsible', () => {
|
||||
render(<ContentWrapper {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('Human Input Node')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('child-content')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('expand-icon')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show/hide content when toggling expansion', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ContentWrapper {...defaultProps} showExpandIcon={true} expanded={false} />)
|
||||
|
||||
// Initially collapsed
|
||||
expect(screen.queryByTestId('child-content')).not.toBeInTheDocument()
|
||||
const expandToggle = screen.getByTestId('expand-icon')
|
||||
expect(expandToggle.querySelector('.i-ri-arrow-right-s-line')).toBeInTheDocument()
|
||||
|
||||
// Expand
|
||||
await user.click(expandToggle)
|
||||
expect(screen.getByTestId('child-content')).toBeInTheDocument()
|
||||
expect(expandToggle.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
|
||||
|
||||
// Collapse
|
||||
await user.click(expandToggle)
|
||||
expect(screen.queryByTestId('child-content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render children initially if expanded is true', () => {
|
||||
render(<ContentWrapper {...defaultProps} showExpandIcon={true} expanded={true} />)
|
||||
|
||||
expect(screen.getByTestId('child-content')).toBeInTheDocument()
|
||||
const expandToggle = screen.getByTestId('expand-icon')
|
||||
expect(expandToggle.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
@@ -26,26 +25,33 @@ const ContentWrapper = ({
|
||||
}, [isExpanded])
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-2xl border-[0.5px] border-components-panel-border bg-background-section p-2 shadow-md', className)}>
|
||||
<div
|
||||
className={cn('rounded-2xl border-[0.5px] border-components-panel-border bg-background-section p-2 shadow-md', className)}
|
||||
data-testid="content-wrapper"
|
||||
>
|
||||
<div className="flex items-center gap-2 p-2">
|
||||
{/* node icon */}
|
||||
<BlockIcon type={BlockEnum.HumanInput} className="shrink-0" />
|
||||
{/* node name */}
|
||||
<div
|
||||
className="system-sm-semibold-uppercase grow truncate text-text-primary"
|
||||
className="grow truncate text-text-primary system-sm-semibold-uppercase"
|
||||
title={nodeTitle}
|
||||
>
|
||||
{nodeTitle}
|
||||
</div>
|
||||
{showExpandIcon && (
|
||||
<div className="shrink-0 cursor-pointer" onClick={handleToggleExpand}>
|
||||
<div
|
||||
className="shrink-0 cursor-pointer"
|
||||
onClick={handleToggleExpand}
|
||||
data-testid="expand-icon"
|
||||
>
|
||||
{
|
||||
isExpanded
|
||||
? (
|
||||
<RiArrowDownSLine className="size-4" />
|
||||
<div className="i-ri-arrow-down-s-line size-4" />
|
||||
)
|
||||
: (
|
||||
<RiArrowRightSLine className="size-4" />
|
||||
<div className="i-ri-arrow-right-s-line size-4" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import ExecutedAction from './executed-action'
|
||||
|
||||
describe('ExecutedAction', () => {
|
||||
it('should render the triggered action information', () => {
|
||||
const executedAction = {
|
||||
id: 'btn_1',
|
||||
title: 'Submit',
|
||||
}
|
||||
|
||||
render(<ExecutedAction executedAction={executedAction} />)
|
||||
|
||||
expect(screen.getByTestId('executed-action')).toBeInTheDocument()
|
||||
|
||||
// Trans component mock from i18n-mock.ts renders a span with data-i18n-key
|
||||
const trans = screen.getByTestId('executed-action').querySelector('span')
|
||||
expect(trans).toHaveAttribute('data-i18n-key', 'nodes.humanInput.userActions.triggered')
|
||||
|
||||
// Check for the trigger icon class
|
||||
expect(screen.getByTestId('executed-action').querySelector('.i-custom-vender-workflow-trigger-all')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,6 @@ import type { ExecutedAction as ExecutedActionType } from './type'
|
||||
import { memo } from 'react'
|
||||
import { Trans } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
|
||||
|
||||
type ExecutedActionProps = {
|
||||
executedAction: ExecutedActionType
|
||||
@@ -12,14 +11,14 @@ const ExecutedAction = ({
|
||||
executedAction,
|
||||
}: ExecutedActionProps) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-1 py-1">
|
||||
<div className="flex flex-col gap-y-1 py-1" data-testid="executed-action">
|
||||
<Divider className="mb-2 mt-1 w-[30px]" />
|
||||
<div className="system-xs-regular flex items-center gap-x-1 text-text-tertiary">
|
||||
<TriggerAll className="size-3.5 shrink-0" />
|
||||
<div className="flex items-center gap-x-1 text-text-tertiary system-xs-regular">
|
||||
<div className="i-custom-vender-workflow-trigger-all size-3.5 shrink-0" />
|
||||
<Trans
|
||||
i18nKey="nodes.humanInput.userActions.triggered"
|
||||
ns="workflow"
|
||||
components={{ strong: <span className="system-xs-medium text-text-secondary"></span> }}
|
||||
components={{ strong: <span className="text-text-secondary system-xs-medium"></span> }}
|
||||
values={{ actionName: executedAction.id }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import ExpirationTime from './expiration-time'
|
||||
import * as utils from './utils'
|
||||
|
||||
// Mock utils to control time-based logic
|
||||
vi.mock('./utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./utils')>()
|
||||
return {
|
||||
...actual,
|
||||
getRelativeTime: vi.fn(),
|
||||
isRelativeTimeSameOrAfter: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
describe('ExpirationTime', () => {
|
||||
it('should render "Future" state with relative time', () => {
|
||||
vi.mocked(utils.getRelativeTime).mockReturnValue('in 2 hours')
|
||||
vi.mocked(utils.isRelativeTimeSameOrAfter).mockReturnValue(true)
|
||||
|
||||
const { container } = render(<ExpirationTime expirationTime={1234567890} />)
|
||||
|
||||
expect(screen.getByTestId('expiration-time')).toHaveClass('text-text-tertiary')
|
||||
expect(screen.getByText('share.humanInput.expirationTimeNowOrFuture:{"relativeTime":"in 2 hours"}')).toBeInTheDocument()
|
||||
expect(container.querySelector('.i-ri-time-line')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render "Expired" state when time is in the past', () => {
|
||||
vi.mocked(utils.getRelativeTime).mockReturnValue('2 hours ago')
|
||||
vi.mocked(utils.isRelativeTimeSameOrAfter).mockReturnValue(false)
|
||||
|
||||
const { container } = render(<ExpirationTime expirationTime={1234567890} />)
|
||||
|
||||
expect(screen.getByTestId('expiration-time')).toHaveClass('text-text-warning')
|
||||
expect(screen.getByText('share.humanInput.expiredTip')).toBeInTheDocument()
|
||||
expect(container.querySelector('.i-ri-alert-fill')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,4 @@
|
||||
'use client'
|
||||
import { RiAlertFill, RiTimeLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { cn } from '@/utils/classnames'
|
||||
@@ -19,8 +18,9 @@ const ExpirationTime = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="expiration-time"
|
||||
className={cn(
|
||||
'system-xs-regular mt-1 flex items-center gap-x-1 text-text-tertiary',
|
||||
'mt-1 flex items-center gap-x-1 text-text-tertiary system-xs-regular',
|
||||
!isSameOrAfter && 'text-text-warning',
|
||||
)}
|
||||
>
|
||||
@@ -28,13 +28,13 @@ const ExpirationTime = ({
|
||||
isSameOrAfter
|
||||
? (
|
||||
<>
|
||||
<RiTimeLine className="size-3.5" />
|
||||
<div className="i-ri-time-line size-3.5" />
|
||||
<span>{t('humanInput.expirationTimeNowOrFuture', { relativeTime, ns: 'share' })}</span>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<RiAlertFill className="size-3.5" />
|
||||
<div className="i-ri-alert-fill size-3.5" />
|
||||
<span>{t('humanInput.expiredTip', { ns: 'share' })}</span>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { HumanInputFormData } from '@/types/workflow'
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import HumanInputForm from './human-input-form'
|
||||
|
||||
vi.mock('./content-item', () => ({
|
||||
default: ({ content, onInputChange }: { content: string, onInputChange: (name: string, value: string) => void }) => (
|
||||
<div data-testid="mock-content-item">
|
||||
{content}
|
||||
<button data-testid="update-input" onClick={() => onInputChange('field1', 'new value')}>Update</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('HumanInputForm', () => {
|
||||
const mockFormData: HumanInputFormData = {
|
||||
form_id: 'form_1',
|
||||
node_id: 'node_1',
|
||||
node_title: 'Title',
|
||||
display_in_ui: true,
|
||||
expiration_time: 0,
|
||||
form_token: 'token_123',
|
||||
form_content: 'Part 1 {{#$output.field1#}} Part 2',
|
||||
inputs: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
output_variable_name: 'field1',
|
||||
default: { type: 'constant', value: 'initial', selector: [] },
|
||||
} as FormInputItem,
|
||||
],
|
||||
actions: [
|
||||
{ id: 'action_1', title: 'Submit', button_style: UserActionButtonType.Primary },
|
||||
{ id: 'action_2', title: 'Cancel', button_style: UserActionButtonType.Default },
|
||||
{ id: 'action_3', title: 'Accent', button_style: UserActionButtonType.Accent },
|
||||
{ id: 'action_4', title: 'Ghost', button_style: UserActionButtonType.Ghost },
|
||||
],
|
||||
resolved_default_values: {},
|
||||
}
|
||||
|
||||
it('should render content parts and action buttons', () => {
|
||||
render(<HumanInputForm formData={mockFormData} />)
|
||||
|
||||
// splitByOutputVar should yield 3 parts: "Part 1 ", "{{#$output.field1#}}", " Part 2"
|
||||
const contentItems = screen.getAllByTestId('mock-content-item')
|
||||
expect(contentItems).toHaveLength(3)
|
||||
expect(contentItems[0]).toHaveTextContent('Part 1')
|
||||
expect(contentItems[1]).toHaveTextContent('{{#$output.field1#}}')
|
||||
expect(contentItems[2]).toHaveTextContent('Part 2')
|
||||
|
||||
const buttons = screen.getAllByTestId('action-button')
|
||||
expect(buttons).toHaveLength(4)
|
||||
expect(buttons[0]).toHaveTextContent('Submit')
|
||||
expect(buttons[1]).toHaveTextContent('Cancel')
|
||||
expect(buttons[2]).toHaveTextContent('Accent')
|
||||
expect(buttons[3]).toHaveTextContent('Ghost')
|
||||
})
|
||||
|
||||
it('should handle input changes and submit correctly', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockOnSubmit = vi.fn().mockResolvedValue(undefined)
|
||||
render(<HumanInputForm formData={mockFormData} onSubmit={mockOnSubmit} />)
|
||||
|
||||
// Update input via mock ContentItem
|
||||
await user.click(screen.getAllByTestId('update-input')[0])
|
||||
|
||||
// Submit
|
||||
const submitButton = screen.getByRole('button', { name: 'Submit' })
|
||||
await user.click(submitButton)
|
||||
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith('token_123', {
|
||||
action: 'action_1',
|
||||
inputs: { field1: 'new value' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable buttons during submission', async () => {
|
||||
const user = userEvent.setup()
|
||||
let resolveSubmit: (value: void | PromiseLike<void>) => void
|
||||
const submitPromise = new Promise<void>((resolve) => {
|
||||
resolveSubmit = resolve
|
||||
})
|
||||
const mockOnSubmit = vi.fn().mockReturnValue(submitPromise)
|
||||
|
||||
render(<HumanInputForm formData={mockFormData} onSubmit={mockOnSubmit} />)
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Submit' })
|
||||
const cancelButton = screen.getByRole('button', { name: 'Cancel' })
|
||||
|
||||
await user.click(submitButton)
|
||||
|
||||
expect(submitButton).toBeDisabled()
|
||||
expect(cancelButton).toBeDisabled()
|
||||
|
||||
// Finish submission
|
||||
await act(async () => {
|
||||
resolveSubmit!(undefined)
|
||||
})
|
||||
|
||||
expect(submitButton).not.toBeDisabled()
|
||||
expect(cancelButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should handle missing resolved_default_values', () => {
|
||||
const formDataWithoutDefaults = { ...mockFormData, resolved_default_values: undefined }
|
||||
render(<HumanInputForm formData={formDataWithoutDefaults as unknown as HumanInputFormData} />)
|
||||
expect(screen.getAllByTestId('mock-content-item')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should handle unsupported input types in initializeInputs', () => {
|
||||
const formDataWithUnsupported = {
|
||||
...mockFormData,
|
||||
inputs: [
|
||||
{
|
||||
type: 'text-input',
|
||||
output_variable_name: 'field2',
|
||||
default: { type: 'variable', value: '', selector: [] },
|
||||
} as FormInputItem,
|
||||
{
|
||||
type: 'number',
|
||||
output_variable_name: 'field3',
|
||||
default: { type: 'constant', value: '0', selector: [] },
|
||||
} as FormInputItem,
|
||||
],
|
||||
resolved_default_values: { field2: 'default value' },
|
||||
}
|
||||
render(<HumanInputForm formData={formDataWithUnsupported} />)
|
||||
expect(screen.getAllByTestId('mock-content-item')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
@@ -49,6 +49,7 @@ const HumanInputForm = ({
|
||||
disabled={isSubmitting}
|
||||
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
|
||||
onClick={() => submit(formToken, action.id, inputs)}
|
||||
data-testid="action-button"
|
||||
>
|
||||
{action.title}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import SubmittedContent from './submitted-content'
|
||||
|
||||
vi.mock('@/app/components/base/markdown', () => ({
|
||||
Markdown: ({ content }: { content: string }) => <div data-testid="mock-markdown">{content}</div>,
|
||||
}))
|
||||
|
||||
describe('SubmittedContent', () => {
|
||||
it('should render Markdown with the provided content', () => {
|
||||
const content = '## Test Content'
|
||||
render(<SubmittedContent content={content} />)
|
||||
|
||||
expect(screen.getByTestId('submitted-content')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('mock-markdown')).toHaveTextContent(content)
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,9 @@ const SubmittedContent = ({
|
||||
content,
|
||||
}: SubmittedContentProps) => {
|
||||
return (
|
||||
<Markdown content={content} />
|
||||
<div data-testid="submitted-content">
|
||||
<Markdown content={content} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { HumanInputFilledFormData } from '@/types/workflow'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SubmittedHumanInputContent } from './submitted'
|
||||
|
||||
vi.mock('@/app/components/base/markdown', () => ({
|
||||
Markdown: ({ content }: { content: string }) => <div data-testid="mock-markdown">{content}</div>,
|
||||
}))
|
||||
|
||||
describe('SubmittedHumanInputContent Integration', () => {
|
||||
const mockFormData: HumanInputFilledFormData = {
|
||||
rendered_content: 'Rendered **Markdown** content',
|
||||
action_id: 'btn_1',
|
||||
action_text: 'Submit Action',
|
||||
node_id: 'node_1',
|
||||
node_title: 'Node Title',
|
||||
}
|
||||
|
||||
it('should render both content and executed action', () => {
|
||||
render(<SubmittedHumanInputContent formData={mockFormData} />)
|
||||
|
||||
// Verify SubmittedContent rendering
|
||||
expect(screen.getByTestId('submitted-content')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('mock-markdown')).toHaveTextContent('Rendered **Markdown** content')
|
||||
|
||||
// Verify ExecutedAction rendering
|
||||
expect(screen.getByTestId('executed-action')).toBeInTheDocument()
|
||||
// Trans component for triggered action. The mock usually renders the key.
|
||||
expect(screen.getByText('nodes.humanInput.userActions.triggered')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { AppContextValue } from '@/context/app-context'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { useSelector } from '@/context/app-context'
|
||||
import Tips from './tips'
|
||||
|
||||
// Mock AppContext's useSelector to control user profile data
|
||||
vi.mock('@/context/app-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/app-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useSelector: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
describe('Tips', () => {
|
||||
const mockEmail = 'test@example.com'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useSelector).mockImplementation((selector: (value: AppContextValue) => unknown) => {
|
||||
return selector({
|
||||
userProfile: {
|
||||
email: mockEmail,
|
||||
},
|
||||
} as AppContextValue)
|
||||
})
|
||||
})
|
||||
|
||||
it('should render email tip in normal mode', () => {
|
||||
render(
|
||||
<Tips
|
||||
showEmailTip={true}
|
||||
isEmailDebugMode={false}
|
||||
showDebugModeTip={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.common.humanInputEmailTip')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.humanInputEmailTipInDebugMode')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('workflow.common.humanInputWebappTip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render email tip in debug mode', () => {
|
||||
render(
|
||||
<Tips
|
||||
showEmailTip={true}
|
||||
isEmailDebugMode={true}
|
||||
showDebugModeTip={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.humanInputEmailTipInDebugMode')).toBeInTheDocument()
|
||||
expect(screen.queryByText('workflow.common.humanInputEmailTip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render debug mode tip', () => {
|
||||
render(
|
||||
<Tips
|
||||
showEmailTip={false}
|
||||
isEmailDebugMode={false}
|
||||
showDebugModeTip={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.common.humanInputWebappTip')).toBeInTheDocument()
|
||||
expect(screen.queryByText('workflow.common.humanInputEmailTip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render nothing when all flags are false', () => {
|
||||
const { container } = render(
|
||||
<Tips
|
||||
showEmailTip={false}
|
||||
isEmailDebugMode={false}
|
||||
showDebugModeTip={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('tips')).toBeEmptyDOMElement()
|
||||
// Divider is outside of tips container, but within the fragment
|
||||
expect(container.querySelector('.v-divider')).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -20,12 +20,12 @@ const Tips = ({
|
||||
return (
|
||||
<>
|
||||
<Divider className="!my-2 w-[30px]" />
|
||||
<div className="space-y-1 pt-1">
|
||||
<div className="space-y-1 pt-1" data-testid="tips">
|
||||
{showEmailTip && !isEmailDebugMode && (
|
||||
<div className="system-xs-regular text-text-secondary">{t('common.humanInputEmailTip', { ns: 'workflow' })}</div>
|
||||
<div className="text-text-secondary system-xs-regular">{t('common.humanInputEmailTip', { ns: 'workflow' })}</div>
|
||||
)}
|
||||
{showEmailTip && isEmailDebugMode && (
|
||||
<div className="system-xs-regular text-text-secondary">
|
||||
<div className="text-text-secondary system-xs-regular">
|
||||
<Trans
|
||||
i18nKey="common.humanInputEmailTipInDebugMode"
|
||||
ns="workflow"
|
||||
@@ -34,7 +34,7 @@ const Tips = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showDebugModeTip && <div className="system-xs-medium text-text-warning">{t('common.humanInputWebappTip', { ns: 'workflow' })}</div>}
|
||||
{showDebugModeTip && <div className="text-text-warning system-xs-medium">{t('common.humanInputWebappTip', { ns: 'workflow' })}</div>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { InputVarType } from '@/app/components/workflow/types'
|
||||
import type { AppContextValue } from '@/context/app-context'
|
||||
import type { HumanInputFormData } from '@/types/workflow'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import { useSelector } from '@/context/app-context'
|
||||
import { UnsubmittedHumanInputContent } from './unsubmitted'
|
||||
|
||||
// Mock AppContext's useSelector to control user profile data
|
||||
vi.mock('@/context/app-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/app-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useSelector: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
describe('UnsubmittedHumanInputContent Integration', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Helper to create valid form data
|
||||
const createMockFormData = (overrides = {}): HumanInputFormData => ({
|
||||
form_id: 'form_123',
|
||||
node_id: 'node_456',
|
||||
node_title: 'Input Form',
|
||||
form_content: 'Fill this out: {{#$output.user_name#}}',
|
||||
inputs: [
|
||||
{
|
||||
type: 'paragraph' as InputVarType,
|
||||
output_variable_name: 'user_name',
|
||||
default: {
|
||||
type: 'constant',
|
||||
value: 'Default value',
|
||||
selector: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{ id: 'btn_1', title: 'Submit', button_style: UserActionButtonType.Primary },
|
||||
],
|
||||
form_token: 'token_123',
|
||||
resolved_default_values: {},
|
||||
expiration_time: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
|
||||
display_in_ui: true,
|
||||
...overrides,
|
||||
} as unknown as HumanInputFormData)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useSelector).mockImplementation((selector: (value: AppContextValue) => unknown) => {
|
||||
return selector({
|
||||
userProfile: {
|
||||
id: 'user_123',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
avatar: '',
|
||||
avatar_url: '',
|
||||
is_password_set: false,
|
||||
},
|
||||
} as AppContextValue)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render form, tips, and expiration time when all conditions met', () => {
|
||||
render(
|
||||
<UnsubmittedHumanInputContent
|
||||
formData={createMockFormData()}
|
||||
showEmailTip={true}
|
||||
showDebugModeTip={true}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Submit')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tips')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('expiration-time')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.common.humanInputWebappTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide ExpirationTime when expiration_time is not a number', () => {
|
||||
const data = createMockFormData({ expiration_time: undefined })
|
||||
render(<UnsubmittedHumanInputContent formData={data} onSubmit={vi.fn()} />)
|
||||
|
||||
expect(screen.queryByTestId('expiration-time')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide Tips when both tip flags are false', () => {
|
||||
render(
|
||||
<UnsubmittedHumanInputContent
|
||||
formData={createMockFormData()}
|
||||
showEmailTip={false}
|
||||
showDebugModeTip={false}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('tips')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render different email tips based on debug mode', () => {
|
||||
const { rerender } = render(
|
||||
<UnsubmittedHumanInputContent
|
||||
formData={createMockFormData()}
|
||||
showEmailTip={true}
|
||||
isEmailDebugMode={false}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.common.humanInputEmailTip')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<UnsubmittedHumanInputContent
|
||||
formData={createMockFormData()}
|
||||
showEmailTip={true}
|
||||
isEmailDebugMode={true}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.humanInputEmailTipInDebugMode')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render "Expired" state when expiration time is in the past', () => {
|
||||
const data = createMockFormData({ expiration_time: Math.floor(Date.now() / 1000) - 3600 })
|
||||
render(<UnsubmittedHumanInputContent formData={data} onSubmit={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('share.humanInput.expiredTip')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactions', () => {
|
||||
it('should update input values and call onSubmit', async () => {
|
||||
const handleSubmit = vi.fn().mockImplementation(() => Promise.resolve())
|
||||
const data = createMockFormData()
|
||||
|
||||
render(<UnsubmittedHumanInputContent formData={data} onSubmit={handleSubmit} />)
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'New Value')
|
||||
|
||||
const submitBtn = screen.getByRole('button', { name: 'Submit' })
|
||||
await user.click(submitBtn)
|
||||
|
||||
expect(handleSubmit).toHaveBeenCalledWith('token_123', {
|
||||
action: 'btn_1',
|
||||
inputs: { user_name: 'New Value' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle loading state during submission', async () => {
|
||||
let resolveSubmit: (value: void | PromiseLike<void>) => void
|
||||
const handleSubmit = vi.fn().mockImplementation(() => new Promise<void>((resolve) => {
|
||||
resolveSubmit = resolve
|
||||
}))
|
||||
const data = createMockFormData()
|
||||
|
||||
render(<UnsubmittedHumanInputContent formData={data} onSubmit={handleSubmit} />)
|
||||
|
||||
const submitBtn = screen.getByRole('button', { name: 'Submit' })
|
||||
await user.click(submitBtn)
|
||||
|
||||
expect(submitBtn).toBeDisabled()
|
||||
expect(handleSubmit).toHaveBeenCalled()
|
||||
|
||||
await waitFor(() => {
|
||||
resolveSubmit!()
|
||||
})
|
||||
|
||||
await waitFor(() => expect(submitBtn).not.toBeDisabled())
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle missing resolved_default_values', () => {
|
||||
const data = createMockFormData({ resolved_default_values: undefined })
|
||||
render(<UnsubmittedHumanInputContent formData={data} onSubmit={vi.fn()} />)
|
||||
expect(screen.getByText('Submit')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return null in ContentItem if field is not found', () => {
|
||||
const data = createMockFormData({
|
||||
form_content: '{{#$output.unknown_field#}}',
|
||||
inputs: [],
|
||||
})
|
||||
const { container } = render(<UnsubmittedHumanInputContent formData={data} onSubmit={vi.fn()} />)
|
||||
// The form will be empty (except for buttons) because unknown_field is not in inputs
|
||||
expect(container.querySelector('textarea')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render text-input type in initializeInputs correctly', () => {
|
||||
const data = createMockFormData({
|
||||
inputs: [
|
||||
{
|
||||
type: 'text-input',
|
||||
output_variable_name: 'var1',
|
||||
label: 'Var 1',
|
||||
required: true,
|
||||
default: { type: 'fixed', value: 'fixed_val' },
|
||||
},
|
||||
],
|
||||
})
|
||||
render(<UnsubmittedHumanInputContent formData={data} onSubmit={vi.fn()} />)
|
||||
// initializeInputs is tested indirectly here.
|
||||
// We can't easily assert the internal state of HumanInputForm, but we can verify it doesn't crash.
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { HumanInputFilledFormData } from '@/types/workflow'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import HumanInputFilledFormList from './human-input-filled-form-list'
|
||||
|
||||
/**
|
||||
* Type-safe factory.
|
||||
* Forces test data to match real interface.
|
||||
*/
|
||||
const createFormData = (
|
||||
overrides: Partial<HumanInputFilledFormData> = {},
|
||||
): HumanInputFilledFormData => ({
|
||||
node_id: 'node-1',
|
||||
node_title: 'Node Title',
|
||||
|
||||
// 👇 IMPORTANT
|
||||
// DO NOT guess properties like `inputs`
|
||||
// Only include fields that actually exist in your project type.
|
||||
// Leave everything else empty via spread.
|
||||
...overrides,
|
||||
} as HumanInputFilledFormData)
|
||||
|
||||
describe('HumanInputFilledFormList', () => {
|
||||
it('renders nothing when list is empty', () => {
|
||||
render(<HumanInputFilledFormList humanInputFilledFormDataList={[]} />)
|
||||
|
||||
expect(screen.queryByText('Node Title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders one form item', () => {
|
||||
const data = [createFormData()]
|
||||
|
||||
render(<HumanInputFilledFormList humanInputFilledFormDataList={data} />)
|
||||
|
||||
expect(screen.getByText('Node Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders multiple form items', () => {
|
||||
const data = [
|
||||
createFormData({ node_id: '1', node_title: 'First' }),
|
||||
createFormData({ node_id: '2', node_title: 'Second' }),
|
||||
]
|
||||
|
||||
render(<HumanInputFilledFormList humanInputFilledFormDataList={data} />)
|
||||
|
||||
expect(screen.getByText('First')).toBeInTheDocument()
|
||||
expect(screen.getByText('Second')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders wrapper container', () => {
|
||||
const { container } = render(
|
||||
<HumanInputFilledFormList humanInputFilledFormDataList={[createFormData()]} />,
|
||||
)
|
||||
|
||||
expect(container.firstChild).toHaveClass('flex')
|
||||
expect(container.firstChild).toHaveClass('flex-col')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { HumanInputFormData } from '@/types/workflow'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { DeliveryMethodType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import HumanInputFormList from './human-input-form-list'
|
||||
|
||||
// Mock child components
|
||||
vi.mock('./human-input-content/content-wrapper', () => ({
|
||||
default: ({ children, nodeTitle }: { children: React.ReactNode, nodeTitle: string }) => (
|
||||
<div data-testid="content-wrapper" data-nodetitle={nodeTitle}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./human-input-content/unsubmitted', () => ({
|
||||
UnsubmittedHumanInputContent: ({ showEmailTip, isEmailDebugMode, showDebugModeTip }: { showEmailTip: boolean, isEmailDebugMode: boolean, showDebugModeTip: boolean }) => (
|
||||
<div data-testid="unsubmitted-content">
|
||||
<span data-testid="email-tip">{showEmailTip ? 'true' : 'false'}</span>
|
||||
<span data-testid="email-debug">{isEmailDebugMode ? 'true' : 'false'}</span>
|
||||
<span data-testid="debug-tip">{showDebugModeTip ? 'true' : 'false'}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('HumanInputFormList', () => {
|
||||
const mockFormData = [
|
||||
{
|
||||
form_id: 'form1',
|
||||
node_id: 'node1',
|
||||
node_title: 'Title 1',
|
||||
display_in_ui: true,
|
||||
},
|
||||
{
|
||||
form_id: 'form2',
|
||||
node_id: 'node2',
|
||||
node_title: 'Title 2',
|
||||
display_in_ui: false,
|
||||
},
|
||||
]
|
||||
|
||||
const mockGetNodeData = vi.fn()
|
||||
|
||||
it('should render empty list when no form data is provided', () => {
|
||||
render(<HumanInputFormList humanInputFormDataList={[]} />)
|
||||
expect(screen.getByTestId('human-input-form-list')).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should render only items with display_in_ui set to true', () => {
|
||||
mockGetNodeData.mockReturnValue({
|
||||
data: {
|
||||
delivery_methods: [],
|
||||
},
|
||||
})
|
||||
render(
|
||||
<HumanInputFormList
|
||||
humanInputFormDataList={mockFormData as HumanInputFormData[]}
|
||||
getHumanInputNodeData={mockGetNodeData}
|
||||
/>,
|
||||
)
|
||||
const items = screen.getAllByTestId('human-input-form-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(screen.getByTestId('content-wrapper')).toHaveAttribute('data-nodetitle', 'Title 1')
|
||||
})
|
||||
|
||||
describe('Delivery Methods Config', () => {
|
||||
it('should set default tips when node data is not found', () => {
|
||||
mockGetNodeData.mockReturnValue(undefined)
|
||||
render(
|
||||
<HumanInputFormList
|
||||
humanInputFormDataList={[mockFormData[0]] as HumanInputFormData[]}
|
||||
getHumanInputNodeData={mockGetNodeData}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('email-tip')).toHaveTextContent('false')
|
||||
expect(screen.getByTestId('email-debug')).toHaveTextContent('false')
|
||||
expect(screen.getByTestId('debug-tip')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should set default tips when delivery_methods is empty', () => {
|
||||
mockGetNodeData.mockReturnValue({ data: { delivery_methods: [] } })
|
||||
render(
|
||||
<HumanInputFormList
|
||||
humanInputFormDataList={[mockFormData[0]] as HumanInputFormData[]}
|
||||
getHumanInputNodeData={mockGetNodeData}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('email-tip')).toHaveTextContent('false')
|
||||
expect(screen.getByTestId('email-debug')).toHaveTextContent('false')
|
||||
expect(screen.getByTestId('debug-tip')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should show tips correctly based on delivery methods', () => {
|
||||
mockGetNodeData.mockReturnValue({
|
||||
data: {
|
||||
delivery_methods: [
|
||||
{ type: DeliveryMethodType.WebApp, enabled: true },
|
||||
{ type: DeliveryMethodType.Email, enabled: true, config: { debug_mode: true } },
|
||||
],
|
||||
},
|
||||
})
|
||||
render(
|
||||
<HumanInputFormList
|
||||
humanInputFormDataList={[mockFormData[0]] as HumanInputFormData[]}
|
||||
getHumanInputNodeData={mockGetNodeData}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('email-tip')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('email-debug')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('debug-tip')).toHaveTextContent('false') // WebApp is enabled
|
||||
})
|
||||
|
||||
it('should show debug mode tip if WebApp is disabled', () => {
|
||||
mockGetNodeData.mockReturnValue({
|
||||
data: {
|
||||
delivery_methods: [
|
||||
{ type: DeliveryMethodType.WebApp, enabled: false },
|
||||
{ type: DeliveryMethodType.Email, enabled: false },
|
||||
],
|
||||
},
|
||||
})
|
||||
render(
|
||||
<HumanInputFormList
|
||||
humanInputFormDataList={[mockFormData[0]] as HumanInputFormData[]}
|
||||
getHumanInputNodeData={mockGetNodeData}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('email-tip')).toHaveTextContent('false')
|
||||
expect(screen.getByTestId('debug-tip')).toHaveTextContent('true')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -45,22 +45,28 @@ const HumanInputFormList = ({
|
||||
const filteredHumanInputFormDataList = humanInputFormDataList.filter(formData => formData.display_in_ui)
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-col gap-y-2">
|
||||
<div
|
||||
className="mt-2 flex flex-col gap-y-2"
|
||||
data-testid="human-input-form-list"
|
||||
>
|
||||
{
|
||||
filteredHumanInputFormDataList.map(formData => (
|
||||
<ContentWrapper
|
||||
<div
|
||||
key={formData.form_id}
|
||||
nodeTitle={formData.node_title}
|
||||
data-testid="human-input-form-item"
|
||||
>
|
||||
<UnsubmittedHumanInputContent
|
||||
key={formData.form_id}
|
||||
formData={formData}
|
||||
showEmailTip={!!deliveryMethodsConfig[formData.node_id]?.showEmailTip}
|
||||
isEmailDebugMode={!!deliveryMethodsConfig[formData.node_id]?.isEmailDebugMode}
|
||||
showDebugModeTip={!!deliveryMethodsConfig[formData.node_id]?.showDebugModeTip}
|
||||
onSubmit={onHumanInputFormSubmit}
|
||||
/>
|
||||
</ContentWrapper>
|
||||
<ContentWrapper
|
||||
nodeTitle={formData.node_title}
|
||||
>
|
||||
<UnsubmittedHumanInputContent
|
||||
formData={formData}
|
||||
showEmailTip={!!deliveryMethodsConfig[formData.node_id]?.showEmailTip}
|
||||
isEmailDebugMode={!!deliveryMethodsConfig[formData.node_id]?.isEmailDebugMode}
|
||||
showDebugModeTip={!!deliveryMethodsConfig[formData.node_id]?.showDebugModeTip}
|
||||
onSubmit={onHumanInputFormSubmit}
|
||||
/>
|
||||
</ContentWrapper>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
65
web/app/components/base/chat/chat/answer/more.spec.tsx
Normal file
65
web/app/components/base/chat/chat/answer/more.spec.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import More from './more'
|
||||
|
||||
describe('More', () => {
|
||||
const mockMoreData = {
|
||||
latency: 0.5,
|
||||
tokens: 100,
|
||||
tokens_per_second: 200,
|
||||
time: '2023-10-27 10:00:00',
|
||||
}
|
||||
|
||||
it('should render all details when all data is provided', () => {
|
||||
render(<More more={mockMoreData} />)
|
||||
|
||||
expect(screen.getByTestId('more-container')).toBeInTheDocument()
|
||||
|
||||
// Check latency
|
||||
expect(screen.getByTestId('more-latency')).toBeInTheDocument()
|
||||
expect(screen.getByText(/timeConsuming/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/0.5/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/second/i)).toBeInTheDocument()
|
||||
|
||||
// Check tokens
|
||||
expect(screen.getByTestId('more-tokens')).toBeInTheDocument()
|
||||
expect(screen.getByText(/tokenCost/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/100/)).toBeInTheDocument()
|
||||
|
||||
// Check tokens per second
|
||||
expect(screen.getByTestId('more-tps')).toBeInTheDocument()
|
||||
expect(screen.getByText(/200 tokens\/s/i)).toBeInTheDocument()
|
||||
|
||||
// Check time
|
||||
expect(screen.getByTestId('more-time')).toBeInTheDocument()
|
||||
expect(screen.getByText('2023-10-27 10:00:00')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render tokens per second when it is missing', () => {
|
||||
const dataWithoutTPS = { ...mockMoreData, tokens_per_second: 0 }
|
||||
render(<More more={dataWithoutTPS} />)
|
||||
|
||||
expect(screen.queryByTestId('more-tps')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render nothing inside container if more prop is missing', () => {
|
||||
render(<More more={undefined} />)
|
||||
const containerDiv = screen.getByTestId('more-container')
|
||||
expect(containerDiv).toBeInTheDocument()
|
||||
expect(containerDiv.children.length).toBe(0)
|
||||
})
|
||||
|
||||
it('should apply group-hover opacity classes', () => {
|
||||
render(<More more={mockMoreData} />)
|
||||
const container = screen.getByTestId('more-container')
|
||||
expect(container).toHaveClass('opacity-0')
|
||||
expect(container).toHaveClass('group-hover:opacity-100')
|
||||
})
|
||||
|
||||
it('should correctly format large token counts', () => {
|
||||
const dataWithLargeTokens = { ...mockMoreData, tokens: 1234567 }
|
||||
render(<More more={dataWithLargeTokens} />)
|
||||
|
||||
// formatNumber(1234567) should return '1,234,567'
|
||||
expect(screen.getByText(/1,234,567/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -13,19 +13,24 @@ const More: FC<MoreProps> = ({
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="system-xs-regular mt-1 flex items-center text-text-quaternary opacity-0 group-hover:opacity-100">
|
||||
<div
|
||||
className="mt-1 flex items-center text-text-quaternary opacity-0 system-xs-regular group-hover:opacity-100"
|
||||
data-testid="more-container"
|
||||
>
|
||||
{
|
||||
more && (
|
||||
<>
|
||||
<div
|
||||
className="mr-2 max-w-[25%] shrink-0 truncate"
|
||||
title={`${t('detail.timeConsuming', { ns: 'appLog' })} ${more.latency}${t('detail.second', { ns: 'appLog' })}`}
|
||||
data-testid="more-latency"
|
||||
>
|
||||
{`${t('detail.timeConsuming', { ns: 'appLog' })} ${more.latency}${t('detail.second', { ns: 'appLog' })}`}
|
||||
</div>
|
||||
<div
|
||||
className="mr-2 max-w-[25%] shrink-0 truncate"
|
||||
title={`${t('detail.tokenCost', { ns: 'appLog' })} ${formatNumber(more.tokens)}`}
|
||||
data-testid="more-tokens"
|
||||
>
|
||||
{`${t('detail.tokenCost', { ns: 'appLog' })} ${formatNumber(more.tokens)}`}
|
||||
</div>
|
||||
@@ -33,6 +38,7 @@ const More: FC<MoreProps> = ({
|
||||
<div
|
||||
className="mr-2 max-w-[25%] shrink-0 truncate"
|
||||
title={`${more.tokens_per_second} tokens/s`}
|
||||
data-testid="more-tps"
|
||||
>
|
||||
{`${more.tokens_per_second} tokens/s`}
|
||||
</div>
|
||||
@@ -41,6 +47,7 @@ const More: FC<MoreProps> = ({
|
||||
<div
|
||||
className="max-w-[25%] shrink-0 truncate"
|
||||
title={more.time}
|
||||
data-testid="more-time"
|
||||
>
|
||||
{more.time}
|
||||
</div>
|
||||
|
||||
726
web/app/components/base/chat/chat/answer/operation.spec.tsx
Normal file
726
web/app/components/base/chat/chat/answer/operation.spec.tsx
Normal file
@@ -0,0 +1,726 @@
|
||||
import type { ChatConfig, ChatItem } from '../../types'
|
||||
import type { ChatContextValue } from '../context'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import * as React from 'react'
|
||||
import { vi } from 'vitest'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import Operation from './operation'
|
||||
|
||||
const {
|
||||
mockSetShowAnnotationFullModal,
|
||||
mockProviderContext,
|
||||
mockT,
|
||||
mockAddAnnotation,
|
||||
} = vi.hoisted(() => {
|
||||
return {
|
||||
mockAddAnnotation: vi.fn(),
|
||||
mockSetShowAnnotationFullModal: vi.fn(),
|
||||
mockT: vi.fn((key: string): string => key),
|
||||
mockProviderContext: {
|
||||
plan: {
|
||||
usage: { annotatedResponse: 0 },
|
||||
total: { annotatedResponse: 100 },
|
||||
},
|
||||
enableBilling: false,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('copy-to-clipboard', () => ({ default: vi.fn() }))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowAnnotationFullModal: mockSetShowAnnotationFullModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockProviderContext,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/annotation', () => ({
|
||||
addAnnotation: mockAddAnnotation,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({
|
||||
AudioPlayerManager: {
|
||||
getInstance: vi.fn(() => ({
|
||||
getAudioPlayer: vi.fn(() => ({
|
||||
playAudio: vi.fn(),
|
||||
pauseAudio: vi.fn(),
|
||||
})),
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/annotation/edit-annotation-modal', () => ({
|
||||
default: ({ isShow, onHide, onEdited, onAdded, onRemove }: {
|
||||
isShow: boolean
|
||||
onHide: () => void
|
||||
onEdited: (q: string, a: string) => void
|
||||
onAdded: (id: string, name: string, q: string, a: string) => void
|
||||
onRemove: () => void
|
||||
}) =>
|
||||
isShow
|
||||
? (
|
||||
<div data-testid="edit-reply-modal">
|
||||
<button data-testid="modal-hide" onClick={onHide}>Close</button>
|
||||
<button data-testid="modal-edit" onClick={() => onEdited('eq', 'ea')}>Edit</button>
|
||||
<button data-testid="modal-add" onClick={() => onAdded('a1', 'author', 'eq', 'ea')}>Add</button>
|
||||
<button data-testid="modal-remove" onClick={onRemove}>Remove</button>
|
||||
</div>
|
||||
)
|
||||
: null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button', () => ({
|
||||
default: function AnnotationCtrlMock({ onAdded, onEdit, cached }: {
|
||||
onAdded: (id: string, authorName: string) => void
|
||||
onEdit: () => void
|
||||
cached: boolean
|
||||
}) {
|
||||
const { setShowAnnotationFullModal } = useModalContext()
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const handleAdd = () => {
|
||||
if (enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse) {
|
||||
setShowAnnotationFullModal()
|
||||
return
|
||||
}
|
||||
onAdded('ann-new', 'Test User')
|
||||
}
|
||||
return (
|
||||
<div data-testid="annotation-ctrl">
|
||||
{cached
|
||||
? (
|
||||
<button data-testid="annotation-edit-btn" onClick={onEdit}>Edit</button>
|
||||
)
|
||||
: (
|
||||
<button data-testid="annotation-add-btn" onClick={handleAdd}>Add</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/new-audio-button', () => ({
|
||||
default: () => <button data-testid="audio-btn">Play</button>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chat/chat/log', () => ({
|
||||
default: () => <button data-testid="log-btn"><div className="i-ri-file-list-3-line" /></button>,
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: vi.fn(() => ({ appId: 'test-app' })),
|
||||
usePathname: vi.fn(() => '/apps/test-app'),
|
||||
}))
|
||||
|
||||
const makeChatConfig = (overrides: Partial<ChatConfig> = {}): ChatConfig => ({
|
||||
opening_statement: '',
|
||||
pre_prompt: '',
|
||||
prompt_type: 'simple' as ChatConfig['prompt_type'],
|
||||
user_input_form: [],
|
||||
dataset_query_variable: '',
|
||||
more_like_this: { enabled: false },
|
||||
suggested_questions_after_answer: { enabled: false },
|
||||
speech_to_text: { enabled: false },
|
||||
text_to_speech: { enabled: false },
|
||||
retriever_resource: { enabled: false },
|
||||
sensitive_word_avoidance: { enabled: false },
|
||||
agent_mode: { enabled: false, tools: [] },
|
||||
dataset_configs: { retrieval_model: 'single' } as ChatConfig['dataset_configs'],
|
||||
system_parameters: {
|
||||
audio_file_size_limit: 10,
|
||||
file_size_limit: 10,
|
||||
image_file_size_limit: 10,
|
||||
video_file_size_limit: 10,
|
||||
workflow_file_upload_limit: 10,
|
||||
},
|
||||
supportFeedback: false,
|
||||
supportAnnotation: false,
|
||||
...overrides,
|
||||
} as ChatConfig)
|
||||
|
||||
const mockContextValue: ChatContextValue = {
|
||||
chatList: [],
|
||||
config: makeChatConfig({ supportFeedback: true }),
|
||||
onFeedback: vi.fn().mockResolvedValue(undefined),
|
||||
onRegenerate: vi.fn(),
|
||||
onAnnotationAdded: vi.fn(),
|
||||
onAnnotationEdited: vi.fn(),
|
||||
onAnnotationRemoved: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mock('../context', () => ({
|
||||
useChatContext: () => mockContextValue,
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: mockT,
|
||||
}),
|
||||
}))
|
||||
|
||||
type OperationProps = {
|
||||
item: ChatItem
|
||||
question: string
|
||||
index: number
|
||||
showPromptLog?: boolean
|
||||
maxSize: number
|
||||
contentWidth: number
|
||||
hasWorkflowProcess: boolean
|
||||
noChatInput?: boolean
|
||||
}
|
||||
|
||||
const baseItem: ChatItem = {
|
||||
id: 'msg-1',
|
||||
content: 'Hello world',
|
||||
isAnswer: true,
|
||||
}
|
||||
|
||||
const baseProps: OperationProps = {
|
||||
item: baseItem,
|
||||
question: 'What is this?',
|
||||
index: 0,
|
||||
maxSize: 500,
|
||||
contentWidth: 300,
|
||||
hasWorkflowProcess: false,
|
||||
}
|
||||
|
||||
describe('Operation', () => {
|
||||
const renderOperation = (props = baseProps) => {
|
||||
return render(
|
||||
<div className="group">
|
||||
<Operation {...props} />
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockContextValue.config = makeChatConfig({ supportFeedback: true })
|
||||
mockContextValue.onFeedback = vi.fn().mockResolvedValue(undefined)
|
||||
mockContextValue.onRegenerate = vi.fn()
|
||||
mockContextValue.onAnnotationAdded = vi.fn()
|
||||
mockContextValue.onAnnotationEdited = vi.fn()
|
||||
mockContextValue.onAnnotationRemoved = vi.fn()
|
||||
mockProviderContext.plan.usage.annotatedResponse = 0
|
||||
mockProviderContext.enableBilling = false
|
||||
mockAddAnnotation.mockResolvedValue({ id: 'ann-new', account: { name: 'Test User' } })
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should hide action buttons for opening statements', () => {
|
||||
const item = { ...baseItem, isOpeningStatement: true }
|
||||
renderOperation({ ...baseProps, item })
|
||||
expect(screen.queryByTestId('operation-actions')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show copy and regenerate buttons', () => {
|
||||
renderOperation()
|
||||
expect(screen.getByTestId('copy-btn')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('regenerate-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide regenerate button when noChatInput is true', () => {
|
||||
renderOperation({ ...baseProps, noChatInput: true })
|
||||
expect(screen.queryByTestId('regenerate-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show TTS button when text_to_speech is enabled', () => {
|
||||
mockContextValue.config = makeChatConfig({ text_to_speech: { enabled: true } })
|
||||
renderOperation()
|
||||
expect(screen.getByTestId('audio-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show annotation button when config supports it', () => {
|
||||
mockContextValue.config = makeChatConfig({
|
||||
supportAnnotation: true,
|
||||
annotation_reply: { id: 'ar-1', score_threshold: 0.5, embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, enabled: true },
|
||||
})
|
||||
renderOperation()
|
||||
expect(screen.getByTestId('annotation-ctrl')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show prompt log when showPromptLog is true', () => {
|
||||
renderOperation({ ...baseProps, showPromptLog: true })
|
||||
expect(screen.getByTestId('log-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show prompt log for opening statements', () => {
|
||||
const item = { ...baseItem, isOpeningStatement: true }
|
||||
renderOperation({ ...baseProps, item, showPromptLog: true })
|
||||
expect(screen.queryByTestId('log-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Copy functionality', () => {
|
||||
it('should copy content on copy click', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
await user.click(screen.getByTestId('copy-btn'))
|
||||
expect(copy).toHaveBeenCalledWith('Hello world')
|
||||
})
|
||||
|
||||
it('should aggregate agent_thoughts for copy content', async () => {
|
||||
const user = userEvent.setup()
|
||||
const item: ChatItem = {
|
||||
...baseItem,
|
||||
content: 'ignored',
|
||||
agent_thoughts: [
|
||||
{ id: '1', thought: 'Hello ', tool: '', tool_input: '', observation: '', message_id: '', conversation_id: '', position: 0 },
|
||||
{ id: '2', thought: 'World', tool: '', tool_input: '', observation: '', message_id: '', conversation_id: '', position: 1 },
|
||||
],
|
||||
}
|
||||
renderOperation({ ...baseProps, item })
|
||||
await user.click(screen.getByTestId('copy-btn'))
|
||||
expect(copy).toHaveBeenCalledWith('Hello World')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Regenerate', () => {
|
||||
it('should call onRegenerate on regenerate click', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
await user.click(screen.getByTestId('regenerate-btn'))
|
||||
expect(mockContextValue.onRegenerate).toHaveBeenCalledWith(baseItem)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hiding controls with humanInputFormDataList', () => {
|
||||
it('should hide TTS/copy/annotation when humanInputFormDataList is present', () => {
|
||||
mockContextValue.config = makeChatConfig({
|
||||
supportFeedback: false,
|
||||
text_to_speech: { enabled: true },
|
||||
supportAnnotation: true,
|
||||
annotation_reply: { id: 'ar-1', score_threshold: 0.5, embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, enabled: true },
|
||||
})
|
||||
const item = { ...baseItem, humanInputFormDataList: [{}] } as ChatItem
|
||||
renderOperation({ ...baseProps, item })
|
||||
expect(screen.queryByTestId('audio-btn')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('copy-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User feedback (no annotation support)', () => {
|
||||
beforeEach(() => {
|
||||
mockContextValue.config = makeChatConfig({ supportFeedback: true, supportAnnotation: false })
|
||||
})
|
||||
|
||||
it('should show like/dislike buttons', () => {
|
||||
renderOperation()
|
||||
const bar = screen.getByTestId('operation-bar')
|
||||
expect(bar.querySelector('.i-ri-thumb-up-line')).toBeInTheDocument()
|
||||
expect(bar.querySelector('.i-ri-thumb-down-line')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onFeedback with like on like click', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
const thumbUp = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')!.closest('button')!
|
||||
await user.click(thumbUp)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'like', content: undefined })
|
||||
})
|
||||
|
||||
it('should open feedback modal on dislike click', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')!
|
||||
await user.click(thumbDown)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should submit dislike feedback from modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')!
|
||||
await user.click(thumbDown)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.type(textarea, 'Bad response')
|
||||
const confirmBtn = screen.getByText(/submit/i)
|
||||
await user.click(confirmBtn)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'dislike', content: 'Bad response' })
|
||||
})
|
||||
|
||||
it('should cancel feedback modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')!
|
||||
await user.click(thumbDown)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
const cancelBtn = screen.getByText(/cancel/i)
|
||||
await user.click(cancelBtn)
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show existing like feedback and allow undo', async () => {
|
||||
const user = userEvent.setup()
|
||||
const item = { ...baseItem, feedback: { rating: 'like' as const } }
|
||||
renderOperation({ ...baseProps, item })
|
||||
const thumbUp = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')!.closest('button')!
|
||||
await user.click(thumbUp)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined })
|
||||
})
|
||||
|
||||
it('should show existing dislike feedback and allow undo', async () => {
|
||||
const user = userEvent.setup()
|
||||
const item = { ...baseItem, feedback: { rating: 'dislike' as const, content: 'bad' } }
|
||||
renderOperation({ ...baseProps, item })
|
||||
const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')!
|
||||
await user.click(thumbDown)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined })
|
||||
})
|
||||
|
||||
it('should undo like when already liked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
// First click to like
|
||||
const thumbUp = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')!.closest('button')!
|
||||
await user.click(thumbUp)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'like', content: undefined })
|
||||
|
||||
// Second click to undo - re-query as it might be a different node
|
||||
const thumbUpUndo = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')!.closest('button')!
|
||||
await user.click(thumbUpUndo)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined })
|
||||
})
|
||||
|
||||
it('should undo dislike when already disliked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')!
|
||||
await user.click(thumbDown)
|
||||
const submitBtn = screen.getByText(/submit/i)
|
||||
await user.click(submitBtn)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'dislike', content: '' })
|
||||
|
||||
// Re-query for undo
|
||||
const thumbDownUndo = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')!
|
||||
await user.click(thumbDownUndo)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined })
|
||||
})
|
||||
|
||||
it('should show tooltip with dislike and content', () => {
|
||||
const item = { ...baseItem, feedback: { rating: 'dislike' as const, content: 'Too slow' } }
|
||||
renderOperation({ ...baseProps, item })
|
||||
const bar = screen.getByTestId('operation-bar')
|
||||
expect(bar.querySelector('.i-ri-thumb-down-line')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show tooltip with only rating', () => {
|
||||
const item = { ...baseItem, feedback: { rating: 'like' as const } }
|
||||
renderOperation({ ...baseProps, item })
|
||||
const bar = screen.getByTestId('operation-bar')
|
||||
expect(bar.querySelector('.i-ri-thumb-up-line')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show feedback bar for opening statements', () => {
|
||||
const item = { ...baseItem, isOpeningStatement: true }
|
||||
renderOperation({ ...baseProps, item })
|
||||
expect(screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show user feedback bar when humanInputFormDataList is present', () => {
|
||||
const item = { ...baseItem, humanInputFormDataList: [{}] } as ChatItem
|
||||
renderOperation({ ...baseProps, item })
|
||||
expect(screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not call feedback when supportFeedback is disabled', async () => {
|
||||
mockContextValue.config = makeChatConfig({ supportFeedback: false })
|
||||
mockContextValue.onFeedback = undefined
|
||||
renderOperation()
|
||||
const bar = screen.getByTestId('operation-bar')
|
||||
expect(bar.querySelectorAll('.i-ri-thumb-up-line').length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Admin feedback (with annotation support)', () => {
|
||||
beforeEach(() => {
|
||||
mockContextValue.config = makeChatConfig({ supportFeedback: true, supportAnnotation: true })
|
||||
})
|
||||
|
||||
it('should show admin like/dislike buttons', () => {
|
||||
renderOperation()
|
||||
const bar = screen.getByTestId('operation-bar')
|
||||
expect(bar.querySelectorAll('.i-ri-thumb-up-line').length).toBeGreaterThanOrEqual(1)
|
||||
expect(bar.querySelectorAll('.i-ri-thumb-down-line').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should call onFeedback with like for admin', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
const thumbs = screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-up-line')
|
||||
const adminThumb = thumbs[thumbs.length - 1].closest('button')!
|
||||
await user.click(adminThumb)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'like', content: undefined })
|
||||
})
|
||||
|
||||
it('should open feedback modal on admin dislike click', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
const thumbs = screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-down-line')
|
||||
const adminThumb = thumbs[thumbs.length - 1].closest('button')!
|
||||
await user.click(adminThumb)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show user feedback read-only in admin bar when user has liked', () => {
|
||||
const item = { ...baseItem, feedback: { rating: 'like' as const } }
|
||||
renderOperation({ ...baseProps, item })
|
||||
const bar = screen.getByTestId('operation-bar')
|
||||
expect(bar.querySelectorAll('.i-ri-thumb-up-line').length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('should show separator in admin bar when user has feedback', () => {
|
||||
const item = { ...baseItem, feedback: { rating: 'dislike' as const } }
|
||||
renderOperation({ ...baseProps, item })
|
||||
const bar = screen.getByTestId('operation-bar')
|
||||
expect(bar.querySelector('.bg-components-actionbar-border')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show existing admin like feedback and allow undo', async () => {
|
||||
const user = userEvent.setup()
|
||||
const item = { ...baseItem, adminFeedback: { rating: 'like' as const } }
|
||||
renderOperation({ ...baseProps, item })
|
||||
const thumbUp = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')!.closest('button')!
|
||||
await user.click(thumbUp)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined })
|
||||
})
|
||||
|
||||
it('should show existing admin dislike and allow undo', async () => {
|
||||
const user = userEvent.setup()
|
||||
const item = { ...baseItem, adminFeedback: { rating: 'dislike' as const } }
|
||||
renderOperation({ ...baseProps, item })
|
||||
const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')!
|
||||
await user.click(thumbDown)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined })
|
||||
})
|
||||
|
||||
it('should undo admin like when already liked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
const thumbs = screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-up-line')
|
||||
const adminThumb = thumbs[thumbs.length - 1].closest('button')!
|
||||
await user.click(adminThumb)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'like', content: undefined })
|
||||
|
||||
const thumbsUndo = screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-up-line')
|
||||
const adminThumbUndo = thumbsUndo[thumbsUndo.length - 1].closest('button')!
|
||||
await user.click(adminThumbUndo)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined })
|
||||
})
|
||||
|
||||
it('should undo admin dislike when already disliked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
const thumbs = screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-down-line')
|
||||
const adminThumb = thumbs[thumbs.length - 1].closest('button')!
|
||||
await user.click(adminThumb)
|
||||
const submitBtn = screen.getByText(/submit/i)
|
||||
await user.click(submitBtn)
|
||||
|
||||
const thumbsUndo = screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-down-line')
|
||||
const adminThumbUndo = thumbsUndo[thumbsUndo.length - 1].closest('button')!
|
||||
await user.click(adminThumbUndo)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined })
|
||||
})
|
||||
|
||||
it('should not show admin feedback bar when humanInputFormDataList is present', () => {
|
||||
const item = { ...baseItem, humanInputFormDataList: [{}] } as ChatItem
|
||||
renderOperation({ ...baseProps, item })
|
||||
expect(screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-up-line').length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Positioning and layout', () => {
|
||||
it('should position right when operationWidth < maxSize', () => {
|
||||
renderOperation({ ...baseProps, maxSize: 500 })
|
||||
const bar = screen.getByTestId('operation-bar')
|
||||
expect(bar.style.left).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should position bottom when operationWidth >= maxSize', () => {
|
||||
renderOperation({ ...baseProps, maxSize: 1 })
|
||||
const bar = screen.getByTestId('operation-bar')
|
||||
expect(bar.style.left).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should apply workflow process class when hasWorkflowProcess is true', () => {
|
||||
renderOperation({ ...baseProps, hasWorkflowProcess: true })
|
||||
const bar = screen.getByTestId('operation-bar')
|
||||
expect(bar.className).toContain('-bottom-4')
|
||||
})
|
||||
|
||||
it('should calculate width correctly for all features combined', () => {
|
||||
mockContextValue.config = makeChatConfig({
|
||||
text_to_speech: { enabled: true },
|
||||
supportAnnotation: true,
|
||||
annotation_reply: { id: 'ar-1', score_threshold: 0.5, embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, enabled: true },
|
||||
supportFeedback: true,
|
||||
})
|
||||
const item = { ...baseItem, feedback: { rating: 'like' as const }, adminFeedback: { rating: 'dislike' as const } }
|
||||
renderOperation({ ...baseProps, item, showPromptLog: true })
|
||||
const bar = screen.getByTestId('operation-bar')
|
||||
expect(bar).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show separator when user has feedback in admin mode', () => {
|
||||
mockContextValue.config = makeChatConfig({ supportFeedback: true, supportAnnotation: true })
|
||||
const item = { ...baseItem, feedback: { rating: 'like' as const } }
|
||||
renderOperation({ ...baseProps, item })
|
||||
const bar = screen.getByTestId('operation-bar')
|
||||
expect(bar.querySelector('.bg-components-actionbar-border')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle missing translation fallbacks in buildFeedbackTooltip', () => {
|
||||
// Mock t to return null for specific keys
|
||||
mockT.mockImplementation((key: string): string => {
|
||||
if (key.includes('Rate') || key.includes('like'))
|
||||
return '' // Safe string fallback
|
||||
|
||||
return key
|
||||
})
|
||||
|
||||
renderOperation()
|
||||
expect(screen.getByTestId('operation-bar')).toBeInTheDocument()
|
||||
|
||||
// Reset to default behavior
|
||||
mockT.mockImplementation(key => key)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Annotation integration', () => {
|
||||
beforeEach(() => {
|
||||
mockContextValue.config = makeChatConfig({
|
||||
supportAnnotation: true,
|
||||
annotation_reply: { id: 'ar-1', score_threshold: 0.5, embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, enabled: true },
|
||||
appId: 'test-app',
|
||||
})
|
||||
})
|
||||
|
||||
it('should add annotation via annotation ctrl button', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
const addBtn = screen.getByTestId('annotation-add-btn')
|
||||
await user.click(addBtn)
|
||||
expect(mockContextValue.onAnnotationAdded).toHaveBeenCalledWith('ann-new', 'Test User', 'What is this?', 'Hello world', 0)
|
||||
})
|
||||
|
||||
it('should show annotation full modal when limit reached', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockProviderContext.enableBilling = true
|
||||
mockProviderContext.plan.usage.annotatedResponse = 100
|
||||
renderOperation()
|
||||
const addBtn = screen.getByTestId('annotation-add-btn')
|
||||
await user.click(addBtn)
|
||||
expect(mockSetShowAnnotationFullModal).toHaveBeenCalled()
|
||||
expect(mockAddAnnotation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should open edit reply modal when cached annotation exists', async () => {
|
||||
const user = userEvent.setup()
|
||||
const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } }
|
||||
renderOperation({ ...baseProps, item })
|
||||
const editBtn = screen.getByTestId('annotation-edit-btn')
|
||||
await user.click(editBtn)
|
||||
expect(screen.getByTestId('edit-reply-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onAnnotationEdited from edit reply modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } }
|
||||
renderOperation({ ...baseProps, item })
|
||||
const editBtn = screen.getByTestId('annotation-edit-btn')
|
||||
await user.click(editBtn)
|
||||
await user.click(screen.getByTestId('modal-edit'))
|
||||
expect(mockContextValue.onAnnotationEdited).toHaveBeenCalledWith('eq', 'ea', 0)
|
||||
})
|
||||
|
||||
it('should call onAnnotationAdded from edit reply modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } }
|
||||
renderOperation({ ...baseProps, item })
|
||||
const editBtn = screen.getByTestId('annotation-edit-btn')
|
||||
await user.click(editBtn)
|
||||
await user.click(screen.getByTestId('modal-add'))
|
||||
expect(mockContextValue.onAnnotationAdded).toHaveBeenCalledWith('a1', 'author', 'eq', 'ea', 0)
|
||||
})
|
||||
|
||||
it('should call onAnnotationRemoved from edit reply modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } }
|
||||
renderOperation({ ...baseProps, item })
|
||||
const editBtn = screen.getByTestId('annotation-edit-btn')
|
||||
await user.click(editBtn)
|
||||
await user.click(screen.getByTestId('modal-remove'))
|
||||
expect(mockContextValue.onAnnotationRemoved).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('should close edit reply modal via onHide', async () => {
|
||||
const user = userEvent.setup()
|
||||
const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } }
|
||||
renderOperation({ ...baseProps, item })
|
||||
const editBtn = screen.getByTestId('annotation-edit-btn')
|
||||
await user.click(editBtn)
|
||||
expect(screen.getByTestId('edit-reply-modal')).toBeInTheDocument()
|
||||
await user.click(screen.getByTestId('modal-hide'))
|
||||
expect(screen.queryByTestId('edit-reply-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TTS audio button', () => {
|
||||
beforeEach(() => {
|
||||
mockContextValue.config = makeChatConfig({ text_to_speech: { enabled: true, voice: 'test-voice' } })
|
||||
})
|
||||
|
||||
it('should show audio play button when TTS enabled', () => {
|
||||
renderOperation()
|
||||
expect(screen.getByTestId('audio-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show audio button for humanInputFormDataList', () => {
|
||||
const item = { ...baseItem, humanInputFormDataList: [{}] } as ChatItem
|
||||
renderOperation({ ...baseProps, item })
|
||||
expect(screen.queryByTestId('audio-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle feedback content with only whitespace', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockContextValue.config = makeChatConfig({ supportFeedback: true })
|
||||
renderOperation()
|
||||
const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')!
|
||||
await user.click(thumbDown)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.type(textarea, ' ')
|
||||
const confirmBtn = screen.getByText(/submit/i)
|
||||
await user.click(confirmBtn)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'dislike', content: ' ' })
|
||||
})
|
||||
|
||||
it('should handle missing onFeedback callback gracefully', async () => {
|
||||
mockContextValue.onFeedback = undefined
|
||||
mockContextValue.config = makeChatConfig({ supportFeedback: true })
|
||||
renderOperation()
|
||||
const bar = screen.getByTestId('operation-bar')
|
||||
expect(bar.querySelector('.i-ri-thumb-up-line')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty agent_thoughts array', async () => {
|
||||
const user = userEvent.setup()
|
||||
const item: ChatItem = { ...baseItem, agent_thoughts: [] }
|
||||
renderOperation({ ...baseProps, item })
|
||||
await user.click(screen.getByTestId('copy-btn'))
|
||||
expect(copy).toHaveBeenCalledWith('Hello world')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,12 +3,6 @@ import type {
|
||||
ChatItem,
|
||||
Feedback,
|
||||
} from '../../types'
|
||||
import {
|
||||
RiClipboardLine,
|
||||
RiResetLeftLine,
|
||||
RiThumbDownLine,
|
||||
RiThumbUpLine,
|
||||
} from '@remixicon/react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import {
|
||||
memo,
|
||||
@@ -127,20 +121,10 @@ const Operation: FC<OperationProps> = ({
|
||||
}
|
||||
|
||||
const handleLikeClick = (target: 'user' | 'admin') => {
|
||||
const currentRating = target === 'admin' ? adminLocalFeedback?.rating : displayUserFeedback?.rating
|
||||
if (currentRating === 'like') {
|
||||
handleFeedback(null, undefined, target)
|
||||
return
|
||||
}
|
||||
handleFeedback('like', undefined, target)
|
||||
}
|
||||
|
||||
const handleDislikeClick = (target: 'user' | 'admin') => {
|
||||
const currentRating = target === 'admin' ? adminLocalFeedback?.rating : displayUserFeedback?.rating
|
||||
if (currentRating === 'dislike') {
|
||||
handleFeedback(null, undefined, target)
|
||||
return
|
||||
}
|
||||
setFeedbackTarget(target)
|
||||
setIsShowFeedbackModal(true)
|
||||
}
|
||||
@@ -186,6 +170,7 @@ const Operation: FC<OperationProps> = ({
|
||||
!hasWorkflowProcess && positionRight && '!top-[9px]',
|
||||
)}
|
||||
style={(!hasWorkflowProcess && positionRight) ? { left: contentWidth + 8 } : {}}
|
||||
data-testid="operation-bar"
|
||||
>
|
||||
{shouldShowUserFeedbackBar && !humanInputFormDataList?.length && (
|
||||
<div className={cn(
|
||||
@@ -204,8 +189,8 @@ const Operation: FC<OperationProps> = ({
|
||||
onClick={() => handleFeedback(null, undefined, 'user')}
|
||||
>
|
||||
{displayUserFeedback?.rating === 'like'
|
||||
? <RiThumbUpLine className="h-4 w-4" />
|
||||
: <RiThumbDownLine className="h-4 w-4" />}
|
||||
? <div className="i-ri-thumb-up-line h-4 w-4" />
|
||||
: <div className="i-ri-thumb-down-line h-4 w-4" />}
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)
|
||||
@@ -215,13 +200,13 @@ const Operation: FC<OperationProps> = ({
|
||||
state={displayUserFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default}
|
||||
onClick={() => handleLikeClick('user')}
|
||||
>
|
||||
<RiThumbUpLine className="h-4 w-4" />
|
||||
<div className="i-ri-thumb-up-line h-4 w-4" />
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
state={displayUserFeedback?.rating === 'dislike' ? ActionButtonState.Destructive : ActionButtonState.Default}
|
||||
onClick={() => handleDislikeClick('user')}
|
||||
>
|
||||
<RiThumbDownLine className="h-4 w-4" />
|
||||
<div className="i-ri-thumb-down-line h-4 w-4" />
|
||||
</ActionButton>
|
||||
</>
|
||||
)}
|
||||
@@ -242,12 +227,12 @@ const Operation: FC<OperationProps> = ({
|
||||
{displayUserFeedback.rating === 'like'
|
||||
? (
|
||||
<ActionButton state={ActionButtonState.Active}>
|
||||
<RiThumbUpLine className="h-4 w-4" />
|
||||
<div className="i-ri-thumb-up-line h-4 w-4" />
|
||||
</ActionButton>
|
||||
)
|
||||
: (
|
||||
<ActionButton state={ActionButtonState.Destructive}>
|
||||
<RiThumbDownLine className="h-4 w-4" />
|
||||
<div className="i-ri-thumb-down-line h-4 w-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
</Tooltip>
|
||||
@@ -266,8 +251,8 @@ const Operation: FC<OperationProps> = ({
|
||||
onClick={() => handleFeedback(null, undefined, 'admin')}
|
||||
>
|
||||
{adminLocalFeedback?.rating === 'like'
|
||||
? <RiThumbUpLine className="h-4 w-4" />
|
||||
: <RiThumbDownLine className="h-4 w-4" />}
|
||||
? <div className="i-ri-thumb-up-line h-4 w-4" />
|
||||
: <div className="i-ri-thumb-down-line h-4 w-4" />}
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)
|
||||
@@ -281,7 +266,7 @@ const Operation: FC<OperationProps> = ({
|
||||
state={adminLocalFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default}
|
||||
onClick={() => handleLikeClick('admin')}
|
||||
>
|
||||
<RiThumbUpLine className="h-4 w-4" />
|
||||
<div className="i-ri-thumb-up-line h-4 w-4" />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
@@ -292,7 +277,7 @@ const Operation: FC<OperationProps> = ({
|
||||
state={adminLocalFeedback?.rating === 'dislike' ? ActionButtonState.Destructive : ActionButtonState.Default}
|
||||
onClick={() => handleDislikeClick('admin')}
|
||||
>
|
||||
<RiThumbDownLine className="h-4 w-4" />
|
||||
<div className="i-ri-thumb-down-line h-4 w-4" />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
@@ -305,7 +290,7 @@ const Operation: FC<OperationProps> = ({
|
||||
</div>
|
||||
)}
|
||||
{!isOpeningStatement && (
|
||||
<div className="ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex">
|
||||
<div className="ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex" data-testid="operation-actions">
|
||||
{(config?.text_to_speech?.enabled && !humanInputFormDataList?.length) && (
|
||||
<NewAudioButton
|
||||
id={id}
|
||||
@@ -314,17 +299,19 @@ const Operation: FC<OperationProps> = ({
|
||||
/>
|
||||
)}
|
||||
{!humanInputFormDataList?.length && (
|
||||
<ActionButton onClick={() => {
|
||||
copy(content)
|
||||
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
|
||||
}}
|
||||
<ActionButton
|
||||
onClick={() => {
|
||||
copy(content)
|
||||
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
|
||||
}}
|
||||
data-testid="copy-btn"
|
||||
>
|
||||
<RiClipboardLine className="h-4 w-4" />
|
||||
<div className="i-ri-clipboard-line h-4 w-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
{!noChatInput && (
|
||||
<ActionButton onClick={() => onRegenerate?.(item)}>
|
||||
<RiResetLeftLine className="h-4 w-4" />
|
||||
<ActionButton onClick={() => onRegenerate?.(item)} data-testid="regenerate-btn">
|
||||
<div className="i-ri-reset-left-line h-4 w-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
{config?.supportAnnotation && config.annotation_reply?.enabled && !humanInputFormDataList?.length && (
|
||||
@@ -366,7 +353,7 @@ const Operation: FC<OperationProps> = ({
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="system-sm-semibold mb-2 block text-text-secondary">
|
||||
<label className="mb-2 block text-text-secondary system-sm-semibold">
|
||||
{t('feedback.content', { ns: 'common' }) || 'Feedback Content'}
|
||||
</label>
|
||||
<Textarea
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { Mock } from 'vitest' // Or 'jest' if using Jest
|
||||
import type { IChatItem } from '../type'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useChatContext } from '../context'
|
||||
import SuggestedQuestions from './suggested-questions'
|
||||
|
||||
// Mock the chat context
|
||||
vi.mock('../context', () => ({
|
||||
useChatContext: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('SuggestedQuestions', () => {
|
||||
const mockOnSend = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Use 'as Mock' instead of 'as any'
|
||||
(useChatContext as Mock).mockReturnValue({
|
||||
onSend: mockOnSend,
|
||||
readonly: false,
|
||||
})
|
||||
})
|
||||
|
||||
const mockItem: IChatItem = {
|
||||
id: '1',
|
||||
content: '',
|
||||
isAnswer: true,
|
||||
isOpeningStatement: true,
|
||||
suggestedQuestions: ['What is Dify?', 'How to use it?', ' ', ''],
|
||||
}
|
||||
|
||||
it('should render suggested questions and filter out empty ones', () => {
|
||||
render(<SuggestedQuestions item={mockItem} />)
|
||||
|
||||
const questions = screen.getAllByTestId('suggested-question')
|
||||
expect(questions).toHaveLength(2)
|
||||
expect(questions[0]).toHaveTextContent('What is Dify?')
|
||||
expect(questions[1]).toHaveTextContent('How to use it?')
|
||||
})
|
||||
|
||||
it('should call onSend when a question is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SuggestedQuestions item={mockItem} />)
|
||||
|
||||
const questions = screen.getAllByTestId('suggested-question')
|
||||
await user.click(questions[0])
|
||||
|
||||
expect(mockOnSend).toHaveBeenCalledWith('What is Dify?')
|
||||
})
|
||||
|
||||
it('should not render if isOpeningStatement is false', () => {
|
||||
render(<SuggestedQuestions item={{ ...mockItem, isOpeningStatement: false }} />)
|
||||
expect(screen.queryByTestId('suggested-question')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render if suggestedQuestions is missing or empty', () => {
|
||||
render(<SuggestedQuestions item={{ ...mockItem, suggestedQuestions: [] }} />)
|
||||
expect(screen.queryByTestId('suggested-question')).not.toBeInTheDocument()
|
||||
|
||||
// Use 'as IChatItem' instead of 'as any'
|
||||
render(<SuggestedQuestions item={{ ...mockItem, suggestedQuestions: undefined } as IChatItem} />)
|
||||
expect(screen.queryByTestId('suggested-question')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should be disabled and not call onSend when readonly is true', async () => {
|
||||
const user = userEvent.setup();
|
||||
// Use 'as Mock' instead of 'as any'
|
||||
(useChatContext as Mock).mockReturnValue({
|
||||
onSend: mockOnSend,
|
||||
readonly: true,
|
||||
})
|
||||
|
||||
render(<SuggestedQuestions item={mockItem} />)
|
||||
|
||||
const questions = screen.getAllByTestId('suggested-question')
|
||||
expect(questions[0]).toHaveClass('pointer-events-none')
|
||||
expect(questions[0]).toHaveClass('opacity-50')
|
||||
|
||||
await user.click(questions[0])
|
||||
expect(mockOnSend).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -26,10 +26,11 @@ const SuggestedQuestions: FC<SuggestedQuestionsProps> = ({
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'system-sm-medium mr-1 mt-1 inline-flex max-w-full shrink-0 cursor-pointer flex-wrap rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3.5 py-2 text-components-button-secondary-accent-text shadow-xs last:mr-0 hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover',
|
||||
'mr-1 mt-1 inline-flex max-w-full shrink-0 cursor-pointer flex-wrap rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3.5 py-2 text-components-button-secondary-accent-text shadow-xs system-sm-medium last:mr-0 hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover',
|
||||
readonly && 'pointer-events-none opacity-50',
|
||||
)}
|
||||
onClick={() => !readonly && onSend?.(question)}
|
||||
data-testid="suggested-question"
|
||||
>
|
||||
{question}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { ToolInfoInThought } from '../type'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ToolDetail from './tool-detail'
|
||||
|
||||
describe('ToolDetail', () => {
|
||||
const mockPayload: ToolInfoInThought = {
|
||||
name: 'test_tool',
|
||||
label: 'Test Tool Label',
|
||||
input: 'test input content',
|
||||
output: 'test output content',
|
||||
isFinished: true,
|
||||
}
|
||||
|
||||
const datasetPayload: ToolInfoInThought = {
|
||||
...mockPayload,
|
||||
name: 'dataset_123',
|
||||
label: 'Dataset Label',
|
||||
}
|
||||
|
||||
it('should render the tool label and "used" state when finished', () => {
|
||||
render(<ToolDetail payload={mockPayload} />)
|
||||
|
||||
expect(screen.getByText('Test Tool Label')).toBeInTheDocument()
|
||||
expect(screen.getByText('tools.thought.used')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the knowledge label and "using" state when not finished and name is a dataset', () => {
|
||||
render(<ToolDetail payload={{ ...datasetPayload, isFinished: false }} />)
|
||||
|
||||
expect(screen.getByText('dataset.knowledge')).toBeInTheDocument()
|
||||
expect(screen.getByText('tools.thought.using')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle expansion and show request/response details on click', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ToolDetail payload={mockPayload} />)
|
||||
|
||||
// Initially collapsed: request/response titles should not be visible
|
||||
expect(screen.queryByText('tools.thought.requestTitle')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(mockPayload.input)).not.toBeInTheDocument()
|
||||
|
||||
// Click to expand
|
||||
const label = screen.getByText('Test Tool Label')
|
||||
await user.click(label)
|
||||
|
||||
// Now expanded
|
||||
expect(screen.getByText('tools.thought.requestTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText(mockPayload.input)).toBeInTheDocument()
|
||||
expect(screen.getByText('tools.thought.responseTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText(mockPayload.output)).toBeInTheDocument()
|
||||
|
||||
// Click again to collapse
|
||||
await user.click(label)
|
||||
expect(screen.queryByText('tools.thought.requestTitle')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply different styles when expanded', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = render(<ToolDetail payload={mockPayload} />)
|
||||
const rootDiv = container.firstChild as HTMLElement
|
||||
const label = screen.getByText('Test Tool Label')
|
||||
const headerDiv = label.parentElement!
|
||||
|
||||
// Initial styles
|
||||
expect(rootDiv).toHaveClass('bg-workflow-process-bg')
|
||||
expect(headerDiv).not.toHaveClass('pb-1.5')
|
||||
|
||||
// Expand
|
||||
await user.click(label)
|
||||
expect(rootDiv).toHaveClass('bg-background-section-burn')
|
||||
expect(headerDiv).toHaveClass('pb-1.5')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { WorkflowProcess } from '../../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import WorkflowProcessItem from './workflow-process'
|
||||
|
||||
// Mock TracingPanel as it's a complex child component
|
||||
vi.mock('@/app/components/workflow/run/tracing-panel', () => ({
|
||||
default: () => <div data-testid="tracing-panel">Tracing Panel</div>,
|
||||
}))
|
||||
|
||||
describe('WorkflowProcessItem', () => {
|
||||
const mockData = {
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
tracing: [
|
||||
{ id: '1', title: 'Start' },
|
||||
{ id: '2', title: 'End' },
|
||||
],
|
||||
}
|
||||
|
||||
it('should render the latest node title when collapsed', () => {
|
||||
render(<WorkflowProcessItem data={mockData as WorkflowProcess} expand={false} />)
|
||||
expect(screen.getByTestId('workflow-process-title')).toHaveTextContent('End')
|
||||
expect(screen.queryByTestId('tracing-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render "Workflow Process" title and TracingPanel when expanded', () => {
|
||||
// We expect t('common.workflowProcess', { ns: 'workflow' }) to be called
|
||||
render(<WorkflowProcessItem data={mockData as WorkflowProcess} expand={true} />)
|
||||
expect(screen.getByText(/workflowProcess/i)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tracing-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle collapse state on header click', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<WorkflowProcessItem data={mockData as WorkflowProcess} expand={false} />)
|
||||
|
||||
const header = screen.getByTestId('workflow-process-header')
|
||||
|
||||
// Expand
|
||||
await user.click(header)
|
||||
expect(screen.getByTestId('tracing-panel')).toBeInTheDocument()
|
||||
expect(screen.getByText(/workflowProcess/i)).toBeInTheDocument()
|
||||
|
||||
// Collapse
|
||||
await user.click(header)
|
||||
expect(screen.queryByTestId('tracing-panel')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('workflow-process-title')).toHaveTextContent('End')
|
||||
})
|
||||
|
||||
it('should render nothing if readonly is true', () => {
|
||||
const { container } = render(<WorkflowProcessItem data={mockData as WorkflowProcess} readonly={true} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
describe('Status Icons', () => {
|
||||
it('should show running spinner when status is Running', () => {
|
||||
render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Running } as WorkflowProcess} />)
|
||||
expect(screen.getByTestId('status-icon-running')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show success circle when status is Succeeded', () => {
|
||||
render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Succeeded } as WorkflowProcess} />)
|
||||
expect(screen.getByTestId('status-icon-success')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error warning when status is Failed', () => {
|
||||
render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Failed } as WorkflowProcess} />)
|
||||
expect(screen.getByTestId('status-icon-failed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error warning when status is Stopped', () => {
|
||||
render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Stopped } as WorkflowProcess} />)
|
||||
expect(screen.getByTestId('status-icon-failed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show pause circle when status is Paused', () => {
|
||||
render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Paused } as WorkflowProcess} />)
|
||||
expect(screen.getByTestId('status-icon-paused')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Background Colors', () => {
|
||||
it('should apply correct background when collapsed for different statuses', () => {
|
||||
const { rerender } = render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Succeeded } as WorkflowProcess} />)
|
||||
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-workflow-process-bg')
|
||||
|
||||
rerender(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Paused } as WorkflowProcess} />)
|
||||
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-workflow-process-paused-bg')
|
||||
|
||||
rerender(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Failed } as WorkflowProcess} />)
|
||||
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-workflow-process-failed-bg')
|
||||
})
|
||||
|
||||
it('should apply correct background when expanded for different statuses', () => {
|
||||
const { rerender } = render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Running } as WorkflowProcess} expand={true} />)
|
||||
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-background-section-burn')
|
||||
|
||||
rerender(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Succeeded } as WorkflowProcess} expand={true} />)
|
||||
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-state-success-hover')
|
||||
|
||||
rerender(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Failed } as WorkflowProcess} expand={true} />)
|
||||
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-state-destructive-hover')
|
||||
|
||||
rerender(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Paused } as WorkflowProcess} expand={true} />)
|
||||
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-state-warning-hover')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,16 +1,10 @@
|
||||
import type { ChatItem, WorkflowProcess } from '../../types'
|
||||
import {
|
||||
RiArrowRightSLine,
|
||||
RiErrorWarningFill,
|
||||
RiLoader2Line,
|
||||
RiPauseCircleFill,
|
||||
} from '@remixicon/react'
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CheckCircle } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
import TracingPanel from '@/app/components/workflow/run/tracing-panel'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { cn } from '@/utils/classnames'
|
||||
@@ -58,35 +52,52 @@ const WorkflowProcessItem = ({
|
||||
collapse && paused && 'bg-workflow-process-paused-bg',
|
||||
collapse && failed && 'bg-workflow-process-failed-bg',
|
||||
)}
|
||||
data-testid="workflow-process-item"
|
||||
>
|
||||
<div
|
||||
className={cn('flex cursor-pointer items-center', !collapse && 'px-1.5')}
|
||||
onClick={() => setCollapse(!collapse)}
|
||||
data-testid="workflow-process-header"
|
||||
>
|
||||
{
|
||||
running && (
|
||||
<RiLoader2Line className="mr-1 h-3.5 w-3.5 shrink-0 animate-spin text-text-tertiary" />
|
||||
<div
|
||||
className="i-ri-loader-2-line mr-1 h-3.5 w-3.5 shrink-0 animate-spin text-text-tertiary"
|
||||
data-testid="status-icon-running"
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
succeeded && (
|
||||
<CheckCircle className="mr-1 h-3.5 w-3.5 shrink-0 text-text-success" />
|
||||
<div
|
||||
className="i-custom-vender-solid-general-check-circle mr-1 h-3.5 w-3.5 shrink-0 text-text-success"
|
||||
data-testid="status-icon-success"
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
failed && (
|
||||
<RiErrorWarningFill className="mr-1 h-3.5 w-3.5 shrink-0 text-text-destructive" />
|
||||
<div
|
||||
className="i-ri-error-warning-fill mr-1 h-3.5 w-3.5 shrink-0 text-text-destructive"
|
||||
data-testid="status-icon-failed"
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
paused && (
|
||||
<RiPauseCircleFill className="mr-1 h-3.5 w-3.5 shrink-0 text-text-warning-secondary" />
|
||||
<div
|
||||
className="i-ri-pause-circle-fill mr-1 h-3.5 w-3.5 shrink-0 text-text-warning-secondary"
|
||||
data-testid="status-icon-paused"
|
||||
/>
|
||||
)
|
||||
}
|
||||
<div className={cn('system-xs-medium text-text-secondary', !collapse && 'grow')}>
|
||||
<div
|
||||
className={cn('text-text-secondary system-xs-medium', !collapse && 'grow')}
|
||||
data-testid="workflow-process-title"
|
||||
>
|
||||
{!collapse ? t('common.workflowProcess', { ns: 'workflow' }) : latestNode?.title}
|
||||
</div>
|
||||
<RiArrowRightSLine className={cn('ml-1 h-4 w-4 text-text-tertiary', !collapse && 'rotate-90')} />
|
||||
<div className={cn('i-ri-arrow-right-s-line ml-1 h-4 w-4 text-text-tertiary', !collapse && 'rotate-90')} />
|
||||
</div>
|
||||
{
|
||||
!collapse && (
|
||||
|
||||
568
web/app/components/base/chat/chat/chat-input-area/index.spec.tsx
Normal file
568
web/app/components/base/chat/chat/chat-input-area/index.spec.tsx
Normal file
@@ -0,0 +1,568 @@
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { TransferMethod } from '@/types/app'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { vi } from 'vitest'
|
||||
import ChatInputArea from './index'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoist shared mock references so they are available inside vi.mock factories
|
||||
// ---------------------------------------------------------------------------
|
||||
const { mockGetPermission, mockNotify } = vi.hoisted(() => ({
|
||||
mockGetPermission: vi.fn().mockResolvedValue(undefined),
|
||||
mockNotify: vi.fn(),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// External dependency mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock('js-audio-recorder', () => ({
|
||||
default: class {
|
||||
static getPermission = mockGetPermission
|
||||
start = vi.fn()
|
||||
stop = vi.fn()
|
||||
getWAVBlob = vi.fn().mockReturnValue(new Blob([''], { type: 'audio/wav' }))
|
||||
getRecordAnalyseData = vi.fn().mockReturnValue(new Uint8Array(128))
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/share', () => ({
|
||||
audioToText: vi.fn().mockResolvedValue({ text: 'Converted text' }),
|
||||
AppSourceType: { webApp: 'webApp', installedApp: 'installedApp' },
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File-uploader store – shared mutable state so individual tests can mutate it
|
||||
// ---------------------------------------------------------------------------
|
||||
const mockFileStore: { files: FileEntity[], setFiles: ReturnType<typeof vi.fn> } = {
|
||||
files: [],
|
||||
setFiles: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/base/file-uploader/store', () => ({
|
||||
useFileStore: () => ({ getState: () => mockFileStore }),
|
||||
useStore: (selector: (s: typeof mockFileStore) => unknown) => selector(mockFileStore),
|
||||
FileContextProvider: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File-uploader hooks – provide stable drag/drop handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
vi.mock('@/app/components/base/file-uploader/hooks', () => ({
|
||||
useFile: () => ({
|
||||
handleDragFileEnter: vi.fn(),
|
||||
handleDragFileLeave: vi.fn(),
|
||||
handleDragFileOver: vi.fn(),
|
||||
handleDropFile: vi.fn(),
|
||||
handleClipboardPasteFile: vi.fn(),
|
||||
isDragActive: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Features context hook – avoids needing FeaturesContext.Provider in the tree
|
||||
// ---------------------------------------------------------------------------
|
||||
// FeatureBar calls: useFeatures(s => s.features)
|
||||
// So the selector receives the store state object; we must nest the features
|
||||
// under a `features` key to match what the real store exposes.
|
||||
const mockFeaturesState = {
|
||||
features: {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
file: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
},
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/base/features/hooks', () => ({
|
||||
useFeatures: (selector: (s: typeof mockFeaturesState) => unknown) =>
|
||||
selector(mockFeaturesState),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Toast context
|
||||
// ---------------------------------------------------------------------------
|
||||
vi.mock('@/app/components/base/toast', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/app/components/base/toast')>(
|
||||
'@/app/components/base/toast',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
useToastContext: () => ({ notify: mockNotify }),
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal layout hook – controls single/multi-line textarea mode
|
||||
// ---------------------------------------------------------------------------
|
||||
let mockIsMultipleLine = false
|
||||
|
||||
vi.mock('./hooks', () => ({
|
||||
useTextAreaHeight: () => ({
|
||||
wrapperRef: { current: document.createElement('div') },
|
||||
textareaRef: { current: document.createElement('textarea') },
|
||||
textValueRef: { current: document.createElement('div') },
|
||||
holdSpaceRef: { current: document.createElement('div') },
|
||||
handleTextareaResize: vi.fn(),
|
||||
get isMultipleLine() {
|
||||
return mockIsMultipleLine
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input-forms validation hook – always passes by default
|
||||
// ---------------------------------------------------------------------------
|
||||
vi.mock('../check-input-forms-hooks', () => ({
|
||||
useCheckInputsForms: () => ({
|
||||
checkInputsForm: vi.fn().mockReturnValue(true),
|
||||
}),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Next.js navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ token: 'test-token' }),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
usePathname: () => '/test',
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixture – typed as FileUpload to avoid implicit any
|
||||
// ---------------------------------------------------------------------------
|
||||
// const mockVisionConfig: FileUpload = {
|
||||
// fileUploadConfig: {
|
||||
// image_file_size_limit: 10,
|
||||
// file_size_limit: 10,
|
||||
// audio_file_size_limit: 10,
|
||||
// video_file_size_limit: 10,
|
||||
// workflow_file_upload_limit: 10,
|
||||
// },
|
||||
// allowed_file_types: [],
|
||||
// allowed_file_extensions: [],
|
||||
// enabled: true,
|
||||
// number_limits: 3,
|
||||
// transfer_methods: ['local_file', 'remote_url'],
|
||||
// } as FileUpload
|
||||
|
||||
const mockVisionConfig: FileUpload = {
|
||||
// Required because of '& EnabledOrDisabled' at the end of your type
|
||||
enabled: true,
|
||||
|
||||
// The nested config object
|
||||
fileUploadConfig: {
|
||||
image_file_size_limit: 10,
|
||||
file_size_limit: 10,
|
||||
audio_file_size_limit: 10,
|
||||
video_file_size_limit: 10,
|
||||
workflow_file_upload_limit: 10,
|
||||
batch_count_limit: 0,
|
||||
image_file_batch_limit: 0,
|
||||
single_chunk_attachment_limit: 0,
|
||||
attachment_image_file_size_limit: 0,
|
||||
file_upload_limit: 0,
|
||||
},
|
||||
|
||||
// These match the keys in your FileUpload type
|
||||
allowed_file_types: [],
|
||||
allowed_file_extensions: [],
|
||||
number_limits: 3,
|
||||
|
||||
// NOTE: Your type defines 'allowed_file_upload_methods',
|
||||
// not 'transfer_methods' at the top level.
|
||||
allowed_file_upload_methods: ['local_file', 'remote_url'] as TransferMethod[],
|
||||
|
||||
// If you wanted to define specific image/video behavior:
|
||||
image: {
|
||||
enabled: true,
|
||||
number_limits: 3,
|
||||
transfer_methods: ['local_file', 'remote_url'] as TransferMethod[],
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal valid FileEntity fixture – avoids undefined `type` crash in FileItem
|
||||
// ---------------------------------------------------------------------------
|
||||
const makeFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
|
||||
id: 'file-1',
|
||||
name: 'photo.png',
|
||||
type: 'image/png', // required: FileItem calls type.split('/')[0]
|
||||
size: 1024,
|
||||
progress: 100,
|
||||
transferMethod: 'local_file',
|
||||
uploadedId: 'uploaded-ok',
|
||||
...overrides,
|
||||
} as FileEntity)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
const getTextarea = () => screen.getByPlaceholderText(/inputPlaceholder/i)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('ChatInputArea', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFileStore.files = []
|
||||
mockIsMultipleLine = false
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render the textarea with default placeholder', () => {
|
||||
render(<ChatInputArea visionConfig={mockVisionConfig} />)
|
||||
expect(getTextarea()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the readonly placeholder when readonly prop is set', () => {
|
||||
render(<ChatInputArea visionConfig={mockVisionConfig} readonly />)
|
||||
expect(screen.getByPlaceholderText(/inputDisabledPlaceholder/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the send button', () => {
|
||||
render(<ChatInputArea visionConfig={mockVisionConfig} />)
|
||||
expect(screen.getByTestId('send-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply disabled styles when the disabled prop is true', () => {
|
||||
const { container } = render(<ChatInputArea visionConfig={mockVisionConfig} disabled />)
|
||||
const disabledWrapper = container.querySelector('.pointer-events-none')
|
||||
expect(disabledWrapper).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the operation section inline when single-line', () => {
|
||||
// mockIsMultipleLine is false by default
|
||||
render(<ChatInputArea visionConfig={mockVisionConfig} />)
|
||||
expect(screen.getByTestId('send-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the operation section below the textarea when multi-line', () => {
|
||||
mockIsMultipleLine = true
|
||||
render(<ChatInputArea visionConfig={mockVisionConfig} />)
|
||||
expect(screen.getByTestId('send-button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Typing', () => {
|
||||
it('should update textarea value as the user types', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ChatInputArea visionConfig={mockVisionConfig} />)
|
||||
|
||||
await user.type(getTextarea(), 'Hello world')
|
||||
|
||||
expect(getTextarea()).toHaveValue('Hello world')
|
||||
})
|
||||
|
||||
it('should clear the textarea after a message is successfully sent', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ChatInputArea onSend={vi.fn()} visionConfig={mockVisionConfig} />)
|
||||
|
||||
await user.type(getTextarea(), 'Hello world')
|
||||
await user.click(screen.getByTestId('send-button'))
|
||||
|
||||
expect(getTextarea()).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Sending Messages', () => {
|
||||
it('should call onSend with query and files when clicking the send button', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSend = vi.fn()
|
||||
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
|
||||
|
||||
await user.type(getTextarea(), 'Hello world')
|
||||
await user.click(screen.getByTestId('send-button'))
|
||||
|
||||
expect(onSend).toHaveBeenCalledTimes(1)
|
||||
expect(onSend).toHaveBeenCalledWith('Hello world', [])
|
||||
})
|
||||
|
||||
it('should call onSend and reset the input when pressing Enter', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSend = vi.fn()
|
||||
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
|
||||
|
||||
await user.type(getTextarea(), 'Hello world{Enter}')
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith('Hello world', [])
|
||||
expect(getTextarea()).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should NOT call onSend when pressing Shift+Enter (inserts newline instead)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSend = vi.fn()
|
||||
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
|
||||
|
||||
await user.type(getTextarea(), 'Hello world{Shift>}{Enter}{/Shift}')
|
||||
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
expect(getTextarea()).toHaveValue('Hello world\n')
|
||||
})
|
||||
|
||||
it('should NOT call onSend in readonly mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSend = vi.fn()
|
||||
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} readonly />)
|
||||
|
||||
await user.click(screen.getByTestId('send-button'))
|
||||
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should pass already-uploaded files to onSend', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSend = vi.fn()
|
||||
|
||||
// makeFile ensures `type` is always a proper MIME string
|
||||
const uploadedFile = makeFile({ id: 'file-1', name: 'photo.png', uploadedId: 'uploaded-123' })
|
||||
mockFileStore.files = [uploadedFile]
|
||||
|
||||
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
|
||||
await user.type(getTextarea(), 'With attachment')
|
||||
await user.click(screen.getByTestId('send-button'))
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith('With attachment', [uploadedFile])
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('History Navigation', () => {
|
||||
it('should restore the last sent message when pressing Cmd+ArrowUp once', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ChatInputArea onSend={vi.fn()} visionConfig={mockVisionConfig} />)
|
||||
const textarea = getTextarea()
|
||||
|
||||
await user.type(textarea, 'First{Enter}')
|
||||
await user.type(textarea, 'Second{Enter}')
|
||||
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}')
|
||||
|
||||
expect(textarea).toHaveValue('Second')
|
||||
})
|
||||
|
||||
it('should go further back in history with repeated Cmd+ArrowUp', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ChatInputArea onSend={vi.fn()} visionConfig={mockVisionConfig} />)
|
||||
const textarea = getTextarea()
|
||||
|
||||
await user.type(textarea, 'First{Enter}')
|
||||
await user.type(textarea, 'Second{Enter}')
|
||||
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}')
|
||||
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}')
|
||||
|
||||
expect(textarea).toHaveValue('First')
|
||||
})
|
||||
|
||||
it('should move forward in history when pressing Cmd+ArrowDown', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ChatInputArea onSend={vi.fn()} visionConfig={mockVisionConfig} />)
|
||||
const textarea = getTextarea()
|
||||
|
||||
await user.type(textarea, 'First{Enter}')
|
||||
await user.type(textarea, 'Second{Enter}')
|
||||
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}') // → Second
|
||||
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}') // → First
|
||||
await user.type(textarea, '{Meta>}{ArrowDown}{/Meta}') // → Second
|
||||
|
||||
expect(textarea).toHaveValue('Second')
|
||||
})
|
||||
|
||||
it('should clear the input when navigating past the most recent history entry', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ChatInputArea onSend={vi.fn()} visionConfig={mockVisionConfig} />)
|
||||
const textarea = getTextarea()
|
||||
|
||||
await user.type(textarea, 'First{Enter}')
|
||||
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}') // → First
|
||||
await user.type(textarea, '{Meta>}{ArrowDown}{/Meta}') // → past end → ''
|
||||
|
||||
expect(textarea).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should not go below the start of history when pressing Cmd+ArrowUp at the boundary', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ChatInputArea onSend={vi.fn()} visionConfig={mockVisionConfig} />)
|
||||
const textarea = getTextarea()
|
||||
|
||||
await user.type(textarea, 'Only{Enter}')
|
||||
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}') // → Only
|
||||
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}') // → '' (seed at index 0)
|
||||
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}') // boundary – should stay at ''
|
||||
|
||||
expect(textarea).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Voice Input', () => {
|
||||
it('should render the voice input button when speech-to-text is enabled', () => {
|
||||
render(<ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />)
|
||||
expect(screen.getByTestId('voice-input-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should NOT render the voice input button when speech-to-text is disabled', () => {
|
||||
render(<ChatInputArea speechToTextConfig={{ enabled: false }} visionConfig={mockVisionConfig} />)
|
||||
expect(screen.queryByTestId('voice-input-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should request microphone permission when the voice button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />)
|
||||
|
||||
await user.click(screen.getByTestId('voice-input-button'))
|
||||
|
||||
expect(mockGetPermission).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should notify with an error when microphone permission is denied', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockGetPermission.mockRejectedValueOnce(new Error('Permission denied'))
|
||||
render(<ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />)
|
||||
|
||||
await user.click(screen.getByTestId('voice-input-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
})
|
||||
|
||||
it('should NOT invoke onSend while voice input is being activated', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSend = vi.fn()
|
||||
render(
|
||||
<ChatInputArea
|
||||
onSend={onSend}
|
||||
speechToTextConfig={{ enabled: true }}
|
||||
visionConfig={mockVisionConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByTestId('voice-input-button'))
|
||||
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Validation', () => {
|
||||
it('should notify and NOT call onSend when the query is blank', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSend = vi.fn()
|
||||
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
|
||||
|
||||
await user.click(screen.getByTestId('send-button'))
|
||||
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'info' }))
|
||||
})
|
||||
|
||||
it('should notify and NOT call onSend when the query contains only whitespace', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSend = vi.fn()
|
||||
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
|
||||
|
||||
await user.type(getTextarea(), ' ')
|
||||
await user.click(screen.getByTestId('send-button'))
|
||||
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'info' }))
|
||||
})
|
||||
|
||||
it('should notify and NOT call onSend while the bot is already responding', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSend = vi.fn()
|
||||
render(<ChatInputArea onSend={onSend} isResponding visionConfig={mockVisionConfig} />)
|
||||
|
||||
await user.type(getTextarea(), 'Hello')
|
||||
await user.click(screen.getByTestId('send-button'))
|
||||
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'info' }))
|
||||
})
|
||||
|
||||
it('should notify and NOT call onSend while a file upload is still in progress', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSend = vi.fn()
|
||||
|
||||
// uploadedId is empty string → upload not yet finished
|
||||
mockFileStore.files = [
|
||||
makeFile({ id: 'file-upload', uploadedId: '', progress: 50 }),
|
||||
]
|
||||
|
||||
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
|
||||
await user.type(getTextarea(), 'Hello')
|
||||
await user.click(screen.getByTestId('send-button'))
|
||||
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'info' }))
|
||||
})
|
||||
|
||||
it('should call onSend normally when all uploaded files have completed', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSend = vi.fn()
|
||||
|
||||
// uploadedId is present → upload finished
|
||||
mockFileStore.files = [makeFile({ uploadedId: 'uploaded-ok' })]
|
||||
|
||||
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
|
||||
await user.type(getTextarea(), 'With completed file')
|
||||
await user.click(screen.getByTestId('send-button'))
|
||||
|
||||
expect(onSend).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Feature Bar', () => {
|
||||
it('should render the FeatureBar section when showFeatureBar is true', () => {
|
||||
const { container } = render(
|
||||
<ChatInputArea visionConfig={mockVisionConfig} showFeatureBar />,
|
||||
)
|
||||
// FeatureBar renders a rounded-bottom container beneath the input
|
||||
expect(container.querySelector('[class*="rounded-b"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should NOT render the FeatureBar when showFeatureBar is false', () => {
|
||||
const { container } = render(
|
||||
<ChatInputArea visionConfig={mockVisionConfig} showFeatureBar={false} />,
|
||||
)
|
||||
expect(container.querySelector('[class*="rounded-b"]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not invoke onFeatureBarClick when the component is in readonly mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onFeatureBarClick = vi.fn()
|
||||
render(
|
||||
<ChatInputArea
|
||||
visionConfig={mockVisionConfig}
|
||||
showFeatureBar
|
||||
readonly
|
||||
onFeatureBarClick={onFeatureBarClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
// In readonly mode the FeatureBar receives `noop` as its click handler.
|
||||
// Click every button that is not a named test-id button to exercise the guard.
|
||||
const buttons = screen.queryAllByRole('button')
|
||||
for (const btn of buttons) {
|
||||
if (!btn.dataset.testid)
|
||||
await user.click(btn)
|
||||
}
|
||||
|
||||
expect(onFeatureBarClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { EnableType } from '../../types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Theme } from '../../embedded-chatbot/theme/theme-context'
|
||||
import Operation from './operation'
|
||||
|
||||
vi.mock('@/app/components/base/file-uploader', () => ({
|
||||
FileUploaderInChatInput: ({ readonly }: { readonly?: boolean }) => (
|
||||
<div data-testid="file-uploader" data-readonly={readonly} />
|
||||
),
|
||||
}))
|
||||
|
||||
const createMockTheme = (overrides?: Partial<Theme>): Theme => {
|
||||
const theme = new Theme()
|
||||
theme.primaryColor = 'rgb(255, 0, 0)'
|
||||
return Object.assign(theme, overrides || {})
|
||||
}
|
||||
|
||||
describe('Operation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render send button always', () => {
|
||||
render(<Operation onSend={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render file uploader when fileConfig.enabled is true', () => {
|
||||
const fileConfig: FileUpload = { enabled: true } as FileUpload
|
||||
|
||||
render(
|
||||
<Operation
|
||||
onSend={vi.fn()}
|
||||
fileConfig={fileConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('file-uploader')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render file uploader when fileConfig is undefined', () => {
|
||||
render(<Operation onSend={vi.fn()} />)
|
||||
|
||||
expect(screen.queryByTestId('file-uploader')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render voice input button when speechToTextConfig.enabled is true', () => {
|
||||
const speechConfig: EnableType = { enabled: true }
|
||||
|
||||
render(
|
||||
<Operation
|
||||
onSend={vi.fn()}
|
||||
speechToTextConfig={speechConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('button')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should not render voice input button when speechToTextConfig.enabled is false', () => {
|
||||
const speechConfig: EnableType = { enabled: false }
|
||||
|
||||
render(
|
||||
<Operation
|
||||
onSend={vi.fn()}
|
||||
speechToTextConfig={speechConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('button')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Send Button Behavior', () => {
|
||||
it('should call onSend when clicked and not readonly', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSend = vi.fn()
|
||||
|
||||
render(<Operation onSend={onSend} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
expect(onSend).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onSend when readonly is true', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSend = vi.fn()
|
||||
|
||||
render(<Operation onSend={onSend} readonly />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should apply theme primaryColor as background style when theme is provided', () => {
|
||||
render(
|
||||
<Operation
|
||||
onSend={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toHaveStyle({
|
||||
backgroundColor: 'rgb(255, 0, 0)',
|
||||
})
|
||||
})
|
||||
|
||||
it('should not apply background style when theme is null', () => {
|
||||
render(
|
||||
<Operation
|
||||
onSend={vi.fn()}
|
||||
theme={null}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button').style.backgroundColor).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Voice Input Button', () => {
|
||||
it('should call onShowVoiceInput when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onShowVoiceInput = vi.fn()
|
||||
|
||||
render(
|
||||
<Operation
|
||||
onSend={vi.fn()}
|
||||
speechToTextConfig={{ enabled: true }}
|
||||
onShowVoiceInput={onShowVoiceInput}
|
||||
/>,
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const voiceButton = buttons[0]
|
||||
|
||||
await user.click(voiceButton)
|
||||
|
||||
expect(onShowVoiceInput).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should disable voice button when readonly is true', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onShowVoiceInput = vi.fn()
|
||||
|
||||
render(
|
||||
<Operation
|
||||
onSend={vi.fn()}
|
||||
speechToTextConfig={{ enabled: true }}
|
||||
onShowVoiceInput={onShowVoiceInput}
|
||||
readonly
|
||||
/>,
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const voiceButton = buttons[0]
|
||||
|
||||
expect(voiceButton).toBeDisabled()
|
||||
|
||||
await user.click(voiceButton)
|
||||
|
||||
expect(onShowVoiceInput).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -51,6 +51,7 @@ const Operation: FC<OperationProps> = ({
|
||||
size="l"
|
||||
disabled={readonly}
|
||||
onClick={onShowVoiceInput}
|
||||
data-testid="voice-input-button"
|
||||
>
|
||||
<RiMicLine className="h-5 w-5" />
|
||||
</ActionButton>
|
||||
@@ -61,6 +62,7 @@ const Operation: FC<OperationProps> = ({
|
||||
className="ml-3 w-8 px-0"
|
||||
variant="primary"
|
||||
onClick={readonly ? noop : onSend}
|
||||
data-testid="send-button"
|
||||
style={
|
||||
theme
|
||||
? {
|
||||
|
||||
364
web/app/components/base/chat/chat/citation/index.spec.tsx
Normal file
364
web/app/components/base/chat/chat/citation/index.spec.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
import type { CitationItem } from '../type'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Citation from './index'
|
||||
|
||||
vi.mock('./popup', () => ({
|
||||
default: ({ data, showHitInfo }: { data: { documentName: string }, showHitInfo?: boolean }) => (
|
||||
<div data-testid="popup" data-show-hit-info={String(!!showHitInfo)}>
|
||||
{data.documentName}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const originalClientWidthDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
|
||||
|
||||
type ClientWidthConfig = {
|
||||
container: number
|
||||
item: number
|
||||
}
|
||||
|
||||
const mockClientWidths = ({ container, item }: ClientWidthConfig) => {
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
||||
get() {
|
||||
const el = this as HTMLElement
|
||||
if (el.className?.includes?.('chat-answer-container') || el.className?.includes?.('my-custom-container'))
|
||||
return container
|
||||
if (el.dataset?.testid === 'citation-measurement-item')
|
||||
return item
|
||||
return 0
|
||||
},
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
const restoreClientWidth = () => {
|
||||
if (originalClientWidthDescriptor)
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidthDescriptor)
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
restoreClientWidth()
|
||||
})
|
||||
|
||||
const makeCitationItem = (overrides: Partial<CitationItem> = {}): CitationItem => ({
|
||||
document_id: 'doc-1',
|
||||
document_name: 'Document One',
|
||||
data_source_type: 'upload_file',
|
||||
segment_id: 'seg-1',
|
||||
content: 'Some content',
|
||||
dataset_id: 'dataset-1',
|
||||
dataset_name: 'Dataset One',
|
||||
segment_position: 1,
|
||||
word_count: 100,
|
||||
hit_count: 5,
|
||||
index_node_hash: 'abc123',
|
||||
score: 0.95,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const setupContainer = (className = 'chat-answer-container') => {
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.className = className
|
||||
document.body.appendChild(wrapper)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('Citation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
restoreClientWidth()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the citation title section', () => {
|
||||
mockClientWidths({ container: 500, item: 50 })
|
||||
setupContainer()
|
||||
render(<Citation data={[makeCitationItem()]} />)
|
||||
expect(screen.getByTestId('citation-title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render one measurement ghost item per unique document', () => {
|
||||
mockClientWidths({ container: 500, item: 50 })
|
||||
setupContainer()
|
||||
render(
|
||||
<Citation data={[
|
||||
makeCitationItem({ document_id: 'doc-1', document_name: 'Alpha' }),
|
||||
makeCitationItem({ document_id: 'doc-2', document_name: 'Beta' }),
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should display the document name inside each measurement item', () => {
|
||||
mockClientWidths({ container: 500, item: 50 })
|
||||
setupContainer()
|
||||
render(<Citation data={[makeCitationItem({ document_name: 'My Report' })]} />)
|
||||
expect(screen.getByTestId('citation-measurement-item')).toHaveTextContent('My Report')
|
||||
})
|
||||
|
||||
it('should render a popup for each resource that fits within the container', () => {
|
||||
mockClientWidths({ container: 840, item: 50 })
|
||||
setupContainer()
|
||||
render(
|
||||
<Citation data={[
|
||||
makeCitationItem({ document_id: 'doc-1' }),
|
||||
makeCitationItem({ document_id: 'doc-2' }),
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByTestId('popup')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should render the citation title i18n key', () => {
|
||||
mockClientWidths({ container: 500, item: 50 })
|
||||
setupContainer()
|
||||
render(<Citation data={[makeCitationItem()]} />)
|
||||
expect(screen.getByText(/citation\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should use chat-answer-container as the default containerClassName', () => {
|
||||
mockClientWidths({ container: 500, item: 50 })
|
||||
setupContainer()
|
||||
render(<Citation data={[makeCitationItem()]} />)
|
||||
expect(screen.getByTestId('citation-title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use a custom containerClassName to resolve the container element', () => {
|
||||
mockClientWidths({ container: 600, item: 50 })
|
||||
setupContainer('my-custom-container')
|
||||
render(<Citation data={[makeCitationItem()]} containerClassName="my-custom-container" />)
|
||||
expect(screen.getByTestId('citation-title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should forward showHitInfo=true to each rendered Popup', () => {
|
||||
mockClientWidths({ container: 840, item: 50 })
|
||||
setupContainer()
|
||||
render(
|
||||
<Citation
|
||||
data={[
|
||||
makeCitationItem({ document_id: 'doc-1' }),
|
||||
makeCitationItem({ document_id: 'doc-2' }),
|
||||
]}
|
||||
showHitInfo={true}
|
||||
/>,
|
||||
)
|
||||
screen.getAllByTestId('popup').forEach(p =>
|
||||
expect(p).toHaveAttribute('data-show-hit-info', 'true'),
|
||||
)
|
||||
})
|
||||
|
||||
it('should forward showHitInfo=false when prop is omitted', () => {
|
||||
mockClientWidths({ container: 840, item: 50 })
|
||||
setupContainer()
|
||||
render(<Citation data={[makeCitationItem({ document_id: 'doc-1' })]} />)
|
||||
screen.getAllByTestId('popup').forEach(p =>
|
||||
expect(p).toHaveAttribute('data-show-hit-info', 'false'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Resource Grouping', () => {
|
||||
it('should merge citations with the same document_id into one resource', () => {
|
||||
mockClientWidths({ container: 500, item: 50 })
|
||||
setupContainer()
|
||||
render(
|
||||
<Citation data={[
|
||||
makeCitationItem({ document_id: 'shared', segment_id: 'seg-1' }),
|
||||
makeCitationItem({ document_id: 'shared', segment_id: 'seg-2' }),
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should create a separate resource for each distinct document_id', () => {
|
||||
mockClientWidths({ container: 500, item: 50 })
|
||||
setupContainer()
|
||||
render(
|
||||
<Citation data={[
|
||||
makeCitationItem({ document_id: 'doc-a' }),
|
||||
makeCitationItem({ document_id: 'doc-b' }),
|
||||
makeCitationItem({ document_id: 'doc-c' }),
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should handle mixed shared and unique document_ids correctly', () => {
|
||||
mockClientWidths({ container: 500, item: 50 })
|
||||
setupContainer()
|
||||
render(
|
||||
<Citation data={[
|
||||
makeCitationItem({ document_id: 'doc-x', segment_id: 'seg-1' }),
|
||||
makeCitationItem({ document_id: 'doc-y', segment_id: 'seg-2' }),
|
||||
makeCitationItem({ document_id: 'doc-x', segment_id: 'seg-3' }),
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Layout Adjustment – all resources fit', () => {
|
||||
it('should show all popups and no more-toggle when every item fits within container', () => {
|
||||
// effective containerWidth = 840 - 40 = 800; each item = 50px → all 3 fit
|
||||
mockClientWidths({ container: 840, item: 50 })
|
||||
setupContainer()
|
||||
render(
|
||||
<Citation data={[
|
||||
makeCitationItem({ document_id: 'doc-1' }),
|
||||
makeCitationItem({ document_id: 'doc-2' }),
|
||||
makeCitationItem({ document_id: 'doc-3' }),
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByTestId('popup')).toHaveLength(3)
|
||||
expect(screen.queryByTestId('citation-more-toggle')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Layout Adjustment – overflow branch: setLimitNumberInOneLine(i - 1)', () => {
|
||||
it('should show more-toggle when backed-out totalWidth + 34 still exceeds containerWidth', () => {
|
||||
// effective = 140 - 40 = 100
|
||||
// i=0: total=80, 80+0=80 ≤ 100 → limit=1
|
||||
// i=1: total=160, 160+4=164 > 100 → overflow; back-out=80; 80+34=114 > 100 → setLimit(0)
|
||||
// 0 < 2 → toggle shown
|
||||
mockClientWidths({ container: 140, item: 80 })
|
||||
setupContainer()
|
||||
render(
|
||||
<Citation data={[
|
||||
makeCitationItem({ document_id: 'doc-1', document_name: 'Doc A' }),
|
||||
makeCitationItem({ document_id: 'doc-2', document_name: 'Doc B' }),
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('citation-more-toggle')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Layout Adjustment – overflow branch: setLimitNumberInOneLine(i)', () => {
|
||||
it('should show more-toggle and limit=i when backed-out totalWidth + 34 fits within containerWidth', () => {
|
||||
// effective = 240 - 40 = 200
|
||||
// i=0: 80+0=80 ≤ 200 → limit=1
|
||||
// i=1: 160+4=164 ≤ 200 → limit=2
|
||||
// i=2: 240+8=248 > 200 → overflow; back-out=160; 160+34=194 ≤ 200 → setLimit(2)
|
||||
// 2 < 3 → toggle shown; 2 popups visible
|
||||
mockClientWidths({ container: 240, item: 80 })
|
||||
setupContainer()
|
||||
render(
|
||||
<Citation data={[
|
||||
makeCitationItem({ document_id: 'doc-1', document_name: 'Doc A' }),
|
||||
makeCitationItem({ document_id: 'doc-2', document_name: 'Doc B' }),
|
||||
makeCitationItem({ document_id: 'doc-3', document_name: 'Doc C' }),
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('citation-more-toggle')).toBeInTheDocument()
|
||||
expect(screen.getAllByTestId('popup')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Show More / Show Less Toggle', () => {
|
||||
const renderOverflowScenario = () => {
|
||||
// effective = 140 - 40 = 100; items=80px
|
||||
// i=0: 80 ≤ 100 → limit=1
|
||||
// i=1: 160+4=164 > 100 → overflow; back-out=80; 80+34=114 > 100 → setLimit(0)
|
||||
// 0 < 3 → toggle shown; 0 popups visible (slice(0, 0) = [])
|
||||
mockClientWidths({ container: 140, item: 80 })
|
||||
setupContainer()
|
||||
render(
|
||||
<Citation data={[
|
||||
makeCitationItem({ document_id: 'doc-1', document_name: 'Doc A' }),
|
||||
makeCitationItem({ document_id: 'doc-2', document_name: 'Doc B' }),
|
||||
makeCitationItem({ document_id: 'doc-3', document_name: 'Doc C' }),
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
return screen.getByTestId('citation-more-toggle')
|
||||
}
|
||||
|
||||
it('should show the overflow count label matching /+\\s*\\d+/ on the more-toggle in collapsed state', () => {
|
||||
renderOverflowScenario()
|
||||
expect(screen.getByTestId('citation-more-toggle').textContent).toMatch(/^\+\s*\d+$/)
|
||||
})
|
||||
|
||||
it('should display the collapse icon div after clicking more-toggle to expand', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOverflowScenario()
|
||||
|
||||
await user.click(screen.getByTestId('citation-more-toggle'))
|
||||
|
||||
expect(document.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return to the count label after clicking the toggle a second time to collapse', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOverflowScenario()
|
||||
|
||||
await user.click(screen.getByTestId('citation-more-toggle'))
|
||||
await user.click(screen.getByTestId('citation-more-toggle'))
|
||||
|
||||
expect(screen.getByTestId('citation-more-toggle').textContent).toMatch(/^\+\s*\d+$/)
|
||||
})
|
||||
|
||||
it('should show all resource popups after expanding via the more-toggle', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOverflowScenario()
|
||||
|
||||
await user.click(screen.getByTestId('citation-more-toggle'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('popup')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render without crashing when data is an empty array', () => {
|
||||
mockClientWidths({ container: 500, item: 0 })
|
||||
setupContainer()
|
||||
render(<Citation data={[]} />)
|
||||
expect(screen.getByTestId('citation-title')).toBeInTheDocument()
|
||||
expect(screen.queryAllByTestId('citation-measurement-item')).toHaveLength(0)
|
||||
expect(screen.queryByTestId('citation-more-toggle')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render correctly with a single citation item that fits', () => {
|
||||
mockClientWidths({ container: 500, item: 50 })
|
||||
setupContainer()
|
||||
render(<Citation data={[makeCitationItem()]} />)
|
||||
expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(1)
|
||||
expect(screen.queryByTestId('citation-more-toggle')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle all citations sharing one document_id as a single resource', () => {
|
||||
mockClientWidths({ container: 500, item: 50 })
|
||||
setupContainer()
|
||||
render(
|
||||
<Citation data={[
|
||||
makeCitationItem({ document_id: 'only', segment_id: 's1' }),
|
||||
makeCitationItem({ document_id: 'only', segment_id: 's2' }),
|
||||
makeCitationItem({ document_id: 'only', segment_id: 's3' }),
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should handle a large number of citation items without throwing', () => {
|
||||
mockClientWidths({ container: 5000, item: 50 })
|
||||
setupContainer()
|
||||
const data = Array.from({ length: 20 }, (_, i) =>
|
||||
makeCitationItem({ document_id: `doc-${i}`, document_name: `Document ${i}` }))
|
||||
expect(() => render(<Citation data={data} />)).not.toThrow()
|
||||
expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(20)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { FC } from 'react'
|
||||
import type { CitationItem } from '../type'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Popup from './popup'
|
||||
@@ -47,37 +46,36 @@ const Citation: FC<CitationProps> = ({
|
||||
return prev
|
||||
}, []), [data])
|
||||
|
||||
const handleAdjustResourcesLayout = () => {
|
||||
useEffect(() => {
|
||||
const containerWidth = document.querySelector(`.${containerClassName}`)!.clientWidth - 40
|
||||
let totalWidth = 0
|
||||
let limit = 0
|
||||
for (let i = 0; i < resources.length; i++) {
|
||||
totalWidth += elesRef.current[i].clientWidth
|
||||
|
||||
if (totalWidth + i * 4 > containerWidth!) {
|
||||
if (totalWidth + i * 4 > containerWidth) {
|
||||
totalWidth -= elesRef.current[i].clientWidth
|
||||
|
||||
if (totalWidth + 34 > containerWidth!)
|
||||
setLimitNumberInOneLine(i - 1)
|
||||
if (totalWidth + 34 > containerWidth)
|
||||
limit = i - 1
|
||||
else
|
||||
setLimitNumberInOneLine(i)
|
||||
limit = i
|
||||
|
||||
break
|
||||
}
|
||||
else {
|
||||
setLimitNumberInOneLine(i + 1)
|
||||
limit = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
handleAdjustResourcesLayout()
|
||||
setLimitNumberInOneLine(limit)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const resourcesLength = resources.length
|
||||
|
||||
return (
|
||||
<div className="-mb-1 mt-3">
|
||||
<div className="system-xs-medium mb-2 flex items-center text-text-tertiary">
|
||||
<div data-testid="citation-title" className="mb-2 flex items-center text-text-tertiary system-xs-medium">
|
||||
{t('chat.citation.title', { ns: 'common' })}
|
||||
<div className="ml-2 h-px grow bg-divider-regular" />
|
||||
</div>
|
||||
@@ -85,17 +83,18 @@ const Citation: FC<CitationProps> = ({
|
||||
{
|
||||
resources.map((res, index) => (
|
||||
<div
|
||||
key={index}
|
||||
key={res.documentId}
|
||||
data-testid="citation-measurement-item"
|
||||
className="absolute left-0 top-0 -z-10 mb-1 mr-1 h-7 w-auto max-w-[240px] whitespace-nowrap pl-7 pr-2 text-xs opacity-0"
|
||||
ref={(ele: any) => (elesRef.current[index] = ele!)}
|
||||
ref={(ele: HTMLDivElement | null) => { elesRef.current[index] = ele! }}
|
||||
>
|
||||
{res.documentName}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
{
|
||||
resources.slice(0, showMore ? resourcesLength : limitNumberInOneLine).map((res, index) => (
|
||||
<div key={index} className="mb-1 mr-1 cursor-pointer">
|
||||
resources.slice(0, showMore ? resourcesLength : limitNumberInOneLine).map(res => (
|
||||
<div key={res.documentId} className="mb-1 mr-1 cursor-pointer">
|
||||
<Popup
|
||||
data={res}
|
||||
showHitInfo={showHitInfo}
|
||||
@@ -106,13 +105,14 @@ const Citation: FC<CitationProps> = ({
|
||||
{
|
||||
limitNumberInOneLine < resourcesLength && (
|
||||
<div
|
||||
className="system-xs-medium flex h-7 cursor-pointer items-center rounded-lg bg-components-panel-bg px-2 text-text-tertiary"
|
||||
data-testid="citation-more-toggle"
|
||||
className="flex h-7 cursor-pointer items-center rounded-lg bg-components-panel-bg px-2 text-text-tertiary system-xs-medium"
|
||||
onClick={() => setShowMore(v => !v)}
|
||||
>
|
||||
{
|
||||
!showMore
|
||||
? `+ ${resourcesLength - limitNumberInOneLine}`
|
||||
: <RiArrowDownSLine className="h-4 w-4 rotate-180 text-text-tertiary" />
|
||||
: <div className="i-ri-arrow-down-s-line h-4 w-4 rotate-180 text-text-tertiary" />
|
||||
}
|
||||
</div>
|
||||
)
|
||||
|
||||
609
web/app/components/base/chat/chat/citation/popup.spec.tsx
Normal file
609
web/app/components/base/chat/chat/citation/popup.spec.tsx
Normal file
@@ -0,0 +1,609 @@
|
||||
import type { Resources } from './index'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useDocumentDownload } from '@/service/knowledge/use-document'
|
||||
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
import Popup from './popup'
|
||||
|
||||
vi.mock('@/service/knowledge/use-document', () => ({
|
||||
useDocumentDownload: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/file-icon', () => ({
|
||||
default: ({ type }: { type: string }) => <div data-testid="file-icon" data-type={type} />,
|
||||
}))
|
||||
|
||||
vi.mock('./progress-tooltip', () => ({
|
||||
default: ({ data }: { data: number }) => <div data-testid="progress-tooltip">{data}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./tooltip', () => ({
|
||||
default: ({ text, data }: { text: string, data: number | string }) => (
|
||||
<div data-testid="citation-tooltip" data-text={text}>{data}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const mockDownloadDocument = vi.fn()
|
||||
const mockUseDocumentDownload = vi.mocked(useDocumentDownload)
|
||||
const mockDownloadUrl = vi.mocked(downloadUrl)
|
||||
|
||||
const makeSource = (overrides: Partial<Resources['sources'][number]> = {}): Resources['sources'][number] => ({
|
||||
dataset_id: 'ds-1',
|
||||
dataset_name: 'Test Dataset',
|
||||
document_id: 'doc-1',
|
||||
segment_id: 'seg-1',
|
||||
segment_position: 1,
|
||||
content: 'Source content here',
|
||||
word_count: 120,
|
||||
hit_count: 3,
|
||||
index_node_hash: 'abcdef1234567',
|
||||
score: 0.85,
|
||||
data_source_type: 'upload_file',
|
||||
document_name: 'test.pdf',
|
||||
...overrides,
|
||||
} as Resources['sources'][number])
|
||||
|
||||
const makeData = (overrides: Partial<Resources> = {}): Resources => ({
|
||||
documentId: 'doc-1',
|
||||
documentName: 'report.pdf',
|
||||
dataSourceType: 'upload_file',
|
||||
sources: [makeSource()],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const openPopup = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.click(screen.getByTestId('popup-trigger'))
|
||||
}
|
||||
|
||||
describe('Popup', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseDocumentDownload.mockReturnValue({
|
||||
mutateAsync: mockDownloadDocument,
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useDocumentDownload>)
|
||||
})
|
||||
|
||||
describe('Rendering – Trigger', () => {
|
||||
it('should render the trigger element', () => {
|
||||
render(<Popup data={makeData()} />)
|
||||
expect(screen.getByTestId('popup-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show the document name in the trigger', () => {
|
||||
render(<Popup data={makeData({ documentName: 'My Report.pdf' })} />)
|
||||
expect(screen.getByTestId('popup-trigger')).toHaveTextContent('My Report.pdf')
|
||||
})
|
||||
|
||||
it('should pass the extracted file extension to FileIcon for non-notion sources', () => {
|
||||
render(<Popup data={makeData({ documentName: 'report.pdf', dataSourceType: 'upload_file' })} />)
|
||||
expect(screen.getAllByTestId('file-icon')[0]).toHaveAttribute('data-type', 'pdf')
|
||||
})
|
||||
|
||||
it('should pass notion as fileType to FileIcon for notion sources', () => {
|
||||
render(<Popup data={makeData({ documentName: 'Notion Page', dataSourceType: 'notion' })} />)
|
||||
expect(screen.getAllByTestId('file-icon')[0]).toHaveAttribute('data-type', 'notion')
|
||||
})
|
||||
|
||||
it('should pass empty string as fileType when document has no extension', () => {
|
||||
render(<Popup data={makeData({ documentName: 'nodotfile', dataSourceType: 'upload_file' })} />)
|
||||
expect(screen.getAllByTestId('file-icon')[0]).toHaveAttribute('data-type', '')
|
||||
})
|
||||
|
||||
it('should not render popup content before trigger is clicked', () => {
|
||||
render(<Popup data={makeData()} />)
|
||||
expect(screen.queryByTestId('popup-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Popup Open / Close', () => {
|
||||
it('should open the popup content on trigger click', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData()} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getByTestId('popup-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close the popup on second trigger click', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData()} />)
|
||||
|
||||
await openPopup(user)
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.queryByTestId('popup-content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should re-open popup after open → close → open cycle', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData()} />)
|
||||
|
||||
await openPopup(user)
|
||||
await openPopup(user)
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getByTestId('popup-content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Popup Header – Download Button', () => {
|
||||
it('should render download button in header for upload_file dataSourceType with dataset_id', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ dataSourceType: 'upload_file' })} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getByTestId('popup-download-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render download button in header for file dataSourceType with dataset_id', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Popup data={makeData({
|
||||
dataSourceType: 'file',
|
||||
sources: [makeSource({ data_source_type: 'file', dataset_id: 'ds-1' })],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getByTestId('popup-download-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render plain document name in header (no button) for notion type', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Popup data={makeData({
|
||||
documentName: 'Notion Doc',
|
||||
dataSourceType: 'notion',
|
||||
sources: [makeSource({ dataset_id: 'ds-1' })],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.queryByTestId('popup-download-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render plain document name in header when dataset_id is absent', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Popup data={makeData({
|
||||
dataSourceType: 'upload_file',
|
||||
sources: [makeSource({ dataset_id: '' })],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.queryByTestId('popup-download-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable the download button while isDownloading is true', async () => {
|
||||
mockUseDocumentDownload.mockReturnValue({
|
||||
mutateAsync: mockDownloadDocument,
|
||||
isPending: true,
|
||||
} as unknown as ReturnType<typeof useDocumentDownload>)
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData()} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getByTestId('popup-download-btn')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Source Items', () => {
|
||||
it('should render one source item per source entry', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ sources: [makeSource(), makeSource({ segment_id: 'seg-2' })] })} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getAllByTestId('popup-source-item')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should render source content text', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ sources: [makeSource({ content: 'Unique content text' })] })} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getByTestId('popup-source-content')).toHaveTextContent('Unique content text')
|
||||
})
|
||||
|
||||
it('should show segment_position when it is truthy', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ sources: [makeSource({ segment_position: 7 })] })} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getByTestId('popup-segment-position')).toHaveTextContent('7')
|
||||
})
|
||||
|
||||
it('should fall back to index + 1 when segment_position is 0', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ sources: [makeSource({ segment_position: 0 })] })} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getByTestId('popup-segment-position')).toHaveTextContent('1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Source Dividers', () => {
|
||||
it('should render a divider between multiple sources', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Popup data={makeData({
|
||||
sources: [makeSource(), makeSource({ segment_id: 'seg-2' }), makeSource({ segment_id: 'seg-3' })],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getAllByTestId('popup-source-divider')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should not render any divider for a single source', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ sources: [makeSource()] })} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.queryByTestId('popup-source-divider')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render exactly n-1 dividers for n sources', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Popup data={makeData({
|
||||
sources: [
|
||||
makeSource({ segment_id: 's1' }),
|
||||
makeSource({ segment_id: 's2' }),
|
||||
makeSource({ segment_id: 's3' }),
|
||||
makeSource({ segment_id: 's4' }),
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getAllByTestId('popup-source-divider')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('showHitInfo=false (default)', () => {
|
||||
it('should not render the dataset link when showHitInfo is false', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData()} showHitInfo={false} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.queryByTestId('popup-dataset-link')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render hit info section when showHitInfo is false', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData()} showHitInfo={false} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.queryByTestId('popup-hit-info')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render Tooltip components when showHitInfo is false', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData()} showHitInfo={false} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.queryAllByTestId('citation-tooltip')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should not render ProgressTooltip when showHitInfo is false', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData()} showHitInfo={false} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.queryByTestId('progress-tooltip')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('showHitInfo=true', () => {
|
||||
const dataWithScore = makeData({ sources: [makeSource({ score: 0.85 })] })
|
||||
|
||||
it('should render the dataset link when showHitInfo is true', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={dataWithScore} showHitInfo={true} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getByTestId('popup-dataset-link')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the dataset link with correct href', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={dataWithScore} showHitInfo={true} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getByTestId('popup-dataset-link')).toHaveAttribute(
|
||||
'href',
|
||||
`/datasets/${dataWithScore.sources[0].dataset_id}/documents/${dataWithScore.sources[0].document_id}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('should render the linkToDataset i18n key in the link', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={dataWithScore} showHitInfo={true} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getByTestId('popup-dataset-link')).toHaveTextContent(/linkToDataset/i)
|
||||
})
|
||||
|
||||
it('should render hit info section when showHitInfo is true', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={dataWithScore} showHitInfo={true} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getByTestId('popup-hit-info')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render three Tooltip components (characters, hitCount, vectorHash)', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={dataWithScore} showHitInfo={true} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getAllByTestId('citation-tooltip')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should render ProgressTooltip when source score is greater than 0', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ sources: [makeSource({ score: 0.9 })] })} showHitInfo={true} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getByTestId('progress-tooltip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render ProgressTooltip when source score is 0', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ sources: [makeSource({ score: 0 })] })} showHitInfo={true} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.queryByTestId('progress-tooltip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass score rounded to 2 decimal places to ProgressTooltip', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ sources: [makeSource({ score: 0.856 })] })} showHitInfo={true} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getByTestId('progress-tooltip')).toHaveTextContent('0.86')
|
||||
})
|
||||
|
||||
it('should pass word_count to the characters Tooltip', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ sources: [makeSource({ word_count: 250 })] })} showHitInfo={true} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
const tooltips = screen.getAllByTestId('citation-tooltip')
|
||||
expect(tooltips[0]).toHaveTextContent('250')
|
||||
})
|
||||
|
||||
it('should pass hit_count to the hitCount Tooltip', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ sources: [makeSource({ hit_count: 7 })] })} showHitInfo={true} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
const tooltips = screen.getAllByTestId('citation-tooltip')
|
||||
expect(tooltips[1]).toHaveTextContent('7')
|
||||
})
|
||||
|
||||
it('should pass truncated index_node_hash (first 7 chars) to vectorHash Tooltip', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ sources: [makeSource({ index_node_hash: 'abcdef1234567' })] })} showHitInfo={true} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
const tooltips = screen.getAllByTestId('citation-tooltip')
|
||||
expect(tooltips[2]).toHaveTextContent('abcdef1')
|
||||
})
|
||||
|
||||
it('should render hit info for each source when multiple sources are present', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Popup
|
||||
data={makeData({
|
||||
sources: [makeSource({ score: 0.9 }), makeSource({ segment_id: 'seg-2', score: 0.7 })],
|
||||
})}
|
||||
showHitInfo={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getAllByTestId('popup-hit-info')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleDownloadUploadFile', () => {
|
||||
it('should call downloadDocument and downloadUrl on successful download', async () => {
|
||||
mockDownloadDocument.mockResolvedValue({ url: 'https://example.com/file.pdf' })
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ dataSourceType: 'upload_file' })} />)
|
||||
|
||||
await openPopup(user)
|
||||
await user.click(screen.getByTestId('popup-download-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDownloadDocument).toHaveBeenCalledWith({ datasetId: 'ds-1', documentId: 'doc-1' })
|
||||
expect(mockDownloadUrl).toHaveBeenCalledWith({ url: 'https://example.com/file.pdf', fileName: 'report.pdf' })
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call downloadUrl when res.url is absent', async () => {
|
||||
mockDownloadDocument.mockResolvedValue({ url: '' })
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ dataSourceType: 'upload_file' })} />)
|
||||
|
||||
await openPopup(user)
|
||||
await user.click(screen.getByTestId('popup-download-btn'))
|
||||
|
||||
await waitFor(() => expect(mockDownloadDocument).toHaveBeenCalled())
|
||||
expect(mockDownloadUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call downloadDocument when dataSourceType is not upload_file or file', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Popup data={makeData({
|
||||
dataSourceType: 'notion',
|
||||
sources: [makeSource({ dataset_id: 'ds-1' })],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.queryByTestId('popup-download-btn')).not.toBeInTheDocument()
|
||||
expect(mockDownloadDocument).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call downloadDocument when isDownloading is true', async () => {
|
||||
mockUseDocumentDownload.mockReturnValue({
|
||||
mutateAsync: mockDownloadDocument,
|
||||
isPending: true,
|
||||
} as unknown as ReturnType<typeof useDocumentDownload>)
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ dataSourceType: 'upload_file' })} />)
|
||||
|
||||
await openPopup(user)
|
||||
await user.click(screen.getByTestId('popup-download-btn'))
|
||||
|
||||
expect(mockDownloadDocument).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use documentId from data.documentId as priority over sources[0].document_id', async () => {
|
||||
mockDownloadDocument.mockResolvedValue({ url: 'https://example.com/file.pdf' })
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Popup data={makeData({
|
||||
documentId: 'primary-doc-id',
|
||||
dataSourceType: 'upload_file',
|
||||
sources: [makeSource({ document_id: 'fallback-doc-id', dataset_id: 'ds-1' })],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
await openPopup(user)
|
||||
await user.click(screen.getByTestId('popup-download-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDownloadDocument).toHaveBeenCalledWith({ datasetId: 'ds-1', documentId: 'primary-doc-id' })
|
||||
})
|
||||
})
|
||||
|
||||
it('should work with file dataSourceType the same as upload_file', async () => {
|
||||
mockDownloadDocument.mockResolvedValue({ url: 'https://example.com/file.pdf' })
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Popup data={makeData({
|
||||
dataSourceType: 'file',
|
||||
sources: [makeSource({ data_source_type: 'file', dataset_id: 'ds-1' })],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
await openPopup(user)
|
||||
await user.click(screen.getByTestId('popup-download-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDownloadDocument).toHaveBeenCalled()
|
||||
expect(mockDownloadUrl).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call downloadDocument when both data.documentId and sources[0].document_id are empty', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Popup data={makeData({
|
||||
documentId: '',
|
||||
dataSourceType: 'upload_file',
|
||||
sources: [makeSource({ document_id: '', dataset_id: 'ds-1' })],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
await openPopup(user)
|
||||
await user.click(screen.getByTestId('popup-download-btn'))
|
||||
|
||||
expect(mockDownloadDocument).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render without crashing with minimum required props', () => {
|
||||
expect(() => render(<Popup data={makeData()} />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should render without crashing with an empty sources array', () => {
|
||||
expect(() => render(<Popup data={makeData({ sources: [] })} />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should render correctly when source has no score (undefined)', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Popup
|
||||
data={makeData({
|
||||
sources: [makeSource({ score: undefined })],
|
||||
})}
|
||||
showHitInfo={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.queryByTestId('progress-tooltip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render correctly when index_node_hash is undefined', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Popup
|
||||
data={makeData({
|
||||
sources: [makeSource({ index_node_hash: undefined })],
|
||||
})}
|
||||
showHitInfo={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
const tooltips = screen.getAllByTestId('citation-tooltip')
|
||||
expect(tooltips[2]).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,15 +4,6 @@ import Link from 'next/link'
|
||||
import { Fragment, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import FileIcon from '@/app/components/base/file-icon'
|
||||
import { ArrowUpRight } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import {
|
||||
BezierCurve03,
|
||||
TypeSquare,
|
||||
} from '@/app/components/base/icons/src/vender/line/editor'
|
||||
import {
|
||||
Hash02,
|
||||
Target04,
|
||||
} from '@/app/components/base/icons/src/vender/line/general'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
@@ -40,23 +31,16 @@ const Popup: FC<PopupProps> = ({
|
||||
|
||||
const { mutateAsync: downloadDocument, isPending: isDownloading } = useDocumentDownload()
|
||||
|
||||
/**
|
||||
* Download the original uploaded file for citations whose data source is upload-file.
|
||||
* We request a signed URL from the dataset document download endpoint, then trigger browser download.
|
||||
*/
|
||||
const handleDownloadUploadFile = async (e: MouseEvent<HTMLElement>) => {
|
||||
// Prevent toggling the citation popup when user clicks the download link.
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// Only upload-file citations can be downloaded this way (needs dataset/document ids).
|
||||
const isUploadFile = data.dataSourceType === 'upload_file' || data.dataSourceType === 'file'
|
||||
const datasetId = data.sources?.[0]?.dataset_id
|
||||
const documentId = data.documentId || data.sources?.[0]?.document_id
|
||||
if (!isUploadFile || !datasetId || !documentId || isDownloading)
|
||||
return
|
||||
|
||||
// Fetch signed URL (usually points to `/files/<id>/file-preview?...&as_attachment=true`).
|
||||
const res = await downloadDocument({ datasetId, documentId })
|
||||
if (res?.url)
|
||||
downloadUrl({ url: res.url, fileName: data.documentName })
|
||||
@@ -73,22 +57,21 @@ const Popup: FC<PopupProps> = ({
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<div className="flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2">
|
||||
<div data-testid="popup-trigger" className="flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2">
|
||||
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
|
||||
{/* Keep the trigger purely for opening the popup (no download link here). */}
|
||||
<div className="truncate text-xs text-text-tertiary">{data.documentName}</div>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
|
||||
<div className="max-w-[360px] rounded-xl bg-background-section-burn shadow-lg backdrop-blur-[5px]">
|
||||
<div data-testid="popup-content" className="max-w-[360px] rounded-xl bg-background-section-burn shadow-lg backdrop-blur-[5px]">
|
||||
<div className="px-4 pb-2 pt-3">
|
||||
<div className="flex h-[18px] items-center">
|
||||
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
|
||||
<div className="system-xs-medium truncate text-text-tertiary">
|
||||
{/* If it's an upload-file reference, the title becomes a download link. */}
|
||||
<div className="truncate text-text-tertiary system-xs-medium">
|
||||
{(data.dataSourceType === 'upload_file' || data.dataSourceType === 'file') && !!data.sources?.[0]?.dataset_id
|
||||
? (
|
||||
<button
|
||||
data-testid="popup-download-btn"
|
||||
type="button"
|
||||
className="cursor-pointer truncate text-text-tertiary hover:underline"
|
||||
onClick={handleDownloadUploadFile}
|
||||
@@ -104,63 +87,71 @@ const Popup: FC<PopupProps> = ({
|
||||
<div className="max-h-[450px] overflow-y-auto rounded-lg bg-components-panel-bg px-4 py-0.5">
|
||||
<div className="w-full">
|
||||
{
|
||||
data.sources.map((source, index) => (
|
||||
<Fragment key={index}>
|
||||
<div className="group py-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex h-5 items-center rounded-md border border-divider-subtle px-1.5">
|
||||
<Hash02 className="mr-0.5 h-3 w-3 text-text-quaternary" />
|
||||
<div className="text-[11px] font-medium text-text-tertiary">
|
||||
{source.segment_position || index + 1}
|
||||
data.sources.map((source, index) => {
|
||||
const itemKey = source.document_id
|
||||
? `${source.document_id}-${source.segment_position ?? index}`
|
||||
: source.index_node_hash ?? `${data.documentId ?? 'doc'}-${index}`
|
||||
|
||||
return (
|
||||
<Fragment key={itemKey}>
|
||||
<div data-testid="popup-source-item" className="group py-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex h-5 items-center rounded-md border border-divider-subtle px-1.5">
|
||||
{/* replaced svg component with tailwind icon class per lint rule */}
|
||||
<i className="i-custom-vender-line-general-hash-02 mr-0.5 h-3 w-3 text-text-quaternary" aria-hidden />
|
||||
<div data-testid="popup-segment-position" className="text-[11px] font-medium text-text-tertiary">
|
||||
{source.segment_position || index + 1}
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
showHitInfo && (
|
||||
<Link
|
||||
data-testid="popup-dataset-link"
|
||||
href={`/datasets/${source.dataset_id}/documents/${source.document_id}`}
|
||||
className="hidden h-[18px] items-center text-xs text-text-accent group-hover:flex"
|
||||
>
|
||||
{t('chat.citation.linkToDataset', { ns: 'common' })}
|
||||
<i className="i-custom-vender-line-arrows-arrow-up-right ml-1 h-3 w-3" aria-hidden />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div data-testid="popup-source-content" className="break-words text-[13px] text-text-secondary">{source.content}</div>
|
||||
{
|
||||
showHitInfo && (
|
||||
<Link
|
||||
href={`/datasets/${source.dataset_id}/documents/${source.document_id}`}
|
||||
className="hidden h-[18px] items-center text-xs text-text-accent group-hover:flex"
|
||||
>
|
||||
{t('chat.citation.linkToDataset', { ns: 'common' })}
|
||||
<ArrowUpRight className="ml-1 h-3 w-3" />
|
||||
</Link>
|
||||
<div data-testid="popup-hit-info" className="mt-2 flex flex-wrap items-center text-text-quaternary system-xs-medium">
|
||||
<Tooltip
|
||||
text={t('chat.citation.characters', { ns: 'common' })}
|
||||
data={source.word_count}
|
||||
icon={<i className="i-custom-vender-line-editor-type-square mr-1 h-3 w-3" aria-hidden />}
|
||||
/>
|
||||
<Tooltip
|
||||
text={t('chat.citation.hitCount', { ns: 'common' })}
|
||||
data={source.hit_count}
|
||||
icon={<i className="i-custom-vender-line-general-target-04 mr-1 h-3 w-3" aria-hidden />}
|
||||
/>
|
||||
<Tooltip
|
||||
text={t('chat.citation.vectorHash', { ns: 'common' })}
|
||||
data={source.index_node_hash?.substring(0, 7)}
|
||||
icon={<i className="i-custom-vender-line-editor-bezier-curve-03 mr-1 h-3 w-3" aria-hidden />}
|
||||
/>
|
||||
{
|
||||
!!source.score && (
|
||||
<ProgressTooltip data={Number(source.score.toFixed(2))} />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className="break-words text-[13px] text-text-secondary">{source.content}</div>
|
||||
{
|
||||
showHitInfo && (
|
||||
<div className="system-xs-medium mt-2 flex flex-wrap items-center text-text-quaternary">
|
||||
<Tooltip
|
||||
text={t('chat.citation.characters', { ns: 'common' })}
|
||||
data={source.word_count}
|
||||
icon={<TypeSquare className="mr-1 h-3 w-3" />}
|
||||
/>
|
||||
<Tooltip
|
||||
text={t('chat.citation.hitCount', { ns: 'common' })}
|
||||
data={source.hit_count}
|
||||
icon={<Target04 className="mr-1 h-3 w-3" />}
|
||||
/>
|
||||
<Tooltip
|
||||
text={t('chat.citation.vectorHash', { ns: 'common' })}
|
||||
data={source.index_node_hash?.substring(0, 7)}
|
||||
icon={<BezierCurve03 className="mr-1 h-3 w-3" />}
|
||||
/>
|
||||
{
|
||||
!!source.score && (
|
||||
<ProgressTooltip data={Number(source.score.toFixed(2))} />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
index !== data.sources.length - 1 && (
|
||||
<div data-testid="popup-source-divider" className="my-1 h-px bg-divider-regular" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
index !== data.sources.length - 1 && (
|
||||
<div className="my-1 h-px bg-divider-regular" />
|
||||
)
|
||||
}
|
||||
</Fragment>
|
||||
))
|
||||
</Fragment>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import ProgressTooltip from './progress-tooltip'
|
||||
|
||||
describe('ProgressTooltip', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render the trigger content', () => {
|
||||
render(<ProgressTooltip data={0.75} />)
|
||||
expect(screen.getByTestId('progress-trigger-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the data value in the trigger', () => {
|
||||
render(<ProgressTooltip data={0.75} />)
|
||||
expect(screen.getByTestId('progress-trigger-content')).toHaveTextContent('0.75')
|
||||
})
|
||||
|
||||
it('should render the progress bar fill element', () => {
|
||||
render(<ProgressTooltip data={0.5} />)
|
||||
expect(screen.getByTestId('progress-bar-fill')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render the tooltip popup before hovering', () => {
|
||||
render(<ProgressTooltip data={0.5} />)
|
||||
expect(screen.queryByTestId('progress-tooltip-popup')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Progress Bar Width', () => {
|
||||
it('should set fill width to data * 100 percent', () => {
|
||||
render(<ProgressTooltip data={0.75} />)
|
||||
expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '75%' })
|
||||
})
|
||||
|
||||
it('should set fill width to 0% when data is 0', () => {
|
||||
render(<ProgressTooltip data={0} />)
|
||||
expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '0%' })
|
||||
})
|
||||
|
||||
it('should set fill width to 100% when data is 1', () => {
|
||||
render(<ProgressTooltip data={1} />)
|
||||
expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '100%' })
|
||||
})
|
||||
|
||||
it('should set fill width to 50% when data is 0.5', () => {
|
||||
render(<ProgressTooltip data={0.5} />)
|
||||
expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '50%' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tooltip Visibility', () => {
|
||||
it('should show the tooltip popup on mouse enter', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ProgressTooltip data={0.8} />)
|
||||
|
||||
await user.hover(screen.getByTestId('progress-trigger-content'))
|
||||
|
||||
expect(screen.getByTestId('progress-tooltip-popup')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the tooltip popup on mouse leave', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ProgressTooltip data={0.8} />)
|
||||
|
||||
await user.hover(screen.getByTestId('progress-trigger-content'))
|
||||
await user.unhover(screen.getByTestId('progress-trigger-content'))
|
||||
|
||||
expect(screen.queryByTestId('progress-tooltip-popup')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show the hitScore i18n key in the tooltip', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ProgressTooltip data={0.8} />)
|
||||
|
||||
await user.hover(screen.getByTestId('progress-trigger-content'))
|
||||
|
||||
expect(screen.getByTestId('progress-tooltip-popup')).toHaveTextContent(/hitScore/i)
|
||||
})
|
||||
|
||||
it('should show the data value inside the tooltip popup', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ProgressTooltip data={0.8} />)
|
||||
|
||||
await user.hover(screen.getByTestId('progress-trigger-content'))
|
||||
|
||||
expect(screen.getByTestId('progress-tooltip-popup')).toHaveTextContent('0.8')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should render correctly with a small fractional value', () => {
|
||||
render(<ProgressTooltip data={0.12} />)
|
||||
expect(screen.getByTestId('progress-bar-fill').getAttribute('style')).toMatch(/width:\s*12/)
|
||||
expect(screen.getByTestId('progress-trigger-content')).toHaveTextContent('0.12')
|
||||
})
|
||||
|
||||
it('should render correctly with a value close to 1', () => {
|
||||
render(<ProgressTooltip data={0.99} />)
|
||||
expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '99%' })
|
||||
})
|
||||
|
||||
it('should update displayed data when prop changes', () => {
|
||||
const { rerender } = render(<ProgressTooltip data={0.3} />)
|
||||
expect(screen.getByTestId('progress-trigger-content')).toHaveTextContent('0.3')
|
||||
|
||||
rerender(<ProgressTooltip data={0.9} />)
|
||||
expect(screen.getByTestId('progress-trigger-content')).toHaveTextContent('0.9')
|
||||
expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '90%' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render without crashing when data is exactly 0', () => {
|
||||
expect(() => render(<ProgressTooltip data={0} />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should render without crashing when data is exactly 1', () => {
|
||||
expect(() => render(<ProgressTooltip data={1} />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should re-show tooltip after hover → unhover → hover cycle', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ProgressTooltip data={0.5} />)
|
||||
|
||||
await user.hover(screen.getByTestId('progress-trigger-content'))
|
||||
await user.unhover(screen.getByTestId('progress-trigger-content'))
|
||||
await user.hover(screen.getByTestId('progress-trigger-content'))
|
||||
|
||||
expect(screen.getByTestId('progress-tooltip-popup')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep tooltip closed without any interaction', () => {
|
||||
render(<ProgressTooltip data={0.42} />)
|
||||
expect(screen.queryByTestId('progress-tooltip-popup')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not call any external handlers by default', () => {
|
||||
const consoleError = vi.spyOn(console, 'error')
|
||||
render(<ProgressTooltip data={0.5} />)
|
||||
expect(consoleError).not.toHaveBeenCalled()
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -27,15 +27,20 @@ const ProgressTooltip: FC<ProgressTooltipProps> = ({
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
<div className="flex grow items-center">
|
||||
<div data-testid="progress-trigger-content" className="flex grow items-center">
|
||||
<div className="mr-1 h-1.5 w-16 overflow-hidden rounded-[3px] border border-components-progress-gray-border">
|
||||
<div className="h-full bg-components-progress-gray-progress" style={{ width: `${data * 100}%` }}></div>
|
||||
<div
|
||||
data-testid="progress-bar-fill"
|
||||
className="h-full bg-components-progress-gray-progress"
|
||||
style={{ width: `${data * 100}%` }}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{data}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 1001 }}>
|
||||
<div className="system-xs-medium rounded-lg bg-components-tooltip-bg p-3 text-text-quaternary shadow-lg">
|
||||
<div data-testid="progress-tooltip-popup" className="rounded-lg bg-components-tooltip-bg p-3 text-text-quaternary shadow-lg system-xs-medium">
|
||||
{t('chat.citation.hitScore', { ns: 'common' })}
|
||||
{' '}
|
||||
{data}
|
||||
|
||||
155
web/app/components/base/chat/chat/citation/tooltip.spec.tsx
Normal file
155
web/app/components/base/chat/chat/citation/tooltip.spec.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Tooltip from './tooltip'
|
||||
|
||||
const renderTooltip = (data: number | string = 42, text = 'Characters', icon = <span data-testid="mock-icon">icon</span>) =>
|
||||
render(<Tooltip data={data} text={text} icon={icon} />)
|
||||
|
||||
describe('Tooltip', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render the trigger content wrapper', () => {
|
||||
renderTooltip()
|
||||
expect(screen.getByTestId('tooltip-trigger-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the icon inside the trigger', () => {
|
||||
renderTooltip(42, 'Characters', <span data-testid="mock-icon">icon</span>)
|
||||
expect(screen.getByTestId('mock-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a numeric data value in the trigger', () => {
|
||||
renderTooltip(123)
|
||||
expect(screen.getByTestId('tooltip-trigger-content')).toHaveTextContent('123')
|
||||
})
|
||||
|
||||
it('should render a string data value in the trigger', () => {
|
||||
renderTooltip('abc123')
|
||||
expect(screen.getByTestId('tooltip-trigger-content')).toHaveTextContent('abc123')
|
||||
})
|
||||
|
||||
it('should not render the tooltip popup before hovering', () => {
|
||||
renderTooltip()
|
||||
expect(screen.queryByTestId('tooltip-popup')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should render the provided text label when tooltip is open', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTooltip(10, 'Word Count')
|
||||
|
||||
await user.hover(screen.getByTestId('tooltip-trigger-content'))
|
||||
|
||||
expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('Word Count')
|
||||
})
|
||||
|
||||
it('should render the data value inside the tooltip popup', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTooltip(99, 'Hit Count')
|
||||
|
||||
await user.hover(screen.getByTestId('tooltip-trigger-content'))
|
||||
|
||||
expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('99')
|
||||
})
|
||||
|
||||
it('should render a string data value inside the tooltip popup', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTooltip('abc1234', 'Vector Hash')
|
||||
|
||||
await user.hover(screen.getByTestId('tooltip-trigger-content'))
|
||||
|
||||
expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('abc1234')
|
||||
})
|
||||
|
||||
it('should render both text and data together inside the tooltip popup', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTooltip(55, 'Characters')
|
||||
|
||||
await user.hover(screen.getByTestId('tooltip-trigger-content'))
|
||||
|
||||
const popup = screen.getByTestId('tooltip-popup')
|
||||
expect(popup).toHaveTextContent('Characters')
|
||||
expect(popup).toHaveTextContent('55')
|
||||
})
|
||||
|
||||
it('should render any arbitrary ReactNode as icon', () => {
|
||||
render(<Tooltip data={1} text="text" icon={<div data-testid="custom-icon">★</div>} />)
|
||||
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update displayed data when prop changes', () => {
|
||||
const { rerender } = render(<Tooltip data={10} text="Words" icon={<span />} />)
|
||||
expect(screen.getByTestId('tooltip-trigger-content')).toHaveTextContent('10')
|
||||
|
||||
rerender(<Tooltip data={20} text="Words" icon={<span />} />)
|
||||
expect(screen.getByTestId('tooltip-trigger-content')).toHaveTextContent('20')
|
||||
})
|
||||
|
||||
it('should update displayed text in popup when prop changes and tooltip is open', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = render(<Tooltip data={10} text="Original" icon={<span />} />)
|
||||
await user.hover(screen.getByTestId('tooltip-trigger-content'))
|
||||
expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('Original')
|
||||
|
||||
rerender(<Tooltip data={10} text="Updated" icon={<span />} />)
|
||||
expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('Updated')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tooltip Visibility', () => {
|
||||
it('should show the tooltip popup on mouse enter', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTooltip()
|
||||
|
||||
await user.hover(screen.getByTestId('tooltip-trigger-content'))
|
||||
|
||||
expect(screen.getByTestId('tooltip-popup')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the tooltip popup on mouse leave', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTooltip()
|
||||
|
||||
await user.hover(screen.getByTestId('tooltip-trigger-content'))
|
||||
await user.unhover(screen.getByTestId('tooltip-trigger-content'))
|
||||
|
||||
expect(screen.queryByTestId('tooltip-popup')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should re-show tooltip after hover → unhover → hover cycle', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTooltip()
|
||||
|
||||
await user.hover(screen.getByTestId('tooltip-trigger-content'))
|
||||
await user.unhover(screen.getByTestId('tooltip-trigger-content'))
|
||||
await user.hover(screen.getByTestId('tooltip-trigger-content'))
|
||||
|
||||
expect(screen.getByTestId('tooltip-popup')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render without crashing when data is 0', () => {
|
||||
expect(() => render(<Tooltip data={0} text="score" icon={<span />} />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should render without crashing when data is an empty string', () => {
|
||||
expect(() => render(<Tooltip data="" text="label" icon={<span />} />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should render without crashing when text is an empty string', () => {
|
||||
expect(() => render(<Tooltip data={1} text="" icon={<span />} />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should keep tooltip closed without any interaction', () => {
|
||||
renderTooltip(0.5)
|
||||
expect(screen.queryByTestId('tooltip-popup')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render data value 0 in the trigger', () => {
|
||||
render(<Tooltip data={0} text="score" icon={<span />} />)
|
||||
expect(screen.getByTestId('tooltip-trigger-content')).toHaveTextContent('0')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -30,13 +30,13 @@ const Tooltip: FC<TooltipProps> = ({
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
<div className="mr-6 flex items-center">
|
||||
<div data-testid="tooltip-trigger-content" className="mr-6 flex items-center">
|
||||
{icon}
|
||||
{data}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 1001 }}>
|
||||
<div className="system-xs-medium rounded-lg bg-components-tooltip-bg p-3 text-text-quaternary shadow-lg">
|
||||
<div data-testid="tooltip-popup" className="rounded-lg bg-components-tooltip-bg p-3 text-text-quaternary shadow-lg system-xs-medium">
|
||||
{text}
|
||||
{' '}
|
||||
{data}
|
||||
|
||||
79
web/app/components/base/chat/chat/content-switch.spec.tsx
Normal file
79
web/app/components/base/chat/chat/content-switch.spec.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import ContentSwitch from './content-switch'
|
||||
|
||||
describe('ContentSwitch', () => {
|
||||
const defaultProps = {
|
||||
count: 3,
|
||||
currentIndex: 1,
|
||||
prevDisabled: false,
|
||||
nextDisabled: false,
|
||||
switchSibling: vi.fn(),
|
||||
}
|
||||
|
||||
it('renders nothing when count is 1 or less', () => {
|
||||
const { container } = render(<ContentSwitch {...defaultProps} count={1} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('renders nothing when currentIndex is undefined', () => {
|
||||
const { container } = render(<ContentSwitch {...defaultProps} currentIndex={undefined} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('renders correctly with current page and total count', () => {
|
||||
render(<ContentSwitch {...defaultProps} currentIndex={0} count={5} />)
|
||||
expect(screen.getByText(/1[^\n\r/\u2028\u2029]*\/.*5/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls switchSibling with "prev" when left button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const switchSibling = vi.fn()
|
||||
render(<ContentSwitch {...defaultProps} switchSibling={switchSibling} />)
|
||||
|
||||
const prevButton = screen.getByRole('button', { name: /previous/i })
|
||||
await user.click(prevButton)
|
||||
|
||||
expect(switchSibling).toHaveBeenCalledWith('prev')
|
||||
})
|
||||
|
||||
it('calls switchSibling with "next" when right button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const switchSibling = vi.fn()
|
||||
render(<ContentSwitch {...defaultProps} switchSibling={switchSibling} />)
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: /next/i })
|
||||
await user.click(nextButton)
|
||||
|
||||
expect(switchSibling).toHaveBeenCalledWith('next')
|
||||
})
|
||||
|
||||
it('applies disabled styles and prevents clicks when prevDisabled is true', async () => {
|
||||
const user = userEvent.setup()
|
||||
const switchSibling = vi.fn()
|
||||
render(<ContentSwitch {...defaultProps} prevDisabled={true} switchSibling={switchSibling} />)
|
||||
|
||||
const prevButton = screen.getByRole('button', { name: /previous/i })
|
||||
|
||||
expect(prevButton).toHaveClass('opacity-30')
|
||||
expect(prevButton).toBeDisabled()
|
||||
|
||||
await user.click(prevButton)
|
||||
expect(switchSibling).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies disabled styles and prevents clicks when nextDisabled is true', async () => {
|
||||
const user = userEvent.setup()
|
||||
const switchSibling = vi.fn()
|
||||
render(<ContentSwitch {...defaultProps} nextDisabled={true} switchSibling={switchSibling} />)
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: /next/i })
|
||||
|
||||
expect(nextButton).toHaveClass('opacity-30')
|
||||
expect(nextButton).toBeDisabled()
|
||||
|
||||
await user.click(nextButton)
|
||||
expect(switchSibling).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -18,6 +18,7 @@ export default function ContentSwitch({
|
||||
<div className="flex items-center justify-center pt-3.5 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous" // Added for accessibility and testing
|
||||
className={`${prevDisabled ? 'opacity-30' : 'opacity-100'}`}
|
||||
disabled={prevDisabled}
|
||||
onClick={() => !prevDisabled && switchSibling('prev')}
|
||||
@@ -32,6 +33,7 @@ export default function ContentSwitch({
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next" // Added for accessibility and testing
|
||||
className={`${nextDisabled ? 'opacity-30' : 'opacity-100'}`}
|
||||
disabled={nextDisabled}
|
||||
onClick={() => !nextDisabled && switchSibling('next')}
|
||||
|
||||
94
web/app/components/base/chat/chat/context.spec.tsx
Normal file
94
web/app/components/base/chat/chat/context.spec.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { ChatItem } from '../types'
|
||||
import type { ChatContextValue } from './context'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { vi } from 'vitest'
|
||||
import { ChatContextProvider, useChatContext } from './context'
|
||||
|
||||
const TestConsumer = () => {
|
||||
const context = useChatContext()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="isResponding">{String(context.isResponding)}</div>
|
||||
<div data-testid="readonly">{String(context.readonly)}</div>
|
||||
<div data-testid="chatListCount">{context.chatList.length}</div>
|
||||
<div data-testid="questionIcon">{context.questionIcon}</div>
|
||||
<button onClick={() => context.onSend?.('test message', [])}>Send Button</button>
|
||||
<button onClick={() => context.onRegenerate?.({ id: '1' } as ChatItem, { message: 'retry' })}>Regenerate Button</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('ChatContextProvider', () => {
|
||||
const mockOnSend = vi.fn()
|
||||
const mockOnRegenerate = vi.fn()
|
||||
|
||||
const defaultProps: ChatContextValue = {
|
||||
config: {} as ChatContextValue['config'],
|
||||
isResponding: false,
|
||||
chatList: [{ id: '1', content: 'hello' } as ChatItem],
|
||||
showPromptLog: false,
|
||||
questionIcon: <span data-testid="custom-icon">Icon</span>,
|
||||
answerIcon: null,
|
||||
onSend: mockOnSend,
|
||||
onRegenerate: mockOnRegenerate,
|
||||
onAnnotationEdited: vi.fn(),
|
||||
onAnnotationAdded: vi.fn(),
|
||||
onAnnotationRemoved: vi.fn(),
|
||||
disableFeedback: false,
|
||||
onFeedback: vi.fn(),
|
||||
getHumanInputNodeData: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should provide context values to children', () => {
|
||||
render(
|
||||
<ChatContextProvider {...defaultProps} readonly={true}>
|
||||
<TestConsumer />
|
||||
</ChatContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('isResponding')).toHaveTextContent('false')
|
||||
expect(screen.getByTestId('readonly')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('chatListCount')).toHaveTextContent('1')
|
||||
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use default values for optional props', () => {
|
||||
const minimalProps = { ...defaultProps, chatList: undefined as unknown as ChatItem[] }
|
||||
|
||||
render(
|
||||
<ChatContextProvider {...minimalProps}>
|
||||
<TestConsumer />
|
||||
</ChatContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('chatListCount')).toHaveTextContent('0')
|
||||
expect(screen.getByTestId('readonly')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should handle callbacks correctly via the hook', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<ChatContextProvider {...defaultProps}>
|
||||
<TestConsumer />
|
||||
</ChatContextProvider>,
|
||||
)
|
||||
|
||||
const sendBtn = screen.getByRole('button', { name: /send button/i })
|
||||
const regenBtn = screen.getByRole('button', { name: /regenerate button/i })
|
||||
|
||||
await user.click(sendBtn)
|
||||
expect(mockOnSend).toHaveBeenCalledWith('test message', [])
|
||||
|
||||
await user.click(regenBtn)
|
||||
expect(mockOnRegenerate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1' }),
|
||||
expect.objectContaining({ message: 'retry' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
606
web/app/components/base/chat/chat/index.spec.tsx
Normal file
606
web/app/components/base/chat/chat/index.spec.tsx
Normal file
@@ -0,0 +1,606 @@
|
||||
import type { ChatConfig, ChatItem, OnSend } from '../types'
|
||||
import type { ChatProps } from './index'
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Chat from './index'
|
||||
|
||||
// ─── Why each mock exists ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Answer – transitively pulls Markdown (rehype/remark/katex), AgentContent,
|
||||
// WorkflowProcessItem and Operation; none can resolve in jsdom.
|
||||
// Question – pulls Markdown, copy-to-clipboard, react-textarea-autosize.
|
||||
// ChatInputArea – pulls js-audio-recorder (requires Web Audio API unavailable in
|
||||
// jsdom) and VoiceInput / FileContextProvider chains.
|
||||
// PromptLogModal– pulls CopyFeedbackNew and deep modal dep chain.
|
||||
// AgentLogModal – pulls @remixicon/react (causes lint push error), useClickAway
|
||||
// from ahooks, and AgentLogDetail (workflow graph renderer).
|
||||
// es-toolkit/compat – debounce must return a fn with .cancel() or the cleanup
|
||||
// effect throws on unmount.
|
||||
//
|
||||
// NOT mocked (run real):
|
||||
// ChatContextProvider – plain context wrapper, zero side-effects.
|
||||
// TryToAsk – only uses Button (base), Divider (base), i18n (global mock).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('./answer', () => ({
|
||||
default: ({ item, responding }: { item: ChatItem, responding?: boolean }) => (
|
||||
<div
|
||||
data-testid="answer-item"
|
||||
data-id={item.id}
|
||||
data-responding={String(!!responding)}
|
||||
>
|
||||
{item.content}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./question', () => ({
|
||||
default: ({ item }: { item: ChatItem }) => (
|
||||
<div data-testid="question-item" data-id={item.id}>{item.content}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./chat-input-area', () => ({
|
||||
default: ({ disabled, readonly }: { disabled?: boolean, readonly?: boolean }) => (
|
||||
<div
|
||||
data-testid="chat-input-area"
|
||||
data-disabled={String(!!disabled)}
|
||||
data-readonly={String(!!readonly)}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/prompt-log-modal', () => ({
|
||||
default: ({ onCancel }: { onCancel: () => void }) => (
|
||||
<div data-testid="prompt-log-modal">
|
||||
<button data-testid="prompt-log-cancel" onClick={onCancel}>cancel</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/agent-log-modal', () => ({
|
||||
default: ({ onCancel }: { onCancel: () => void }) => (
|
||||
<div data-testid="agent-log-modal">
|
||||
<button data-testid="agent-log-cancel" onClick={onCancel}>cancel</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('es-toolkit/compat', () => ({
|
||||
debounce: (fn: (...args: unknown[]) => void) => {
|
||||
const debounced = (...args: unknown[]) => fn(...args)
|
||||
debounced.cancel = vi.fn()
|
||||
return debounced
|
||||
},
|
||||
}))
|
||||
|
||||
// ─── ResizeObserver capture ───────────────────────────────────────────────────
|
||||
|
||||
type ResizeCallback = (entries: ResizeObserverEntry[], observer: ResizeObserver) => void
|
||||
let capturedResizeCallbacks: ResizeCallback[] = []
|
||||
|
||||
const makeResizeEntry = (blockSize: number, inlineSize: number): ResizeObserverEntry => ({
|
||||
borderBoxSize: [{ blockSize, inlineSize } as ResizeObserverSize],
|
||||
contentBoxSize: [{ blockSize, inlineSize } as ResizeObserverSize],
|
||||
contentRect: new DOMRect(0, 0, inlineSize, blockSize),
|
||||
devicePixelContentBoxSize: [{ blockSize, inlineSize } as ResizeObserverSize],
|
||||
target: document.createElement('div'),
|
||||
})
|
||||
|
||||
// ─── Factories ────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeChatItem = (overrides: Partial<ChatItem> = {}): ChatItem => ({
|
||||
id: `item-${Math.random().toString(36).slice(2)}`,
|
||||
content: 'Test content',
|
||||
isAnswer: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const mockSetCurrentLogItem = vi.fn()
|
||||
const mockSetShowPromptLogModal = vi.fn()
|
||||
const mockSetShowAgentLogModal = vi.fn()
|
||||
|
||||
const baseStoreState = {
|
||||
currentLogItem: undefined,
|
||||
setCurrentLogItem: mockSetCurrentLogItem,
|
||||
showPromptLogModal: false,
|
||||
setShowPromptLogModal: mockSetShowPromptLogModal,
|
||||
showAgentLogModal: false,
|
||||
setShowAgentLogModal: mockSetShowAgentLogModal,
|
||||
}
|
||||
|
||||
const renderChat = (props: Partial<ChatProps> = {}) =>
|
||||
render(<Chat chatList={[]} {...props} />)
|
||||
|
||||
// ─── Suite ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Chat', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
capturedResizeCallbacks = []
|
||||
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
cb(0)
|
||||
return 0
|
||||
})
|
||||
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
private cb: ResizeCallback
|
||||
constructor(cb: ResizeCallback) {
|
||||
this.cb = cb
|
||||
capturedResizeCallbacks.push(cb)
|
||||
}
|
||||
|
||||
observe() { }
|
||||
unobserve() { }
|
||||
disconnect() { }
|
||||
})
|
||||
|
||||
useAppStore.setState(baseStoreState)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing with an empty chatList', () => {
|
||||
renderChat()
|
||||
expect(screen.getByTestId('chat-root')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render chatNode when provided', () => {
|
||||
renderChat({ chatNode: <div data-testid="slot-node">slot</div> })
|
||||
expect(screen.getByTestId('slot-node')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply flex-col to root when isTryApp=true', () => {
|
||||
renderChat({ isTryApp: true })
|
||||
expect(screen.getByTestId('chat-root')).toHaveClass('flex', 'flex-col')
|
||||
})
|
||||
|
||||
it('should not have flex-col when isTryApp is falsy', () => {
|
||||
renderChat({ isTryApp: false })
|
||||
expect(screen.getByTestId('chat-root')).not.toHaveClass('flex-col')
|
||||
})
|
||||
|
||||
it('should apply chatContainerClassName to the scroll container', () => {
|
||||
renderChat({ chatContainerClassName: 'my-custom-class' })
|
||||
expect(screen.getByTestId('chat-container')).toHaveClass('my-custom-class')
|
||||
})
|
||||
|
||||
it('should apply px-8 spacing by default', () => {
|
||||
const { container } = renderChat({ noSpacing: false })
|
||||
expect(container.querySelector('.w-full')).toHaveClass('px-8')
|
||||
})
|
||||
|
||||
it('should omit px-8 when noSpacing=true', () => {
|
||||
const { container } = renderChat({ noSpacing: true })
|
||||
expect(container.querySelector('.w-full')).not.toHaveClass('px-8')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Chat List', () => {
|
||||
it('should render a Question for a non-answer item', () => {
|
||||
renderChat({ chatList: [makeChatItem({ id: 'q1', isAnswer: false })] })
|
||||
expect(screen.getByTestId('question-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render an Answer for an answer item', () => {
|
||||
renderChat({ chatList: [makeChatItem({ id: 'a1', isAnswer: true })] })
|
||||
expect(screen.getByTestId('answer-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render both Question and Answer from a mixed chatList', () => {
|
||||
renderChat({
|
||||
chatList: [
|
||||
makeChatItem({ id: 'q1', isAnswer: false }),
|
||||
makeChatItem({ id: 'a1', isAnswer: true }),
|
||||
],
|
||||
})
|
||||
expect(screen.getByTestId('question-item')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('answer-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass responding=true only to the last answer when isResponding=true', () => {
|
||||
renderChat({
|
||||
isResponding: true,
|
||||
chatList: [
|
||||
makeChatItem({ id: 'q1', isAnswer: false }),
|
||||
makeChatItem({ id: 'a1', isAnswer: true }),
|
||||
makeChatItem({ id: 'q2', isAnswer: false }),
|
||||
makeChatItem({ id: 'a2', isAnswer: true }),
|
||||
],
|
||||
})
|
||||
const answers = screen.getAllByTestId('answer-item')
|
||||
expect(answers[0]).toHaveAttribute('data-responding', 'false')
|
||||
expect(answers[1]).toHaveAttribute('data-responding', 'true')
|
||||
})
|
||||
|
||||
it('should pass responding=false to all answers when isResponding=false', () => {
|
||||
renderChat({
|
||||
isResponding: false,
|
||||
chatList: [
|
||||
makeChatItem({ id: 'a1', isAnswer: true }),
|
||||
makeChatItem({ id: 'a2', isAnswer: true }),
|
||||
],
|
||||
})
|
||||
screen.getAllByTestId('answer-item').forEach(el =>
|
||||
expect(el).toHaveAttribute('data-responding', 'false'),
|
||||
)
|
||||
})
|
||||
|
||||
it('should render correct counts for a long mixed chatList', () => {
|
||||
const chatList = Array.from({ length: 6 }, (_, i) =>
|
||||
makeChatItem({ id: `item-${i}`, isAnswer: i % 2 === 1 }))
|
||||
renderChat({ chatList })
|
||||
expect(screen.getAllByTestId('question-item')).toHaveLength(3)
|
||||
expect(screen.getAllByTestId('answer-item')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Stop Responding Button', () => {
|
||||
it('should show the stop button when isResponding=true and noStopResponding is falsy', () => {
|
||||
renderChat({ isResponding: true, noStopResponding: false })
|
||||
expect(screen.getByTestId('stop-responding-container')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the stop button when noStopResponding=true', () => {
|
||||
renderChat({ isResponding: true, noStopResponding: true })
|
||||
expect(screen.queryByTestId('stop-responding-container')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the stop button when isResponding=false', () => {
|
||||
renderChat({ isResponding: false, noStopResponding: false })
|
||||
expect(screen.queryByTestId('stop-responding-container')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onStopResponding when the stop button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onStopResponding = vi.fn()
|
||||
renderChat({ isResponding: true, noStopResponding: false, onStopResponding })
|
||||
|
||||
await user.click(screen.getByText(/stopResponding/i))
|
||||
|
||||
expect(onStopResponding).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render the stopResponding i18n key', () => {
|
||||
renderChat({ isResponding: true, noStopResponding: false })
|
||||
expect(screen.getByText(/stopResponding/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TryToAsk (real component)', () => {
|
||||
const tryToAskConfig: ChatConfig = {
|
||||
suggested_questions_after_answer: { enabled: true },
|
||||
} as ChatConfig
|
||||
|
||||
const mockOnSend = vi.fn() as unknown as OnSend
|
||||
|
||||
it('should render the tryToAsk i18n key when all conditions are met', () => {
|
||||
renderChat({
|
||||
config: tryToAskConfig,
|
||||
suggestedQuestions: ['What is AI?'],
|
||||
onSend: mockOnSend,
|
||||
})
|
||||
expect(screen.getByText(/tryToAsk/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render each suggested question as a button', () => {
|
||||
renderChat({
|
||||
config: tryToAskConfig,
|
||||
suggestedQuestions: ['First question', 'Second question'],
|
||||
onSend: mockOnSend,
|
||||
})
|
||||
expect(screen.getByText('First question')).toBeInTheDocument()
|
||||
expect(screen.getByText('Second question')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onSend with the question text when a suggestion button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSend = vi.fn() as unknown as OnSend
|
||||
renderChat({
|
||||
config: tryToAskConfig,
|
||||
suggestedQuestions: ['Ask this'],
|
||||
onSend,
|
||||
})
|
||||
|
||||
await user.click(screen.getByText('Ask this'))
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith('Ask this')
|
||||
})
|
||||
|
||||
it('should not render TryToAsk when suggested_questions_after_answer is disabled', () => {
|
||||
renderChat({
|
||||
config: { suggested_questions_after_answer: { enabled: false } } as ChatConfig,
|
||||
suggestedQuestions: ['q1'],
|
||||
onSend: mockOnSend,
|
||||
})
|
||||
expect(screen.queryByText(/tryToAsk/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render TryToAsk when suggestedQuestions is an empty array', () => {
|
||||
renderChat({
|
||||
config: tryToAskConfig,
|
||||
suggestedQuestions: [],
|
||||
onSend: mockOnSend,
|
||||
})
|
||||
expect(screen.queryByText(/tryToAsk/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render TryToAsk when suggestedQuestions is undefined', () => {
|
||||
renderChat({
|
||||
config: tryToAskConfig,
|
||||
suggestedQuestions: undefined,
|
||||
onSend: mockOnSend,
|
||||
})
|
||||
expect(screen.queryByText(/tryToAsk/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render TryToAsk when onSend is undefined', () => {
|
||||
renderChat({
|
||||
config: tryToAskConfig,
|
||||
suggestedQuestions: ['q1'],
|
||||
onSend: undefined,
|
||||
})
|
||||
expect(screen.queryByText(/tryToAsk/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render TryToAsk when config is undefined', () => {
|
||||
renderChat({
|
||||
config: undefined,
|
||||
suggestedQuestions: ['q1'],
|
||||
onSend: mockOnSend,
|
||||
})
|
||||
expect(screen.queryByText(/tryToAsk/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ChatInputArea', () => {
|
||||
it('should render when noChatInput is falsy', () => {
|
||||
renderChat({ noChatInput: false })
|
||||
expect(screen.getByTestId('chat-input-area')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render when noChatInput=true', () => {
|
||||
renderChat({ noChatInput: true })
|
||||
expect(screen.queryByTestId('chat-input-area')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass disabled=true when inputDisabled=true', () => {
|
||||
renderChat({ inputDisabled: true })
|
||||
expect(screen.getByTestId('chat-input-area')).toHaveAttribute('data-disabled', 'true')
|
||||
})
|
||||
|
||||
it('should pass disabled=false when inputDisabled is falsy', () => {
|
||||
renderChat({ inputDisabled: false })
|
||||
expect(screen.getByTestId('chat-input-area')).toHaveAttribute('data-disabled', 'false')
|
||||
})
|
||||
|
||||
it('should pass readonly=true to ChatInputArea when readonly=true', () => {
|
||||
renderChat({ readonly: true })
|
||||
expect(screen.getByTestId('chat-input-area')).toHaveAttribute('data-readonly', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PromptLogModal', () => {
|
||||
it('should render when showPromptLogModal=true and hideLogModal is falsy', () => {
|
||||
useAppStore.setState({ ...baseStoreState, showPromptLogModal: true })
|
||||
renderChat({ hideLogModal: false })
|
||||
expect(screen.getByTestId('prompt-log-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render when showPromptLogModal=false', () => {
|
||||
useAppStore.setState({ ...baseStoreState, showPromptLogModal: false })
|
||||
renderChat()
|
||||
expect(screen.queryByTestId('prompt-log-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render when hideLogModal=true even if showPromptLogModal=true', () => {
|
||||
useAppStore.setState({ ...baseStoreState, showPromptLogModal: true })
|
||||
renderChat({ hideLogModal: true })
|
||||
expect(screen.queryByTestId('prompt-log-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call setCurrentLogItem and setShowPromptLogModal(false) on cancel', async () => {
|
||||
const user = userEvent.setup()
|
||||
useAppStore.setState({ ...baseStoreState, showPromptLogModal: true })
|
||||
renderChat({ hideLogModal: false })
|
||||
|
||||
await user.click(screen.getByTestId('prompt-log-cancel'))
|
||||
|
||||
expect(mockSetCurrentLogItem).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentLogModal', () => {
|
||||
it('should render when showAgentLogModal=true and hideLogModal is falsy', () => {
|
||||
useAppStore.setState({ ...baseStoreState, showAgentLogModal: true })
|
||||
renderChat({ hideLogModal: false })
|
||||
expect(screen.getByTestId('agent-log-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render when showAgentLogModal=false', () => {
|
||||
useAppStore.setState({ ...baseStoreState, showAgentLogModal: false })
|
||||
renderChat()
|
||||
expect(screen.queryByTestId('agent-log-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render when hideLogModal=true even if showAgentLogModal=true', () => {
|
||||
useAppStore.setState({ ...baseStoreState, showAgentLogModal: true })
|
||||
renderChat({ hideLogModal: true })
|
||||
expect(screen.queryByTestId('agent-log-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call setCurrentLogItem and setShowAgentLogModal(false) on cancel', async () => {
|
||||
const user = userEvent.setup()
|
||||
useAppStore.setState({ ...baseStoreState, showAgentLogModal: true })
|
||||
renderChat({ hideLogModal: false })
|
||||
|
||||
await user.click(screen.getByTestId('agent-log-cancel'))
|
||||
|
||||
expect(mockSetCurrentLogItem).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetShowAgentLogModal).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Window Resize', () => {
|
||||
it('should register a resize listener on mount', () => {
|
||||
const addSpy = vi.spyOn(window, 'addEventListener')
|
||||
renderChat()
|
||||
expect(addSpy).toHaveBeenCalledWith('resize', expect.any(Function))
|
||||
})
|
||||
|
||||
it('should remove the resize listener on unmount', () => {
|
||||
const removeSpy = vi.spyOn(window, 'removeEventListener')
|
||||
const { unmount } = renderChat()
|
||||
unmount()
|
||||
expect(removeSpy).toHaveBeenCalledWith('resize', expect.any(Function))
|
||||
})
|
||||
|
||||
it('should not throw when the resize event fires', () => {
|
||||
renderChat()
|
||||
expect(() => window.dispatchEvent(new Event('resize'))).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ResizeObserver Callbacks', () => {
|
||||
it('should set paddingBottom on chatContainer from the footer blockSize', () => {
|
||||
renderChat({
|
||||
chatList: [
|
||||
makeChatItem({ id: 'q1', isAnswer: false }),
|
||||
makeChatItem({ id: 'a1', isAnswer: true }),
|
||||
],
|
||||
})
|
||||
const containerCb = capturedResizeCallbacks[0]
|
||||
if (containerCb) {
|
||||
act(() => containerCb([makeResizeEntry(80, 400)], {} as ResizeObserver))
|
||||
expect(screen.getByTestId('chat-container').style.paddingBottom).toBe('80px')
|
||||
}
|
||||
})
|
||||
|
||||
it('should set footer width from the container inlineSize', () => {
|
||||
renderChat()
|
||||
const footerCb = capturedResizeCallbacks[1]
|
||||
if (footerCb) {
|
||||
act(() => footerCb([makeResizeEntry(50, 600)], {} as ResizeObserver))
|
||||
expect(screen.getByTestId('chat-footer').style.width).toBe('600px')
|
||||
}
|
||||
})
|
||||
|
||||
it('should disconnect both observers on unmount', () => {
|
||||
const disconnectSpy = vi.fn()
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
observe() { }
|
||||
unobserve() { }
|
||||
disconnect = disconnectSpy
|
||||
})
|
||||
const { unmount } = renderChat()
|
||||
unmount()
|
||||
expect(disconnectSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Scroll Behavior', () => {
|
||||
it('should not throw when chatList has 1 item (scroll guard: length > 1 not met)', () => {
|
||||
expect(() => renderChat({ chatList: [makeChatItem({ id: 'q1' })] })).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not throw when a scroll event fires on the container', () => {
|
||||
renderChat()
|
||||
expect(() =>
|
||||
screen.getByTestId('chat-container').dispatchEvent(new Event('scroll')),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('should set userScrolled when distanceToBottom exceeds threshold', () => {
|
||||
renderChat()
|
||||
const container = screen.getByTestId('chat-container')
|
||||
Object.defineProperty(container, 'scrollHeight', { value: 1000, configurable: true })
|
||||
Object.defineProperty(container, 'clientHeight', { value: 400, configurable: true })
|
||||
Object.defineProperty(container, 'scrollTop', { value: 0, configurable: true })
|
||||
expect(() => container.dispatchEvent(new Event('scroll'))).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not set userScrolled when distanceToBottom is within threshold', () => {
|
||||
renderChat()
|
||||
const container = screen.getByTestId('chat-container')
|
||||
Object.defineProperty(container, 'scrollHeight', { value: 500, configurable: true })
|
||||
Object.defineProperty(container, 'clientHeight', { value: 400, configurable: true })
|
||||
Object.defineProperty(container, 'scrollTop', { value: 99, configurable: true })
|
||||
expect(() => container.dispatchEvent(new Event('scroll'))).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ChatList Scroll Reset', () => {
|
||||
it('should not throw with empty chatList (length <= 1 branch)', () => {
|
||||
expect(() => renderChat({ chatList: [] })).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not throw with exactly one item (length <= 1 branch)', () => {
|
||||
expect(() => renderChat({ chatList: [makeChatItem({ id: 'msg-1' })] })).not.toThrow()
|
||||
})
|
||||
|
||||
it('should reset scroll state when the first message ID changes on rerender', () => {
|
||||
const { rerender } = renderChat({
|
||||
chatList: [makeChatItem({ id: 'first' }), makeChatItem({ id: 'second' })],
|
||||
})
|
||||
expect(() =>
|
||||
rerender(<Chat chatList={[makeChatItem({ id: 'new-first' }), makeChatItem({ id: 'new-second' })]} />),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not reset scroll when the first message ID is unchanged', () => {
|
||||
const item1 = makeChatItem({ id: 'stable-id' })
|
||||
const { rerender } = renderChat({ chatList: [item1, makeChatItem({ id: 'second' })] })
|
||||
expect(() =>
|
||||
rerender(<Chat chatList={[item1, makeChatItem({ id: 'third' })]} />),
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sidebar Collapse State', () => {
|
||||
it('should schedule a resize via setTimeout when sidebarCollapseState becomes false', () => {
|
||||
vi.useFakeTimers()
|
||||
const { rerender } = renderChat({ sidebarCollapseState: true })
|
||||
rerender(<Chat chatList={[]} sidebarCollapseState={false} />)
|
||||
expect(() => vi.runAllTimers()).not.toThrow()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should not schedule a resize when sidebarCollapseState stays true', () => {
|
||||
vi.useFakeTimers()
|
||||
renderChat({ sidebarCollapseState: true })
|
||||
expect(() => vi.runAllTimers()).not.toThrow()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render without crashing with no optional props', () => {
|
||||
expect(() => render(<Chat chatList={[]} />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle readonly=true without crashing', () => {
|
||||
expect(() => renderChat({ readonly: true })).not.toThrow()
|
||||
})
|
||||
|
||||
it('should render no modals when both modal flags are false', () => {
|
||||
useAppStore.setState({ ...baseStoreState, showPromptLogModal: false, showAgentLogModal: false })
|
||||
renderChat()
|
||||
expect(screen.queryByTestId('prompt-log-modal')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('agent-log-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render both modals when both flags are true and hideLogModal is false', () => {
|
||||
useAppStore.setState({ ...baseStoreState, showPromptLogModal: true, showAgentLogModal: true })
|
||||
renderChat({ hideLogModal: false })
|
||||
expect(screen.getByTestId('prompt-log-modal')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('agent-log-modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -26,7 +26,6 @@ import { useShallow } from 'zustand/react/shallow'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import AgentLogModal from '@/app/components/base/agent-log-modal'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
|
||||
import PromptLogModal from '@/app/components/base/prompt-log-modal'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Answer from './answer'
|
||||
@@ -188,7 +187,6 @@ const Chat: FC<ChatProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (chatFooterRef.current && chatContainerRef.current) {
|
||||
// container padding bottom
|
||||
const resizeContainerObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { blockSize } = entry.borderBoxSize[0]
|
||||
@@ -198,7 +196,6 @@ const Chat: FC<ChatProps> = ({
|
||||
})
|
||||
resizeContainerObserver.observe(chatFooterRef.current)
|
||||
|
||||
// footer width
|
||||
const resizeFooterObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { inlineSize } = entry.borderBoxSize[0]
|
||||
@@ -237,20 +234,19 @@ const Chat: FC<ChatProps> = ({
|
||||
return () => container.removeEventListener('scroll', setUserScrolled)
|
||||
}, [])
|
||||
|
||||
// Reset user scroll state when conversation changes or a new chat starts
|
||||
// Track the first message ID to detect conversation switches (fixes #29820)
|
||||
const prevFirstMessageIdRef = useRef<string | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
const firstMessageId = chatList[0]?.id
|
||||
// Reset when: new chat (length <= 1) OR conversation switched (first message ID changed)
|
||||
if (chatList.length <= 1 || (firstMessageId && prevFirstMessageIdRef.current !== firstMessageId))
|
||||
userScrolledRef.current = false
|
||||
prevFirstMessageIdRef.current = firstMessageId
|
||||
}, [chatList])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sidebarCollapseState)
|
||||
setTimeout(() => handleWindowResize(), 200)
|
||||
if (!sidebarCollapseState) {
|
||||
const timer = setTimeout(() => handleWindowResize(), 200)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [handleWindowResize, sidebarCollapseState])
|
||||
|
||||
const hasTryToAsk = config?.suggested_questions_after_answer?.enabled && !!suggestedQuestions?.length && onSend
|
||||
@@ -273,8 +269,9 @@ const Chat: FC<ChatProps> = ({
|
||||
onFeedback={onFeedback}
|
||||
getHumanInputNodeData={getHumanInputNodeData}
|
||||
>
|
||||
<div className={cn('relative h-full', isTryApp && 'flex flex-col')}>
|
||||
<div data-testid="chat-root" className={cn('relative h-full', isTryApp && 'flex flex-col')}>
|
||||
<div
|
||||
data-testid="chat-container"
|
||||
ref={chatContainerRef}
|
||||
className={cn('relative h-full overflow-y-auto overflow-x-hidden', isTryApp && 'h-0 grow', chatContainerClassName)}
|
||||
>
|
||||
@@ -323,6 +320,7 @@ const Chat: FC<ChatProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="chat-footer"
|
||||
className={`absolute bottom-0 z-10 flex justify-center bg-chat-input-mask ${(hasTryToAsk || !noChatInput || !noStopResponding) && chatFooterClassName}`}
|
||||
ref={chatFooterRef}
|
||||
>
|
||||
@@ -332,9 +330,10 @@ const Chat: FC<ChatProps> = ({
|
||||
>
|
||||
{
|
||||
!noStopResponding && isResponding && (
|
||||
<div className="mb-2 flex justify-center">
|
||||
<div data-testid="stop-responding-container" className="mb-2 flex justify-center">
|
||||
<Button className="border-components-panel-border bg-components-panel-bg text-components-button-secondary-text" onClick={onStopResponding}>
|
||||
<StopCircle className="mr-[5px] h-3.5 w-3.5" />
|
||||
{/* eslint-disable-next-line tailwindcss/no-unknown-classes */}
|
||||
<div className="i-custom-vender-solid-mediaanddevices-stop-circle mr-[5px] h-3.5 w-3.5" />
|
||||
<span className="text-xs font-normal">{t('operation.stopResponding', { ns: 'appDebug' })}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import LoadingAnim from './index'
|
||||
|
||||
describe('LoadingAnim', () => {
|
||||
it('should render correctly with text type', () => {
|
||||
const { container } = render(<LoadingAnim type="text" />)
|
||||
const element = container.firstChild as HTMLElement
|
||||
|
||||
expect(element).toBeInTheDocument()
|
||||
expect(element.className).toMatch(/dot-flashing/)
|
||||
expect(element.className).toMatch(/text/)
|
||||
})
|
||||
|
||||
it('should render correctly with avatar type', () => {
|
||||
const { container } = render(<LoadingAnim type="avatar" />)
|
||||
const element = container.firstChild as HTMLElement
|
||||
|
||||
expect(element).toBeInTheDocument()
|
||||
expect(element.className).toMatch(/dot-flashing/)
|
||||
expect(element.className).toMatch(/avatar/)
|
||||
})
|
||||
})
|
||||
129
web/app/components/base/chat/chat/log/index.spec.tsx
Normal file
129
web/app/components/base/chat/chat/log/index.spec.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { IChatItem, ThoughtItem } from '@/app/components/base/chat/chat/type'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { vi } from 'vitest'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Log from './index'
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('Log', () => {
|
||||
const mockSetCurrentLogItem = vi.fn()
|
||||
const mockSetShowPromptLogModal = vi.fn()
|
||||
const mockSetShowAgentLogModal = vi.fn()
|
||||
const mockSetShowMessageLogModal = vi.fn()
|
||||
|
||||
const createLogItem = (overrides?: Partial<IChatItem>): IChatItem => ({
|
||||
id: '1',
|
||||
content: 'test',
|
||||
isAnswer: true, // Required per your IChatItem type
|
||||
workflow_run_id: '',
|
||||
agent_thoughts: [],
|
||||
message_files: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(useAppStore).mockImplementation(selector => selector({
|
||||
// State properties
|
||||
appSidebarExpand: 'expand',
|
||||
currentLogModalActiveTab: 'question',
|
||||
showPromptLogModal: false,
|
||||
showAgentLogModal: false,
|
||||
showMessageLogModal: false,
|
||||
showAppConfigureFeaturesModal: false, // Fixed: Added missing required property
|
||||
currentLogItem: null,
|
||||
// Action functions
|
||||
setCurrentLogItem: mockSetCurrentLogItem,
|
||||
setShowPromptLogModal: mockSetShowPromptLogModal,
|
||||
setShowAgentLogModal: mockSetShowAgentLogModal,
|
||||
setShowMessageLogModal: mockSetShowMessageLogModal,
|
||||
} as unknown as Parameters<typeof selector>[0])) // Fixed: Double cast to avoid overlap error
|
||||
})
|
||||
|
||||
it('should render correctly', () => {
|
||||
render(<Log logItem={createLogItem()} />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show message log modal when workflow_run_id exists', async () => {
|
||||
const user = userEvent.setup()
|
||||
const logItem = createLogItem({ workflow_run_id: 'run-123' })
|
||||
|
||||
render(<Log logItem={logItem} />)
|
||||
const container = screen.getByRole('button').parentElement
|
||||
if (container)
|
||||
await user.click(container)
|
||||
|
||||
expect(mockSetCurrentLogItem).toHaveBeenCalledWith(logItem)
|
||||
expect(mockSetShowMessageLogModal).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should show agent log modal when agent_thoughts exists and workflow_run_id is missing', async () => {
|
||||
const user = userEvent.setup()
|
||||
const thought: ThoughtItem = {
|
||||
id: 't1',
|
||||
tool: 'test',
|
||||
thought: 'thinking',
|
||||
tool_input: '',
|
||||
message_id: 'm1',
|
||||
conversation_id: 'c1',
|
||||
observation: '',
|
||||
position: 1,
|
||||
}
|
||||
const logItem = createLogItem({
|
||||
workflow_run_id: '',
|
||||
agent_thoughts: [thought],
|
||||
})
|
||||
|
||||
render(<Log logItem={logItem} />)
|
||||
const container = screen.getByRole('button').parentElement
|
||||
if (container)
|
||||
await user.click(container)
|
||||
|
||||
expect(mockSetCurrentLogItem).toHaveBeenCalledWith(logItem)
|
||||
expect(mockSetShowAgentLogModal).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should show prompt log modal when both workflow_run_id and agent_thoughts are missing', async () => {
|
||||
const user = userEvent.setup()
|
||||
const logItem = createLogItem({
|
||||
workflow_run_id: '',
|
||||
agent_thoughts: [],
|
||||
})
|
||||
|
||||
render(<Log logItem={logItem} />)
|
||||
const container = screen.getByRole('button').parentElement
|
||||
if (container)
|
||||
await user.click(container)
|
||||
|
||||
expect(mockSetCurrentLogItem).toHaveBeenCalledWith(logItem)
|
||||
expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should prevent event propagation on click', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
// 1. Spy on both the standard propagation and the immediate propagation
|
||||
const stopPropagationSpy = vi.spyOn(Event.prototype, 'stopPropagation')
|
||||
const stopImmediatePropagationSpy = vi.spyOn(Event.prototype, 'stopImmediatePropagation')
|
||||
|
||||
render(<Log logItem={createLogItem()} />)
|
||||
|
||||
// Find the container div that has the onClick handler
|
||||
const container = screen.getByRole('button').parentElement
|
||||
|
||||
if (container)
|
||||
await user.click(container)
|
||||
|
||||
// 2. Assert that both were called
|
||||
expect(stopPropagationSpy).toHaveBeenCalled()
|
||||
expect(stopImmediatePropagationSpy).toHaveBeenCalled()
|
||||
|
||||
// 3. Clean up spies (Good practice to avoid interfering with other tests)
|
||||
stopPropagationSpy.mockRestore()
|
||||
stopImmediatePropagationSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
267
web/app/components/base/chat/chat/question.spec.tsx
Normal file
267
web/app/components/base/chat/chat/question.spec.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import type { Theme } from '../embedded-chatbot/theme/theme-context'
|
||||
import type { ChatConfig, ChatItem, OnRegenerate } from '../types'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import * as React from 'react'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
import Toast from '../../toast'
|
||||
import { ThemeBuilder } from '../embedded-chatbot/theme/theme-context'
|
||||
import { ChatContextProvider } from './context'
|
||||
import Question from './question'
|
||||
|
||||
// Global Mocks
|
||||
vi.mock('@react-aria/interactions', () => ({
|
||||
useFocusVisible: () => ({ isFocusVisible: false }),
|
||||
}))
|
||||
vi.mock('copy-to-clipboard', () => ({ default: vi.fn() }))
|
||||
|
||||
// Mock ResizeObserver and capture lifecycle for targeted coverage
|
||||
const observeMock = vi.fn()
|
||||
const unobserveMock = vi.fn()
|
||||
const disconnectMock = vi.fn()
|
||||
let resizeCallback: ResizeObserverCallback | null = null
|
||||
|
||||
class MockResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeCallback = callback
|
||||
}
|
||||
|
||||
observe = observeMock
|
||||
unobserve = unobserveMock
|
||||
disconnect = disconnectMock
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', MockResizeObserver)
|
||||
|
||||
type RenderProps = {
|
||||
theme?: Theme | null
|
||||
questionIcon?: React.ReactNode
|
||||
enableEdit?: boolean
|
||||
switchSibling?: (siblingMessageId: string) => void
|
||||
hideAvatar?: boolean
|
||||
answerIcon?: React.ReactNode
|
||||
}
|
||||
|
||||
const makeItem = (overrides: Partial<ChatItem> = {}): ChatItem => ({
|
||||
id: 'q-1',
|
||||
content: 'This is the question content',
|
||||
message_files: [],
|
||||
siblingCount: 3,
|
||||
siblingIndex: 0,
|
||||
prevSibling: null,
|
||||
nextSibling: 'q-2',
|
||||
...overrides,
|
||||
} as unknown as ChatItem)
|
||||
|
||||
const renderWithProvider = (
|
||||
item: ChatItem,
|
||||
onRegenerate: OnRegenerate = vi.fn() as unknown as OnRegenerate,
|
||||
props: RenderProps = {},
|
||||
) => {
|
||||
return render(
|
||||
<ChatContextProvider
|
||||
config={{} as unknown as (ChatConfig | undefined)}
|
||||
isResponding={false}
|
||||
chatList={[]}
|
||||
showPromptLog={false}
|
||||
questionIcon={props.questionIcon}
|
||||
answerIcon={props.answerIcon}
|
||||
onSend={vi.fn()}
|
||||
onRegenerate={onRegenerate}
|
||||
onAnnotationEdited={vi.fn()}
|
||||
onAnnotationAdded={vi.fn()}
|
||||
onAnnotationRemoved={vi.fn()}
|
||||
disableFeedback={false}
|
||||
onFeedback={vi.fn()}
|
||||
getHumanInputNodeData={vi.fn()}
|
||||
>
|
||||
<Question
|
||||
item={item}
|
||||
theme={props.theme}
|
||||
questionIcon={props.questionIcon}
|
||||
enableEdit={props.enableEdit}
|
||||
switchSibling={props.switchSibling}
|
||||
hideAvatar={props.hideAvatar}
|
||||
/>
|
||||
</ChatContextProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Question component', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resizeCallback = null
|
||||
})
|
||||
|
||||
it('should render the question content container and default avatar when hideAvatar is false', () => {
|
||||
const { container } = renderWithProvider(makeItem())
|
||||
|
||||
const markdown = container.querySelector('.markdown-body')
|
||||
expect(markdown).toBeInTheDocument()
|
||||
|
||||
const avatar = container.querySelector('.h-10.w-10') || container.querySelector('.h-10.w-10.shrink-0')
|
||||
expect(avatar).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should hide avatar when hideAvatar is true', () => {
|
||||
const { container } = renderWithProvider(makeItem(), vi.fn() as unknown as OnRegenerate, { hideAvatar: true })
|
||||
const avatar = container.querySelector('.h-10.w-10')
|
||||
expect(avatar).toBeNull()
|
||||
})
|
||||
|
||||
it('should observe content width resize and update layout accurately', () => {
|
||||
renderWithProvider(makeItem())
|
||||
|
||||
expect(observeMock).toHaveBeenCalled()
|
||||
expect(resizeCallback).not.toBeNull()
|
||||
|
||||
// Mock HTML element clientWidth to trigger logic mapping line coverage
|
||||
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 500 })
|
||||
|
||||
act(() => {
|
||||
if (resizeCallback) {
|
||||
resizeCallback([], {} as ResizeObserver)
|
||||
}
|
||||
})
|
||||
|
||||
const actionContainer = screen.getByTestId('action-container')
|
||||
// 500 width + 8 offset defined in styles
|
||||
expect(actionContainer).toHaveStyle({ right: '508px' })
|
||||
|
||||
// Restore original
|
||||
if (originalClientWidth) {
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth)
|
||||
}
|
||||
})
|
||||
|
||||
it('should disconnect ResizeObserver on component unmount', () => {
|
||||
const { unmount } = renderWithProvider(makeItem())
|
||||
unmount()
|
||||
expect(disconnectMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call copy-to-clipboard and show a toast when copy action is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const toastSpy = vi.spyOn(Toast, 'notify')
|
||||
|
||||
renderWithProvider(makeItem())
|
||||
|
||||
const copyBtn = screen.getByTestId('copy-btn')
|
||||
await user.click(copyBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(copy).toHaveBeenCalledWith('This is the question content')
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not show edit action when enableEdit is false', () => {
|
||||
renderWithProvider(makeItem(), vi.fn() as unknown as OnRegenerate, { enableEdit: false })
|
||||
|
||||
expect(screen.getByTestId('copy-btn')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('edit-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should enter edit mode when edit action clicked, allow editing and call onRegenerate on resend', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onRegenerate = vi.fn() as unknown as OnRegenerate
|
||||
|
||||
renderWithProvider(makeItem(), onRegenerate)
|
||||
|
||||
const editBtn = screen.getByTestId('edit-btn')
|
||||
await user.click(editBtn)
|
||||
|
||||
const textbox = await screen.findByRole('textbox')
|
||||
expect(textbox).toHaveValue('This is the question content')
|
||||
|
||||
await user.clear(textbox)
|
||||
await user.type(textbox, 'Edited question')
|
||||
|
||||
const resendBtn = screen.getByRole('button', { name: /chat.resend/i })
|
||||
await user.click(resendBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRegenerate).toHaveBeenCalledWith(makeItem(), { message: 'Edited question', files: [] })
|
||||
})
|
||||
})
|
||||
|
||||
it('should cancel editing and revert to original markdown when cancel is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = renderWithProvider(makeItem())
|
||||
|
||||
const editBtn = screen.getByTestId('edit-btn')
|
||||
await user.click(editBtn)
|
||||
|
||||
const textbox = await screen.findByRole('textbox')
|
||||
await user.clear(textbox)
|
||||
await user.type(textbox, 'Edited question')
|
||||
|
||||
const cancelBtn = screen.getByRole('button', { name: /operation.cancel/i })
|
||||
await user.click(cancelBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
const md = container.querySelector('.markdown-body')
|
||||
expect(md).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should switch siblings when prev/next buttons are clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const switchSibling = vi.fn()
|
||||
const item = makeItem({ prevSibling: 'q-prev', nextSibling: 'q-next', siblingIndex: 1 })
|
||||
|
||||
renderWithProvider(item, vi.fn() as unknown as OnRegenerate, { switchSibling })
|
||||
|
||||
const prevBtn = screen.getByRole('button', { name: /previous/i })
|
||||
const nextBtn = screen.getByRole('button', { name: /next/i })
|
||||
|
||||
await user.click(prevBtn)
|
||||
await user.click(nextBtn)
|
||||
|
||||
expect(switchSibling).toHaveBeenCalledTimes(2)
|
||||
expect(switchSibling).toHaveBeenCalledWith('q-prev')
|
||||
expect(switchSibling).toHaveBeenCalledWith('q-next')
|
||||
})
|
||||
|
||||
it('should render prev disabled when no prevSibling is provided', () => {
|
||||
const item = makeItem({ prevSibling: undefined, nextSibling: 'q-next', siblingIndex: 0, siblingCount: 2 })
|
||||
renderWithProvider(item, vi.fn() as unknown as OnRegenerate)
|
||||
|
||||
const prevBtn = screen.getByRole('button', { name: /previous/i })
|
||||
const nextBtn = screen.getByRole('button', { name: /next/i })
|
||||
|
||||
expect(prevBtn).toBeDisabled()
|
||||
expect(nextBtn).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should render message files block when message_files provided (audio file branch covered)', () => {
|
||||
const files = [
|
||||
{
|
||||
name: 'audio1.mp3',
|
||||
url: 'https://example.com/audio1.mp3',
|
||||
type: 'audio/mpeg',
|
||||
previewUrl: 'https://example.com/audio1.mp3',
|
||||
size: 1234,
|
||||
} as unknown as FileEntity,
|
||||
]
|
||||
|
||||
renderWithProvider(makeItem({ message_files: files }))
|
||||
|
||||
expect(screen.getByText(/audio1.mp3/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply theme bubble styles when theme provided', () => {
|
||||
const themeBuilder = new ThemeBuilder()
|
||||
themeBuilder.buildTheme('#ff0000', false)
|
||||
const theme = themeBuilder.theme
|
||||
|
||||
renderWithProvider(makeItem(), vi.fn() as unknown as OnRegenerate, { theme })
|
||||
|
||||
const contentContainer = screen.getByTestId('question-content')
|
||||
expect(contentContainer.getAttribute('style')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
} from 'react'
|
||||
import type { Theme } from '../embedded-chatbot/theme/theme-context'
|
||||
import type { ChatItem } from '../types'
|
||||
import { RiClipboardLine, RiEditLine } from '@remixicon/react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import {
|
||||
memo,
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Textarea from 'react-textarea-autosize'
|
||||
import { FileList } from '@/app/components/base/file-uploader'
|
||||
import { User } from '@/app/components/base/icons/src/public/avatar'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import ActionButton from '../../action-button'
|
||||
@@ -107,25 +105,29 @@ const Question: FC<QuestionProps> = ({
|
||||
<div className={cn('group relative mr-4 flex max-w-full items-start overflow-x-hidden pl-14', isEditing && 'flex-1')}>
|
||||
<div className={cn('mr-2 gap-1', isEditing ? 'hidden' : 'flex')}>
|
||||
<div
|
||||
data-testid="action-container"
|
||||
className="absolute hidden gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex"
|
||||
style={{ right: contentWidth + 8 }}
|
||||
>
|
||||
<ActionButton onClick={() => {
|
||||
copy(content)
|
||||
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
|
||||
}}
|
||||
<ActionButton
|
||||
data-testid="copy-btn"
|
||||
onClick={() => {
|
||||
copy(content)
|
||||
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
|
||||
}}
|
||||
>
|
||||
<RiClipboardLine className="h-4 w-4" />
|
||||
<div className="i-ri-clipboard-line h-4 w-4" />
|
||||
</ActionButton>
|
||||
{enableEdit && (
|
||||
<ActionButton onClick={handleEdit}>
|
||||
<RiEditLine className="h-4 w-4" />
|
||||
<ActionButton data-testid="edit-btn" onClick={handleEdit}>
|
||||
<div className="i-ri-edit-line h-4 w-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={contentRef}
|
||||
data-testid="question-content"
|
||||
className="w-full rounded-2xl bg-background-gradient-bg-fill-chat-bubble-bg-3 px-4 py-3 text-sm text-text-primary"
|
||||
style={theme?.chatBubbleColorStyle ? CssTransform(theme.chatBubbleColorStyle) : {}}
|
||||
>
|
||||
@@ -150,7 +152,7 @@ const Question: FC<QuestionProps> = ({
|
||||
<div className="max-h-[158px] overflow-y-auto overflow-x-hidden">
|
||||
<Textarea
|
||||
className={cn(
|
||||
'body-lg-regular w-full p-1 leading-6 text-text-tertiary outline-none',
|
||||
'w-full p-1 leading-6 text-text-tertiary outline-none body-lg-regular',
|
||||
)}
|
||||
autoFocus
|
||||
minRows={1}
|
||||
@@ -181,7 +183,7 @@ const Question: FC<QuestionProps> = ({
|
||||
{
|
||||
questionIcon || (
|
||||
<div className="h-full w-full rounded-full border-[0.5px] border-black/5">
|
||||
<User className="h-full w-full" />
|
||||
<div className="i-custom-public-avatar-user h-full w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
345
web/app/components/base/chat/chat/thought/index.spec.tsx
Normal file
345
web/app/components/base/chat/chat/thought/index.spec.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
import type { ThoughtItem } from '../type'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import Thought from './index'
|
||||
|
||||
describe('Thought', () => {
|
||||
const createThought = (overrides?: Partial<ThoughtItem>): ThoughtItem => ({
|
||||
id: 'test-id',
|
||||
tool: 'test-tool',
|
||||
tool_input: 'test input',
|
||||
observation: 'test output',
|
||||
...overrides,
|
||||
} as ThoughtItem)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render single tool thought in collapsed state', () => {
|
||||
const thought = createThought()
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
expect(screen.getByText(/used/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('test-tool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render multiple tool thoughts from JSON array', () => {
|
||||
const thought = createThought({
|
||||
tool: JSON.stringify(['tool1', 'tool2']),
|
||||
tool_input: JSON.stringify(['input1', 'input2']),
|
||||
observation: JSON.stringify(['output1', 'output2']),
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={false} />)
|
||||
|
||||
expect(screen.getAllByText(/using/i)).toHaveLength(2)
|
||||
expect(screen.getByText('tool1')).toBeInTheDocument()
|
||||
expect(screen.getByText('tool2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show input and output when expanded', async () => {
|
||||
const user = userEvent.setup()
|
||||
const thought = createThought({
|
||||
tool_input: 'test input data',
|
||||
observation: 'test output data',
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
expect(screen.queryByText('test input data')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('test output data')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText(/used/i))
|
||||
|
||||
expect(screen.getByText('test input data')).toBeInTheDocument()
|
||||
expect(screen.getByText('test output data')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should show finished state with correct text', () => {
|
||||
const thought = createThought()
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
expect(screen.getByText(/used/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show in-progress state with correct text', () => {
|
||||
const thought = createThought()
|
||||
|
||||
render(<Thought thought={thought} isFinished={false} />)
|
||||
|
||||
expect(screen.getByText(/using/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tool labels', () => {
|
||||
it('should use tool name when no labels provided', () => {
|
||||
const thought = createThought({
|
||||
tool: 'custom-tool',
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
expect(screen.getByText('custom-tool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fallback to tool name when tool_labels is undefined', () => {
|
||||
const thought = createThought({
|
||||
tool: 'fallback-tool',
|
||||
tool_labels: undefined,
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
expect(screen.getByText('fallback-tool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fallback to tool name when toolName property is missing', () => {
|
||||
const thought = createThought({
|
||||
tool: 'another-tool',
|
||||
tool_labels: {},
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
expect(screen.getByText('another-tool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fallback to tool name when language property is missing', () => {
|
||||
const thought = createThought({
|
||||
tool: 'test-tool',
|
||||
tool_labels: {
|
||||
toolName: {
|
||||
en_US: 'English Label',
|
||||
zh_Hans: '中文标签',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
expect(screen.getByText('test-tool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show knowledge label for dataset tools', () => {
|
||||
const thought = createThought({
|
||||
tool: 'dataset_123',
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
expect(screen.getByText(/knowledge/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Value parsing', () => {
|
||||
it('should handle invalid JSON in tool field', () => {
|
||||
const thought = createThought({
|
||||
tool: 'invalid-json-{',
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
expect(screen.getByText('invalid-json-{')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle non-array JSON in tool field', () => {
|
||||
const thought = createThought({
|
||||
tool: JSON.stringify({ name: 'object-tool' }),
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
expect(screen.getByText('{"name":"object-tool"}')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle invalid JSON in tool_input when parsing array', async () => {
|
||||
const user = userEvent.setup()
|
||||
const thought = createThought({
|
||||
tool: JSON.stringify(['tool1']),
|
||||
tool_input: 'invalid-json-{',
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
await user.click(screen.getByText(/used/i))
|
||||
|
||||
expect(screen.getByText('invalid-json-{')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle invalid JSON in observation when parsing array', async () => {
|
||||
const user = userEvent.setup()
|
||||
const thought = createThought({
|
||||
tool: JSON.stringify(['tool1']),
|
||||
observation: 'invalid-json-[',
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
await user.click(screen.getByText(/used/i))
|
||||
|
||||
expect(screen.getByText('invalid-json-[')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should extract correct values from JSON arrays by index', async () => {
|
||||
const user = userEvent.setup()
|
||||
const thought = createThought({
|
||||
tool: JSON.stringify(['tool1', 'tool2', 'tool3']),
|
||||
tool_input: JSON.stringify(['input1', 'input2', 'input3']),
|
||||
observation: JSON.stringify(['output1', 'output2', 'output3']),
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
const toolSections = screen.getAllByText(/used/i)
|
||||
expect(toolSections).toHaveLength(3)
|
||||
|
||||
await user.click(toolSections[0])
|
||||
expect(screen.getByText('input1')).toBeInTheDocument()
|
||||
expect(screen.getByText('output1')).toBeInTheDocument()
|
||||
|
||||
await user.click(toolSections[1])
|
||||
expect(screen.getByText('input2')).toBeInTheDocument()
|
||||
expect(screen.getByText('output2')).toBeInTheDocument()
|
||||
|
||||
await user.click(toolSections[2])
|
||||
expect(screen.getByText('input3')).toBeInTheDocument()
|
||||
expect(screen.getByText('output3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use original value when isValueArray is false', async () => {
|
||||
const user = userEvent.setup()
|
||||
const thought = createThought({
|
||||
tool: 'single-tool',
|
||||
tool_input: 'regular input',
|
||||
observation: 'regular output',
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
await user.click(screen.getByText(/used/i))
|
||||
|
||||
expect(screen.getByText('regular input')).toBeInTheDocument()
|
||||
expect(screen.getByText('regular output')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User interactions', () => {
|
||||
it('should toggle expand state on click', async () => {
|
||||
const user = userEvent.setup()
|
||||
const thought = createThought({
|
||||
tool_input: 'test input',
|
||||
observation: 'test output',
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
expect(screen.queryByText('test input')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText(/used/i))
|
||||
expect(screen.getByText('test input')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText(/used/i))
|
||||
expect(screen.queryByText('test input')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should expand multiple tools independently', async () => {
|
||||
const user = userEvent.setup()
|
||||
const thought = createThought({
|
||||
tool: JSON.stringify(['tool1', 'tool2']),
|
||||
tool_input: JSON.stringify(['input1', 'input2']),
|
||||
observation: JSON.stringify(['output1', 'output2']),
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
const toolHeaders = screen.getAllByText(/used/i)
|
||||
|
||||
await user.click(toolHeaders[0])
|
||||
expect(screen.getByText('input1')).toBeInTheDocument()
|
||||
expect(screen.queryByText('input2')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(toolHeaders[1])
|
||||
expect(screen.getByText('input1')).toBeInTheDocument()
|
||||
expect(screen.getByText('input2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multiple tools with labels', () => {
|
||||
it('should render multiple tools with dataset prefix', () => {
|
||||
const thought = createThought({
|
||||
tool: JSON.stringify(['dataset_123', 'dataset_456']),
|
||||
tool_input: JSON.stringify(['input1', 'input2']),
|
||||
observation: JSON.stringify(['output1', 'output2']),
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
expect(screen.getAllByText(/knowledge/i)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should handle mixed dataset and regular tools', () => {
|
||||
const thought = createThought({
|
||||
tool: JSON.stringify(['dataset_123', 'regular-tool']),
|
||||
tool_input: JSON.stringify(['input1', 'input2']),
|
||||
observation: JSON.stringify(['output1', 'output2']),
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
expect(screen.getByText(/knowledge/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('regular-tool')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle empty tool_input', async () => {
|
||||
const user = userEvent.setup()
|
||||
const thought = createThought({
|
||||
tool_input: '',
|
||||
observation: 'output',
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
await user.click(screen.getByText(/used/i))
|
||||
|
||||
expect(screen.getByText(/requestTitle/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty observation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const thought = createThought({
|
||||
tool_input: 'input',
|
||||
observation: '',
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
await user.click(screen.getByText(/used/i))
|
||||
|
||||
expect(screen.getByText(/responseTitle/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle JSON array with undefined elements', async () => {
|
||||
const user = userEvent.setup()
|
||||
const thought = createThought({
|
||||
tool: JSON.stringify(['tool1', 'tool2']),
|
||||
tool_input: JSON.stringify(['input1']),
|
||||
observation: JSON.stringify(['output1']),
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
const toolHeaders = screen.getAllByText(/used/i)
|
||||
await user.click(toolHeaders[1])
|
||||
|
||||
expect(screen.getByText('tool2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
102
web/app/components/base/chat/chat/try-to-ask.spec.tsx
Normal file
102
web/app/components/base/chat/chat/try-to-ask.spec.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { OnSend } from '../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import TryToAsk from './try-to-ask'
|
||||
|
||||
describe('TryToAsk', () => {
|
||||
const mockOnSend: OnSend = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders the component with header text', () => {
|
||||
render(
|
||||
<TryToAsk
|
||||
suggestedQuestions={['Question 1']}
|
||||
onSend={mockOnSend}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText(/tryToAsk/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all suggested questions as buttons', () => {
|
||||
const questions = ['What is AI?', 'How does it work?', 'Tell me more']
|
||||
|
||||
render(
|
||||
<TryToAsk
|
||||
suggestedQuestions={questions}
|
||||
onSend={mockOnSend}
|
||||
/>,
|
||||
)
|
||||
|
||||
questions.forEach((question) => {
|
||||
expect(screen.getByRole('button', { name: question })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onSend with the correct question when button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const questions = ['Question 1', 'Question 2', 'Question 3']
|
||||
|
||||
render(
|
||||
<TryToAsk
|
||||
suggestedQuestions={questions}
|
||||
onSend={mockOnSend}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Question 2' }))
|
||||
|
||||
expect(mockOnSend).toHaveBeenCalledTimes(1)
|
||||
expect(mockOnSend).toHaveBeenCalledWith('Question 2')
|
||||
})
|
||||
|
||||
it('calls onSend for each button click', async () => {
|
||||
const user = userEvent.setup()
|
||||
const questions = ['First', 'Second', 'Third']
|
||||
|
||||
render(
|
||||
<TryToAsk
|
||||
suggestedQuestions={questions}
|
||||
onSend={mockOnSend}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'First' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Third' }))
|
||||
|
||||
expect(mockOnSend).toHaveBeenCalledTimes(2)
|
||||
expect(mockOnSend).toHaveBeenNthCalledWith(1, 'First')
|
||||
expect(mockOnSend).toHaveBeenNthCalledWith(2, 'Third')
|
||||
})
|
||||
|
||||
it('renders no buttons when suggestedQuestions is empty', () => {
|
||||
render(
|
||||
<TryToAsk
|
||||
suggestedQuestions={[]}
|
||||
onSend={mockOnSend}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renders single question correctly', async () => {
|
||||
const user = userEvent.setup()
|
||||
const question = 'Single question'
|
||||
|
||||
render(
|
||||
<TryToAsk
|
||||
suggestedQuestions={[question]}
|
||||
onSend={mockOnSend}
|
||||
/>,
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button', { name: question })
|
||||
expect(button).toBeInTheDocument()
|
||||
|
||||
await user.click(button)
|
||||
expect(mockOnSend).toHaveBeenCalledWith(question)
|
||||
})
|
||||
})
|
||||
@@ -1460,26 +1460,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/chat/chat/answer/human-input-content/content-wrapper.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/chat/chat/answer/human-input-content/executed-action.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/base/chat/chat/answer/human-input-content/expiration-time.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/chat/chat/answer/human-input-content/tips.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/base/chat/chat/answer/human-input-content/utils.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -1496,21 +1476,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/chat/chat/answer/more.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/chat/chat/answer/operation.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/chat/chat/answer/suggested-questions.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/chat/chat/answer/tool-detail.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 5
|
||||
@@ -1519,9 +1484,6 @@
|
||||
"app/components/base/chat/chat/answer/workflow-process.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 1
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/chat/chat/chat-input-area/index.tsx": {
|
||||
@@ -1539,27 +1501,6 @@
|
||||
},
|
||||
"app/components/base/chat/chat/citation/index.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 3
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 2
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/chat/chat/citation/popup.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/base/chat/chat/citation/progress-tooltip.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/chat/chat/citation/tooltip.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
@@ -1584,11 +1525,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/base/chat/chat/question.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/chat/chat/try-to-ask.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
|
||||
Reference in New Issue
Block a user