From 78d47fa28fea76ff14f5d115a27b0ec66927a90c Mon Sep 17 00:00:00 2001 From: yyh Date: Thu, 11 Dec 2025 18:15:07 +0800 Subject: [PATCH] test: add comprehensive tests for ContentSection, hooks, and MetaSection components --- .../item/content-section.spec.tsx | 388 ++++++++ .../app/text-generate/item/hooks.spec.ts | 411 ++++++++ .../app/text-generate/item/index.spec.tsx | 908 +++++++++++++----- .../text-generate/item/meta-section.spec.tsx | 720 ++++++++++++++ 4 files changed, 2177 insertions(+), 250 deletions(-) create mode 100644 web/app/components/app/text-generate/item/content-section.spec.tsx create mode 100644 web/app/components/app/text-generate/item/hooks.spec.ts create mode 100644 web/app/components/app/text-generate/item/meta-section.spec.tsx diff --git a/web/app/components/app/text-generate/item/content-section.spec.tsx b/web/app/components/app/text-generate/item/content-section.spec.tsx new file mode 100644 index 0000000000..3d0137e37c --- /dev/null +++ b/web/app/components/app/text-generate/item/content-section.spec.tsx @@ -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 }) => ( +
{data.resultText}
+ ), +})) + +jest.mock('@/app/components/base/markdown', () => ({ + Markdown: ({ content }: { content: string }) => ( +
{content}
+ ), +})) + +jest.mock('./result-tab', () => ({ + __esModule: true, + default: ({ currentTab }: { currentTab: string }) => ( +
{currentTab}
+ ), +})) + +// ============================================================================ +// Test Utilities +// ============================================================================ + +const mockT = ((key: string) => key) as TFunction + +/** + * Creates base props with sensible defaults for ContentSection testing. + */ +const createBaseProps = (overrides?: Partial[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 => ({ + status: 'succeeded', + tracing: [], + expand: true, + resultText: 'workflow result', + ...overrides, +} as WorkflowProcess) + +const createSiteInfo = (overrides?: Partial): 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() + expect(screen.getByTestId('markdown')).toHaveTextContent('Hello World') + }) + + it('should not render markdown when content is not a string', () => { + render() + expect(screen.queryByTestId('markdown')).not.toBeInTheDocument() + }) + + it('should apply rounded styling when not in side panel', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('rounded-2xl') + }) + + it('should not apply rounded styling when in side panel', () => { + const { container } = render() + 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( + , + ) + expect(screen.getByText('task-123')).toBeInTheDocument() + }) + + it('should show task ID with depth suffix at depth > 1', () => { + render( + , + ) + expect(screen.getByText('task-123-2')).toBeInTheDocument() + }) + + it('should show execution label with task ID', () => { + render( + , + ) + 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() + expect(screen.getByText('share.generation.batchFailed.outputPlaceholder')).toBeInTheDocument() + }) + + it('should not show markdown content when isError is true', () => { + render() + expect(screen.queryByTestId('markdown')).not.toBeInTheDocument() + }) + + it('should apply error styling to task ID when isError is true', () => { + render( + , + ) + 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( + , + ) + expect(screen.getByTestId('workflow-process')).toBeInTheDocument() + }) + + it('should not render WorkflowProcessItem when siteInfo is null', () => { + render( + , + ) + expect(screen.queryByTestId('workflow-process')).not.toBeInTheDocument() + }) + + it('should show task ID within workflow section', () => { + render( + , + ) + expect(screen.getByText('wf-task-123')).toBeInTheDocument() + }) + + it('should not render ResultTab when isError is true', () => { + render( + , + ) + expect(screen.queryByTestId('result-tab')).not.toBeInTheDocument() + }) + + it('should render ResultTab when not error', () => { + render( + , + ) + 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( + , + ) + expect(screen.getByText('runLog.result')).toBeInTheDocument() + expect(screen.getByText('runLog.detail')).toBeInTheDocument() + }) + + it('should not show tabs when showResultTabs is false', () => { + render( + , + ) + 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( + , + ) + + fireEvent.click(screen.getByText('runLog.result')) + expect(onSwitchTab).toHaveBeenCalledWith('RESULT') + }) + + it('should call onSwitchTab when DETAIL tab is clicked', () => { + const onSwitchTab = jest.fn() + render( + , + ) + + fireEvent.click(screen.getByText('runLog.detail')) + expect(onSwitchTab).toHaveBeenCalledWith('DETAIL') + }) + + it('should highlight active tab', () => { + render( + , + ) + + 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() + expect(screen.getByTestId('markdown')).toHaveTextContent('') + }) + + it('should handle null siteInfo', () => { + render( + , + ) + // Should not crash and WorkflowProcessItem should not render + expect(screen.queryByTestId('workflow-process')).not.toBeInTheDocument() + }) + + it('should handle undefined workflowProcessData', () => { + render() + expect(screen.queryByTestId('workflow-process')).not.toBeInTheDocument() + }) + + it('should handle show_workflow_steps false in siteInfo', () => { + render( + , + ) + expect(screen.getByTestId('workflow-process')).toBeInTheDocument() + }) + + it('should handle hideProcessDetail prop', () => { + render( + , + ) + // Component should still render + expect(screen.getByTestId('workflow-process')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/app/text-generate/item/hooks.spec.ts b/web/app/components/app/text-generate/item/hooks.spec.ts new file mode 100644 index 0000000000..69755aca7f --- /dev/null +++ b/web/app/components/app/text-generate/item/hooks.spec.ts @@ -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) + }) + }) +}) diff --git a/web/app/components/app/text-generate/item/index.spec.tsx b/web/app/components/app/text-generate/item/index.spec.tsx index 3cd0c855c2..d76d1606d2 100644 --- a/web/app/components/app/text-generate/item/index.spec.tsx +++ b/web/app/components/app/text-generate/item/index.spec.tsx @@ -5,28 +5,18 @@ import copy from 'copy-to-clipboard' import type { IGenerationItemProps } from './index' import GenerationItem from './index' -jest.mock('react-i18next', () => { - const translate = (key: string) => { - const map: Record = { - 'appDebug.errorMessage.waitForResponse': 'please-wait', - 'common.actionMsg.copySuccessfully': 'copied', - 'runLog.result': 'Result', - 'runLog.detail': 'Detail', - 'share.generation.execution': 'Execution', - 'share.generation.batchFailed.outputPlaceholder': 'failed', - 'common.unit.char': 'chars', - } - return map[key] ?? key - } - return { - useTranslation: () => ({ - t: translate, - }), - } -}) +// ============================================================================ +// Mock Setup +// ============================================================================ + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) jest.mock('next/navigation', () => ({ - useParams: jest.fn(), + useParams: jest.fn(() => ({ appId: 'app-123' })), })) jest.mock('@/app/components/base/chat/chat/context', () => ({ @@ -36,24 +26,27 @@ jest.mock('@/app/components/base/chat/chat/context', () => ({ })) jest.mock('@/app/components/app/store', () => { - const store = { - setCurrentLogItem: jest.fn(), - setShowPromptLogModal: jest.fn(), - } - const useStore = (selector?: (state: typeof store) => unknown) => (selector ? selector(store) : store) + const mockSetCurrentLogItem = jest.fn() + const mockSetShowPromptLogModal = jest.fn() return { - __esModule: true, - useStore, - __store: store, + useStore: (selector: (state: Record) => jest.Mock) => { + const store = { + setCurrentLogItem: mockSetCurrentLogItem, + setShowPromptLogModal: mockSetShowPromptLogModal, + } + return selector(store) + }, + __mockSetCurrentLogItem: mockSetCurrentLogItem, + __mockSetShowPromptLogModal: mockSetShowPromptLogModal, } }) jest.mock('@/app/components/base/toast', () => { - const notify = jest.fn() + const mockToastNotify = jest.fn() return { __esModule: true, - default: { notify }, - __notify: notify, + default: { notify: mockToastNotify }, + __mockToastNotify: mockToastNotify, } }) @@ -63,124 +56,175 @@ jest.mock('copy-to-clipboard', () => ({ })) jest.mock('@/service/share', () => { - const fetchMoreLikeThis = jest.fn() - const updateFeedback = jest.fn() + const mockFetchMoreLikeThis = jest.fn() + const mockUpdateFeedback = jest.fn() return { __esModule: true, - fetchMoreLikeThis, - updateFeedback, + fetchMoreLikeThis: mockFetchMoreLikeThis, + updateFeedback: mockUpdateFeedback, } }) -jest.mock('@/service/debug', () => ({ - __esModule: true, - fetchTextGenerationMessage: jest.fn(), -})) +jest.mock('@/service/debug', () => { + const mockFetchTextGenerationMessage = jest.fn() + return { + __esModule: true, + fetchTextGenerationMessage: mockFetchTextGenerationMessage, + } +}) jest.mock('@/app/components/base/loading', () => ({ __esModule: true, default: () =>
, })) +// Simple mock for ContentSection - just render content jest.mock('./content-section', () => { - const React = require('react') - const calls: any[] = [] - const Component = (props: any) => { - calls.push(props) - return React.createElement( - 'div', - { 'data-testid': `content-section-${props.taskId ?? 'none'}` }, - typeof props.content === 'string' ? props.content : 'content', + const mockContentSection = jest.fn() + const Component = (props: Record) => { + mockContentSection(props) + return ( +
+ {typeof props.content === 'string' ? props.content : 'content'} +
) } return { __esModule: true, default: Component, - __calls: calls, + __mockContentSection: mockContentSection, } }) +// Simple mock for MetaSection - expose action buttons based on props jest.mock('./meta-section', () => { - const React = require('react') - const calls: any[] = [] - const Component = (props: any) => { - calls.push(props) - return React.createElement( - 'div', - { 'data-testid': `meta-section-${props.messageId ?? 'none'}` }, - props.showCharCount && React.createElement('span', { 'data-testid': `char-count-${props.messageId ?? 'none'}` }, props.charCount), - !props.isInWebApp && !props.isInstalledApp && !props.isResponding && React.createElement( - 'button', - { - 'data-testid': `open-log-${props.messageId ?? 'none'}`, - 'disabled': !props.messageId || props.isError, - 'onClick': props.onOpenLogModal, - }, - 'open-log', - ), - props.moreLikeThis && React.createElement( - 'button', - { - 'data-testid': `more-like-${props.messageId ?? 'none'}`, - 'disabled': props.disableMoreLikeThis, - 'onClick': props.onMoreLikeThis, - }, - 'more-like', - ), - props.canCopy && React.createElement( - 'button', - { - 'data-testid': `copy-${props.messageId ?? 'none'}`, - 'disabled': !props.messageId || props.isError, - 'onClick': props.onCopy, - }, - 'copy', - ), - props.isInWebApp && props.isError && React.createElement( - 'button', - { 'data-testid': `retry-${props.messageId ?? 'none'}`, 'onClick': props.onRetry }, - 'retry', - ), - props.isInWebApp && !props.isWorkflow && React.createElement( - 'button', - { - 'data-testid': `save-${props.messageId ?? 'none'}`, - 'disabled': !props.messageId || props.isError, - 'onClick': () => props.onSave?.(props.messageId as string), - }, - 'save', - ), - (props.supportFeedback || props.isInWebApp) && !props.isWorkflow && !props.isError && props.messageId && props.onFeedback && React.createElement( - 'button', - { 'data-testid': `feedback-${props.messageId}`, 'onClick': () => props.onFeedback?.({ rating: 'like' }) }, - 'feedback', - ), + const mockMetaSection = jest.fn() + const Component = (props: Record) => { + 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 ( +
+ {showCharCount && ( + {charCount} + )} + {!isInWebApp && !isInstalledApp && !isResponding && ( + + )} + {moreLikeThis && ( + + )} + {canCopy && ( + + )} + {isInWebApp && isError && ( + + )} + {isInWebApp && !isWorkflow && ( + + )} + {(supportFeedback || isInWebApp) && !isWorkflow && !isError && messageId && onFeedback && ( + + )} +
) } return { __esModule: true, default: Component, - __calls: calls, + __mockMetaSection: mockMetaSection, } }) -const mockUseParams = jest.requireMock('next/navigation').useParams as jest.Mock -const mockStoreModule = jest.requireMock('@/app/components/app/store') -const mockStore = mockStoreModule.__store as { setCurrentLogItem: jest.Mock; setShowPromptLogModal: jest.Mock } -const mockToastNotify = jest.requireMock('@/app/components/base/toast').__notify as jest.Mock 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 contentSectionCalls = jest.requireMock('./content-section').__calls as any[] -const metaSectionCalls = jest.requireMock('./meta-section').__calls as any[] +const mockContentSection = jest.requireMock('./content-section').__mockContentSection as jest.Mock +const mockMetaSection = jest.requireMock('./meta-section').__mockMetaSection as jest.Mock -const translations = { - wait: 'please-wait', - copySuccess: 'copied', -} +// ============================================================================ +// Test Utilities +// ============================================================================ -const getBaseProps = (overrides?: Partial): IGenerationItemProps => ({ +/** + * Creates base props with sensible defaults for GenerationItem testing. + * Uses factory pattern for flexible test data creation. + */ +const createBaseProps = (overrides?: Partial): IGenerationItemProps => ({ isError: false, onRetry: jest.fn(), content: 'response text', @@ -192,6 +236,9 @@ const getBaseProps = (overrides?: Partial): IGenerationIte ...overrides, }) +/** + * Creates a deferred promise for testing async flows. + */ const createDeferred = () => { let resolve!: (value: T) => void const promise = new Promise((res) => { @@ -200,182 +247,543 @@ const createDeferred = () => { return { promise, resolve } } +// ============================================================================ +// Test Suite +// ============================================================================ + describe('GenerationItem', () => { beforeEach(() => { jest.clearAllMocks() - metaSectionCalls.length = 0 - contentSectionCalls.length = 0 mockUseParams.mockReturnValue({ appId: 'app-123' }) mockFetchMoreLikeThis.mockResolvedValue({ answer: 'child-answer', id: 'child-id' }) }) - it('exports a memoized component', () => { - expect((GenerationItem as any).$$typeof).toBe(Symbol.for('react.memo')) + // -------------------------------------------------------------------------- + // 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')) + }) }) - it('shows loading indicator when isLoading is true', () => { - render() - expect(screen.getByTestId('loading')).toBeInTheDocument() + // -------------------------------------------------------------------------- + // Loading State Tests + // Tests for the loading indicator display behavior + // -------------------------------------------------------------------------- + describe('Loading State', () => { + it('should show loading indicator when isLoading is true', () => { + render() + expect(screen.getByTestId('loading')).toBeInTheDocument() + }) + + it('should not show loading indicator when isLoading is false', () => { + render() + expect(screen.queryByTestId('loading')).not.toBeInTheDocument() + }) + + it('should not show loading indicator when isLoading is undefined', () => { + render() + expect(screen.queryByTestId('loading')).not.toBeInTheDocument() + }) }) - it('prevents requesting more-like-this when message id is missing', async () => { - const user = userEvent.setup() - render() + // -------------------------------------------------------------------------- + // 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() - await user.click(screen.getByTestId('more-like-none')) + await user.click(screen.getByTestId('more-like-none')) - expect(mockFetchMoreLikeThis).not.toHaveBeenCalled() - expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'warning', - message: translations.wait, - })) - }) - - it('guards more-like-this while querying', async () => { - const user = userEvent.setup() - const deferred = createDeferred<{ answer: string; id: string }>() - mockFetchMoreLikeThis.mockReturnValueOnce(deferred.promise) - - render() - - await user.click(screen.getByTestId('more-like-message-1')) - await waitFor(() => expect(mockFetchMoreLikeThis).toHaveBeenCalledTimes(1)) - await waitFor(() => expect(metaSectionCalls.length).toBeGreaterThan(1)) - - await user.click(screen.getByTestId('more-like-message-1')) - expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'warning', - message: translations.wait, - })) - - deferred.resolve({ answer: 'child-deferred', id: 'deferred-id' }) - await waitFor(() => expect(screen.getByTestId('meta-section-deferred-id')).toBeInTheDocument()) - }) - - it('fetches more-like-this content and renders the child item', async () => { - const user = userEvent.setup() - mockFetchMoreLikeThis.mockResolvedValue({ answer: 'child content', id: 'child-generated' }) - - render() - - 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()) - expect(metaSectionCalls.some(call => call.messageId === 'child-generated')).toBe(true) - }) - - it('opens log modal and formats the log payload', 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() - - await user.click(screen.getByTestId('open-log-log-id')) - - await waitFor(() => expect(mockFetchTextGenerationMessage).toHaveBeenCalledWith({ appId: 'app-123', messageId: 'log-id' })) - expect(mockStore.setCurrentLogItem).toHaveBeenCalledWith(expect.objectContaining({ - log: expect.arrayContaining([ - expect.objectContaining({ role: 'user', text: 'hi' }), + expect(mockFetchMoreLikeThis).not.toHaveBeenCalled() + expect(mockToastNotify).toHaveBeenCalledWith( expect.objectContaining({ - role: 'assistant', - text: 'assistant answer', - files: [{ id: 'a1', belongs_to: 'assistant' }], + type: 'warning', + message: 'appDebug.errorMessage.waitForResponse', }), - ]), - })) - expect(mockStore.setShowPromptLogModal).toHaveBeenCalledWith(true) + ) + }) + + it('should prevent request when message id is undefined', async () => { + const user = userEvent.setup() + render() + + 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() + + // 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() + + 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() + 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() + + await user.click(screen.getByTestId('more-like-message-1')) + await waitFor(() => + expect(screen.getByTestId('meta-section-child-to-clear')).toBeInTheDocument(), + ) + + rerender() + + 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( + , + ) + + await user.click(screen.getByTestId('more-like-message-1')) + + await waitFor(() => + expect(mockFetchMoreLikeThis).toHaveBeenCalledWith('message-1', true, 'install-123'), + ) + }) }) - it('copies plain and structured content for non-workflow responses', async () => { - const user = userEvent.setup() + // -------------------------------------------------------------------------- + // 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() - await user.click(screen.getByTestId('copy-copy-plain')) - expect(mockCopy).toHaveBeenCalledWith('copy me') + render() - render() - await user.click(screen.getByTestId('copy-copy-object')) - expect(mockCopy).toHaveBeenCalledWith(JSON.stringify({ foo: 'bar' })) + await user.click(screen.getByTestId('open-log-log-id')) - expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'success', - message: translations.copySuccess, - })) + 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() + + 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() + + // 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() + }) }) - it('copies workflow result text when available', async () => { - const user = userEvent.setup() - render( - , - ) + // -------------------------------------------------------------------------- + // Copy Functionality Tests + // Tests for copying content to clipboard + // -------------------------------------------------------------------------- + describe('Copy Functionality', () => { + it('should copy plain string content', async () => { + const user = userEvent.setup() - await waitFor(() => expect(screen.getByTestId('copy-workflow-id')).toBeInTheDocument()) - await user.click(screen.getByTestId('copy-workflow-id')) + render( + , + ) - expect(mockCopy).toHaveBeenCalledWith('workflow result') + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + await user.click(screen.getByTestId('copy-copy-empty')) + + expect(mockCopy).toHaveBeenCalledWith('') + }) }) - it('updates feedback for generated child message', async () => { - const user = userEvent.setup() - mockFetchMoreLikeThis.mockResolvedValue({ answer: 'child response', id: 'child-feedback' }) + // -------------------------------------------------------------------------- + // 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() + render( + , + ) - await user.click(screen.getByTestId('more-like-message-1')) - await waitFor(() => expect(screen.getByTestId('feedback-child-feedback')).toBeInTheDocument()) + 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')) + await user.click(screen.getByTestId('feedback-child-feedback')) - expect(mockUpdateFeedback).toHaveBeenCalledWith( - { url: '/messages/child-feedback/feedbacks', body: { rating: 'like' } }, - true, - 'install-1', - ) + expect(mockUpdateFeedback).toHaveBeenCalledWith( + { url: '/messages/child-feedback/feedbacks', body: { rating: 'like' } }, + true, + 'install-1', + ) + }) }) - it('clears generated child when controlClearMoreLikeThis changes', async () => { - const user = userEvent.setup() - mockFetchMoreLikeThis.mockResolvedValue({ answer: 'child response', id: 'child-to-clear' }) - const baseProps = getBaseProps() - const { rerender } = render() + // -------------------------------------------------------------------------- + // Props Passing Tests + // Tests verifying correct props are passed to child components + // -------------------------------------------------------------------------- + describe('Props Passing', () => { + it('should pass correct props to ContentSection', () => { + render( + , + ) - await user.click(screen.getByTestId('more-like-message-1')) - await waitFor(() => expect(screen.getByTestId('meta-section-child-to-clear')).toBeInTheDocument()) + expect(mockContentSection).toHaveBeenCalledWith( + expect.objectContaining({ + taskId: 'task-123', + isError: true, + content: 'test content', + hideProcessDetail: true, + depth: 1, + }), + ) + }) - rerender() + it('should pass correct props to MetaSection', () => { + render( + , + ) - await waitFor(() => expect(screen.queryByTestId('meta-section-child-to-clear')).not.toBeInTheDocument()) + 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() + + expect(mockMetaSection).toHaveBeenCalledWith( + expect.objectContaining({ + showCharCount: false, + }), + ) + }) }) - it('disables more-like-this at maximum depth', () => { - render() - expect(screen.getByTestId('more-like-max-depth')).toBeDisabled() + // -------------------------------------------------------------------------- + // 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( + , + ) + + 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() + + await user.click(screen.getByTestId('more-like-callback-more')) + + await waitFor(() => + expect(mockFetchMoreLikeThis).toHaveBeenCalledWith('callback-more', false, undefined), + ) + }) }) - it('keeps copy handler stable when unrelated props change', () => { - const props = getBaseProps({ messageId: 'stable-copy', moreLikeThis: false }) - const { rerender } = render() - const firstOnCopy = metaSectionCalls[metaSectionCalls.length - 1].onCopy + // -------------------------------------------------------------------------- + // Boundary Condition Tests + // Tests for edge cases and boundary conditions + // -------------------------------------------------------------------------- + describe('Boundary Conditions', () => { + it('should handle null content gracefully', () => { + render() + expect(screen.getByTestId('content-section-none')).toBeInTheDocument() + }) - rerender() - const lastOnCopy = metaSectionCalls[metaSectionCalls.length - 1].onCopy + it('should handle undefined content gracefully', () => { + render() + expect(screen.getByTestId('content-section-none')).toBeInTheDocument() + }) - expect(firstOnCopy).toBe(lastOnCopy) + it('should handle empty object content', () => { + render() + expect(screen.getByTestId('content-section-none')).toBeInTheDocument() + }) + + it('should handle depth at boundary (depth = 1)', () => { + render() + expect(mockMetaSection).toHaveBeenCalledWith( + expect.objectContaining({ + disableMoreLikeThis: false, + }), + ) + }) + + it('should handle depth at boundary (depth = 2)', () => { + render() + expect(mockMetaSection).toHaveBeenCalledWith( + expect.objectContaining({ + disableMoreLikeThis: false, + }), + ) + }) + + it('should handle depth at maximum (depth = 3)', () => { + render() + expect(mockMetaSection).toHaveBeenCalledWith( + expect.objectContaining({ + disableMoreLikeThis: true, + }), + ) + }) + + it('should handle missing appId in params', async () => { + const user = userEvent.setup() + mockUseParams.mockReturnValue({}) + + render() + + 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() + 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() + + 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( + , + ) + + expect(screen.getByTestId('retry-retry-error')).toBeInTheDocument() + }) + + it('should disable copy button on error', () => { + render( + , + ) + + expect(screen.getByTestId('copy-copy-error')).toBeDisabled() + }) }) }) diff --git a/web/app/components/app/text-generate/item/meta-section.spec.tsx b/web/app/components/app/text-generate/item/meta-section.spec.tsx new file mode 100644 index 0000000000..d99859a226 --- /dev/null +++ b/web/app/components/app/text-generate/item/meta-section.spec.tsx @@ -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 + }) => ( + + ), + 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 }) => ( + + ), +})) + +// ============================================================================ +// Test Utilities +// ============================================================================ + +const mockT = ((key: string) => key) as TFunction + +/** + * Creates base props with sensible defaults for MetaSection testing. + */ +const createBaseProps = (overrides?: Partial[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() + expect(screen.getByText(/150/)).toBeInTheDocument() + expect(screen.getByText(/common.unit.char/)).toBeInTheDocument() + }) + + it('should not show character count when showCharCount is false', () => { + render() + expect(screen.queryByText(/150/)).not.toBeInTheDocument() + }) + + it('should handle zero character count', () => { + render() + expect(screen.getByText(/0/)).toBeInTheDocument() + }) + + it('should handle undefined character count', () => { + render() + 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( + , + ) + // 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( + , + ) + // 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( + , + ) + }) + + it('should not show log button when isResponding is true', () => { + const onOpenLogModal = jest.fn() + render( + , + ) + }) + + it('should disable log button when isError is true', () => { + const onOpenLogModal = jest.fn() + render( + , + ) + 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( + , + ) + 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() + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThan(0) + }) + + it('should not show more like this button when moreLikeThis is false', () => { + render() + // Button count should be lower + }) + + it('should disable more like this button when disableMoreLikeThis is true', () => { + render( + , + ) + 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( + , + ) + 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( + , + ) + 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( + , + ) + expect(screen.queryByTestId('audio-button')).not.toBeInTheDocument() + }) + + it('should not show audio button when messageId is null', () => { + render( + , + ) + 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() + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThan(0) + }) + + it('should not show copy button when canCopy is false', () => { + render() + // Copy button should not be rendered + }) + + it('should disable copy button when isError is true', () => { + render( + , + ) + 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( + , + ) + 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( + , + ) + 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( + , + ) + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThan(0) + }) + + it('should not show retry button when isInWebApp is false', () => { + render( + , + ) + // Retry button should not render + }) + + it('should not show retry button when isError is false', () => { + render( + , + ) + // Retry button should not render + }) + + it('should call onRetry when retry button is clicked', () => { + const onRetry = jest.fn() + render( + , + ) + 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( + , + ) + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThan(0) + }) + + it('should not show save button when isWorkflow is true', () => { + render( + , + ) + // Save button should not render in workflow mode + }) + + it('should disable save button when isError is true', () => { + render( + , + ) + 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( + , + ) + 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( + , + ) + 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( + , + ) + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThan(0) + }) + + it('should show feedback when isInWebApp is true', () => { + render( + , + ) + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThan(0) + }) + + it('should not show feedback when isWorkflow is true', () => { + render( + , + ) + // Feedback section should not render + }) + + it('should not show feedback when isError is true', () => { + render( + , + ) + // Feedback section should not render + }) + + it('should not show feedback when messageId is null', () => { + render( + , + ) + // Feedback section should not render + }) + + it('should not show feedback when onFeedback is undefined', () => { + render( + , + ) + // Feedback section should not render + }) + + it('should handle like feedback state', () => { + render( + , + ) + 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( + , + ) + 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( + , + ) + expect(container.firstChild).toHaveClass('pl-10') + }) + + it('should not apply indent class when shouldIndentForChild is false', () => { + const { container } = render( + , + ) + 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( + , + ) + // Should render without crashing + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThan(0) // At least log button + }) + + it('should handle undefined messageId', () => { + render() + // Should render without crashing + }) + + it('should handle empty string messageId', () => { + render() + // 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( + , + ) + const audioButton = screen.getByTestId('audio-button') + // undefined becomes null in data attribute + expect(audioButton).not.toHaveAttribute('data-voice', 'some-value') + }) + }) +})