mirror of
https://github.com/langgenius/dify.git
synced 2026-04-24 21:05:48 +08:00
test: add unit tests for rag-pipeline components to improve coverage and reliability
- Introduced new test files for RagPipelineChildren, PipelineScreenShot, QAItem, and various input field components. - Enhanced testing for rendering, interactions, and state management across multiple components. - Ensured comprehensive coverage for input field utilities and document processing functionalities.
This commit is contained in:
@ -0,0 +1,141 @@
|
||||
import type { EnvironmentVariable } from '@/app/components/workflow/types'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { DSL_EXPORT_CHECK } from '@/app/components/workflow/constants'
|
||||
import RagPipelineChildren from '../rag-pipeline-children'
|
||||
|
||||
let mockShowImportDSLModal = false
|
||||
let mockSubscription: ((value: { type: string, payload?: { data?: EnvironmentVariable[] } }) => void) | null = null
|
||||
|
||||
const {
|
||||
mockSetShowImportDSLModal,
|
||||
mockHandlePaneContextmenuCancel,
|
||||
mockExportCheck,
|
||||
mockHandleExportDSL,
|
||||
mockUseRagPipelineSearch,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSetShowImportDSLModal: vi.fn((value: boolean) => {
|
||||
mockShowImportDSLModal = value
|
||||
}),
|
||||
mockHandlePaneContextmenuCancel: vi.fn(),
|
||||
mockExportCheck: vi.fn(),
|
||||
mockHandleExportDSL: vi.fn(),
|
||||
mockUseRagPipelineSearch: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: {
|
||||
useSubscription: (callback: (value: { type: string, payload?: { data?: EnvironmentVariable[] } }) => void) => {
|
||||
mockSubscription = callback
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: {
|
||||
showImportDSLModal: boolean
|
||||
setShowImportDSLModal: typeof mockSetShowImportDSLModal
|
||||
}) => unknown) => selector({
|
||||
showImportDSLModal: mockShowImportDSLModal,
|
||||
setShowImportDSLModal: mockSetShowImportDSLModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useDSL: () => ({
|
||||
exportCheck: mockExportCheck,
|
||||
handleExportDSL: mockHandleExportDSL,
|
||||
}),
|
||||
usePanelInteractions: () => ({
|
||||
handlePaneContextmenuCancel: mockHandlePaneContextmenuCancel,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/use-rag-pipeline-search', () => ({
|
||||
useRagPipelineSearch: mockUseRagPipelineSearch,
|
||||
}))
|
||||
|
||||
vi.mock('../../../workflow/plugin-dependency', () => ({
|
||||
default: () => <div data-testid="plugin-dependency" />,
|
||||
}))
|
||||
|
||||
vi.mock('../panel', () => ({
|
||||
default: () => <div data-testid="rag-panel" />,
|
||||
}))
|
||||
|
||||
vi.mock('../publish-toast', () => ({
|
||||
default: () => <div data-testid="publish-toast" />,
|
||||
}))
|
||||
|
||||
vi.mock('../rag-pipeline-header', () => ({
|
||||
default: () => <div data-testid="rag-header" />,
|
||||
}))
|
||||
|
||||
vi.mock('../update-dsl-modal', () => ({
|
||||
default: ({ onCancel }: { onCancel: () => void }) => (
|
||||
<div data-testid="update-dsl-modal">
|
||||
<button onClick={onCancel}>close import</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
|
||||
default: ({
|
||||
envList,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: {
|
||||
envList: EnvironmentVariable[]
|
||||
onConfirm: () => void
|
||||
onClose: () => void
|
||||
}) => (
|
||||
<div data-testid="dsl-export-modal">
|
||||
<div>{envList.map(env => env.name).join(',')}</div>
|
||||
<button onClick={onConfirm}>confirm export</button>
|
||||
<button onClick={onClose}>close export</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('RagPipelineChildren', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockShowImportDSLModal = false
|
||||
mockSubscription = null
|
||||
})
|
||||
|
||||
it('should render the main pipeline children and the import modal when enabled', () => {
|
||||
mockShowImportDSLModal = true
|
||||
|
||||
render(<RagPipelineChildren />)
|
||||
|
||||
fireEvent.click(screen.getByText('close import'))
|
||||
|
||||
expect(mockUseRagPipelineSearch).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByTestId('plugin-dependency')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('rag-header')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('rag-panel')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('publish-toast')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('update-dsl-modal')).toBeInTheDocument()
|
||||
expect(mockSetShowImportDSLModal).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should show the DSL export confirmation modal after receiving the export event', () => {
|
||||
render(<RagPipelineChildren />)
|
||||
|
||||
act(() => {
|
||||
mockSubscription?.({
|
||||
type: DSL_EXPORT_CHECK,
|
||||
payload: {
|
||||
data: [{ name: 'API_KEY' } as EnvironmentVariable],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('confirm export'))
|
||||
|
||||
expect(screen.getByTestId('dsl-export-modal')).toHaveTextContent('API_KEY')
|
||||
expect(mockHandleExportDSL).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,29 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import PipelineScreenShot from '../screenshot'
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({
|
||||
theme: 'dark',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', () => ({
|
||||
basePath: '/console',
|
||||
}))
|
||||
|
||||
describe('PipelineScreenShot', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should build themed screenshot sources', () => {
|
||||
const { container } = render(<PipelineScreenShot />)
|
||||
const sources = container.querySelectorAll('source')
|
||||
|
||||
expect(sources).toHaveLength(3)
|
||||
expect(sources[0]).toHaveAttribute('srcset', '/console/screenshots/dark/Pipeline.png')
|
||||
expect(sources[1]).toHaveAttribute('srcset', '/console/screenshots/dark/Pipeline@2x.png')
|
||||
expect(sources[2]).toHaveAttribute('srcset', '/console/screenshots/dark/Pipeline@3x.png')
|
||||
expect(screen.getByAltText('Pipeline Screenshot')).toHaveAttribute('src', '/console/screenshots/dark/Pipeline.png')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,23 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import QAItem from '../q-a-item'
|
||||
import { QAItemType } from '../types'
|
||||
|
||||
describe('QAItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the question prefix', () => {
|
||||
render(<QAItem type={QAItemType.Question} text="What is Dify?" />)
|
||||
|
||||
expect(screen.getByText('Q')).toBeInTheDocument()
|
||||
expect(screen.getByText('What is Dify?')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the answer prefix', () => {
|
||||
render(<QAItem type={QAItemType.Answer} text="An LLM app platform." />)
|
||||
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
expect(screen.getByText('An LLM app platform.')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,97 @@
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { VAR_ITEM_TEMPLATE_IN_PIPELINE } from '@/config'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { convertFormDataToINputField, convertToInputFieldFormData } from '../utils'
|
||||
|
||||
describe('input-field editor utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should convert pipeline input vars into form data', () => {
|
||||
const result = convertToInputFieldFormData({
|
||||
type: PipelineInputVarType.multiFiles,
|
||||
label: 'Upload files',
|
||||
variable: 'documents',
|
||||
max_length: 5,
|
||||
default_value: 'initial-value',
|
||||
required: false,
|
||||
tooltips: 'Tooltip text',
|
||||
options: ['a', 'b'],
|
||||
placeholder: 'Select files',
|
||||
unit: 'MB',
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
allowed_file_types: [SupportUploadFileTypes.document],
|
||||
allowed_file_extensions: ['pdf'],
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
type: PipelineInputVarType.multiFiles,
|
||||
label: 'Upload files',
|
||||
variable: 'documents',
|
||||
maxLength: 5,
|
||||
default: 'initial-value',
|
||||
required: false,
|
||||
tooltips: 'Tooltip text',
|
||||
options: ['a', 'b'],
|
||||
placeholder: 'Select files',
|
||||
unit: 'MB',
|
||||
allowedFileUploadMethods: [TransferMethod.local_file],
|
||||
allowedTypesAndExtensions: {
|
||||
allowedFileTypes: [SupportUploadFileTypes.document],
|
||||
allowedFileExtensions: ['pdf'],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should fall back to the default input variable template', () => {
|
||||
const result = convertToInputFieldFormData()
|
||||
|
||||
expect(result).toEqual({
|
||||
type: VAR_ITEM_TEMPLATE_IN_PIPELINE.type,
|
||||
label: VAR_ITEM_TEMPLATE_IN_PIPELINE.label,
|
||||
variable: VAR_ITEM_TEMPLATE_IN_PIPELINE.variable,
|
||||
maxLength: undefined,
|
||||
required: VAR_ITEM_TEMPLATE_IN_PIPELINE.required,
|
||||
options: VAR_ITEM_TEMPLATE_IN_PIPELINE.options,
|
||||
allowedTypesAndExtensions: {},
|
||||
})
|
||||
})
|
||||
|
||||
it('should convert form data back into pipeline input variables', () => {
|
||||
const result = convertFormDataToINputField({
|
||||
type: PipelineInputVarType.select,
|
||||
label: 'Category',
|
||||
variable: 'category',
|
||||
maxLength: 10,
|
||||
default: 'books',
|
||||
required: true,
|
||||
tooltips: 'Pick one',
|
||||
options: ['books', 'music'],
|
||||
placeholder: 'Choose',
|
||||
unit: '',
|
||||
allowedFileUploadMethods: [TransferMethod.local_file],
|
||||
allowedTypesAndExtensions: {
|
||||
allowedFileTypes: [SupportUploadFileTypes.document],
|
||||
allowedFileExtensions: ['txt'],
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
type: PipelineInputVarType.select,
|
||||
label: 'Category',
|
||||
variable: 'category',
|
||||
max_length: 10,
|
||||
default_value: 'books',
|
||||
required: true,
|
||||
tooltips: 'Pick one',
|
||||
options: ['books', 'music'],
|
||||
placeholder: 'Choose',
|
||||
unit: '',
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
allowed_file_types: [SupportUploadFileTypes.document],
|
||||
allowed_file_extensions: ['txt'],
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,79 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import { useStore } from '@tanstack/react-form'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import HiddenFields from '../hidden-fields'
|
||||
import { useHiddenConfigurations } from '../hooks'
|
||||
|
||||
type MockForm = {
|
||||
store: object
|
||||
}
|
||||
|
||||
const {
|
||||
mockForm,
|
||||
mockInputField,
|
||||
} = vi.hoisted(() => ({
|
||||
mockForm: {
|
||||
store: {},
|
||||
} as MockForm,
|
||||
mockInputField: vi.fn(({ config }: { config: { variable: string } }) => {
|
||||
return function FieldComponent() {
|
||||
return <div data-testid="input-field">{config.variable}</div>
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-form', () => ({
|
||||
useStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/form', () => ({
|
||||
withForm: ({ render }: {
|
||||
render: (props: { form: MockForm }) => React.ReactNode
|
||||
}) => ({ form }: { form?: MockForm }) => render({ form: form ?? mockForm }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/form/form-scenarios/input-field/field', () => ({
|
||||
default: mockInputField,
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useHiddenConfigurations: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('HiddenFields', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useStore).mockImplementation((_, selector) => selector({
|
||||
values: {
|
||||
options: ['option-a', 'option-b'],
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
it('should build fields from the hidden configuration list', () => {
|
||||
vi.mocked(useHiddenConfigurations).mockReturnValue([
|
||||
{ variable: 'default' },
|
||||
{ variable: 'tooltips' },
|
||||
] as ReturnType<typeof useHiddenConfigurations>)
|
||||
|
||||
const HiddenFieldsComp = HiddenFields({ initialData: { variable: 'field_1' } }) as unknown as ComponentType
|
||||
render(<HiddenFieldsComp />)
|
||||
|
||||
expect(useHiddenConfigurations).toHaveBeenCalledWith({
|
||||
options: ['option-a', 'option-b'],
|
||||
})
|
||||
expect(mockInputField).toHaveBeenCalledTimes(2)
|
||||
expect(screen.getAllByTestId('input-field')).toHaveLength(2)
|
||||
expect(screen.getByText('default')).toBeInTheDocument()
|
||||
expect(screen.getByText('tooltips')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render nothing when there are no hidden configurations', () => {
|
||||
vi.mocked(useHiddenConfigurations).mockReturnValue([])
|
||||
|
||||
const HiddenFieldsComp = HiddenFields({}) as unknown as ComponentType
|
||||
const { container } = render(<HiddenFieldsComp />)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,85 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useConfigurations } from '../hooks'
|
||||
import InitialFields from '../initial-fields'
|
||||
|
||||
type MockForm = {
|
||||
store: object
|
||||
getFieldValue: (fieldName: string) => unknown
|
||||
setFieldValue: (fieldName: string, value: unknown) => void
|
||||
}
|
||||
|
||||
const {
|
||||
mockForm,
|
||||
mockInputField,
|
||||
} = vi.hoisted(() => ({
|
||||
mockForm: {
|
||||
store: {},
|
||||
getFieldValue: vi.fn(),
|
||||
setFieldValue: vi.fn(),
|
||||
} as MockForm,
|
||||
mockInputField: vi.fn(({ config }: { config: { variable: string } }) => {
|
||||
return function FieldComponent() {
|
||||
return <div data-testid="input-field">{config.variable}</div>
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/form', () => ({
|
||||
withForm: ({ render }: {
|
||||
render: (props: { form: MockForm }) => React.ReactNode
|
||||
}) => ({ form }: { form?: MockForm }) => render({ form: form ?? mockForm }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/form/form-scenarios/input-field/field', () => ({
|
||||
default: mockInputField,
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useConfigurations: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('InitialFields', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should build initial fields with the form accessors and supportFile flag', () => {
|
||||
vi.mocked(useConfigurations).mockReturnValue([
|
||||
{ variable: 'type' },
|
||||
{ variable: 'label' },
|
||||
] as ReturnType<typeof useConfigurations>)
|
||||
|
||||
const InitialFieldsComp = InitialFields({
|
||||
initialData: { variable: 'field_1' },
|
||||
supportFile: true,
|
||||
}) as unknown as ComponentType
|
||||
render(<InitialFieldsComp />)
|
||||
|
||||
expect(useConfigurations).toHaveBeenCalledWith(expect.objectContaining({
|
||||
supportFile: true,
|
||||
getFieldValue: expect.any(Function),
|
||||
setFieldValue: expect.any(Function),
|
||||
}))
|
||||
expect(screen.getAllByTestId('input-field')).toHaveLength(2)
|
||||
expect(screen.getByText('type')).toBeInTheDocument()
|
||||
expect(screen.getByText('label')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should delegate field accessors to the underlying form instance', () => {
|
||||
vi.mocked(useConfigurations).mockReturnValue([] as ReturnType<typeof useConfigurations>)
|
||||
mockForm.getFieldValue = vi.fn(() => 'label-value')
|
||||
mockForm.setFieldValue = vi.fn()
|
||||
|
||||
const InitialFieldsComp = InitialFields({ supportFile: false }) as unknown as ComponentType
|
||||
render(<InitialFieldsComp />)
|
||||
|
||||
const call = vi.mocked(useConfigurations).mock.calls[0]?.[0]
|
||||
const value = call?.getFieldValue('label')
|
||||
call?.setFieldValue('label', 'next-value')
|
||||
|
||||
expect(value).toBe('label-value')
|
||||
expect(mockForm.getFieldValue).toHaveBeenCalledWith('label')
|
||||
expect(mockForm.setFieldValue).toHaveBeenCalledWith('label', 'next-value')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,69 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import { useStore } from '@tanstack/react-form'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import { useHiddenFieldNames } from '../hooks'
|
||||
import ShowAllSettings from '../show-all-settings'
|
||||
|
||||
type MockForm = {
|
||||
store: object
|
||||
}
|
||||
|
||||
const mockForm = vi.hoisted(() => ({
|
||||
store: {},
|
||||
})) as MockForm
|
||||
|
||||
vi.mock('@tanstack/react-form', () => ({
|
||||
useStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/form', () => ({
|
||||
withForm: ({ render }: {
|
||||
render: (props: { form: MockForm }) => React.ReactNode
|
||||
}) => ({ form }: { form?: MockForm }) => render({ form: form ?? mockForm }),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useHiddenFieldNames: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('ShowAllSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useStore).mockImplementation((_, selector) => selector({
|
||||
values: {
|
||||
type: PipelineInputVarType.textInput,
|
||||
},
|
||||
}))
|
||||
vi.mocked(useHiddenFieldNames).mockReturnValue('default value, placeholder')
|
||||
})
|
||||
|
||||
it('should render the summary and hidden field names', () => {
|
||||
const ShowAllSettingsComp = ShowAllSettings({
|
||||
handleShowAllSettings: vi.fn(),
|
||||
}) as unknown as ComponentType
|
||||
render(<ShowAllSettingsComp />)
|
||||
|
||||
expect(useHiddenFieldNames).toHaveBeenCalledWith(PipelineInputVarType.textInput)
|
||||
expect(screen.getByText('appDebug.variableConfig.showAllSettings')).toBeInTheDocument()
|
||||
expect(screen.getByText('default value, placeholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call the click handler when the row is pressed', () => {
|
||||
const handleShowAllSettings = vi.fn()
|
||||
const ShowAllSettingsComp = ShowAllSettings({
|
||||
handleShowAllSettings,
|
||||
}) as unknown as ComponentType
|
||||
render(<ShowAllSettingsComp />)
|
||||
|
||||
fireEvent.click(screen.getByText('appDebug.variableConfig.showAllSettings'))
|
||||
|
||||
expect(handleShowAllSettings).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,89 @@
|
||||
import type { InputVar } from '@/models/pipeline'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import FieldItem from '../field-item'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createInputVar = (overrides: Partial<InputVar> = {}): InputVar => ({
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'Field Label',
|
||||
variable: 'field_name',
|
||||
max_length: 48,
|
||||
default_value: '',
|
||||
required: true,
|
||||
tooltips: '',
|
||||
options: [],
|
||||
placeholder: '',
|
||||
unit: '',
|
||||
allowed_file_upload_methods: [],
|
||||
allowed_file_types: [],
|
||||
allowed_file_extensions: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('FieldItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the variable, label, and required badge', () => {
|
||||
render(
|
||||
<FieldItem
|
||||
payload={createInputVar()}
|
||||
index={0}
|
||||
onClickEdit={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('field_name')).toBeInTheDocument()
|
||||
expect(screen.getByText('Field Label')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.start.required')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show edit and delete controls on hover and trigger both callbacks', () => {
|
||||
const onClickEdit = vi.fn()
|
||||
const onRemove = vi.fn()
|
||||
const { container } = render(
|
||||
<FieldItem
|
||||
payload={createInputVar({ variable: 'custom_field' })}
|
||||
index={2}
|
||||
onClickEdit={onClickEdit}
|
||||
onRemove={onRemove}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.mouseEnter(container.firstChild!)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
fireEvent.click(buttons[0])
|
||||
fireEvent.click(buttons[1])
|
||||
|
||||
expect(onClickEdit).toHaveBeenCalledWith('custom_field')
|
||||
expect(onRemove).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('should keep the row readonly when readonly is enabled', () => {
|
||||
const onClickEdit = vi.fn()
|
||||
const onRemove = vi.fn()
|
||||
const { container } = render(
|
||||
<FieldItem
|
||||
readonly
|
||||
payload={createInputVar()}
|
||||
index={0}
|
||||
onClickEdit={onClickEdit}
|
||||
onRemove={onRemove}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.mouseEnter(container.firstChild!)
|
||||
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(0)
|
||||
expect(onClickEdit).not.toHaveBeenCalled()
|
||||
expect(onRemove).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,91 @@
|
||||
import type { InputVar } from '@/models/pipeline'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import FieldListContainer from '../field-list-container'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('react-sortablejs', () => ({
|
||||
ReactSortable: ({
|
||||
children,
|
||||
list,
|
||||
setList,
|
||||
disabled,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
list: Array<{ id: string }>
|
||||
setList: (list: Array<{ id: string }>) => void
|
||||
disabled?: boolean
|
||||
}) => (
|
||||
<div data-testid="sortable" data-disabled={String(disabled)}>
|
||||
{children}
|
||||
<button onClick={() => setList(list)}>same list</button>
|
||||
<button onClick={() => setList([...list].reverse())}>reverse list</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createInputVar = (variable: string): InputVar => ({
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: variable,
|
||||
variable,
|
||||
max_length: 48,
|
||||
default_value: '',
|
||||
required: true,
|
||||
tooltips: '',
|
||||
options: [],
|
||||
placeholder: '',
|
||||
unit: '',
|
||||
allowed_file_upload_methods: [],
|
||||
allowed_file_types: [],
|
||||
allowed_file_extensions: [],
|
||||
})
|
||||
|
||||
describe('FieldListContainer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the field items and ignore unchanged sort events', () => {
|
||||
const onListSortChange = vi.fn()
|
||||
render(
|
||||
<FieldListContainer
|
||||
inputFields={[createInputVar('field_1'), createInputVar('field_2')]}
|
||||
onListSortChange={onListSortChange}
|
||||
onRemoveField={vi.fn()}
|
||||
onEditField={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('same list'))
|
||||
|
||||
expect(screen.getAllByText('field_1')).toHaveLength(2)
|
||||
expect(screen.getAllByText('field_2')).toHaveLength(2)
|
||||
expect(onListSortChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should forward changed sort lists and honor readonly mode', () => {
|
||||
const onListSortChange = vi.fn()
|
||||
render(
|
||||
<FieldListContainer
|
||||
readonly
|
||||
inputFields={[createInputVar('field_1'), createInputVar('field_2')]}
|
||||
onListSortChange={onListSortChange}
|
||||
onRemoveField={vi.fn()}
|
||||
onEditField={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('reverse list'))
|
||||
|
||||
expect(screen.getByTestId('sortable')).toHaveAttribute('data-disabled', 'true')
|
||||
expect(onListSortChange).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ id: 'field_2' }),
|
||||
expect.objectContaining({ id: 'field_1' }),
|
||||
])
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,24 @@
|
||||
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Datasource from '../datasource'
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useToolIcon: () => 'tool-icon',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/block-icon', () => ({
|
||||
default: ({ toolIcon }: { toolIcon: string }) => <div data-testid="block-icon">{toolIcon}</div>,
|
||||
}))
|
||||
|
||||
describe('Datasource', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the datasource title and icon', () => {
|
||||
render(<Datasource nodeData={{ title: 'Knowledge Base' } as DataSourceNodeType} />)
|
||||
|
||||
expect(screen.getByTestId('block-icon')).toHaveTextContent('tool-icon')
|
||||
expect(screen.getByText('Knowledge Base')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,29 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import GlobalInputs from '../global-inputs'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({
|
||||
popupContent,
|
||||
}: {
|
||||
popupContent: React.ReactNode
|
||||
}) => <div data-testid="tooltip">{popupContent}</div>,
|
||||
}))
|
||||
|
||||
describe('GlobalInputs', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the title and tooltip copy', () => {
|
||||
render(<GlobalInputs />)
|
||||
|
||||
expect(screen.getByText('datasetPipeline.inputFieldPanel.globalInputs.title')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tooltip')).toHaveTextContent('datasetPipeline.inputFieldPanel.globalInputs.tooltip')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,79 @@
|
||||
import type { Datasource } from '../../../test-run/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import DataSource from '../data-source'
|
||||
|
||||
const {
|
||||
mockOnSelect,
|
||||
mockUseDraftPipelinePreProcessingParams,
|
||||
} = vi.hoisted(() => ({
|
||||
mockOnSelect: vi.fn(),
|
||||
mockUseDraftPipelinePreProcessingParams: vi.fn(() => ({
|
||||
data: {
|
||||
variables: [{ variable: 'source' }],
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: { pipelineId: string }) => string) => selector({ pipelineId: 'pipeline-1' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
useDraftPipelinePreProcessingParams: mockUseDraftPipelinePreProcessingParams,
|
||||
}))
|
||||
|
||||
vi.mock('../../../test-run/preparation/data-source-options', () => ({
|
||||
default: ({
|
||||
onSelect,
|
||||
dataSourceNodeId,
|
||||
}: {
|
||||
onSelect: (data: Datasource) => void
|
||||
dataSourceNodeId: string
|
||||
}) => (
|
||||
<div data-testid="data-source-options" data-node-id={dataSourceNodeId}>
|
||||
<button
|
||||
onClick={() => onSelect({ nodeId: 'source-node' } as Datasource)}
|
||||
>
|
||||
select datasource
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../form', () => ({
|
||||
default: ({ variables }: { variables: Array<{ variable: string }> }) => (
|
||||
<div data-testid="preview-form">{variables.map(item => item.variable).join(',')}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('DataSource preview', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the datasource selection step and forward selected values', () => {
|
||||
render(
|
||||
<DataSource
|
||||
onSelect={mockOnSelect}
|
||||
dataSourceNodeId="node-1"
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('select datasource'))
|
||||
|
||||
expect(screen.getByText('datasetPipeline.inputFieldPanel.preview.stepOneTitle')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('data-source-options')).toHaveAttribute('data-node-id', 'node-1')
|
||||
expect(screen.getByTestId('preview-form')).toHaveTextContent('source')
|
||||
expect(mockUseDraftPipelinePreProcessingParams).toHaveBeenCalledWith({
|
||||
pipeline_id: 'pipeline-1',
|
||||
node_id: 'node-1',
|
||||
}, true)
|
||||
expect(mockOnSelect).toHaveBeenCalledWith({ nodeId: 'source-node' })
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,64 @@
|
||||
import type { RAGPipelineVariables } from '@/models/pipeline'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Form from '../form'
|
||||
|
||||
type MockForm = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const {
|
||||
mockForm,
|
||||
mockBaseField,
|
||||
mockUseInitialData,
|
||||
mockUseConfigurations,
|
||||
} = vi.hoisted(() => ({
|
||||
mockForm: {
|
||||
id: 'form-1',
|
||||
} as MockForm,
|
||||
mockBaseField: vi.fn(({ config }: { config: { variable: string } }) => {
|
||||
return function FieldComponent() {
|
||||
return <div data-testid="base-field">{config.variable}</div>
|
||||
}
|
||||
}),
|
||||
mockUseInitialData: vi.fn(() => ({ source: 'node-1' })),
|
||||
mockUseConfigurations: vi.fn(() => [{ variable: 'source' }, { variable: 'chunkSize' }]),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/form', () => ({
|
||||
useAppForm: () => mockForm,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/form/form-scenarios/base/field', () => ({
|
||||
default: mockBaseField,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/rag-pipeline/hooks/use-input-fields', () => ({
|
||||
useInitialData: mockUseInitialData,
|
||||
useConfigurations: mockUseConfigurations,
|
||||
}))
|
||||
|
||||
describe('Preview form', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should build fields from the pipeline variable configuration', () => {
|
||||
render(<Form variables={[{ variable: 'source' }] as unknown as RAGPipelineVariables} />)
|
||||
|
||||
expect(mockUseInitialData).toHaveBeenCalled()
|
||||
expect(mockUseConfigurations).toHaveBeenCalled()
|
||||
expect(screen.getAllByTestId('base-field')).toHaveLength(2)
|
||||
expect(screen.getByText('source')).toBeInTheDocument()
|
||||
expect(screen.getByText('chunkSize')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should prevent the native form submission', () => {
|
||||
const { container } = render(<Form variables={[] as unknown as RAGPipelineVariables} />)
|
||||
const form = container.querySelector('form')!
|
||||
const submitEvent = new Event('submit', { bubbles: true, cancelable: true })
|
||||
|
||||
form.dispatchEvent(submitEvent)
|
||||
|
||||
expect(submitEvent.defaultPrevented).toBe(true)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,45 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import ProcessDocuments from '../process-documents'
|
||||
|
||||
const mockUseDraftPipelineProcessingParams = vi.hoisted(() => vi.fn(() => ({
|
||||
data: {
|
||||
variables: [{ variable: 'chunkSize' }],
|
||||
},
|
||||
})))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: { pipelineId: string }) => string) => selector({ pipelineId: 'pipeline-1' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
useDraftPipelineProcessingParams: mockUseDraftPipelineProcessingParams,
|
||||
}))
|
||||
|
||||
vi.mock('../form', () => ({
|
||||
default: ({ variables }: { variables: Array<{ variable: string }> }) => (
|
||||
<div data-testid="preview-form">{variables.map(item => item.variable).join(',')}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('ProcessDocuments preview', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the processing step and its variables', () => {
|
||||
render(<ProcessDocuments dataSourceNodeId="node-2" />)
|
||||
|
||||
expect(screen.getByText('datasetPipeline.inputFieldPanel.preview.stepTwoTitle')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('preview-form')).toHaveTextContent('chunkSize')
|
||||
expect(mockUseDraftPipelineProcessingParams).toHaveBeenCalledWith({
|
||||
pipeline_id: 'pipeline-1',
|
||||
node_id: 'node-2',
|
||||
}, true)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,70 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Header from '../header'
|
||||
|
||||
const {
|
||||
mockSetIsPreparingDataSource,
|
||||
mockHandleCancelDebugAndPreviewPanel,
|
||||
mockWorkflowStore,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSetIsPreparingDataSource: vi.fn(),
|
||||
mockHandleCancelDebugAndPreviewPanel: vi.fn(),
|
||||
mockWorkflowStore: {
|
||||
getState: vi.fn(() => ({
|
||||
isPreparingDataSource: true,
|
||||
setIsPreparingDataSource: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => mockWorkflowStore,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useWorkflowInteractions: () => ({
|
||||
handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiCloseLine: () => <span data-testid="close-icon" />,
|
||||
}))
|
||||
|
||||
describe('TestRun header', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkflowStore.getState.mockReturnValue({
|
||||
isPreparingDataSource: true,
|
||||
setIsPreparingDataSource: mockSetIsPreparingDataSource,
|
||||
})
|
||||
})
|
||||
|
||||
it('should render the title and reset preparing state on close', () => {
|
||||
render(<Header />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('close-icon').parentElement!)
|
||||
|
||||
expect(screen.getByText('datasetPipeline.testRun.title')).toBeInTheDocument()
|
||||
expect(mockSetIsPreparingDataSource).toHaveBeenCalledWith(false)
|
||||
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should only cancel the panel when the datasource preparation flag is false', () => {
|
||||
mockWorkflowStore.getState.mockReturnValue({
|
||||
isPreparingDataSource: false,
|
||||
setIsPreparingDataSource: mockSetIsPreparingDataSource,
|
||||
})
|
||||
|
||||
render(<Header />)
|
||||
fireEvent.click(screen.getByTestId('close-icon').parentElement!)
|
||||
|
||||
expect(mockSetIsPreparingDataSource).not.toHaveBeenCalled()
|
||||
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,20 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import FooterTips from '../footer-tips'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('FooterTips', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the localized footer copy', () => {
|
||||
render(<FooterTips />)
|
||||
|
||||
expect(screen.getByText('datasetPipeline.testRun.tooltip')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,41 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import StepIndicator from '../step-indicator'
|
||||
|
||||
describe('StepIndicator', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render all step labels and highlight the current step', () => {
|
||||
const { container } = render(
|
||||
<StepIndicator
|
||||
currentStep={2}
|
||||
steps={[
|
||||
{ label: 'Select source', value: 'source' },
|
||||
{ label: 'Process docs', value: 'process' },
|
||||
{ label: 'Run test', value: 'run' },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Select source')).toBeInTheDocument()
|
||||
expect(screen.getByText('Process docs')).toBeInTheDocument()
|
||||
expect(screen.getByText('Run test')).toBeInTheDocument()
|
||||
expect(container.querySelector('.bg-state-accent-solid')).toBeInTheDocument()
|
||||
expect(screen.getByText('Process docs').parentElement).toHaveClass('text-state-accent-solid')
|
||||
})
|
||||
|
||||
it('should keep inactive steps in the tertiary state', () => {
|
||||
render(
|
||||
<StepIndicator
|
||||
currentStep={1}
|
||||
steps={[
|
||||
{ label: 'Select source', value: 'source' },
|
||||
{ label: 'Process docs', value: 'process' },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Process docs').parentElement).toHaveClass('text-text-tertiary')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,49 @@
|
||||
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import OptionCard from '../option-card'
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useToolIcon: () => 'source-icon',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/block-icon', () => ({
|
||||
default: ({ toolIcon }: { toolIcon: string }) => <div data-testid="block-icon">{toolIcon}</div>,
|
||||
}))
|
||||
|
||||
describe('OptionCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the datasource label and icon', () => {
|
||||
render(
|
||||
<OptionCard
|
||||
label="Website Crawl"
|
||||
value="website"
|
||||
selected={false}
|
||||
nodeData={{ title: 'Website Crawl' } as DataSourceNodeType}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('block-icon')).toHaveTextContent('source-icon')
|
||||
expect(screen.getByText('Website Crawl')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onClick with the card value and apply selected styles', () => {
|
||||
const onClick = vi.fn()
|
||||
render(
|
||||
<OptionCard
|
||||
label="Online Drive"
|
||||
value="online-drive"
|
||||
selected
|
||||
nodeData={{ title: 'Online Drive' } as DataSourceNodeType}
|
||||
onClick={onClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Online Drive'))
|
||||
|
||||
expect(onClick).toHaveBeenCalledWith('online-drive')
|
||||
expect(screen.getByText('Online Drive')).toHaveClass('text-text-primary')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,73 @@
|
||||
import type { CustomActionsProps } from '@/app/components/base/form/components/form/actions'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import Actions from '../actions'
|
||||
|
||||
let mockWorkflowRunningData: { result: { status: WorkflowRunningStatus } } | undefined
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: { workflowRunningData: typeof mockWorkflowRunningData }) => unknown) => selector({
|
||||
workflowRunningData: mockWorkflowRunningData,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createFormParams = (overrides: Partial<CustomActionsProps> = {}): CustomActionsProps => ({
|
||||
form: {
|
||||
handleSubmit: vi.fn(),
|
||||
} as unknown as CustomActionsProps['form'],
|
||||
isSubmitting: false,
|
||||
canSubmit: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Document processing actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkflowRunningData = undefined
|
||||
})
|
||||
|
||||
it('should render back/process actions and trigger both callbacks', () => {
|
||||
const onBack = vi.fn()
|
||||
const formParams = createFormParams()
|
||||
|
||||
render(<Actions formParams={formParams} onBack={onBack} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.backToDataSource' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.process' }))
|
||||
|
||||
expect(onBack).toHaveBeenCalledTimes(1)
|
||||
expect(formParams.form.handleSubmit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should disable processing when runDisabled or the workflow is already running', () => {
|
||||
const { rerender } = render(
|
||||
<Actions
|
||||
formParams={createFormParams()}
|
||||
onBack={vi.fn()}
|
||||
runDisabled
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'datasetPipeline.operations.process' })).toBeDisabled()
|
||||
|
||||
mockWorkflowRunningData = {
|
||||
result: {
|
||||
status: WorkflowRunningStatus.Running,
|
||||
},
|
||||
}
|
||||
rerender(
|
||||
<Actions
|
||||
formParams={createFormParams()}
|
||||
onBack={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: /datasetPipeline\.operations\.process/i })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,32 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useInputVariables } from '../hooks'
|
||||
|
||||
const mockUseDraftPipelineProcessingParams = vi.hoisted(() => vi.fn(() => ({
|
||||
data: { variables: [{ variable: 'chunkSize' }] },
|
||||
isFetching: true,
|
||||
})))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: { pipelineId: string }) => string) => selector({ pipelineId: 'pipeline-1' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
useDraftPipelineProcessingParams: mockUseDraftPipelineProcessingParams,
|
||||
}))
|
||||
|
||||
describe('useInputVariables', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should query processing params with the current pipeline id and datasource node id', () => {
|
||||
const { result } = renderHook(() => useInputVariables('datasource-node'))
|
||||
|
||||
expect(mockUseDraftPipelineProcessingParams).toHaveBeenCalledWith({
|
||||
pipeline_id: 'pipeline-1',
|
||||
node_id: 'datasource-node',
|
||||
})
|
||||
expect(result.current.isFetchingParams).toBe(true)
|
||||
expect(result.current.paramsConfig).toEqual({ variables: [{ variable: 'chunkSize' }] })
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,140 @@
|
||||
import type { ZodSchema } from 'zod'
|
||||
import type { CustomActionsProps } from '@/app/components/base/form/components/form/actions'
|
||||
import type { BaseConfiguration } from '@/app/components/base/form/form-scenarios/base/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import Options from '../options'
|
||||
|
||||
const {
|
||||
mockFormValue,
|
||||
mockHandleSubmit,
|
||||
mockToastError,
|
||||
mockBaseField,
|
||||
} = vi.hoisted(() => ({
|
||||
mockFormValue: { chunkSize: 256 } as Record<string, unknown>,
|
||||
mockHandleSubmit: vi.fn(),
|
||||
mockToastError: vi.fn(),
|
||||
mockBaseField: vi.fn(({ config }: { config: { variable: string } }) => {
|
||||
return function FieldComponent() {
|
||||
return <div data-testid="base-field">{config.variable}</div>
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
error: mockToastError,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/form/form-scenarios/base/field', () => ({
|
||||
default: mockBaseField,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/form', () => ({
|
||||
useAppForm: ({
|
||||
onSubmit,
|
||||
validators,
|
||||
}: {
|
||||
onSubmit: (params: { value: Record<string, unknown> }) => void
|
||||
validators?: {
|
||||
onSubmit?: (params: { value: Record<string, unknown> }) => string | undefined
|
||||
}
|
||||
}) => ({
|
||||
handleSubmit: () => {
|
||||
const validationResult = validators?.onSubmit?.({ value: mockFormValue })
|
||||
if (!validationResult)
|
||||
onSubmit({ value: mockFormValue })
|
||||
mockHandleSubmit()
|
||||
},
|
||||
AppForm: ({ children }: { children: React.ReactNode }) => <div data-testid="app-form">{children}</div>,
|
||||
Actions: ({ CustomActions }: { CustomActions: (props: CustomActionsProps) => React.ReactNode }) => (
|
||||
<div data-testid="form-actions">
|
||||
{CustomActions({
|
||||
form: {
|
||||
handleSubmit: mockHandleSubmit,
|
||||
} as unknown as CustomActionsProps['form'],
|
||||
isSubmitting: false,
|
||||
canSubmit: true,
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
}))
|
||||
|
||||
const createSchema = (success: boolean): ZodSchema => ({
|
||||
safeParse: vi.fn(() => {
|
||||
if (success)
|
||||
return { success: true }
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
issues: [{
|
||||
path: ['chunkSize'],
|
||||
message: 'Invalid value',
|
||||
}],
|
||||
},
|
||||
}
|
||||
}),
|
||||
}) as unknown as ZodSchema
|
||||
|
||||
describe('Document processing options', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render base fields and the custom actions slot', () => {
|
||||
render(
|
||||
<Options
|
||||
initialData={{ chunkSize: 100 }}
|
||||
configurations={[{ variable: 'chunkSize' } as BaseConfiguration]}
|
||||
schema={createSchema(true)}
|
||||
CustomActions={() => <div data-testid="custom-actions">custom actions</div>}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('base-field')).toHaveTextContent('chunkSize')
|
||||
expect(screen.getByTestId('form-actions')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('custom-actions')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should validate and toast the first schema error before submitting', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
const { container } = render(
|
||||
<Options
|
||||
initialData={{ chunkSize: 100 }}
|
||||
configurations={[]}
|
||||
schema={createSchema(false)}
|
||||
CustomActions={() => <div>actions</div>}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.submit(container.querySelector('form')!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith('Path: chunkSize Error: Invalid value')
|
||||
})
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should submit the parsed form value when validation succeeds', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
const { container } = render(
|
||||
<Options
|
||||
initialData={{ chunkSize: 100 }}
|
||||
configurations={[]}
|
||||
schema={createSchema(true)}
|
||||
CustomActions={() => <div>actions</div>}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.submit(container.querySelector('form')!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(mockFormValue)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,84 @@
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
import { formatPreviewChunks } from '../utils'
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
RAG_PIPELINE_PREVIEW_CHUNK_NUM: 2,
|
||||
}))
|
||||
|
||||
describe('result preview utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return undefined for empty outputs', () => {
|
||||
expect(formatPreviewChunks(undefined)).toBeUndefined()
|
||||
expect(formatPreviewChunks(null)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should format text chunks and limit them to the preview length', () => {
|
||||
const result = formatPreviewChunks({
|
||||
chunk_structure: ChunkingMode.text,
|
||||
preview: [
|
||||
{ content: 'Chunk 1', summary: 'S1' },
|
||||
{ content: 'Chunk 2', summary: 'S2' },
|
||||
{ content: 'Chunk 3', summary: 'S3' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(result).toEqual([
|
||||
{ content: 'Chunk 1', summary: 'S1' },
|
||||
{ content: 'Chunk 2', summary: 'S2' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should format paragraph and full-doc parent-child previews differently', () => {
|
||||
const paragraph = formatPreviewChunks({
|
||||
chunk_structure: ChunkingMode.parentChild,
|
||||
parent_mode: 'paragraph',
|
||||
preview: [
|
||||
{ content: 'Parent 1', child_chunks: ['c1', 'c2', 'c3'] },
|
||||
{ content: 'Parent 2', child_chunks: ['c4'] },
|
||||
{ content: 'Parent 3', child_chunks: ['c5'] },
|
||||
],
|
||||
})
|
||||
const fullDoc = formatPreviewChunks({
|
||||
chunk_structure: ChunkingMode.parentChild,
|
||||
parent_mode: 'full-doc',
|
||||
preview: [
|
||||
{ content: 'Parent 1', child_chunks: ['c1', 'c2', 'c3'] },
|
||||
],
|
||||
})
|
||||
|
||||
expect(paragraph).toEqual({
|
||||
parent_mode: 'paragraph',
|
||||
parent_child_chunks: [
|
||||
{ parent_content: 'Parent 1', parent_summary: undefined, child_contents: ['c1', 'c2', 'c3'], parent_mode: 'paragraph' },
|
||||
{ parent_content: 'Parent 2', parent_summary: undefined, child_contents: ['c4'], parent_mode: 'paragraph' },
|
||||
],
|
||||
})
|
||||
expect(fullDoc).toEqual({
|
||||
parent_mode: 'full-doc',
|
||||
parent_child_chunks: [
|
||||
{ parent_content: 'Parent 1', child_contents: ['c1', 'c2'], parent_mode: 'full-doc' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should format qa previews and limit them to the preview size', () => {
|
||||
const result = formatPreviewChunks({
|
||||
chunk_structure: ChunkingMode.qa,
|
||||
qa_preview: [
|
||||
{ question: 'Q1', answer: 'A1' },
|
||||
{ question: 'Q2', answer: 'A2' },
|
||||
{ question: 'Q3', answer: 'A3' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
qa_chunks: [
|
||||
{ question: 'Q1', answer: 'A1' },
|
||||
{ question: 'Q2', answer: 'A2' },
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,64 @@
|
||||
import type { WorkflowRunningData } from '@/app/components/workflow/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Tab from '../tab'
|
||||
|
||||
const createWorkflowRunningData = (): WorkflowRunningData => ({
|
||||
task_id: 'task-1',
|
||||
message_id: 'message-1',
|
||||
conversation_id: 'conversation-1',
|
||||
result: {
|
||||
workflow_id: 'workflow-1',
|
||||
inputs: '{}',
|
||||
inputs_truncated: false,
|
||||
process_data: '{}',
|
||||
process_data_truncated: false,
|
||||
outputs: '{}',
|
||||
outputs_truncated: false,
|
||||
status: 'succeeded',
|
||||
elapsed_time: 10,
|
||||
total_tokens: 20,
|
||||
created_at: Date.now(),
|
||||
finished_at: Date.now(),
|
||||
steps: 1,
|
||||
total_steps: 1,
|
||||
},
|
||||
tracing: [],
|
||||
})
|
||||
|
||||
describe('Tab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render an active tab and pass its value on click', () => {
|
||||
const onClick = vi.fn()
|
||||
render(
|
||||
<Tab
|
||||
isActive
|
||||
label="Preview"
|
||||
value="preview"
|
||||
workflowRunningData={createWorkflowRunningData()}
|
||||
onClick={onClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Preview' }))
|
||||
|
||||
expect(screen.getByRole('button')).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
|
||||
expect(onClick).toHaveBeenCalledWith('preview')
|
||||
})
|
||||
|
||||
it('should disable the tab when workflow run data is unavailable', () => {
|
||||
render(
|
||||
<Tab
|
||||
isActive={false}
|
||||
label="Trace"
|
||||
value="trace"
|
||||
onClick={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Trace' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Trace' })).toHaveClass('opacity-30')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,41 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import InputFieldButton from '../input-field-button'
|
||||
|
||||
const {
|
||||
mockSetShowInputFieldPanel,
|
||||
mockSetShowEnvPanel,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSetShowInputFieldPanel: vi.fn(),
|
||||
mockSetShowEnvPanel: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: {
|
||||
setShowInputFieldPanel: typeof mockSetShowInputFieldPanel
|
||||
setShowEnvPanel: typeof mockSetShowEnvPanel
|
||||
}) => unknown) => selector({
|
||||
setShowInputFieldPanel: mockSetShowInputFieldPanel,
|
||||
setShowEnvPanel: mockSetShowEnvPanel,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('InputFieldButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should open the input field panel and close the env panel', () => {
|
||||
render(<InputFieldButton />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.inputField' }))
|
||||
|
||||
expect(mockSetShowInputFieldPanel).toHaveBeenCalledWith(true)
|
||||
expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,92 @@
|
||||
import type { Viewport } from 'reactflow'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { processNodesWithoutDataSource } from '../nodes'
|
||||
|
||||
vi.mock('@/app/components/workflow/constants', () => ({
|
||||
CUSTOM_NODE: 'custom',
|
||||
NODE_WIDTH_X_OFFSET: 400,
|
||||
START_INITIAL_POSITION: { x: 100, y: 100 },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/data-source-empty/constants', () => ({
|
||||
CUSTOM_DATA_SOURCE_EMPTY_NODE: 'data-source-empty',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/note-node/constants', () => ({
|
||||
CUSTOM_NOTE_NODE: 'note',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/note-node/types', () => ({
|
||||
NoteTheme: { blue: 'blue' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/utils', () => ({
|
||||
generateNewNode: ({ id, type, data, position }: { id: string, type: string, data: object, position: { x: number, y: number } }) => ({
|
||||
newNode: { id, type, data, position },
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('processNodesWithoutDataSource', () => {
|
||||
it('should return the original nodes when a datasource node already exists', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: 'node-1',
|
||||
type: 'custom',
|
||||
data: { type: BlockEnum.DataSource },
|
||||
position: { x: 100, y: 100 },
|
||||
},
|
||||
] as Node[]
|
||||
const viewport: Viewport = { x: 0, y: 0, zoom: 1 }
|
||||
|
||||
const result = processNodesWithoutDataSource(nodes, viewport)
|
||||
|
||||
expect(result.nodes).toBe(nodes)
|
||||
expect(result.viewport).toBe(viewport)
|
||||
})
|
||||
|
||||
it('should prepend datasource empty and note nodes when the pipeline starts without a datasource', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: 'node-1',
|
||||
type: 'custom',
|
||||
data: { type: BlockEnum.KnowledgeBase },
|
||||
position: { x: 300, y: 200 },
|
||||
},
|
||||
] as Node[]
|
||||
|
||||
const result = processNodesWithoutDataSource(nodes, { x: 0, y: 0, zoom: 2 })
|
||||
|
||||
expect(result.nodes[0]).toEqual(expect.objectContaining({
|
||||
id: 'data-source-empty',
|
||||
type: 'data-source-empty',
|
||||
position: { x: -100, y: 200 },
|
||||
}))
|
||||
expect(result.nodes[1]).toEqual(expect.objectContaining({
|
||||
id: 'note',
|
||||
type: 'note',
|
||||
position: { x: -100, y: 300 },
|
||||
}))
|
||||
expect(result.viewport).toEqual({
|
||||
x: 400,
|
||||
y: -200,
|
||||
zoom: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('should leave nodes unchanged when there is no custom node to anchor from', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: 'node-1',
|
||||
type: 'note',
|
||||
data: { type: BlockEnum.Answer },
|
||||
position: { x: 100, y: 100 },
|
||||
},
|
||||
] as Node[]
|
||||
|
||||
const result = processNodesWithoutDataSource(nodes)
|
||||
|
||||
expect(result.nodes).toBe(nodes)
|
||||
expect(result.viewport).toBeUndefined()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user