mirror of
https://github.com/langgenius/dify.git
synced 2026-05-02 08:28:03 +08:00
test(workflow): add unit tests for workflow components (#33741)
Co-authored-by: CodingOnStar <hanxujiang@dify.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render } from '@testing-library/react'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import EmbeddingModel from './embedding-model'
|
||||
import EmbeddingModel from '../embedding-model'
|
||||
|
||||
const mockUseModelList = vi.hoisted(() => vi.fn())
|
||||
const mockModelSelector = vi.hoisted(() => vi.fn(() => <div data-testid="model-selector">selector</div>))
|
||||
@ -0,0 +1,74 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { ChunkStructureEnum, IndexMethodEnum } from '../../types'
|
||||
import IndexMethod from '../index-method'
|
||||
|
||||
describe('IndexMethod', () => {
|
||||
it('should render both index method options for general chunks and notify option changes', () => {
|
||||
const onIndexMethodChange = vi.fn()
|
||||
|
||||
render(
|
||||
<IndexMethod
|
||||
chunkStructure={ChunkStructureEnum.general}
|
||||
indexMethod={IndexMethodEnum.QUALIFIED}
|
||||
keywordNumber={5}
|
||||
onIndexMethodChange={onIndexMethodChange}
|
||||
onKeywordNumberChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('datasetCreation.stepTwo.qualified')).toBeInTheDocument()
|
||||
expect(screen.getByText('datasetSettings.form.indexMethodEconomy')).toBeInTheDocument()
|
||||
expect(screen.getByText('datasetCreation.stepTwo.recommend')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('datasetSettings.form.indexMethodEconomy'))
|
||||
|
||||
expect(onIndexMethodChange).toHaveBeenCalledWith(IndexMethodEnum.ECONOMICAL)
|
||||
})
|
||||
|
||||
it('should update the keyword number when the economical option is active', () => {
|
||||
const onKeywordNumberChange = vi.fn()
|
||||
const { container } = render(
|
||||
<IndexMethod
|
||||
chunkStructure={ChunkStructureEnum.general}
|
||||
indexMethod={IndexMethodEnum.ECONOMICAL}
|
||||
keywordNumber={5}
|
||||
onIndexMethodChange={vi.fn()}
|
||||
onKeywordNumberChange={onKeywordNumberChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(container.querySelector('input') as HTMLInputElement, { target: { value: '7' } })
|
||||
|
||||
expect(onKeywordNumberChange).toHaveBeenCalledWith(7)
|
||||
})
|
||||
|
||||
it('should disable keyword controls when readonly is enabled', () => {
|
||||
const { container } = render(
|
||||
<IndexMethod
|
||||
chunkStructure={ChunkStructureEnum.general}
|
||||
indexMethod={IndexMethodEnum.ECONOMICAL}
|
||||
keywordNumber={5}
|
||||
onIndexMethodChange={vi.fn()}
|
||||
onKeywordNumberChange={vi.fn()}
|
||||
readonly
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('input')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should hide the economical option for non-general chunk structures', () => {
|
||||
render(
|
||||
<IndexMethod
|
||||
chunkStructure={ChunkStructureEnum.parent_child}
|
||||
indexMethod={IndexMethodEnum.QUALIFIED}
|
||||
keywordNumber={5}
|
||||
onIndexMethodChange={vi.fn()}
|
||||
onKeywordNumberChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('datasetCreation.stepTwo.qualified')).toBeInTheDocument()
|
||||
expect(screen.queryByText('datasetSettings.form.indexMethodEconomy')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,74 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import OptionCard from '../option-card'
|
||||
|
||||
describe('OptionCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The card should expose selection, child expansion, and readonly click behavior.
|
||||
describe('Interaction', () => {
|
||||
it('should call onClick with the card id and render active children', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(
|
||||
<OptionCard
|
||||
id="qualified"
|
||||
selectedId="qualified"
|
||||
title="High Quality"
|
||||
description="Use embedding retrieval."
|
||||
isRecommended
|
||||
enableRadio
|
||||
onClick={onClick}
|
||||
>
|
||||
<div>Advanced controls</div>
|
||||
</OptionCard>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('datasetCreation.stepTwo.recommend')).toBeInTheDocument()
|
||||
expect(screen.getByText('Advanced controls')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('High Quality'))
|
||||
|
||||
expect(onClick).toHaveBeenCalledWith('qualified')
|
||||
})
|
||||
|
||||
it('should not trigger selection when the card is readonly', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(
|
||||
<OptionCard
|
||||
id="economical"
|
||||
title="Economical"
|
||||
readonly
|
||||
onClick={onClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Economical'))
|
||||
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support function-based wrapper, class, and icon props without enabling selection', () => {
|
||||
render(
|
||||
<OptionCard
|
||||
id="inactive"
|
||||
selectedId="qualified"
|
||||
title="Inactive card"
|
||||
enableSelect={false}
|
||||
wrapperClassName={isActive => (isActive ? 'wrapper-active' : 'wrapper-inactive')}
|
||||
className={isActive => (isActive ? 'body-active' : 'body-inactive')}
|
||||
icon={isActive => <span data-testid="option-icon">{isActive ? 'active' : 'inactive'}</span>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Inactive card').closest('.wrapper-inactive')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('option-icon')).toHaveTextContent('inactive')
|
||||
expect(screen.getByText('Inactive card').closest('.body-inactive')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,47 @@
|
||||
import { render, renderHook } from '@testing-library/react'
|
||||
import { ChunkStructureEnum } from '../../../types'
|
||||
import { useChunkStructure } from '../hooks'
|
||||
|
||||
const renderIcon = (icon: ReturnType<typeof useChunkStructure>['options'][number]['icon'], isActive: boolean) => {
|
||||
if (typeof icon !== 'function')
|
||||
throw new Error('expected icon renderer')
|
||||
|
||||
return icon(isActive)
|
||||
}
|
||||
|
||||
describe('useChunkStructure', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The hook should expose ordered options and a lookup map for every chunk structure variant.
|
||||
describe('Options', () => {
|
||||
it('should return all chunk structure options and map them by id', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
expect(result.current.options).toHaveLength(3)
|
||||
expect(result.current.options.map(option => option.id)).toEqual([
|
||||
ChunkStructureEnum.general,
|
||||
ChunkStructureEnum.parent_child,
|
||||
ChunkStructureEnum.question_answer,
|
||||
])
|
||||
expect(result.current.optionMap[ChunkStructureEnum.general].title).toBe('datasetCreation.stepTwo.general')
|
||||
expect(result.current.optionMap[ChunkStructureEnum.parent_child].title).toBe('datasetCreation.stepTwo.parentChild')
|
||||
expect(result.current.optionMap[ChunkStructureEnum.question_answer].title).toBe('Q&A')
|
||||
})
|
||||
|
||||
it('should expose active and inactive icon renderers for every option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const generalInactive = render(<>{renderIcon(result.current.optionMap[ChunkStructureEnum.general].icon, false)}</>).container.firstChild as HTMLElement
|
||||
const generalActive = render(<>{renderIcon(result.current.optionMap[ChunkStructureEnum.general].icon, true)}</>).container.firstChild as HTMLElement
|
||||
const parentChildActive = render(<>{renderIcon(result.current.optionMap[ChunkStructureEnum.parent_child].icon, true)}</>).container.firstChild as HTMLElement
|
||||
const questionAnswerActive = render(<>{renderIcon(result.current.optionMap[ChunkStructureEnum.question_answer].icon, true)}</>).container.firstChild as HTMLElement
|
||||
|
||||
expect(generalInactive).toHaveClass('text-text-tertiary')
|
||||
expect(generalActive).toHaveClass('text-util-colors-indigo-indigo-600')
|
||||
expect(parentChildActive).toHaveClass('text-util-colors-blue-light-blue-light-500')
|
||||
expect(questionAnswerActive).toHaveClass('text-util-colors-teal-teal-600')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ChunkStructureEnum } from '../../types'
|
||||
import ChunkStructure from './index'
|
||||
import { ChunkStructureEnum } from '../../../types'
|
||||
import ChunkStructure from '../index'
|
||||
|
||||
const mockUseChunkStructure = vi.hoisted(() => vi.fn())
|
||||
|
||||
@ -15,15 +15,15 @@ vi.mock('@/app/components/workflow/nodes/_base/components/layout', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./hooks', () => ({
|
||||
vi.mock('../hooks', () => ({
|
||||
useChunkStructure: mockUseChunkStructure,
|
||||
}))
|
||||
|
||||
vi.mock('../option-card', () => ({
|
||||
vi.mock('../../option-card', () => ({
|
||||
default: ({ title }: { title: string }) => <div data-testid="option-card">{title}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./selector', () => ({
|
||||
vi.mock('../selector', () => ({
|
||||
default: ({ trigger, value }: { trigger?: ReactNode, value?: string }) => (
|
||||
<div data-testid="selector">
|
||||
{value ?? 'no-value'}
|
||||
@ -32,7 +32,7 @@ vi.mock('./selector', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./instruction', () => ({
|
||||
vi.mock('../instruction', () => ({
|
||||
default: ({ className }: { className?: string }) => <div data-testid="instruction" className={className}>Instruction</div>,
|
||||
}))
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { ChunkStructureEnum } from '../../../types'
|
||||
import Selector from '../selector'
|
||||
|
||||
const options = [
|
||||
{
|
||||
id: ChunkStructureEnum.general,
|
||||
icon: <span>G</span>,
|
||||
title: 'General',
|
||||
description: 'General description',
|
||||
effectColor: 'blue',
|
||||
},
|
||||
{
|
||||
id: ChunkStructureEnum.parent_child,
|
||||
icon: <span>P</span>,
|
||||
title: 'Parent child',
|
||||
description: 'Parent child description',
|
||||
effectColor: 'purple',
|
||||
},
|
||||
]
|
||||
|
||||
describe('ChunkStructureSelector', () => {
|
||||
it('should open the selector panel and close it after selecting an option', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<Selector
|
||||
options={options}
|
||||
value={ChunkStructureEnum.general}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.panel.change' }))
|
||||
|
||||
expect(screen.getByText('workflow.nodes.knowledgeBase.changeChunkStructure')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Parent child'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(ChunkStructureEnum.parent_child)
|
||||
expect(screen.queryByText('workflow.nodes.knowledgeBase.changeChunkStructure')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not open the selector when readonly is enabled', () => {
|
||||
render(
|
||||
<Selector
|
||||
options={options}
|
||||
onChange={vi.fn()}
|
||||
readonly
|
||||
trigger={<button type="button">custom-trigger</button>}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'custom-trigger' }))
|
||||
|
||||
expect(screen.queryByText('workflow.nodes.knowledgeBase.changeChunkStructure')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,29 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Instruction from '../index'
|
||||
|
||||
const mockUseDocLink = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: mockUseDocLink,
|
||||
}))
|
||||
|
||||
describe('ChunkStructureInstruction', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseDocLink.mockReturnValue((path: string) => `https://docs.example.com${path}`)
|
||||
})
|
||||
|
||||
// The instruction card should render the learning copy and link to the chunking guide.
|
||||
describe('Rendering', () => {
|
||||
it('should render the title, message, and learn-more link', () => {
|
||||
render(<Instruction className="custom-class" />)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.knowledgeBase.chunkStructureTip.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.knowledgeBase.chunkStructureTip.message')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'workflow.nodes.knowledgeBase.chunkStructureTip.learnMore' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://docs.example.com/use-dify/knowledge/create-knowledge/chunking-and-cleaning-text',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,27 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import Line from '../line'
|
||||
|
||||
describe('ChunkStructureInstructionLine', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The line should switch between vertical and horizontal SVG assets.
|
||||
describe('Rendering', () => {
|
||||
it('should render the vertical line by default', () => {
|
||||
const { container } = render(<Line />)
|
||||
const svg = container.querySelector('svg')
|
||||
|
||||
expect(svg).toHaveAttribute('width', '2')
|
||||
expect(svg).toHaveAttribute('height', '132')
|
||||
})
|
||||
|
||||
it('should render the horizontal line when requested', () => {
|
||||
const { container } = render(<Line type="horizontal" />)
|
||||
const svg = container.querySelector('svg')
|
||||
|
||||
expect(svg).toHaveAttribute('width', '240')
|
||||
expect(svg).toHaveAttribute('height', '2')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,38 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import {
|
||||
HybridSearchModeEnum,
|
||||
IndexMethodEnum,
|
||||
RetrievalSearchMethodEnum,
|
||||
} from '../../../types'
|
||||
import { useRetrievalSetting } from '../hooks'
|
||||
|
||||
describe('useRetrievalSetting', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The hook should switch between economical and qualified retrieval option sets.
|
||||
describe('Options', () => {
|
||||
it('should return semantic, full-text, and hybrid options for qualified indexing', () => {
|
||||
const { result } = renderHook(() => useRetrievalSetting(IndexMethodEnum.QUALIFIED))
|
||||
|
||||
expect(result.current.options.map(option => option.id)).toEqual([
|
||||
RetrievalSearchMethodEnum.semantic,
|
||||
RetrievalSearchMethodEnum.fullText,
|
||||
RetrievalSearchMethodEnum.hybrid,
|
||||
])
|
||||
expect(result.current.hybridSearchModeOptions.map(option => option.id)).toEqual([
|
||||
HybridSearchModeEnum.WeightedScore,
|
||||
HybridSearchModeEnum.RerankingModel,
|
||||
])
|
||||
})
|
||||
|
||||
it('should return only keyword search for economical indexing', () => {
|
||||
const { result } = renderHook(() => useRetrievalSetting(IndexMethodEnum.ECONOMICAL))
|
||||
|
||||
expect(result.current.options.map(option => option.id)).toEqual([
|
||||
RetrievalSearchMethodEnum.keywordSearch,
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,60 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { createDocLinkMock, resolveDocLink } from '@/app/components/workflow/__tests__/i18n'
|
||||
import { IndexMethodEnum } from '../../../types'
|
||||
import RetrievalSetting from '../index'
|
||||
|
||||
const mockUseDocLink = createDocLinkMock()
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => mockUseDocLink,
|
||||
}))
|
||||
|
||||
const baseProps = {
|
||||
onRetrievalSearchMethodChange: vi.fn(),
|
||||
onHybridSearchModeChange: vi.fn(),
|
||||
onWeightedScoreChange: vi.fn(),
|
||||
onTopKChange: vi.fn(),
|
||||
onScoreThresholdChange: vi.fn(),
|
||||
onScoreThresholdEnabledChange: vi.fn(),
|
||||
onRerankingModelEnabledChange: vi.fn(),
|
||||
onRerankingModelChange: vi.fn(),
|
||||
topK: 3,
|
||||
scoreThreshold: 0.5,
|
||||
isScoreThresholdEnabled: false,
|
||||
}
|
||||
|
||||
describe('RetrievalSetting', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the learn-more link and qualified retrieval method options', () => {
|
||||
render(
|
||||
<RetrievalSetting
|
||||
{...baseProps}
|
||||
indexMethod={IndexMethodEnum.QUALIFIED}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'datasetSettings.form.retrievalSetting.learnMore' })).toHaveAttribute(
|
||||
'href',
|
||||
resolveDocLink('/use-dify/knowledge/create-knowledge/setting-indexing-methods'),
|
||||
)
|
||||
expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.retrieval.full_text_search.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.retrieval.hybrid_search.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render only the economical retrieval method for economical indexing', () => {
|
||||
render(
|
||||
<RetrievalSetting
|
||||
{...baseProps}
|
||||
indexMethod={IndexMethodEnum.ECONOMICAL}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('dataset.retrieval.keyword_search.title')).toBeInTheDocument()
|
||||
expect(screen.queryByText('dataset.retrieval.semantic_search.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('dataset.retrieval.hybrid_search.title')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,15 +1,14 @@
|
||||
import type {
|
||||
DefaultModel,
|
||||
Model,
|
||||
ModelItem,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
ModelStatusEnum,
|
||||
ModelTypeEnum,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import RerankingModelSelector from './reranking-model-selector'
|
||||
createModel,
|
||||
createModelItem,
|
||||
} from '@/app/components/workflow/__tests__/model-provider-fixtures'
|
||||
import RerankingModelSelector from '../reranking-model-selector'
|
||||
|
||||
type MockModelSelectorProps = {
|
||||
defaultModel?: DefaultModel
|
||||
@ -37,38 +36,19 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-selec
|
||||
),
|
||||
}))
|
||||
|
||||
const createModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
|
||||
model: 'rerank-v3',
|
||||
label: { en_US: 'Rerank V3', zh_Hans: 'Rerank V3' },
|
||||
model_type: ModelTypeEnum.rerank,
|
||||
fetch_from: ConfigurationMethodEnum.predefinedModel,
|
||||
status: ModelStatusEnum.active,
|
||||
model_properties: {},
|
||||
load_balancing_enabled: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createModel = (overrides: Partial<Model> = {}): Model => ({
|
||||
provider: 'cohere',
|
||||
icon_small: {
|
||||
en_US: 'https://example.com/cohere.png',
|
||||
zh_Hans: 'https://example.com/cohere.png',
|
||||
},
|
||||
icon_small_dark: {
|
||||
en_US: 'https://example.com/cohere-dark.png',
|
||||
zh_Hans: 'https://example.com/cohere-dark.png',
|
||||
},
|
||||
label: { en_US: 'Cohere', zh_Hans: 'Cohere' },
|
||||
models: [createModelItem()],
|
||||
status: ModelStatusEnum.active,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('RerankingModelSelector', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseModelListAndDefaultModel.mockReturnValue({
|
||||
modelList: [createModel()],
|
||||
modelList: [createModel({
|
||||
provider: 'cohere',
|
||||
label: { en_US: 'Cohere', zh_Hans: 'Cohere' },
|
||||
models: [createModelItem({
|
||||
model: 'rerank-v3',
|
||||
model_type: ModelTypeEnum.rerank,
|
||||
label: { en_US: 'Rerank V3', zh_Hans: 'Rerank V3' },
|
||||
})],
|
||||
})],
|
||||
defaultModel: undefined,
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,229 @@
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
} from '@testing-library/react'
|
||||
import {
|
||||
HybridSearchModeEnum,
|
||||
RetrievalSearchMethodEnum,
|
||||
WeightedScoreEnum,
|
||||
} from '../../../types'
|
||||
import SearchMethodOption from '../search-method-option'
|
||||
|
||||
const mockUseModelListAndDefaultModel = vi.hoisted(() => vi.fn())
|
||||
const mockUseProviderContext = vi.hoisted(() => vi.fn())
|
||||
const mockUseCredentialPanelState = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/header/account-setting/model-provider-page/hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useModelListAndDefaultModel: (...args: Parameters<typeof actual.useModelListAndDefaultModel>) => mockUseModelListAndDefaultModel(...args),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockUseProviderContext(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state', () => ({
|
||||
useCredentialPanelState: (...args: unknown[]) => mockUseCredentialPanelState(...args),
|
||||
}))
|
||||
|
||||
const SearchIcon: ComponentType<SVGProps<SVGSVGElement>> = props => (
|
||||
<svg aria-hidden="true" {...props} />
|
||||
)
|
||||
|
||||
const hybridSearchModeOptions = [
|
||||
{
|
||||
id: HybridSearchModeEnum.WeightedScore,
|
||||
title: 'Weighted mode',
|
||||
description: 'Use weighted score',
|
||||
},
|
||||
{
|
||||
id: HybridSearchModeEnum.RerankingModel,
|
||||
title: 'Rerank mode',
|
||||
description: 'Use reranking model',
|
||||
},
|
||||
]
|
||||
|
||||
const weightedScore = {
|
||||
weight_type: WeightedScoreEnum.Customized,
|
||||
vector_setting: {
|
||||
vector_weight: 0.8,
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-3-large',
|
||||
},
|
||||
keyword_setting: {
|
||||
keyword_weight: 0.2,
|
||||
},
|
||||
}
|
||||
|
||||
const createProps = () => ({
|
||||
option: {
|
||||
id: RetrievalSearchMethodEnum.semantic,
|
||||
icon: SearchIcon,
|
||||
title: 'Semantic title',
|
||||
description: 'Semantic description',
|
||||
effectColor: 'purple',
|
||||
},
|
||||
hybridSearchModeOptions,
|
||||
searchMethod: RetrievalSearchMethodEnum.semantic,
|
||||
onRetrievalSearchMethodChange: vi.fn(),
|
||||
hybridSearchMode: HybridSearchModeEnum.WeightedScore,
|
||||
onHybridSearchModeChange: vi.fn(),
|
||||
weightedScore,
|
||||
onWeightedScoreChange: vi.fn(),
|
||||
rerankingModelEnabled: false,
|
||||
onRerankingModelEnabledChange: vi.fn(),
|
||||
rerankingModel: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
onRerankingModelChange: vi.fn(),
|
||||
topK: 3,
|
||||
onTopKChange: vi.fn(),
|
||||
scoreThreshold: 0.5,
|
||||
onScoreThresholdChange: vi.fn(),
|
||||
isScoreThresholdEnabled: true,
|
||||
onScoreThresholdEnabledChange: vi.fn(),
|
||||
showMultiModalTip: false,
|
||||
})
|
||||
|
||||
describe('SearchMethodOption', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseModelListAndDefaultModel.mockReturnValue({
|
||||
modelList: [],
|
||||
defaultModel: undefined,
|
||||
})
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
modelProviders: [],
|
||||
})
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
variant: 'api-active',
|
||||
priority: 'apiKeyOnly',
|
||||
supportsCredits: false,
|
||||
showPrioritySwitcher: false,
|
||||
hasCredentials: true,
|
||||
isCreditsExhausted: false,
|
||||
credentialName: undefined,
|
||||
credits: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('should render semantic search controls and notify retrieval and reranking changes', () => {
|
||||
const props = createProps()
|
||||
|
||||
render(<SearchMethodOption {...props} />)
|
||||
|
||||
expect(screen.getByText('Semantic title')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.modelProvider.rerankModel.key')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.detailPanel.configureModel')).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('switch')).toHaveLength(2)
|
||||
|
||||
fireEvent.click(screen.getByText('Semantic title'))
|
||||
fireEvent.click(screen.getAllByRole('switch')[0])
|
||||
|
||||
expect(props.onRetrievalSearchMethodChange).toHaveBeenCalledWith(RetrievalSearchMethodEnum.semantic)
|
||||
expect(props.onRerankingModelEnabledChange).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should render the reranking switch for full-text search as well', () => {
|
||||
const props = createProps()
|
||||
|
||||
render(
|
||||
<SearchMethodOption
|
||||
{...props}
|
||||
option={{
|
||||
...props.option,
|
||||
id: RetrievalSearchMethodEnum.fullText,
|
||||
title: 'Full-text title',
|
||||
}}
|
||||
searchMethod={RetrievalSearchMethodEnum.fullText}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Full-text title')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.modelProvider.rerankModel.key')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Full-text title'))
|
||||
|
||||
expect(props.onRetrievalSearchMethodChange).toHaveBeenCalledWith(RetrievalSearchMethodEnum.fullText)
|
||||
})
|
||||
|
||||
it('should render hybrid weighted-score controls without reranking model selector', () => {
|
||||
const props = createProps()
|
||||
|
||||
render(
|
||||
<SearchMethodOption
|
||||
{...props}
|
||||
option={{
|
||||
...props.option,
|
||||
id: RetrievalSearchMethodEnum.hybrid,
|
||||
title: 'Hybrid title',
|
||||
}}
|
||||
searchMethod={RetrievalSearchMethodEnum.hybrid}
|
||||
hybridSearchMode={HybridSearchModeEnum.WeightedScore}
|
||||
showMultiModalTip
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Weighted mode')).toBeInTheDocument()
|
||||
expect(screen.getByText('Rerank mode')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.weightedScore.semantic')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.weightedScore.keyword')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.modelProvider.rerankModel.key')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('datasetSettings.form.retrievalSetting.multiModalTip')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Rerank mode'))
|
||||
|
||||
expect(props.onHybridSearchModeChange).toHaveBeenCalledWith(HybridSearchModeEnum.RerankingModel)
|
||||
})
|
||||
|
||||
it('should render the hybrid reranking selector when reranking mode is selected', () => {
|
||||
const props = createProps()
|
||||
|
||||
render(
|
||||
<SearchMethodOption
|
||||
{...props}
|
||||
option={{
|
||||
...props.option,
|
||||
id: RetrievalSearchMethodEnum.hybrid,
|
||||
title: 'Hybrid title',
|
||||
}}
|
||||
searchMethod={RetrievalSearchMethodEnum.hybrid}
|
||||
hybridSearchMode={HybridSearchModeEnum.RerankingModel}
|
||||
showMultiModalTip
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('plugin.detailPanel.configureModel')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.modelProvider.rerankModel.key')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('dataset.weightedScore.semantic')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('datasetSettings.form.retrievalSetting.multiModalTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the score-threshold control for keyword search', () => {
|
||||
const props = createProps()
|
||||
|
||||
render(
|
||||
<SearchMethodOption
|
||||
{...props}
|
||||
option={{
|
||||
...props.option,
|
||||
id: RetrievalSearchMethodEnum.keywordSearch,
|
||||
title: 'Keyword title',
|
||||
}}
|
||||
searchMethod={RetrievalSearchMethodEnum.keywordSearch}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: '9' } })
|
||||
|
||||
expect(screen.getAllByRole('textbox')).toHaveLength(1)
|
||||
expect(screen.queryAllByRole('switch')).toHaveLength(0)
|
||||
expect(props.onTopKChange).toHaveBeenCalledWith(9)
|
||||
})
|
||||
})
|
||||
@ -32,4 +32,38 @@ describe('TopKAndScoreThreshold', () => {
|
||||
|
||||
expect(defaultProps.onScoreThresholdChange).toHaveBeenCalledWith(0.46)
|
||||
})
|
||||
|
||||
it('should hide the score-threshold column when requested', () => {
|
||||
render(<TopKAndScoreThreshold {...defaultProps} hiddenScoreThreshold />)
|
||||
|
||||
expect(screen.getAllByRole('textbox')).toHaveLength(1)
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fall back to zero when the number fields are cleared', () => {
|
||||
render(
|
||||
<TopKAndScoreThreshold
|
||||
{...defaultProps}
|
||||
scoreThreshold={undefined}
|
||||
isScoreThresholdEnabled
|
||||
/>,
|
||||
)
|
||||
|
||||
const [topKInput, scoreThresholdInput] = screen.getAllByRole('textbox')
|
||||
fireEvent.change(topKInput, { target: { value: '' } })
|
||||
|
||||
expect(defaultProps.onTopKChange).toHaveBeenCalledWith(0)
|
||||
expect(scoreThresholdInput).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should default the score-threshold switch to off when the flag is missing', () => {
|
||||
render(
|
||||
<TopKAndScoreThreshold
|
||||
{...defaultProps}
|
||||
isScoreThresholdEnabled={undefined}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user