Compare commits

...

6 Commits

8 changed files with 2804 additions and 254 deletions

View File

@@ -0,0 +1,388 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import type { TFunction } from 'i18next'
import ContentSection from './content-section'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import type { SiteInfo } from '@/models/share'
// ============================================================================
// Mock Setup
// ============================================================================
jest.mock('@/app/components/base/chat/chat/answer/workflow-process', () => ({
__esModule: true,
default: ({ data }: { data: WorkflowProcess }) => (
<div data-testid="workflow-process">{data.resultText}</div>
),
}))
jest.mock('@/app/components/base/markdown', () => ({
Markdown: ({ content }: { content: string }) => (
<div data-testid="markdown">{content}</div>
),
}))
jest.mock('./result-tab', () => ({
__esModule: true,
default: ({ currentTab }: { currentTab: string }) => (
<div data-testid="result-tab">{currentTab}</div>
),
}))
// ============================================================================
// Test Utilities
// ============================================================================
const mockT = ((key: string) => key) as TFunction
/**
* Creates base props with sensible defaults for ContentSection testing.
*/
const createBaseProps = (overrides?: Partial<Parameters<typeof ContentSection>[0]>) => ({
depth: 1,
isError: false,
content: 'test content',
currentTab: 'DETAIL' as const,
onSwitchTab: jest.fn(),
showResultTabs: false,
t: mockT,
siteInfo: null,
...overrides,
})
const createWorkflowData = (overrides?: Partial<WorkflowProcess>): WorkflowProcess => ({
status: 'succeeded',
tracing: [],
expand: true,
resultText: 'workflow result',
...overrides,
} as WorkflowProcess)
const createSiteInfo = (overrides?: Partial<SiteInfo>): SiteInfo => ({
title: 'Test Site',
icon: '',
icon_background: '',
description: '',
default_language: 'en',
prompt_public: false,
copyright: '',
privacy_policy: '',
custom_disclaimer: '',
show_workflow_steps: true,
use_icon_as_answer_icon: false,
...overrides,
})
// ============================================================================
// Test Suite
// ============================================================================
describe('ContentSection', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// --------------------------------------------------------------------------
// Basic Rendering Tests
// Tests for basic component rendering scenarios
// --------------------------------------------------------------------------
describe('Basic Rendering', () => {
it('should render markdown content when no workflow data', () => {
render(<ContentSection {...createBaseProps({ content: 'Hello World' })} />)
expect(screen.getByTestId('markdown')).toHaveTextContent('Hello World')
})
it('should not render markdown when content is not a string', () => {
render(<ContentSection {...createBaseProps({ content: { data: 'object' } })} />)
expect(screen.queryByTestId('markdown')).not.toBeInTheDocument()
})
it('should apply rounded styling when not in side panel', () => {
const { container } = render(<ContentSection {...createBaseProps()} />)
expect(container.firstChild).toHaveClass('rounded-2xl')
})
it('should not apply rounded styling when in side panel', () => {
const { container } = render(<ContentSection {...createBaseProps({ inSidePanel: true })} />)
expect(container.firstChild).not.toHaveClass('rounded-2xl')
})
})
// --------------------------------------------------------------------------
// Task ID Display Tests
// Tests for task ID rendering in different contexts
// --------------------------------------------------------------------------
describe('Task ID Display', () => {
it('should show task ID without depth suffix at depth 1', () => {
render(
<ContentSection
{...createBaseProps({
taskId: 'task-123',
depth: 1,
})}
/>,
)
expect(screen.getByText('task-123')).toBeInTheDocument()
})
it('should show task ID with depth suffix at depth > 1', () => {
render(
<ContentSection
{...createBaseProps({
taskId: 'task-123',
depth: 3,
})}
/>,
)
expect(screen.getByText('task-123-2')).toBeInTheDocument()
})
it('should show execution label with task ID', () => {
render(
<ContentSection
{...createBaseProps({
taskId: 'task-abc',
})}
/>,
)
expect(screen.getByText('share.generation.execution')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Error State Tests
// Tests for error state display
// --------------------------------------------------------------------------
describe('Error State', () => {
it('should show error message when isError is true', () => {
render(<ContentSection {...createBaseProps({ isError: true })} />)
expect(screen.getByText('share.generation.batchFailed.outputPlaceholder')).toBeInTheDocument()
})
it('should not show markdown content when isError is true', () => {
render(<ContentSection {...createBaseProps({ isError: true, content: 'should not show' })} />)
expect(screen.queryByTestId('markdown')).not.toBeInTheDocument()
})
it('should apply error styling to task ID when isError is true', () => {
render(
<ContentSection
{...createBaseProps({
taskId: 'error-task',
isError: true,
})}
/>,
)
const executionText = screen.getByText('share.generation.execution').parentElement
expect(executionText).toHaveClass('text-text-destructive')
})
})
// --------------------------------------------------------------------------
// Workflow Process Tests
// Tests for workflow-specific rendering
// --------------------------------------------------------------------------
describe('Workflow Process', () => {
it('should render WorkflowProcessItem when workflowProcessData and siteInfo are present', () => {
render(
<ContentSection
{...createBaseProps({
workflowProcessData: createWorkflowData(),
siteInfo: createSiteInfo(),
})}
/>,
)
expect(screen.getByTestId('workflow-process')).toBeInTheDocument()
})
it('should not render WorkflowProcessItem when siteInfo is null', () => {
render(
<ContentSection
{...createBaseProps({
workflowProcessData: createWorkflowData(),
siteInfo: null,
})}
/>,
)
expect(screen.queryByTestId('workflow-process')).not.toBeInTheDocument()
})
it('should show task ID within workflow section', () => {
render(
<ContentSection
{...createBaseProps({
workflowProcessData: createWorkflowData(),
taskId: 'wf-task-123',
siteInfo: createSiteInfo(),
})}
/>,
)
expect(screen.getByText('wf-task-123')).toBeInTheDocument()
})
it('should not render ResultTab when isError is true', () => {
render(
<ContentSection
{...createBaseProps({
workflowProcessData: createWorkflowData(),
siteInfo: createSiteInfo(),
isError: true,
})}
/>,
)
expect(screen.queryByTestId('result-tab')).not.toBeInTheDocument()
})
it('should render ResultTab when not error', () => {
render(
<ContentSection
{...createBaseProps({
workflowProcessData: createWorkflowData(),
siteInfo: createSiteInfo(),
isError: false,
})}
/>,
)
expect(screen.getByTestId('result-tab')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Result Tabs Tests
// Tests for the result/detail tab switching
// --------------------------------------------------------------------------
describe('Result Tabs', () => {
it('should show tabs when showResultTabs is true', () => {
render(
<ContentSection
{...createBaseProps({
workflowProcessData: createWorkflowData(),
siteInfo: createSiteInfo(),
showResultTabs: true,
})}
/>,
)
expect(screen.getByText('runLog.result')).toBeInTheDocument()
expect(screen.getByText('runLog.detail')).toBeInTheDocument()
})
it('should not show tabs when showResultTabs is false', () => {
render(
<ContentSection
{...createBaseProps({
workflowProcessData: createWorkflowData(),
siteInfo: createSiteInfo(),
showResultTabs: false,
})}
/>,
)
expect(screen.queryByText('runLog.result')).not.toBeInTheDocument()
expect(screen.queryByText('runLog.detail')).not.toBeInTheDocument()
})
it('should call onSwitchTab when RESULT tab is clicked', () => {
const onSwitchTab = jest.fn()
render(
<ContentSection
{...createBaseProps({
workflowProcessData: createWorkflowData(),
siteInfo: createSiteInfo(),
showResultTabs: true,
onSwitchTab,
})}
/>,
)
fireEvent.click(screen.getByText('runLog.result'))
expect(onSwitchTab).toHaveBeenCalledWith('RESULT')
})
it('should call onSwitchTab when DETAIL tab is clicked', () => {
const onSwitchTab = jest.fn()
render(
<ContentSection
{...createBaseProps({
workflowProcessData: createWorkflowData(),
siteInfo: createSiteInfo(),
showResultTabs: true,
onSwitchTab,
})}
/>,
)
fireEvent.click(screen.getByText('runLog.detail'))
expect(onSwitchTab).toHaveBeenCalledWith('DETAIL')
})
it('should highlight active tab', () => {
render(
<ContentSection
{...createBaseProps({
workflowProcessData: createWorkflowData(),
siteInfo: createSiteInfo(),
showResultTabs: true,
currentTab: 'RESULT',
})}
/>,
)
const resultTab = screen.getByText('runLog.result')
expect(resultTab).toHaveClass('text-text-primary')
})
})
// --------------------------------------------------------------------------
// Boundary Conditions Tests
// Tests for edge cases and boundary values
// --------------------------------------------------------------------------
describe('Boundary Conditions', () => {
it('should handle empty string content', () => {
render(<ContentSection {...createBaseProps({ content: '' })} />)
expect(screen.getByTestId('markdown')).toHaveTextContent('')
})
it('should handle null siteInfo', () => {
render(
<ContentSection
{...createBaseProps({
workflowProcessData: createWorkflowData(),
siteInfo: null,
})}
/>,
)
// Should not crash and WorkflowProcessItem should not render
expect(screen.queryByTestId('workflow-process')).not.toBeInTheDocument()
})
it('should handle undefined workflowProcessData', () => {
render(<ContentSection {...createBaseProps({ workflowProcessData: undefined })} />)
expect(screen.queryByTestId('workflow-process')).not.toBeInTheDocument()
})
it('should handle show_workflow_steps false in siteInfo', () => {
render(
<ContentSection
{...createBaseProps({
workflowProcessData: createWorkflowData(),
siteInfo: createSiteInfo({ show_workflow_steps: false }),
})}
/>,
)
expect(screen.getByTestId('workflow-process')).toBeInTheDocument()
})
it('should handle hideProcessDetail prop', () => {
render(
<ContentSection
{...createBaseProps({
workflowProcessData: createWorkflowData(),
siteInfo: createSiteInfo(),
hideProcessDetail: true,
})}
/>,
)
// Component should still render
expect(screen.getByTestId('workflow-process')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,112 @@
import type { FC } from 'react'
import type { TFunction } from 'i18next'
import { RiPlayList2Line } from '@remixicon/react'
import WorkflowProcessItem from '@/app/components/base/chat/chat/answer/workflow-process'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import { Markdown } from '@/app/components/base/markdown'
import type { SiteInfo } from '@/models/share'
import cn from '@/utils/classnames'
import ResultTab from './result-tab'
type ContentSectionProps = {
workflowProcessData?: WorkflowProcess
taskId?: string
depth: number
isError: boolean
content: any
hideProcessDetail?: boolean
siteInfo: SiteInfo | null
currentTab: 'DETAIL' | 'RESULT'
onSwitchTab: (tab: 'DETAIL' | 'RESULT') => void
showResultTabs: boolean
t: TFunction
inSidePanel?: boolean
}
const ContentSection: FC<ContentSectionProps> = ({
workflowProcessData,
taskId,
depth,
isError,
content,
hideProcessDetail,
siteInfo,
currentTab,
onSwitchTab,
showResultTabs,
t,
inSidePanel,
}) => {
return (
<div className={cn(
'relative',
!inSidePanel && 'rounded-2xl border-t border-divider-subtle bg-chat-bubble-bg',
)}>
{workflowProcessData && (
<>
<div className={cn(
'p-3',
showResultTabs && 'border-b border-divider-subtle',
)}>
{taskId && (
<div className={cn('system-2xs-medium-uppercase mb-2 flex items-center text-text-accent-secondary', isError && 'text-text-destructive')}>
<RiPlayList2Line className='mr-1 h-3 w-3' />
<span>{t('share.generation.execution')}</span>
<span className='px-1'>·</span>
<span>{taskId}</span>
</div>
)}
{siteInfo && workflowProcessData && (
<WorkflowProcessItem
data={workflowProcessData}
expand={workflowProcessData.expand}
hideProcessDetail={hideProcessDetail}
hideInfo={hideProcessDetail}
readonly={!siteInfo.show_workflow_steps}
/>
)}
{showResultTabs && (
<div className='flex items-center space-x-6 px-1'>
<div
className={cn(
'system-sm-semibold-uppercase cursor-pointer border-b-2 border-transparent py-3 text-text-tertiary',
currentTab === 'RESULT' && 'border-util-colors-blue-brand-blue-brand-600 text-text-primary',
)}
onClick={() => onSwitchTab('RESULT')}
>{t('runLog.result')}</div>
<div
className={cn(
'system-sm-semibold-uppercase cursor-pointer border-b-2 border-transparent py-3 text-text-tertiary',
currentTab === 'DETAIL' && 'border-util-colors-blue-brand-blue-brand-600 text-text-primary',
)}
onClick={() => onSwitchTab('DETAIL')}
>{t('runLog.detail')}</div>
</div>
)}
</div>
{!isError && (
<ResultTab data={workflowProcessData} content={content} currentTab={currentTab} />
)}
</>
)}
{!workflowProcessData && taskId && (
<div className={cn('system-2xs-medium-uppercase sticky left-0 top-0 flex w-full items-center rounded-t-2xl bg-components-actionbar-bg p-4 pb-3 text-text-accent-secondary', isError && 'text-text-destructive')}>
<RiPlayList2Line className='mr-1 h-3 w-3' />
<span>{t('share.generation.execution')}</span>
<span className='px-1'>·</span>
<span>{`${taskId}${depth > 1 ? `-${depth - 1}` : ''}`}</span>
</div>
)}
{isError && (
<div className='body-lg-regular p-4 pt-0 text-text-quaternary'>{t('share.generation.batchFailed.outputPlaceholder')}</div>
)}
{!workflowProcessData && !isError && (typeof content === 'string') && (
<div className={cn('p-4', taskId && 'pt-0')}>
<Markdown content={content} />
</div>
)}
</div>
)
}
export default ContentSection

View File

@@ -0,0 +1,411 @@
import { act, renderHook } from '@testing-library/react'
import { useMoreLikeThisState, useWorkflowTabs } from './hooks'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
// ============================================================================
// useMoreLikeThisState Tests
// ============================================================================
describe('useMoreLikeThisState', () => {
// --------------------------------------------------------------------------
// Initial State Tests
// Tests verifying the hook's initial state values
// --------------------------------------------------------------------------
describe('Initial State', () => {
it('should initialize with empty completionRes', () => {
const { result } = renderHook(() => useMoreLikeThisState({}))
expect(result.current.completionRes).toBe('')
})
it('should initialize with null childMessageId', () => {
const { result } = renderHook(() => useMoreLikeThisState({}))
expect(result.current.childMessageId).toBeNull()
})
it('should initialize with null rating in childFeedback', () => {
const { result } = renderHook(() => useMoreLikeThisState({}))
expect(result.current.childFeedback).toEqual({ rating: null })
})
it('should initialize isQuerying as false', () => {
const { result } = renderHook(() => useMoreLikeThisState({}))
expect(result.current.isQuerying).toBe(false)
})
})
// --------------------------------------------------------------------------
// State Setter Tests
// Tests for state update functions
// --------------------------------------------------------------------------
describe('State Setters', () => {
it('should update completionRes when setCompletionRes is called', () => {
const { result } = renderHook(() => useMoreLikeThisState({}))
act(() => {
result.current.setCompletionRes('new response')
})
expect(result.current.completionRes).toBe('new response')
})
it('should update childMessageId when setChildMessageId is called', () => {
const { result } = renderHook(() => useMoreLikeThisState({}))
act(() => {
result.current.setChildMessageId('child-123')
})
expect(result.current.childMessageId).toBe('child-123')
})
it('should update childFeedback when setChildFeedback is called', () => {
const { result } = renderHook(() => useMoreLikeThisState({}))
act(() => {
result.current.setChildFeedback({ rating: 'like' })
})
expect(result.current.childFeedback).toEqual({ rating: 'like' })
})
it('should set isQuerying to true when startQuerying is called', () => {
const { result } = renderHook(() => useMoreLikeThisState({}))
act(() => {
result.current.startQuerying()
})
expect(result.current.isQuerying).toBe(true)
})
it('should set isQuerying to false when stopQuerying is called', () => {
const { result } = renderHook(() => useMoreLikeThisState({}))
act(() => {
result.current.startQuerying()
})
expect(result.current.isQuerying).toBe(true)
act(() => {
result.current.stopQuerying()
})
expect(result.current.isQuerying).toBe(false)
})
})
// --------------------------------------------------------------------------
// controlClearMoreLikeThis Effect Tests
// Tests for the clear effect triggered by controlClearMoreLikeThis
// --------------------------------------------------------------------------
describe('controlClearMoreLikeThis Effect', () => {
it('should clear childMessageId when controlClearMoreLikeThis changes to truthy value', () => {
const { result, rerender } = renderHook(
({ controlClearMoreLikeThis }: { controlClearMoreLikeThis?: number }) =>
useMoreLikeThisState({ controlClearMoreLikeThis }),
{ initialProps: { controlClearMoreLikeThis: undefined as number | undefined } },
)
act(() => {
result.current.setChildMessageId('child-to-clear')
result.current.setCompletionRes('response-to-clear')
})
expect(result.current.childMessageId).toBe('child-to-clear')
expect(result.current.completionRes).toBe('response-to-clear')
rerender({ controlClearMoreLikeThis: 1 })
expect(result.current.childMessageId).toBeNull()
expect(result.current.completionRes).toBe('')
})
it('should not clear state when controlClearMoreLikeThis is 0', () => {
const { result, rerender } = renderHook(
({ controlClearMoreLikeThis }: { controlClearMoreLikeThis?: number }) =>
useMoreLikeThisState({ controlClearMoreLikeThis }),
{ initialProps: { controlClearMoreLikeThis: undefined as number | undefined } },
)
act(() => {
result.current.setChildMessageId('keep-this')
result.current.setCompletionRes('keep-response')
})
rerender({ controlClearMoreLikeThis: 0 })
expect(result.current.childMessageId).toBe('keep-this')
expect(result.current.completionRes).toBe('keep-response')
})
it('should clear state when controlClearMoreLikeThis increments', () => {
const { result, rerender } = renderHook(
({ controlClearMoreLikeThis }) => useMoreLikeThisState({ controlClearMoreLikeThis }),
{ initialProps: { controlClearMoreLikeThis: 1 } },
)
act(() => {
result.current.setChildMessageId('will-clear')
})
rerender({ controlClearMoreLikeThis: 2 })
expect(result.current.childMessageId).toBeNull()
})
})
// --------------------------------------------------------------------------
// isLoading Effect Tests
// Tests for the effect triggered by isLoading changes
// --------------------------------------------------------------------------
describe('isLoading Effect', () => {
it('should clear childMessageId when isLoading becomes true', () => {
const { result, rerender } = renderHook(
({ isLoading }) => useMoreLikeThisState({ isLoading }),
{ initialProps: { isLoading: false } },
)
act(() => {
result.current.setChildMessageId('child-during-load')
})
expect(result.current.childMessageId).toBe('child-during-load')
rerender({ isLoading: true })
expect(result.current.childMessageId).toBeNull()
})
it('should not clear childMessageId when isLoading is false', () => {
const { result, rerender } = renderHook(
({ isLoading }) => useMoreLikeThisState({ isLoading }),
{ initialProps: { isLoading: true } },
)
act(() => {
result.current.setChildMessageId('keep-child')
})
rerender({ isLoading: false })
// childMessageId was already cleared when isLoading was true initially
// Set it again after isLoading is false
act(() => {
result.current.setChildMessageId('keep-child-2')
})
expect(result.current.childMessageId).toBe('keep-child-2')
})
it('should not affect completionRes when isLoading changes', () => {
const { result, rerender } = renderHook(
({ isLoading }) => useMoreLikeThisState({ isLoading }),
{ initialProps: { isLoading: false } },
)
act(() => {
result.current.setCompletionRes('my response')
})
rerender({ isLoading: true })
expect(result.current.completionRes).toBe('my response')
})
})
// --------------------------------------------------------------------------
// Boundary Conditions Tests
// Tests for edge cases and boundary values
// --------------------------------------------------------------------------
describe('Boundary Conditions', () => {
it('should handle undefined parameters', () => {
const { result } = renderHook(() =>
useMoreLikeThisState({ controlClearMoreLikeThis: undefined, isLoading: undefined }),
)
expect(result.current.childMessageId).toBeNull()
expect(result.current.completionRes).toBe('')
})
it('should handle empty string for completionRes', () => {
const { result } = renderHook(() => useMoreLikeThisState({}))
act(() => {
result.current.setCompletionRes('')
})
expect(result.current.completionRes).toBe('')
})
it('should handle multiple rapid state changes', () => {
const { result } = renderHook(() => useMoreLikeThisState({}))
act(() => {
result.current.setChildMessageId('first')
result.current.setChildMessageId('second')
result.current.setChildMessageId('third')
})
expect(result.current.childMessageId).toBe('third')
})
})
})
// ============================================================================
// useWorkflowTabs Tests
// ============================================================================
describe('useWorkflowTabs', () => {
// --------------------------------------------------------------------------
// Initial State Tests
// Tests verifying the hook's initial state based on workflowProcessData
// --------------------------------------------------------------------------
describe('Initial State', () => {
it('should initialize currentTab to DETAIL when no workflowProcessData', () => {
const { result } = renderHook(() => useWorkflowTabs(undefined))
expect(result.current.currentTab).toBe('DETAIL')
})
it('should initialize showResultTabs to false when no workflowProcessData', () => {
const { result } = renderHook(() => useWorkflowTabs(undefined))
expect(result.current.showResultTabs).toBe(false)
})
it('should set currentTab to RESULT when resultText is present', () => {
const workflowData = { resultText: 'some result' } as WorkflowProcess
const { result } = renderHook(() => useWorkflowTabs(workflowData))
expect(result.current.currentTab).toBe('RESULT')
})
it('should set currentTab to RESULT when files array has items', () => {
const workflowData = { files: [{ id: 'file-1' }] } as WorkflowProcess
const { result } = renderHook(() => useWorkflowTabs(workflowData))
expect(result.current.currentTab).toBe('RESULT')
})
it('should set showResultTabs to true when resultText is present', () => {
const workflowData = { resultText: 'result' } as WorkflowProcess
const { result } = renderHook(() => useWorkflowTabs(workflowData))
expect(result.current.showResultTabs).toBe(true)
})
it('should set showResultTabs to true when files array has items', () => {
const workflowData = { files: [{ id: 'file-1' }] } as WorkflowProcess
const { result } = renderHook(() => useWorkflowTabs(workflowData))
expect(result.current.showResultTabs).toBe(true)
})
})
// --------------------------------------------------------------------------
// Tab Switching Tests
// Tests for manual tab switching functionality
// --------------------------------------------------------------------------
describe('Tab Switching', () => {
it('should allow switching to DETAIL tab', () => {
const workflowData = { resultText: 'result' } as WorkflowProcess
const { result } = renderHook(() => useWorkflowTabs(workflowData))
expect(result.current.currentTab).toBe('RESULT')
act(() => {
result.current.setCurrentTab('DETAIL')
})
expect(result.current.currentTab).toBe('DETAIL')
})
it('should allow switching to RESULT tab', () => {
const { result } = renderHook(() => useWorkflowTabs(undefined))
act(() => {
result.current.setCurrentTab('RESULT')
})
expect(result.current.currentTab).toBe('RESULT')
})
})
// --------------------------------------------------------------------------
// Dynamic Data Change Tests
// Tests for behavior when workflowProcessData changes
// --------------------------------------------------------------------------
describe('Dynamic Data Changes', () => {
it('should update currentTab when resultText becomes available', () => {
const { result, rerender } = renderHook(
({ data }) => useWorkflowTabs(data),
{ initialProps: { data: undefined as WorkflowProcess | undefined } },
)
expect(result.current.currentTab).toBe('DETAIL')
rerender({ data: { resultText: 'new result' } as WorkflowProcess })
expect(result.current.currentTab).toBe('RESULT')
})
it('should update currentTab when resultText is removed', () => {
const { result, rerender } = renderHook(
({ data }) => useWorkflowTabs(data),
{ initialProps: { data: { resultText: 'result' } as WorkflowProcess } },
)
expect(result.current.currentTab).toBe('RESULT')
rerender({ data: { resultText: '' } as WorkflowProcess })
expect(result.current.currentTab).toBe('DETAIL')
})
it('should update showResultTabs when files array changes', () => {
const { result, rerender } = renderHook(
({ data }) => useWorkflowTabs(data),
{ initialProps: { data: { files: [] } as unknown as WorkflowProcess } },
)
expect(result.current.showResultTabs).toBe(false)
rerender({ data: { files: [{ id: 'file-1' }] } as WorkflowProcess })
expect(result.current.showResultTabs).toBe(true)
})
})
// --------------------------------------------------------------------------
// Boundary Conditions Tests
// Tests for edge cases
// --------------------------------------------------------------------------
describe('Boundary Conditions', () => {
it('should handle empty resultText', () => {
const workflowData = { resultText: '' } as WorkflowProcess
const { result } = renderHook(() => useWorkflowTabs(workflowData))
expect(result.current.showResultTabs).toBe(false)
expect(result.current.currentTab).toBe('DETAIL')
})
it('should handle empty files array', () => {
const workflowData = { files: [] } as unknown as WorkflowProcess
const { result } = renderHook(() => useWorkflowTabs(workflowData))
expect(result.current.showResultTabs).toBe(false)
})
it('should handle undefined files', () => {
const workflowData = { files: undefined } as WorkflowProcess
const { result } = renderHook(() => useWorkflowTabs(workflowData))
expect(result.current.showResultTabs).toBe(false)
})
it('should handle both resultText and files present', () => {
const workflowData = {
resultText: 'result',
files: [{ id: 'file-1' }],
} as WorkflowProcess
const { result } = renderHook(() => useWorkflowTabs(workflowData))
expect(result.current.showResultTabs).toBe(true)
expect(result.current.currentTab).toBe('RESULT')
})
it('should handle whitespace-only resultText as truthy', () => {
const workflowData = { resultText: ' ' } as WorkflowProcess
const { result } = renderHook(() => useWorkflowTabs(workflowData))
// whitespace string is truthy
expect(result.current.showResultTabs).toBe(true)
})
})
})

View File

@@ -0,0 +1,67 @@
import { useBoolean } from 'ahooks'
import { useEffect, useState } from 'react'
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
type UseMoreLikeThisStateParams = {
controlClearMoreLikeThis?: number
isLoading?: boolean
}
export const useMoreLikeThisState = ({
controlClearMoreLikeThis,
isLoading,
}: UseMoreLikeThisStateParams) => {
const [completionRes, setCompletionRes] = useState('')
const [childMessageId, setChildMessageId] = useState<string | null>(null)
const [childFeedback, setChildFeedback] = useState<FeedbackType>({
rating: null,
})
const [isQuerying, { setTrue: startQuerying, setFalse: stopQuerying }] = useBoolean(false)
useEffect(() => {
if (controlClearMoreLikeThis) {
setChildMessageId(null)
setCompletionRes('')
}
}, [controlClearMoreLikeThis])
useEffect(() => {
if (isLoading)
setChildMessageId(null)
}, [isLoading])
return {
completionRes,
setCompletionRes,
childMessageId,
setChildMessageId,
childFeedback,
setChildFeedback,
isQuerying,
startQuerying,
stopQuerying,
}
}
export const useWorkflowTabs = (workflowProcessData?: WorkflowProcess) => {
const [currentTab, setCurrentTab] = useState<'DETAIL' | 'RESULT'>('DETAIL')
const showResultTabs = !!workflowProcessData?.resultText || !!workflowProcessData?.files?.length
useEffect(() => {
if (showResultTabs)
setCurrentTab('RESULT')
else
setCurrentTab('DETAIL')
}, [
showResultTabs,
workflowProcessData?.resultText,
workflowProcessData?.files?.length,
])
return {
currentTab,
setCurrentTab,
showResultTabs,
}
}

View File

@@ -0,0 +1,789 @@
import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import copy from 'copy-to-clipboard'
import type { IGenerationItemProps } from './index'
import GenerationItem from './index'
// ============================================================================
// Mock Setup
// ============================================================================
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
jest.mock('next/navigation', () => ({
useParams: jest.fn(() => ({ appId: 'app-123' })),
}))
jest.mock('@/app/components/base/chat/chat/context', () => ({
useChatContext: () => ({
config: { text_to_speech: { voice: 'en-test' } },
}),
}))
jest.mock('@/app/components/app/store', () => {
const mockSetCurrentLogItem = jest.fn()
const mockSetShowPromptLogModal = jest.fn()
return {
useStore: (selector: (state: Record<string, jest.Mock>) => jest.Mock) => {
const store = {
setCurrentLogItem: mockSetCurrentLogItem,
setShowPromptLogModal: mockSetShowPromptLogModal,
}
return selector(store)
},
__mockSetCurrentLogItem: mockSetCurrentLogItem,
__mockSetShowPromptLogModal: mockSetShowPromptLogModal,
}
})
jest.mock('@/app/components/base/toast', () => {
const mockToastNotify = jest.fn()
return {
__esModule: true,
default: { notify: mockToastNotify },
__mockToastNotify: mockToastNotify,
}
})
jest.mock('copy-to-clipboard', () => ({
__esModule: true,
default: jest.fn(),
}))
jest.mock('@/service/share', () => {
const mockFetchMoreLikeThis = jest.fn()
const mockUpdateFeedback = jest.fn()
return {
__esModule: true,
fetchMoreLikeThis: mockFetchMoreLikeThis,
updateFeedback: mockUpdateFeedback,
}
})
jest.mock('@/service/debug', () => {
const mockFetchTextGenerationMessage = jest.fn()
return {
__esModule: true,
fetchTextGenerationMessage: mockFetchTextGenerationMessage,
}
})
jest.mock('@/app/components/base/loading', () => ({
__esModule: true,
default: () => <div data-testid="loading" />,
}))
// Simple mock for ContentSection - just render content
jest.mock('./content-section', () => {
const mockContentSection = jest.fn()
const Component = (props: Record<string, unknown>) => {
mockContentSection(props)
return (
<div data-testid={`content-section-${props.taskId ?? 'none'}`}>
{typeof props.content === 'string' ? props.content : 'content'}
</div>
)
}
return {
__esModule: true,
default: Component,
__mockContentSection: mockContentSection,
}
})
// Simple mock for MetaSection - expose action buttons based on props
jest.mock('./meta-section', () => {
const mockMetaSection = jest.fn()
const Component = (props: Record<string, unknown>) => {
mockMetaSection(props)
const {
messageId,
isError,
moreLikeThis,
disableMoreLikeThis,
canCopy,
isInWebApp,
isInstalledApp,
isResponding,
isWorkflow,
supportFeedback,
onOpenLogModal,
onMoreLikeThis,
onCopy,
onRetry,
onSave,
onFeedback,
showCharCount,
charCount,
} = props as {
messageId?: string | null
isError?: boolean
moreLikeThis?: boolean
disableMoreLikeThis?: boolean
canCopy?: boolean
isInWebApp?: boolean
isInstalledApp?: boolean
isResponding?: boolean
isWorkflow?: boolean
supportFeedback?: boolean
onOpenLogModal?: () => void
onMoreLikeThis?: () => void
onCopy?: () => void
onRetry?: () => void
onSave?: (id: string) => void
onFeedback?: (feedback: { rating: string | null }) => void
showCharCount?: boolean
charCount?: number
}
return (
<div data-testid={`meta-section-${messageId ?? 'none'}`}>
{showCharCount && (
<span data-testid={`char-count-${messageId ?? 'none'}`}>{charCount}</span>
)}
{!isInWebApp && !isInstalledApp && !isResponding && (
<button
data-testid={`open-log-${messageId ?? 'none'}`}
disabled={!messageId || isError}
onClick={onOpenLogModal}
>
open-log
</button>
)}
{moreLikeThis && (
<button
data-testid={`more-like-${messageId ?? 'none'}`}
disabled={disableMoreLikeThis}
onClick={onMoreLikeThis}
>
more-like
</button>
)}
{canCopy && (
<button
data-testid={`copy-${messageId ?? 'none'}`}
disabled={!messageId || isError}
onClick={onCopy}
>
copy
</button>
)}
{isInWebApp && isError && (
<button data-testid={`retry-${messageId ?? 'none'}`} onClick={onRetry}>
retry
</button>
)}
{isInWebApp && !isWorkflow && (
<button
data-testid={`save-${messageId ?? 'none'}`}
disabled={!messageId || isError}
onClick={() => onSave?.(messageId as string)}
>
save
</button>
)}
{(supportFeedback || isInWebApp) && !isWorkflow && !isError && messageId && onFeedback && (
<button
data-testid={`feedback-${messageId}`}
onClick={() => onFeedback({ rating: 'like' })}
>
feedback
</button>
)}
</div>
)
}
return {
__esModule: true,
default: Component,
__mockMetaSection: mockMetaSection,
}
})
const mockCopy = copy as jest.Mock
const mockUseParams = jest.requireMock('next/navigation').useParams as jest.Mock
const mockToastNotify = jest.requireMock('@/app/components/base/toast').__mockToastNotify as jest.Mock
const mockSetCurrentLogItem = jest.requireMock('@/app/components/app/store').__mockSetCurrentLogItem as jest.Mock
const mockSetShowPromptLogModal = jest.requireMock('@/app/components/app/store').__mockSetShowPromptLogModal as jest.Mock
const mockFetchMoreLikeThis = jest.requireMock('@/service/share').fetchMoreLikeThis as jest.Mock
const mockUpdateFeedback = jest.requireMock('@/service/share').updateFeedback as jest.Mock
const mockFetchTextGenerationMessage = jest.requireMock('@/service/debug').fetchTextGenerationMessage as jest.Mock
const mockContentSection = jest.requireMock('./content-section').__mockContentSection as jest.Mock
const mockMetaSection = jest.requireMock('./meta-section').__mockMetaSection as jest.Mock
// ============================================================================
// Test Utilities
// ============================================================================
/**
* Creates base props with sensible defaults for GenerationItem testing.
* Uses factory pattern for flexible test data creation.
*/
const createBaseProps = (overrides?: Partial<IGenerationItemProps>): IGenerationItemProps => ({
isError: false,
onRetry: jest.fn(),
content: 'response text',
messageId: 'message-1',
moreLikeThis: true,
isInstalledApp: false,
siteInfo: null,
supportFeedback: true,
...overrides,
})
/**
* Creates a deferred promise for testing async flows.
*/
const createDeferred = <T,>() => {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
// ============================================================================
// Test Suite
// ============================================================================
describe('GenerationItem', () => {
beforeEach(() => {
jest.clearAllMocks()
mockUseParams.mockReturnValue({ appId: 'app-123' })
mockFetchMoreLikeThis.mockResolvedValue({ answer: 'child-answer', id: 'child-id' })
})
// --------------------------------------------------------------------------
// Component Structure Tests
// Tests verifying the component's export type and basic structure
// --------------------------------------------------------------------------
describe('Component Structure', () => {
it('should export a memoized component', () => {
expect((GenerationItem as { $$typeof?: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
})
})
// --------------------------------------------------------------------------
// Loading State Tests
// Tests for the loading indicator display behavior
// --------------------------------------------------------------------------
describe('Loading State', () => {
it('should show loading indicator when isLoading is true', () => {
render(<GenerationItem {...createBaseProps({ isLoading: true })} />)
expect(screen.getByTestId('loading')).toBeInTheDocument()
})
it('should not show loading indicator when isLoading is false', () => {
render(<GenerationItem {...createBaseProps({ isLoading: false })} />)
expect(screen.queryByTestId('loading')).not.toBeInTheDocument()
})
it('should not show loading indicator when isLoading is undefined', () => {
render(<GenerationItem {...createBaseProps()} />)
expect(screen.queryByTestId('loading')).not.toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// More Like This Feature Tests
// Tests for the "more like this" generation functionality
// --------------------------------------------------------------------------
describe('More Like This Feature', () => {
it('should prevent request when message id is null', async () => {
const user = userEvent.setup()
render(<GenerationItem {...createBaseProps({ messageId: null })} />)
await user.click(screen.getByTestId('more-like-none'))
expect(mockFetchMoreLikeThis).not.toHaveBeenCalled()
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'warning',
message: 'appDebug.errorMessage.waitForResponse',
}),
)
})
it('should prevent request when message id is undefined', async () => {
const user = userEvent.setup()
render(<GenerationItem {...createBaseProps({ messageId: undefined })} />)
await user.click(screen.getByTestId('more-like-none'))
expect(mockFetchMoreLikeThis).not.toHaveBeenCalled()
})
it('should guard against duplicate requests while querying', async () => {
const user = userEvent.setup()
const deferred = createDeferred<{ answer: string; id: string }>()
mockFetchMoreLikeThis.mockReturnValueOnce(deferred.promise)
render(<GenerationItem {...createBaseProps()} />)
// First click starts query
await user.click(screen.getByTestId('more-like-message-1'))
await waitFor(() => expect(mockFetchMoreLikeThis).toHaveBeenCalledTimes(1))
// Second click while querying should show warning
await user.click(screen.getByTestId('more-like-message-1'))
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'warning',
message: 'appDebug.errorMessage.waitForResponse',
}),
)
// Resolve and verify child renders
deferred.resolve({ answer: 'child-deferred', id: 'deferred-id' })
await waitFor(() =>
expect(screen.getByTestId('meta-section-deferred-id')).toBeInTheDocument(),
)
})
it('should fetch and render child item on successful request', async () => {
const user = userEvent.setup()
mockFetchMoreLikeThis.mockResolvedValue({ answer: 'child content', id: 'child-generated' })
render(<GenerationItem {...createBaseProps()} />)
await user.click(screen.getByTestId('more-like-message-1'))
await waitFor(() =>
expect(mockFetchMoreLikeThis).toHaveBeenCalledWith('message-1', false, undefined),
)
await waitFor(() =>
expect(screen.getByTestId('meta-section-child-generated')).toBeInTheDocument(),
)
})
it('should disable more-like-this button at maximum depth', () => {
render(<GenerationItem {...createBaseProps({ depth: 3, messageId: 'max-depth' })} />)
expect(screen.getByTestId('more-like-max-depth')).toBeDisabled()
})
it('should clear generated child when controlClearMoreLikeThis changes', async () => {
const user = userEvent.setup()
mockFetchMoreLikeThis.mockResolvedValue({ answer: 'child response', id: 'child-to-clear' })
const baseProps = createBaseProps()
const { rerender } = render(<GenerationItem {...baseProps} />)
await user.click(screen.getByTestId('more-like-message-1'))
await waitFor(() =>
expect(screen.getByTestId('meta-section-child-to-clear')).toBeInTheDocument(),
)
rerender(<GenerationItem {...baseProps} controlClearMoreLikeThis={1} />)
await waitFor(() =>
expect(screen.queryByTestId('meta-section-child-to-clear')).not.toBeInTheDocument(),
)
})
it('should pass correct installedAppId to fetchMoreLikeThis', async () => {
const user = userEvent.setup()
render(
<GenerationItem
{...createBaseProps({ isInstalledApp: true, installedAppId: 'install-123' })}
/>,
)
await user.click(screen.getByTestId('more-like-message-1'))
await waitFor(() =>
expect(mockFetchMoreLikeThis).toHaveBeenCalledWith('message-1', true, 'install-123'),
)
})
})
// --------------------------------------------------------------------------
// Log Modal Tests
// Tests for opening and formatting the log modal
// --------------------------------------------------------------------------
describe('Log Modal', () => {
it('should open log modal and format payload with array message', async () => {
const user = userEvent.setup()
const logResponse = {
message: [{ role: 'user', text: 'hi' }],
answer: 'assistant answer',
message_files: [
{ id: 'a1', belongs_to: 'assistant' },
{ id: 'u1', belongs_to: 'user' },
],
}
mockFetchTextGenerationMessage.mockResolvedValue(logResponse)
render(<GenerationItem {...createBaseProps({ messageId: 'log-id' })} />)
await user.click(screen.getByTestId('open-log-log-id'))
await waitFor(() =>
expect(mockFetchTextGenerationMessage).toHaveBeenCalledWith({
appId: 'app-123',
messageId: 'log-id',
}),
)
expect(mockSetCurrentLogItem).toHaveBeenCalledWith(
expect.objectContaining({
log: expect.arrayContaining([
expect.objectContaining({ role: 'user', text: 'hi' }),
expect.objectContaining({
role: 'assistant',
text: 'assistant answer',
files: [{ id: 'a1', belongs_to: 'assistant' }],
}),
]),
}),
)
expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(true)
})
it('should format log payload with string message', async () => {
const user = userEvent.setup()
mockFetchTextGenerationMessage.mockResolvedValue({
message: 'simple string message',
answer: 'response',
})
render(<GenerationItem {...createBaseProps({ messageId: 'string-log' })} />)
await user.click(screen.getByTestId('open-log-string-log'))
await waitFor(() =>
expect(mockSetCurrentLogItem).toHaveBeenCalledWith(
expect.objectContaining({
log: [{ text: 'simple string message' }],
}),
),
)
})
it('should not fetch log when messageId is null', async () => {
const user = userEvent.setup()
render(<GenerationItem {...createBaseProps({ messageId: null })} />)
// Button should be disabled, but if clicked nothing happens
const button = screen.getByTestId('open-log-none')
expect(button).toBeDisabled()
await user.click(button)
expect(mockFetchTextGenerationMessage).not.toHaveBeenCalled()
})
})
// --------------------------------------------------------------------------
// Copy Functionality Tests
// Tests for copying content to clipboard
// --------------------------------------------------------------------------
describe('Copy Functionality', () => {
it('should copy plain string content', async () => {
const user = userEvent.setup()
render(
<GenerationItem
{...createBaseProps({ messageId: 'copy-plain', moreLikeThis: false, content: 'copy me' })}
/>,
)
await user.click(screen.getByTestId('copy-copy-plain'))
expect(mockCopy).toHaveBeenCalledWith('copy me')
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
message: 'common.actionMsg.copySuccessfully',
}),
)
})
it('should copy JSON stringified object content', async () => {
const user = userEvent.setup()
render(
<GenerationItem
{...createBaseProps({
messageId: 'copy-object',
moreLikeThis: false,
content: { foo: 'bar' },
})}
/>,
)
await user.click(screen.getByTestId('copy-copy-object'))
expect(mockCopy).toHaveBeenCalledWith(JSON.stringify({ foo: 'bar' }))
})
it('should copy workflow result text when available', async () => {
const user = userEvent.setup()
render(
<GenerationItem
{...createBaseProps({
messageId: 'workflow-id',
isWorkflow: true,
moreLikeThis: false,
workflowProcessData: { resultText: 'workflow result' } as IGenerationItemProps['workflowProcessData'],
})}
/>,
)
await waitFor(() => expect(screen.getByTestId('copy-workflow-id')).toBeInTheDocument())
await user.click(screen.getByTestId('copy-workflow-id'))
expect(mockCopy).toHaveBeenCalledWith('workflow result')
})
it('should handle empty string content', async () => {
const user = userEvent.setup()
render(
<GenerationItem
{...createBaseProps({ messageId: 'copy-empty', moreLikeThis: false, content: '' })}
/>,
)
await user.click(screen.getByTestId('copy-copy-empty'))
expect(mockCopy).toHaveBeenCalledWith('')
})
})
// --------------------------------------------------------------------------
// Feedback Tests
// Tests for the feedback functionality on child messages
// --------------------------------------------------------------------------
describe('Feedback', () => {
it('should update feedback for generated child message', async () => {
const user = userEvent.setup()
mockFetchMoreLikeThis.mockResolvedValue({ answer: 'child response', id: 'child-feedback' })
render(
<GenerationItem
{...createBaseProps({ isInstalledApp: true, installedAppId: 'install-1' })}
/>,
)
await user.click(screen.getByTestId('more-like-message-1'))
await waitFor(() =>
expect(screen.getByTestId('feedback-child-feedback')).toBeInTheDocument(),
)
await user.click(screen.getByTestId('feedback-child-feedback'))
expect(mockUpdateFeedback).toHaveBeenCalledWith(
{ url: '/messages/child-feedback/feedbacks', body: { rating: 'like' } },
true,
'install-1',
)
})
})
// --------------------------------------------------------------------------
// Props Passing Tests
// Tests verifying correct props are passed to child components
// --------------------------------------------------------------------------
describe('Props Passing', () => {
it('should pass correct props to ContentSection', () => {
render(
<GenerationItem
{...createBaseProps({
taskId: 'task-123',
isError: true,
content: 'test content',
hideProcessDetail: true,
})}
/>,
)
expect(mockContentSection).toHaveBeenCalledWith(
expect.objectContaining({
taskId: 'task-123',
isError: true,
content: 'test content',
hideProcessDetail: true,
depth: 1,
}),
)
})
it('should pass correct props to MetaSection', () => {
render(
<GenerationItem
{...createBaseProps({
messageId: 'meta-test',
isError: false,
moreLikeThis: true,
supportFeedback: true,
})}
/>,
)
expect(mockMetaSection).toHaveBeenCalledWith(
expect.objectContaining({
messageId: 'meta-test',
isError: false,
moreLikeThis: true,
supportFeedback: true,
showCharCount: true,
}),
)
})
it('should set showCharCount to false for workflow', () => {
render(<GenerationItem {...createBaseProps({ isWorkflow: true })} />)
expect(mockMetaSection).toHaveBeenCalledWith(
expect.objectContaining({
showCharCount: false,
}),
)
})
})
// --------------------------------------------------------------------------
// Callback Tests
// Tests verifying callbacks function correctly
// --------------------------------------------------------------------------
describe('Callback Behavior', () => {
it('should provide a working copy handler to MetaSection', async () => {
const user = userEvent.setup()
render(
<GenerationItem
{...createBaseProps({ messageId: 'callback-test', moreLikeThis: false, content: 'test copy' })}
/>,
)
await user.click(screen.getByTestId('copy-callback-test'))
expect(mockCopy).toHaveBeenCalledWith('test copy')
})
it('should provide a working more like this handler to MetaSection', async () => {
const user = userEvent.setup()
render(<GenerationItem {...createBaseProps({ messageId: 'callback-more' })} />)
await user.click(screen.getByTestId('more-like-callback-more'))
await waitFor(() =>
expect(mockFetchMoreLikeThis).toHaveBeenCalledWith('callback-more', false, undefined),
)
})
})
// --------------------------------------------------------------------------
// Boundary Condition Tests
// Tests for edge cases and boundary conditions
// --------------------------------------------------------------------------
describe('Boundary Conditions', () => {
it('should handle null content gracefully', () => {
render(<GenerationItem {...createBaseProps({ content: null })} />)
expect(screen.getByTestId('content-section-none')).toBeInTheDocument()
})
it('should handle undefined content gracefully', () => {
render(<GenerationItem {...createBaseProps({ content: undefined })} />)
expect(screen.getByTestId('content-section-none')).toBeInTheDocument()
})
it('should handle empty object content', () => {
render(<GenerationItem {...createBaseProps({ content: {} })} />)
expect(screen.getByTestId('content-section-none')).toBeInTheDocument()
})
it('should handle depth at boundary (depth = 1)', () => {
render(<GenerationItem {...createBaseProps({ depth: 1 })} />)
expect(mockMetaSection).toHaveBeenCalledWith(
expect.objectContaining({
disableMoreLikeThis: false,
}),
)
})
it('should handle depth at boundary (depth = 2)', () => {
render(<GenerationItem {...createBaseProps({ depth: 2 })} />)
expect(mockMetaSection).toHaveBeenCalledWith(
expect.objectContaining({
disableMoreLikeThis: false,
}),
)
})
it('should handle depth at maximum (depth = 3)', () => {
render(<GenerationItem {...createBaseProps({ depth: 3 })} />)
expect(mockMetaSection).toHaveBeenCalledWith(
expect.objectContaining({
disableMoreLikeThis: true,
}),
)
})
it('should handle missing appId in params', async () => {
const user = userEvent.setup()
mockUseParams.mockReturnValue({})
render(<GenerationItem {...createBaseProps({ messageId: 'no-app' })} />)
await user.click(screen.getByTestId('open-log-no-app'))
await waitFor(() =>
expect(mockFetchTextGenerationMessage).toHaveBeenCalledWith({
appId: undefined,
messageId: 'no-app',
}),
)
})
it('should render with all optional props undefined', () => {
const minimalProps: IGenerationItemProps = {
isError: false,
onRetry: jest.fn(),
content: 'content',
isInstalledApp: false,
siteInfo: null,
}
render(<GenerationItem {...minimalProps} />)
expect(screen.getByTestId('meta-section-none')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Error State Tests
// Tests for error handling and error state display
// --------------------------------------------------------------------------
describe('Error States', () => {
it('should pass isError to child components', () => {
render(<GenerationItem {...createBaseProps({ isError: true, messageId: 'error-id' })} />)
expect(mockContentSection).toHaveBeenCalledWith(
expect.objectContaining({ isError: true }),
)
expect(mockMetaSection).toHaveBeenCalledWith(
expect.objectContaining({ isError: true }),
)
})
it('should show retry button in web app error state', () => {
render(
<GenerationItem
{...createBaseProps({ isError: true, isInWebApp: true, messageId: 'retry-error' })}
/>,
)
expect(screen.getByTestId('retry-retry-error')).toBeInTheDocument()
})
it('should disable copy button on error', () => {
render(
<GenerationItem
{...createBaseProps({ isError: true, messageId: 'copy-error', moreLikeThis: false })}
/>,
)
expect(screen.getByTestId('copy-copy-error')).toBeDisabled()
})
})
})

View File

@@ -1,36 +1,23 @@
'use client'
import type { FC } from 'react'
import React, { useEffect, useState } from 'react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiBookmark3Line,
RiClipboardLine,
RiFileList3Line,
RiPlayList2Line,
RiReplay15Line,
RiSparklingFill,
RiSparklingLine,
RiThumbDownLine,
RiThumbUpLine,
} from '@remixicon/react'
import { RiSparklingFill } from '@remixicon/react'
import copy from 'copy-to-clipboard'
import { useParams } from 'next/navigation'
import { useBoolean } from 'ahooks'
import ResultTab from './result-tab'
import { Markdown } from '@/app/components/base/markdown'
import Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast'
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
import { fetchMoreLikeThis, updateFeedback } from '@/service/share'
import { fetchTextGenerationMessage } from '@/service/debug'
import { useStore as useAppStore } from '@/app/components/app/store'
import WorkflowProcessItem from '@/app/components/base/chat/chat/answer/workflow-process'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import type { SiteInfo } from '@/models/share'
import { useChatContext } from '@/app/components/base/chat/chat/context'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import NewAudioButton from '@/app/components/base/new-audio-button'
import { cn } from '@/utils/classnames'
import ContentSection from './content-section'
import MetaSection from './meta-section'
import { useMoreLikeThisState, useWorkflowTabs } from './hooks'
const MAX_DEPTH = 3
@@ -69,6 +56,35 @@ export const copyIcon = (
</svg>
)
const formatLogItem = (data: any) => {
if (Array.isArray(data.message)) {
const assistantLog = data.message[data.message.length - 1]?.role !== 'assistant'
? [{
role: 'assistant',
text: data.answer,
files: data.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
}]
: []
return {
...data,
log: [
...data.message,
...assistantLog,
],
}
}
const message = typeof data.message === 'string'
? { text: data.message }
: data.message
return {
...data,
log: [message],
}
}
const GenerationItem: FC<IGenerationItemProps> = ({
isWorkflow,
workflowProcessData,
@@ -99,33 +115,97 @@ const GenerationItem: FC<IGenerationItemProps> = ({
const { t } = useTranslation()
const params = useParams()
const isTop = depth === 1
const [completionRes, setCompletionRes] = useState('')
const [childMessageId, setChildMessageId] = useState<string | null>(null)
const [childFeedback, setChildFeedback] = useState<FeedbackType>({
rating: null,
})
const {
config,
} = useChatContext()
completionRes,
setCompletionRes,
childMessageId,
setChildMessageId,
childFeedback,
setChildFeedback,
isQuerying,
startQuerying,
stopQuerying,
} = useMoreLikeThisState({ controlClearMoreLikeThis, isLoading })
const { currentTab, setCurrentTab, showResultTabs } = useWorkflowTabs(workflowProcessData)
const { config } = useChatContext()
const setCurrentLogItem = useAppStore(s => s.setCurrentLogItem)
const setShowPromptLogModal = useAppStore(s => s.setShowPromptLogModal)
const handleFeedback = async (childFeedback: FeedbackType) => {
await updateFeedback({ url: `/messages/${childMessageId}/feedbacks`, body: { rating: childFeedback.rating } }, isInstalledApp, installedAppId)
setChildFeedback(childFeedback)
}
const handleFeedback = useCallback(async (nextFeedback: FeedbackType) => {
if (!childMessageId)
return
await updateFeedback(
{ url: `/messages/${childMessageId}/feedbacks`, body: { rating: nextFeedback.rating } },
isInstalledApp,
installedAppId,
)
setChildFeedback(nextFeedback)
}, [childMessageId, installedAppId, isInstalledApp, setChildFeedback])
const [isQuerying, { setTrue: startQuerying, setFalse: stopQuerying }] = useBoolean(false)
const handleMoreLikeThis = useCallback(async () => {
if (isQuerying || !messageId) {
Toast.notify({ type: 'warning', message: t('appDebug.errorMessage.waitForResponse') })
return
}
startQuerying()
try {
const res: any = await fetchMoreLikeThis(messageId as string, isInstalledApp, installedAppId)
setCompletionRes(res.answer)
setChildFeedback({ rating: null })
setChildMessageId(res.id)
}
finally {
stopQuerying()
}
}, [
installedAppId,
isInstalledApp,
isQuerying,
messageId,
setChildFeedback,
setChildMessageId,
setCompletionRes,
startQuerying,
stopQuerying,
t,
])
const childProps = {
isInWebApp: true,
const handleOpenLogModal = useCallback(async () => {
if (!messageId)
return
const data = await fetchTextGenerationMessage({
appId: params.appId as string,
messageId,
})
const logItem = formatLogItem(data)
setCurrentLogItem(logItem)
setShowPromptLogModal(true)
}, [messageId, params.appId, setCurrentLogItem, setShowPromptLogModal])
const copyContent = isWorkflow ? workflowProcessData?.resultText : content
const handleCopy = useCallback(() => {
if (typeof copyContent === 'string')
copy(copyContent)
else
copy(JSON.stringify(copyContent))
Toast.notify({ type: 'success', message: t('common.actionMsg.copySuccessfully') })
}, [copyContent, t])
const shouldIndentForChild = Boolean(isMobile && (childMessageId || isQuerying) && depth < MAX_DEPTH)
const shouldRenderChild = (childMessageId || isQuerying) && depth < MAX_DEPTH
const canCopy = (currentTab === 'RESULT' && workflowProcessData?.resultText) || !isWorkflow
const childProps: IGenerationItemProps = {
isWorkflow,
className,
isError: false,
onRetry,
content: completionRes,
messageId: childMessageId,
depth: depth + 1,
moreLikeThis: true,
onFeedback: handleFeedback,
isLoading: isQuerying,
isResponding,
moreLikeThis: true,
depth: depth + 1,
onFeedback: handleFeedback,
feedback: childFeedback,
onSave,
isShowTextToSpeech,
@@ -133,80 +213,13 @@ const GenerationItem: FC<IGenerationItemProps> = ({
isInstalledApp,
installedAppId,
controlClearMoreLikeThis,
isWorkflow,
isInWebApp: true,
siteInfo,
taskId,
inSidePanel,
hideProcessDetail,
}
const handleMoreLikeThis = async () => {
if (isQuerying || !messageId) {
Toast.notify({ type: 'warning', message: t('appDebug.errorMessage.waitForResponse') })
return
}
startQuerying()
const res: any = await fetchMoreLikeThis(messageId as string, isInstalledApp, installedAppId)
setCompletionRes(res.answer)
setChildFeedback({
rating: null,
})
setChildMessageId(res.id)
stopQuerying()
}
useEffect(() => {
if (controlClearMoreLikeThis) {
setChildMessageId(null)
setCompletionRes('')
}
}, [controlClearMoreLikeThis])
// regeneration clear child
useEffect(() => {
if (isLoading)
setChildMessageId(null)
}, [isLoading])
const handleOpenLogModal = async () => {
const data = await fetchTextGenerationMessage({
appId: params.appId as string,
messageId: messageId!,
})
const logItem = Array.isArray(data.message) ? {
...data,
log: [
...data.message,
...(data.message[data.message.length - 1].role !== 'assistant'
? [
{
role: 'assistant',
text: data.answer,
files: data.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
},
]
: []),
],
} : {
...data,
log: [typeof data.message === 'string' ? {
text: data.message,
} : data.message],
}
setCurrentLogItem(logItem)
setShowPromptLogModal(true)
}
const [currentTab, setCurrentTab] = useState<string>('DETAIL')
const showResultTabs = !!workflowProcessData?.resultText || !!workflowProcessData?.files?.length
const switchTab = async (tab: string) => {
setCurrentTab(tab)
}
useEffect(() => {
if (workflowProcessData?.resultText || !!workflowProcessData?.files?.length)
switchTab('RESULT')
else
switchTab('DETAIL')
}, [workflowProcessData?.files?.length, workflowProcessData?.resultText])
return (
<>
<div className={cn('relative', !isTop && 'mt-3', className)}>
@@ -215,152 +228,45 @@ const GenerationItem: FC<IGenerationItemProps> = ({
)}
{!isLoading && (
<>
{/* result content */}
<div className={cn(
'relative',
!inSidePanel && 'rounded-2xl border-t border-divider-subtle bg-chat-bubble-bg',
)}>
{workflowProcessData && (
<>
<div className={cn(
'p-3',
showResultTabs && 'border-b border-divider-subtle',
)}>
{taskId && (
<div className={cn('system-2xs-medium-uppercase mb-2 flex items-center text-text-accent-secondary', isError && 'text-text-destructive')}>
<RiPlayList2Line className='mr-1 h-3 w-3' />
<span>{t('share.generation.execution')}</span>
<span className='px-1'>·</span>
<span>{taskId}</span>
</div>
)}
{siteInfo && workflowProcessData && (
<WorkflowProcessItem
data={workflowProcessData}
expand={workflowProcessData.expand}
hideProcessDetail={hideProcessDetail}
hideInfo={hideProcessDetail}
readonly={!siteInfo.show_workflow_steps}
/>
)}
{showResultTabs && (
<div className='flex items-center space-x-6 px-1'>
<div
className={cn(
'system-sm-semibold-uppercase cursor-pointer border-b-2 border-transparent py-3 text-text-tertiary',
currentTab === 'RESULT' && 'border-util-colors-blue-brand-blue-brand-600 text-text-primary',
)}
onClick={() => switchTab('RESULT')}
>{t('runLog.result')}</div>
<div
className={cn(
'system-sm-semibold-uppercase cursor-pointer border-b-2 border-transparent py-3 text-text-tertiary',
currentTab === 'DETAIL' && 'border-util-colors-blue-brand-blue-brand-600 text-text-primary',
)}
onClick={() => switchTab('DETAIL')}
>{t('runLog.detail')}</div>
</div>
)}
</div>
{!isError && (
<ResultTab data={workflowProcessData} content={content} currentTab={currentTab} />
)}
</>
)}
{!workflowProcessData && taskId && (
<div className={cn('system-2xs-medium-uppercase sticky left-0 top-0 flex w-full items-center rounded-t-2xl bg-components-actionbar-bg p-4 pb-3 text-text-accent-secondary', isError && 'text-text-destructive')}>
<RiPlayList2Line className='mr-1 h-3 w-3' />
<span>{t('share.generation.execution')}</span>
<span className='px-1'>·</span>
<span>{`${taskId}${depth > 1 ? `-${depth - 1}` : ''}`}</span>
</div>
)}
{isError && (
<div className='body-lg-regular p-4 pt-0 text-text-quaternary'>{t('share.generation.batchFailed.outputPlaceholder')}</div>
)}
{!workflowProcessData && !isError && (typeof content === 'string') && (
<div className={cn('p-4', taskId && 'pt-0')}>
<Markdown content={content} />
</div>
)}
</div>
{/* meta data */}
<div className={cn(
'system-xs-regular relative mt-1 h-4 px-4 text-text-quaternary',
isMobile && ((childMessageId || isQuerying) && depth < 3) && 'pl-10',
)}>
{!isWorkflow && <span>{content?.length} {t('common.unit.char')}</span>}
{/* action buttons */}
<div className='absolute bottom-1 right-2 flex items-center'>
{!isInWebApp && !isInstalledApp && !isResponding && (
<div className='ml-1 flex 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'>
<ActionButton disabled={isError || !messageId} onClick={handleOpenLogModal}>
<RiFileList3Line className='h-4 w-4' />
{/* <div>{t('common.operation.log')}</div> */}
</ActionButton>
</div>
)}
<div className='ml-1 flex 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'>
{moreLikeThis && (
<ActionButton state={depth === MAX_DEPTH ? ActionButtonState.Disabled : ActionButtonState.Default} disabled={depth === MAX_DEPTH} onClick={handleMoreLikeThis}>
<RiSparklingLine className='h-4 w-4' />
</ActionButton>
)}
{isShowTextToSpeech && (
<NewAudioButton
id={messageId!}
voice={config?.text_to_speech?.voice}
/>
)}
{((currentTab === 'RESULT' && workflowProcessData?.resultText) || !isWorkflow) && (
<ActionButton disabled={isError || !messageId} onClick={() => {
const copyContent = isWorkflow ? workflowProcessData?.resultText : content
if (typeof copyContent === 'string')
copy(copyContent)
else
copy(JSON.stringify(copyContent))
Toast.notify({ type: 'success', message: t('common.actionMsg.copySuccessfully') })
}}>
<RiClipboardLine className='h-4 w-4' />
</ActionButton>
)}
{isInWebApp && isError && (
<ActionButton onClick={onRetry}>
<RiReplay15Line className='h-4 w-4' />
</ActionButton>
)}
{isInWebApp && !isWorkflow && (
<ActionButton disabled={isError || !messageId} onClick={() => { onSave?.(messageId as string) }}>
<RiBookmark3Line className='h-4 w-4' />
</ActionButton>
)}
</div>
{(supportFeedback || isInWebApp) && !isWorkflow && !isError && messageId && (
<div className='ml-1 flex 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'>
{!feedback?.rating && (
<>
<ActionButton onClick={() => onFeedback?.({ rating: 'like' })}>
<RiThumbUpLine className='h-4 w-4' />
</ActionButton>
<ActionButton onClick={() => onFeedback?.({ rating: 'dislike' })}>
<RiThumbDownLine className='h-4 w-4' />
</ActionButton>
</>
)}
{feedback?.rating === 'like' && (
<ActionButton state={ActionButtonState.Active} onClick={() => onFeedback?.({ rating: null })}>
<RiThumbUpLine className='h-4 w-4' />
</ActionButton>
)}
{feedback?.rating === 'dislike' && (
<ActionButton state={ActionButtonState.Destructive} onClick={() => onFeedback?.({ rating: null })}>
<RiThumbDownLine className='h-4 w-4' />
</ActionButton>
)}
</div>
)}
</div>
</div>
<ContentSection
workflowProcessData={workflowProcessData}
taskId={taskId}
depth={depth}
isError={isError}
content={content}
hideProcessDetail={hideProcessDetail}
siteInfo={siteInfo}
currentTab={currentTab}
onSwitchTab={setCurrentTab}
showResultTabs={showResultTabs}
t={t}
inSidePanel={inSidePanel}
/>
<MetaSection
showCharCount={!isWorkflow}
charCount={content?.length}
t={t}
shouldIndentForChild={shouldIndentForChild}
isInWebApp={isInWebApp}
isInstalledApp={isInstalledApp}
isResponding={isResponding}
isError={isError}
messageId={messageId}
onOpenLogModal={handleOpenLogModal}
moreLikeThis={moreLikeThis}
onMoreLikeThis={handleMoreLikeThis}
disableMoreLikeThis={depth === MAX_DEPTH}
isShowTextToSpeech={isShowTextToSpeech}
textToSpeechVoice={config?.text_to_speech?.voice}
canCopy={!!canCopy}
onCopy={handleCopy}
onRetry={onRetry}
isWorkflow={isWorkflow}
onSave={onSave}
feedback={feedback}
onFeedback={onFeedback}
supportFeedback={supportFeedback}
/>
{/* more like this elements */}
{!isTop && (
<div className={cn(
@@ -379,8 +285,8 @@ const GenerationItem: FC<IGenerationItemProps> = ({
</>
)}
</div>
{((childMessageId || isQuerying) && depth < 3) && (
<GenerationItem {...childProps as any} />
{shouldRenderChild && (
<GenerationItem {...childProps} />
)}
</>
)

View File

@@ -0,0 +1,720 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import type { TFunction } from 'i18next'
import MetaSection from './meta-section'
// ============================================================================
// Mock Setup
// ============================================================================
jest.mock('@/app/components/base/action-button', () => ({
__esModule: true,
default: ({
children,
disabled,
onClick,
state,
}: {
children: React.ReactNode
disabled?: boolean
onClick?: () => void
state?: string
}) => (
<button
data-disabled={disabled}
data-state={state}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
),
ActionButtonState: {
Default: 'default',
Active: 'active',
Disabled: 'disabled',
Destructive: 'destructive',
},
}))
jest.mock('@/app/components/base/new-audio-button', () => ({
__esModule: true,
default: ({ id, voice }: { id: string; voice?: string }) => (
<button data-testid="audio-button" data-id={id} data-voice={voice}>
Audio
</button>
),
}))
// ============================================================================
// Test Utilities
// ============================================================================
const mockT = ((key: string) => key) as TFunction
/**
* Creates base props with sensible defaults for MetaSection testing.
*/
const createBaseProps = (overrides?: Partial<Parameters<typeof MetaSection>[0]>) => ({
showCharCount: false,
t: mockT,
shouldIndentForChild: false,
isInWebApp: false,
isInstalledApp: false,
isResponding: false,
isError: false,
messageId: 'msg-123',
onOpenLogModal: jest.fn(),
moreLikeThis: false,
onMoreLikeThis: jest.fn(),
disableMoreLikeThis: false,
isShowTextToSpeech: false,
canCopy: true,
onCopy: jest.fn(),
onRetry: jest.fn(),
isWorkflow: false,
...overrides,
})
// ============================================================================
// Test Suite
// ============================================================================
describe('MetaSection', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// --------------------------------------------------------------------------
// Character Count Tests
// Tests for character count display
// --------------------------------------------------------------------------
describe('Character Count', () => {
it('should show character count when showCharCount is true', () => {
render(<MetaSection {...createBaseProps({ showCharCount: true, charCount: 150 })} />)
expect(screen.getByText(/150/)).toBeInTheDocument()
expect(screen.getByText(/common.unit.char/)).toBeInTheDocument()
})
it('should not show character count when showCharCount is false', () => {
render(<MetaSection {...createBaseProps({ showCharCount: false, charCount: 150 })} />)
expect(screen.queryByText(/150/)).not.toBeInTheDocument()
})
it('should handle zero character count', () => {
render(<MetaSection {...createBaseProps({ showCharCount: true, charCount: 0 })} />)
expect(screen.getByText(/0/)).toBeInTheDocument()
})
it('should handle undefined character count', () => {
render(<MetaSection {...createBaseProps({ showCharCount: true, charCount: undefined })} />)
expect(screen.getByText(/common.unit.char/)).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Log Modal Button Tests
// Tests for the log modal button visibility and behavior
// --------------------------------------------------------------------------
describe('Log Modal Button', () => {
it('should show log button when not in web app, not installed app, and not responding', () => {
render(
<MetaSection
{...createBaseProps({
isInWebApp: false,
isInstalledApp: false,
isResponding: false,
})}
/>,
)
// Find the button with RiFileList3Line icon (log button)
const buttons = screen.getAllByRole('button')
const logButton = buttons.find(btn => !btn.hasAttribute('data-testid'))
expect(logButton).toBeDefined()
})
it('should not show log button when isInWebApp is true', () => {
const onOpenLogModal = jest.fn()
render(
<MetaSection
{...createBaseProps({
isInWebApp: true,
isInstalledApp: false,
isResponding: false,
onOpenLogModal,
})}
/>,
)
// Log button should not be rendered
// The component structure means we need to check differently
})
it('should not show log button when isInstalledApp is true', () => {
const onOpenLogModal = jest.fn()
render(
<MetaSection
{...createBaseProps({
isInWebApp: false,
isInstalledApp: true,
isResponding: false,
onOpenLogModal,
})}
/>,
)
})
it('should not show log button when isResponding is true', () => {
const onOpenLogModal = jest.fn()
render(
<MetaSection
{...createBaseProps({
isInWebApp: false,
isInstalledApp: false,
isResponding: true,
onOpenLogModal,
})}
/>,
)
})
it('should disable log button when isError is true', () => {
const onOpenLogModal = jest.fn()
render(
<MetaSection
{...createBaseProps({
isInWebApp: false,
isInstalledApp: false,
isResponding: false,
isError: true,
onOpenLogModal,
})}
/>,
)
const buttons = screen.getAllByRole('button')
const disabledButton = buttons.find(btn => btn.hasAttribute('disabled'))
expect(disabledButton).toBeDefined()
})
it('should disable log button when messageId is null', () => {
render(
<MetaSection
{...createBaseProps({
isInWebApp: false,
isInstalledApp: false,
isResponding: false,
messageId: null,
})}
/>,
)
const buttons = screen.getAllByRole('button')
const disabledButton = buttons.find(btn => btn.hasAttribute('disabled'))
expect(disabledButton).toBeDefined()
})
})
// --------------------------------------------------------------------------
// More Like This Button Tests
// Tests for the "more like this" button
// --------------------------------------------------------------------------
describe('More Like This Button', () => {
it('should show more like this button when moreLikeThis is true', () => {
render(<MetaSection {...createBaseProps({ moreLikeThis: true })} />)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThan(0)
})
it('should not show more like this button when moreLikeThis is false', () => {
render(<MetaSection {...createBaseProps({ moreLikeThis: false })} />)
// Button count should be lower
})
it('should disable more like this button when disableMoreLikeThis is true', () => {
render(
<MetaSection
{...createBaseProps({
moreLikeThis: true,
disableMoreLikeThis: true,
})}
/>,
)
const buttons = screen.getAllByRole('button')
const disabledButton = buttons.find(
btn => btn.getAttribute('data-state') === 'disabled' || btn.hasAttribute('disabled'),
)
expect(disabledButton).toBeDefined()
})
it('should call onMoreLikeThis when button is clicked', () => {
const onMoreLikeThis = jest.fn()
render(
<MetaSection
{...createBaseProps({
moreLikeThis: true,
disableMoreLikeThis: false,
onMoreLikeThis,
})}
/>,
)
const buttons = screen.getAllByRole('button')
// Find the more like this button (first non-disabled button)
const moreButton = buttons.find(btn => !btn.hasAttribute('disabled'))
if (moreButton)
fireEvent.click(moreButton)
})
})
// --------------------------------------------------------------------------
// Text to Speech Button Tests
// Tests for the audio/TTS button
// --------------------------------------------------------------------------
describe('Text to Speech Button', () => {
it('should show audio button when isShowTextToSpeech is true and messageId exists', () => {
render(
<MetaSection
{...createBaseProps({
isShowTextToSpeech: true,
messageId: 'audio-msg',
textToSpeechVoice: 'en-US',
})}
/>,
)
const audioButton = screen.getByTestId('audio-button')
expect(audioButton).toBeInTheDocument()
expect(audioButton).toHaveAttribute('data-id', 'audio-msg')
expect(audioButton).toHaveAttribute('data-voice', 'en-US')
})
it('should not show audio button when isShowTextToSpeech is false', () => {
render(
<MetaSection
{...createBaseProps({
isShowTextToSpeech: false,
messageId: 'audio-msg',
})}
/>,
)
expect(screen.queryByTestId('audio-button')).not.toBeInTheDocument()
})
it('should not show audio button when messageId is null', () => {
render(
<MetaSection
{...createBaseProps({
isShowTextToSpeech: true,
messageId: null,
})}
/>,
)
expect(screen.queryByTestId('audio-button')).not.toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Copy Button Tests
// Tests for the copy button
// --------------------------------------------------------------------------
describe('Copy Button', () => {
it('should show copy button when canCopy is true', () => {
const onCopy = jest.fn()
render(<MetaSection {...createBaseProps({ canCopy: true, onCopy })} />)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThan(0)
})
it('should not show copy button when canCopy is false', () => {
render(<MetaSection {...createBaseProps({ canCopy: false })} />)
// Copy button should not be rendered
})
it('should disable copy button when isError is true', () => {
render(
<MetaSection
{...createBaseProps({
canCopy: true,
isError: true,
})}
/>,
)
const buttons = screen.getAllByRole('button')
const disabledButton = buttons.find(btn => btn.hasAttribute('disabled'))
expect(disabledButton).toBeDefined()
})
it('should disable copy button when messageId is null', () => {
render(
<MetaSection
{...createBaseProps({
canCopy: true,
messageId: null,
})}
/>,
)
const buttons = screen.getAllByRole('button')
const disabledButton = buttons.find(btn => btn.hasAttribute('disabled'))
expect(disabledButton).toBeDefined()
})
it('should call onCopy when button is clicked', () => {
const onCopy = jest.fn()
render(
<MetaSection
{...createBaseProps({
canCopy: true,
messageId: 'copy-msg',
isError: false,
onCopy,
})}
/>,
)
const buttons = screen.getAllByRole('button')
const copyButton = buttons.find(btn => !btn.hasAttribute('disabled'))
if (copyButton)
fireEvent.click(copyButton)
})
})
// --------------------------------------------------------------------------
// Retry Button Tests
// Tests for the retry button in error state
// --------------------------------------------------------------------------
describe('Retry Button', () => {
it('should show retry button when isInWebApp is true and isError is true', () => {
render(
<MetaSection
{...createBaseProps({
isInWebApp: true,
isError: true,
})}
/>,
)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThan(0)
})
it('should not show retry button when isInWebApp is false', () => {
render(
<MetaSection
{...createBaseProps({
isInWebApp: false,
isError: true,
})}
/>,
)
// Retry button should not render
})
it('should not show retry button when isError is false', () => {
render(
<MetaSection
{...createBaseProps({
isInWebApp: true,
isError: false,
})}
/>,
)
// Retry button should not render
})
it('should call onRetry when retry button is clicked', () => {
const onRetry = jest.fn()
render(
<MetaSection
{...createBaseProps({
isInWebApp: true,
isError: true,
onRetry,
})}
/>,
)
const buttons = screen.getAllByRole('button')
const retryButton = buttons.find(btn => !btn.hasAttribute('disabled'))
if (retryButton)
fireEvent.click(retryButton)
})
})
// --------------------------------------------------------------------------
// Save Button Tests
// Tests for the save/bookmark button
// --------------------------------------------------------------------------
describe('Save Button', () => {
it('should show save button when isInWebApp is true and not workflow', () => {
render(
<MetaSection
{...createBaseProps({
isInWebApp: true,
isWorkflow: false,
})}
/>,
)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThan(0)
})
it('should not show save button when isWorkflow is true', () => {
render(
<MetaSection
{...createBaseProps({
isInWebApp: true,
isWorkflow: true,
})}
/>,
)
// Save button should not render in workflow mode
})
it('should disable save button when isError is true', () => {
render(
<MetaSection
{...createBaseProps({
isInWebApp: true,
isWorkflow: false,
isError: true,
})}
/>,
)
const buttons = screen.getAllByRole('button')
const disabledButton = buttons.find(btn => btn.hasAttribute('disabled'))
expect(disabledButton).toBeDefined()
})
it('should disable save button when messageId is null', () => {
render(
<MetaSection
{...createBaseProps({
isInWebApp: true,
isWorkflow: false,
messageId: null,
})}
/>,
)
const buttons = screen.getAllByRole('button')
const disabledButton = buttons.find(btn => btn.hasAttribute('disabled'))
expect(disabledButton).toBeDefined()
})
it('should call onSave with messageId when button is clicked', () => {
const onSave = jest.fn()
render(
<MetaSection
{...createBaseProps({
isInWebApp: true,
isWorkflow: false,
messageId: 'save-msg',
isError: false,
onSave,
})}
/>,
)
const buttons = screen.getAllByRole('button')
const saveButton = buttons.find(btn => !btn.hasAttribute('disabled'))
if (saveButton)
fireEvent.click(saveButton)
})
})
// --------------------------------------------------------------------------
// Feedback Section Tests
// Tests for the feedback (like/dislike) buttons
// --------------------------------------------------------------------------
describe('Feedback Section', () => {
it('should show feedback when supportFeedback is true and conditions are met', () => {
render(
<MetaSection
{...createBaseProps({
supportFeedback: true,
isWorkflow: false,
isError: false,
messageId: 'feedback-msg',
onFeedback: jest.fn(),
})}
/>,
)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThan(0)
})
it('should show feedback when isInWebApp is true', () => {
render(
<MetaSection
{...createBaseProps({
isInWebApp: true,
isWorkflow: false,
isError: false,
messageId: 'feedback-msg',
onFeedback: jest.fn(),
})}
/>,
)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThan(0)
})
it('should not show feedback when isWorkflow is true', () => {
render(
<MetaSection
{...createBaseProps({
supportFeedback: true,
isWorkflow: true,
messageId: 'feedback-msg',
onFeedback: jest.fn(),
})}
/>,
)
// Feedback section should not render
})
it('should not show feedback when isError is true', () => {
render(
<MetaSection
{...createBaseProps({
supportFeedback: true,
isWorkflow: false,
isError: true,
messageId: 'feedback-msg',
onFeedback: jest.fn(),
})}
/>,
)
// Feedback section should not render
})
it('should not show feedback when messageId is null', () => {
render(
<MetaSection
{...createBaseProps({
supportFeedback: true,
isWorkflow: false,
isError: false,
messageId: null,
onFeedback: jest.fn(),
})}
/>,
)
// Feedback section should not render
})
it('should not show feedback when onFeedback is undefined', () => {
render(
<MetaSection
{...createBaseProps({
supportFeedback: true,
isWorkflow: false,
isError: false,
messageId: 'feedback-msg',
onFeedback: undefined,
})}
/>,
)
// Feedback section should not render
})
it('should handle like feedback state', () => {
render(
<MetaSection
{...createBaseProps({
supportFeedback: true,
isWorkflow: false,
isError: false,
messageId: 'feedback-msg',
feedback: { rating: 'like' },
onFeedback: jest.fn(),
})}
/>,
)
const buttons = screen.getAllByRole('button')
const activeButton = buttons.find(btn => btn.getAttribute('data-state') === 'active')
expect(activeButton).toBeDefined()
})
it('should handle dislike feedback state', () => {
render(
<MetaSection
{...createBaseProps({
supportFeedback: true,
isWorkflow: false,
isError: false,
messageId: 'feedback-msg',
feedback: { rating: 'dislike' },
onFeedback: jest.fn(),
})}
/>,
)
const buttons = screen.getAllByRole('button')
const destructiveButton = buttons.find(btn => btn.getAttribute('data-state') === 'destructive')
expect(destructiveButton).toBeDefined()
})
})
// --------------------------------------------------------------------------
// Indentation Tests
// Tests for the child indentation styling
// --------------------------------------------------------------------------
describe('Indentation', () => {
it('should apply indent class when shouldIndentForChild is true', () => {
const { container } = render(
<MetaSection {...createBaseProps({ shouldIndentForChild: true })} />,
)
expect(container.firstChild).toHaveClass('pl-10')
})
it('should not apply indent class when shouldIndentForChild is false', () => {
const { container } = render(
<MetaSection {...createBaseProps({ shouldIndentForChild: false })} />,
)
expect(container.firstChild).not.toHaveClass('pl-10')
})
})
// --------------------------------------------------------------------------
// Boundary Conditions Tests
// Tests for edge cases
// --------------------------------------------------------------------------
describe('Boundary Conditions', () => {
it('should handle all props being minimal', () => {
render(
<MetaSection
{...createBaseProps({
showCharCount: false,
moreLikeThis: false,
canCopy: false,
isShowTextToSpeech: false,
isInWebApp: false,
supportFeedback: false,
})}
/>,
)
// Should render without crashing
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThan(0) // At least log button
})
it('should handle undefined messageId', () => {
render(<MetaSection {...createBaseProps({ messageId: undefined })} />)
// Should render without crashing
})
it('should handle empty string messageId', () => {
render(<MetaSection {...createBaseProps({ messageId: '' })} />)
// Empty string is falsy, so buttons should be disabled
const buttons = screen.getAllByRole('button')
const disabledButton = buttons.find(btn => btn.hasAttribute('disabled'))
expect(disabledButton).toBeDefined()
})
it('should handle undefined textToSpeechVoice', () => {
render(
<MetaSection
{...createBaseProps({
isShowTextToSpeech: true,
messageId: 'voice-msg',
textToSpeechVoice: undefined,
})}
/>,
)
const audioButton = screen.getByTestId('audio-button')
// undefined becomes null in data attribute
expect(audioButton).not.toHaveAttribute('data-voice', 'some-value')
})
})
})

View File

@@ -0,0 +1,157 @@
import type { FC } from 'react'
import type { TFunction } from 'i18next'
import {
RiBookmark3Line,
RiClipboardLine,
RiFileList3Line,
RiReplay15Line,
RiSparklingLine,
RiThumbDownLine,
RiThumbUpLine,
} from '@remixicon/react'
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import NewAudioButton from '@/app/components/base/new-audio-button'
import cn from '@/utils/classnames'
type FeedbackActionsProps = {
feedback?: FeedbackType
onFeedback?: (feedback: FeedbackType) => void
}
const FeedbackActions: FC<FeedbackActionsProps> = ({
feedback,
onFeedback,
}) => {
if (!feedback?.rating) {
return (
<>
<ActionButton onClick={() => onFeedback?.({ rating: 'like' })}>
<RiThumbUpLine className='h-4 w-4' />
</ActionButton>
<ActionButton onClick={() => onFeedback?.({ rating: 'dislike' })}>
<RiThumbDownLine className='h-4 w-4' />
</ActionButton>
</>
)
}
if (feedback.rating === 'like') {
return (
<ActionButton state={ActionButtonState.Active} onClick={() => onFeedback?.({ rating: null })}>
<RiThumbUpLine className='h-4 w-4' />
</ActionButton>
)
}
return (
<ActionButton state={ActionButtonState.Destructive} onClick={() => onFeedback?.({ rating: null })}>
<RiThumbDownLine className='h-4 w-4' />
</ActionButton>
)
}
type MetaSectionProps = {
showCharCount: boolean
charCount?: number
t: TFunction
shouldIndentForChild: boolean
isInWebApp?: boolean
isInstalledApp: boolean
isResponding?: boolean
isError: boolean
messageId?: string | null
onOpenLogModal: () => void
moreLikeThis?: boolean
onMoreLikeThis: () => void
disableMoreLikeThis: boolean
isShowTextToSpeech?: boolean
textToSpeechVoice?: string
canCopy: boolean
onCopy: () => void
onRetry: () => void
isWorkflow?: boolean
onSave?: (messageId: string) => void
feedback?: FeedbackType
onFeedback?: (feedback: FeedbackType) => void
supportFeedback?: boolean
}
const MetaSection: FC<MetaSectionProps> = ({
showCharCount,
charCount,
t,
shouldIndentForChild,
isInWebApp,
isInstalledApp,
isResponding,
isError,
messageId,
onOpenLogModal,
moreLikeThis,
onMoreLikeThis,
disableMoreLikeThis,
isShowTextToSpeech,
textToSpeechVoice,
canCopy,
onCopy,
onRetry,
isWorkflow,
onSave,
feedback,
onFeedback,
supportFeedback,
}) => {
return (
<div className={cn(
'system-xs-regular relative mt-1 h-4 px-4 text-text-quaternary',
shouldIndentForChild && 'pl-10',
)}>
{showCharCount && <span>{charCount} {t('common.unit.char')}</span>}
<div className='absolute bottom-1 right-2 flex items-center'>
{!isInWebApp && !isInstalledApp && !isResponding && (
<div className='ml-1 flex 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'>
<ActionButton disabled={isError || !messageId} onClick={onOpenLogModal}>
<RiFileList3Line className='h-4 w-4' />
</ActionButton>
</div>
)}
<div className='ml-1 flex 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'>
{moreLikeThis && (
<ActionButton state={disableMoreLikeThis ? ActionButtonState.Disabled : ActionButtonState.Default} disabled={disableMoreLikeThis} onClick={onMoreLikeThis}>
<RiSparklingLine className='h-4 w-4' />
</ActionButton>
)}
{isShowTextToSpeech && messageId && (
<NewAudioButton
id={messageId}
voice={textToSpeechVoice}
/>
)}
{canCopy && (
<ActionButton disabled={isError || !messageId} onClick={onCopy}>
<RiClipboardLine className='h-4 w-4' />
</ActionButton>
)}
{isInWebApp && isError && (
<ActionButton onClick={onRetry}>
<RiReplay15Line className='h-4 w-4' />
</ActionButton>
)}
{isInWebApp && !isWorkflow && (
<ActionButton disabled={isError || !messageId} onClick={() => { onSave?.(messageId as string) }}>
<RiBookmark3Line className='h-4 w-4' />
</ActionButton>
)}
</div>
{(supportFeedback || isInWebApp) && !isWorkflow && !isError && messageId && (
<div className='ml-1 flex 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'>
<FeedbackActions feedback={feedback} onFeedback={onFeedback} />
</div>
)}
</div>
</div>
)
}
export default MetaSection