mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 01:18:05 +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:
@ -6,16 +6,18 @@ import type {
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { createDocLinkMock } from '../../../../__tests__/i18n'
|
||||
import { AgentStrategy } from '../agent-strategy'
|
||||
|
||||
const createI18nLabel = (text: string) => ({ en_US: text, zh_Hans: text })
|
||||
const mockDocLink = createDocLinkMock('/docs')
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useDefaultModel: () => ({ data: null }),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => () => '/docs',
|
||||
useDocLink: () => mockDocLink,
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-i18n', () => ({
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Field from './field'
|
||||
import Field from '../field'
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ popupContent }: { popupContent: React.ReactNode }) => <div>{popupContent}</div>,
|
||||
@ -1,8 +1,8 @@
|
||||
import type { CommonNodeType } from '../../../types'
|
||||
import type { CommonNodeType } from '../../../../types'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { renderWorkflowComponent } from '../../../__tests__/workflow-test-env'
|
||||
import { BlockEnum, NodeRunningStatus } from '../../../types'
|
||||
import NodeControl from './node-control'
|
||||
import { renderWorkflowComponent } from '../../../../__tests__/workflow-test-env'
|
||||
import { BlockEnum, NodeRunningStatus } from '../../../../types'
|
||||
import NodeControl from '../node-control'
|
||||
|
||||
const {
|
||||
mockHandleNodeSelect,
|
||||
@ -14,8 +14,8 @@ const {
|
||||
|
||||
let mockPluginInstallLocked = false
|
||||
|
||||
vi.mock('../../../hooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../hooks')>('../../../hooks')
|
||||
vi.mock('../../../../hooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../../hooks')>('../../../../hooks')
|
||||
return {
|
||||
...actual,
|
||||
useNodesInteractions: () => ({
|
||||
@ -24,15 +24,15 @@ vi.mock('../../../hooks', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../../utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../utils')>('../../../utils')
|
||||
vi.mock('../../../../utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../../utils')>('../../../../utils')
|
||||
return {
|
||||
...actual,
|
||||
canRunBySingle: mockCanRunBySingle,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./panel-operator', () => ({
|
||||
vi.mock('../panel-operator', () => ({
|
||||
default: ({ onOpenChange }: { onOpenChange: (open: boolean) => void }) => (
|
||||
<>
|
||||
<button type="button" onClick={() => onOpenChange(true)}>open panel</button>
|
||||
@ -0,0 +1,83 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import Collapse from '../index'
|
||||
|
||||
describe('Collapse', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Collapse should toggle local state when interactive and stay fixed when disabled.
|
||||
describe('Interaction', () => {
|
||||
it('should expand collapsed content and notify onCollapse when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCollapse = vi.fn()
|
||||
|
||||
render(
|
||||
<Collapse
|
||||
trigger={<div>Advanced</div>}
|
||||
onCollapse={onCollapse}
|
||||
>
|
||||
<div>Collapse content</div>
|
||||
</Collapse>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('Collapse content')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('Advanced'))
|
||||
|
||||
expect(screen.getByText('Collapse content')).toBeInTheDocument()
|
||||
expect(onCollapse).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should keep content collapsed when disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCollapse = vi.fn()
|
||||
|
||||
render(
|
||||
<Collapse
|
||||
disabled
|
||||
trigger={<div>Disabled section</div>}
|
||||
onCollapse={onCollapse}
|
||||
>
|
||||
<div>Hidden content</div>
|
||||
</Collapse>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Disabled section'))
|
||||
|
||||
expect(screen.queryByText('Hidden content')).not.toBeInTheDocument()
|
||||
expect(onCollapse).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should respect controlled collapse state and render function triggers', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCollapse = vi.fn()
|
||||
|
||||
render(
|
||||
<Collapse
|
||||
collapsed={false}
|
||||
hideCollapseIcon
|
||||
operations={<button type="button">Operation</button>}
|
||||
trigger={collapseIcon => (
|
||||
<div>
|
||||
<span>Controlled section</span>
|
||||
{collapseIcon}
|
||||
</div>
|
||||
)}
|
||||
onCollapse={onCollapse}
|
||||
>
|
||||
<div>Visible content</div>
|
||||
</Collapse>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Visible content')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Operation' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('Controlled section'))
|
||||
|
||||
expect(onCollapse).toHaveBeenCalledWith(true)
|
||||
expect(screen.getByText('Visible content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,18 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import InputField from '../index'
|
||||
|
||||
describe('InputField', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The placeholder field should render its title, body, and add action.
|
||||
describe('Rendering', () => {
|
||||
it('should render the default field title and content', () => {
|
||||
render(<InputField />)
|
||||
|
||||
expect(screen.getAllByText('input field')).toHaveLength(2)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { FieldTitle } from './field-title'
|
||||
import { FieldTitle } from '../field-title'
|
||||
|
||||
vi.mock('@/app/components/base/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
@ -0,0 +1,35 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { BoxGroupField, FieldTitle } from '../index'
|
||||
|
||||
describe('layout index', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The barrel exports should compose the public layout primitives without extra wrappers.
|
||||
describe('Rendering', () => {
|
||||
it('should render BoxGroupField from the barrel export', () => {
|
||||
render(
|
||||
<BoxGroupField
|
||||
fieldProps={{
|
||||
fieldTitleProps: {
|
||||
title: 'Input',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Body content
|
||||
</BoxGroupField>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Input')).toBeInTheDocument()
|
||||
expect(screen.getByText('Body content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render FieldTitle from the barrel export', () => {
|
||||
render(<FieldTitle title="Advanced" subTitle="Extra details" />)
|
||||
|
||||
expect(screen.getByText('Advanced')).toBeInTheDocument()
|
||||
expect(screen.getByText('Extra details')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,195 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Edge, Node } from '@/app/components/workflow/types'
|
||||
import { screen } from '@testing-library/react'
|
||||
import {
|
||||
createEdge,
|
||||
createNode,
|
||||
} from '@/app/components/workflow/__tests__/fixtures'
|
||||
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import {
|
||||
useAvailableBlocks,
|
||||
useNodesInteractions,
|
||||
useNodesReadOnly,
|
||||
useToolIcon,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import NextStep from '../index'
|
||||
|
||||
vi.mock('@/app/components/workflow/block-selector', () => ({
|
||||
default: ({ trigger }: { trigger: ((open: boolean) => ReactNode) | ReactNode }) => {
|
||||
return (
|
||||
<div data-testid="next-step-block-selector">
|
||||
{typeof trigger === 'function' ? trigger(false) : trigger}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useAvailableBlocks: vi.fn(),
|
||||
useNodesInteractions: vi.fn(),
|
||||
useNodesReadOnly: vi.fn(),
|
||||
useToolIcon: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const mockUseAvailableBlocks = vi.mocked(useAvailableBlocks)
|
||||
const mockUseNodesInteractions = vi.mocked(useNodesInteractions)
|
||||
const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly)
|
||||
const mockUseToolIcon = vi.mocked(useToolIcon)
|
||||
|
||||
const createAvailableBlocksResult = (): ReturnType<typeof useAvailableBlocks> => ({
|
||||
getAvailableBlocks: vi.fn(() => ({
|
||||
availablePrevBlocks: [],
|
||||
availableNextBlocks: [],
|
||||
})),
|
||||
availablePrevBlocks: [],
|
||||
availableNextBlocks: [],
|
||||
})
|
||||
|
||||
const renderComponent = (selectedNode: Node, nodes: Node[], edges: Edge[] = []) =>
|
||||
renderWorkflowFlowComponent(
|
||||
<NextStep selectedNode={selectedNode} />,
|
||||
{
|
||||
nodes,
|
||||
edges,
|
||||
canvasStyle: {
|
||||
width: 600,
|
||||
height: 400,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
describe('NextStep', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAvailableBlocks.mockReturnValue(createAvailableBlocksResult())
|
||||
mockUseNodesInteractions.mockReturnValue({
|
||||
handleNodeSelect: vi.fn(),
|
||||
handleNodeAdd: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useNodesInteractions>)
|
||||
mockUseNodesReadOnly.mockReturnValue({
|
||||
nodesReadOnly: true,
|
||||
} as ReturnType<typeof useNodesReadOnly>)
|
||||
mockUseToolIcon.mockReturnValue('')
|
||||
})
|
||||
|
||||
// NextStep should summarize linear next nodes and failure branches from the real ReactFlow graph.
|
||||
describe('Rendering', () => {
|
||||
it('should render connected next nodes and the parallel add action for the default source handle', () => {
|
||||
const selectedNode = createNode({
|
||||
id: 'selected-node',
|
||||
data: {
|
||||
type: BlockEnum.Code,
|
||||
title: 'Selected Node',
|
||||
},
|
||||
})
|
||||
const nextNode = createNode({
|
||||
id: 'next-node',
|
||||
data: {
|
||||
type: BlockEnum.Answer,
|
||||
title: 'Next Node',
|
||||
},
|
||||
})
|
||||
const edge = createEdge({
|
||||
source: 'selected-node',
|
||||
target: 'next-node',
|
||||
sourceHandle: 'source',
|
||||
})
|
||||
|
||||
renderComponent(selectedNode, [selectedNode, nextNode], [edge])
|
||||
|
||||
expect(screen.getByText('Next Node')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.common.addParallelNode')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render configured branch names when target branches are present', () => {
|
||||
const selectedNode = createNode({
|
||||
id: 'selected-node',
|
||||
data: {
|
||||
type: BlockEnum.Code,
|
||||
title: 'Selected Node',
|
||||
_targetBranches: [{
|
||||
id: 'branch-a',
|
||||
name: 'Approved',
|
||||
}],
|
||||
},
|
||||
})
|
||||
const nextNode = createNode({
|
||||
id: 'next-node',
|
||||
data: {
|
||||
type: BlockEnum.Answer,
|
||||
title: 'Branch Node',
|
||||
},
|
||||
})
|
||||
const edge = createEdge({
|
||||
source: 'selected-node',
|
||||
target: 'next-node',
|
||||
sourceHandle: 'branch-a',
|
||||
})
|
||||
|
||||
renderComponent(selectedNode, [selectedNode, nextNode], [edge])
|
||||
|
||||
expect(screen.getByText('Approved')).toBeInTheDocument()
|
||||
expect(screen.getByText('Branch Node')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.common.addParallelNode')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should number question-classifier branches even when no target node is connected', () => {
|
||||
const selectedNode = createNode({
|
||||
id: 'selected-node',
|
||||
data: {
|
||||
type: BlockEnum.QuestionClassifier,
|
||||
title: 'Classifier',
|
||||
_targetBranches: [{
|
||||
id: 'branch-b',
|
||||
name: 'Original branch name',
|
||||
}],
|
||||
},
|
||||
})
|
||||
const danglingEdge = createEdge({
|
||||
source: 'selected-node',
|
||||
target: 'missing-node',
|
||||
sourceHandle: 'branch-b',
|
||||
})
|
||||
|
||||
renderComponent(selectedNode, [selectedNode], [danglingEdge])
|
||||
|
||||
expect(screen.getByText('workflow.nodes.questionClassifiers.class 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.panel.selectNextStep')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the failure branch when the node has error handling enabled', () => {
|
||||
const selectedNode = createNode({
|
||||
id: 'selected-node',
|
||||
data: {
|
||||
type: BlockEnum.Code,
|
||||
title: 'Selected Node',
|
||||
error_strategy: ErrorHandleTypeEnum.failBranch,
|
||||
},
|
||||
})
|
||||
const failNode = createNode({
|
||||
id: 'fail-node',
|
||||
data: {
|
||||
type: BlockEnum.Answer,
|
||||
title: 'Failure Node',
|
||||
},
|
||||
})
|
||||
const failEdge = createEdge({
|
||||
source: 'selected-node',
|
||||
target: 'fail-node',
|
||||
sourceHandle: ErrorHandleTypeEnum.failBranch,
|
||||
})
|
||||
|
||||
renderComponent(selectedNode, [selectedNode, failNode], [failEdge])
|
||||
|
||||
expect(screen.getByText('workflow.common.onFailure')).toBeInTheDocument()
|
||||
expect(screen.getByText('Failure Node')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.common.addFailureBranch')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,162 @@
|
||||
import type { UseQueryResult } from '@tanstack/react-query'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import {
|
||||
useNodeDataUpdate,
|
||||
useNodeMetaData,
|
||||
useNodesInteractions,
|
||||
useNodesReadOnly,
|
||||
useNodesSyncDraft,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useAllWorkflowTools } from '@/service/use-tools'
|
||||
import PanelOperator from '../index'
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useNodeDataUpdate: vi.fn(),
|
||||
useNodeMetaData: vi.fn(),
|
||||
useNodesInteractions: vi.fn(),
|
||||
useNodesReadOnly: vi.fn(),
|
||||
useNodesSyncDraft: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAllWorkflowTools: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../change-block', () => ({
|
||||
default: () => <div data-testid="panel-operator-change-block" />,
|
||||
}))
|
||||
|
||||
const mockUseNodeDataUpdate = vi.mocked(useNodeDataUpdate)
|
||||
const mockUseNodeMetaData = vi.mocked(useNodeMetaData)
|
||||
const mockUseNodesInteractions = vi.mocked(useNodesInteractions)
|
||||
const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly)
|
||||
const mockUseNodesSyncDraft = vi.mocked(useNodesSyncDraft)
|
||||
const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools)
|
||||
|
||||
const createQueryResult = <T,>(data: T): UseQueryResult<T, Error> => ({
|
||||
data,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
isFetching: false,
|
||||
isRefetching: false,
|
||||
isLoadingError: false,
|
||||
isRefetchError: false,
|
||||
isInitialLoading: false,
|
||||
isPaused: false,
|
||||
isEnabled: true,
|
||||
status: 'success',
|
||||
fetchStatus: 'idle',
|
||||
dataUpdatedAt: Date.now(),
|
||||
errorUpdatedAt: 0,
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
errorUpdateCount: 0,
|
||||
isFetched: true,
|
||||
isFetchedAfterMount: true,
|
||||
isPlaceholderData: false,
|
||||
isStale: false,
|
||||
promise: Promise.resolve(data),
|
||||
} as UseQueryResult<T, Error>)
|
||||
|
||||
const renderComponent = (showHelpLink: boolean = true, onOpenChange?: (open: boolean) => void) =>
|
||||
renderWorkflowFlowComponent(
|
||||
<PanelOperator
|
||||
id="node-1"
|
||||
data={{
|
||||
title: 'Code Node',
|
||||
desc: '',
|
||||
type: BlockEnum.Code,
|
||||
}}
|
||||
triggerClassName="panel-operator-trigger"
|
||||
onOpenChange={onOpenChange}
|
||||
showHelpLink={showHelpLink}
|
||||
/>,
|
||||
{
|
||||
nodes: [],
|
||||
edges: [],
|
||||
},
|
||||
)
|
||||
|
||||
describe('PanelOperator', () => {
|
||||
const handleNodeSelect = vi.fn()
|
||||
const handleNodeDataUpdate = vi.fn()
|
||||
const handleSyncWorkflowDraft = vi.fn()
|
||||
const handleNodeDelete = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseNodeDataUpdate.mockReturnValue({
|
||||
handleNodeDataUpdate,
|
||||
handleNodeDataUpdateWithSyncDraft: vi.fn(),
|
||||
})
|
||||
mockUseNodeMetaData.mockReturnValue({
|
||||
isTypeFixed: false,
|
||||
isSingleton: false,
|
||||
isUndeletable: false,
|
||||
description: 'Node description',
|
||||
author: 'Dify',
|
||||
helpLinkUri: 'https://docs.example.com/node',
|
||||
} as ReturnType<typeof useNodeMetaData>)
|
||||
mockUseNodesInteractions.mockReturnValue({
|
||||
handleNodeDelete,
|
||||
handleNodesDuplicate: vi.fn(),
|
||||
handleNodeSelect,
|
||||
handleNodesCopy: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useNodesInteractions>)
|
||||
mockUseNodesReadOnly.mockReturnValue({
|
||||
nodesReadOnly: false,
|
||||
} as ReturnType<typeof useNodesReadOnly>)
|
||||
mockUseNodesSyncDraft.mockReturnValue({
|
||||
doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined),
|
||||
handleSyncWorkflowDraft,
|
||||
syncWorkflowDraftWhenPageClose: vi.fn(),
|
||||
})
|
||||
mockUseAllWorkflowTools.mockReturnValue(createQueryResult<ToolWithProvider[]>([]))
|
||||
})
|
||||
|
||||
// The operator should open the real popup, expose actionable items, and respect help-link visibility.
|
||||
describe('Popup Interaction', () => {
|
||||
it('should open the popup and trigger single-run actions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onOpenChange = vi.fn()
|
||||
const { container } = renderComponent(true, onOpenChange)
|
||||
|
||||
await user.click(container.querySelector('.panel-operator-trigger') as HTMLElement)
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true)
|
||||
expect(screen.getByText('workflow.panel.runThisStep')).toBeInTheDocument()
|
||||
expect(screen.getByText('Node description')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('workflow.panel.runThisStep'))
|
||||
|
||||
expect(handleNodeSelect).toHaveBeenCalledWith('node-1')
|
||||
expect(handleNodeDataUpdate).toHaveBeenCalledWith({
|
||||
id: 'node-1',
|
||||
data: { _isSingleRun: true },
|
||||
})
|
||||
expect(handleSyncWorkflowDraft).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should hide the help link when showHelpLink is false', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = renderComponent(false)
|
||||
|
||||
await user.click(container.querySelector('.panel-operator-trigger') as HTMLElement)
|
||||
|
||||
expect(screen.queryByText('workflow.panel.helpLink')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Node description')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,4 +1,4 @@
|
||||
import matchTheSchemaType from './match-schema-type'
|
||||
import matchTheSchemaType from '../match-schema-type'
|
||||
|
||||
describe('match the schema type', () => {
|
||||
it('should return true for identical primitive types', () => {
|
||||
@ -0,0 +1,43 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import { VariableLabelInNode, VariableLabelInText } from '../index'
|
||||
|
||||
describe('variable-label index', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The barrel exports should render the node and text variants with the expected variable metadata.
|
||||
describe('Rendering', () => {
|
||||
it('should render the node variant with node label and variable type', () => {
|
||||
render(
|
||||
<VariableLabelInNode
|
||||
nodeType={BlockEnum.Code}
|
||||
nodeTitle="Source Node"
|
||||
variables={['source-node', 'answer']}
|
||||
variableType={VarType.string}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Source Node')).toBeInTheDocument()
|
||||
expect(screen.getByText('answer')).toBeInTheDocument()
|
||||
expect(screen.getByText('String')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the text variant with the shortened variable path', () => {
|
||||
render(
|
||||
<VariableLabelInText
|
||||
nodeType={BlockEnum.Code}
|
||||
nodeTitle="Source Node"
|
||||
variables={['source-node', 'payload', 'answer']}
|
||||
notShowFullPath
|
||||
isExceptionVariable
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('exception-variable')).toBeInTheDocument()
|
||||
expect(screen.getByText('Source Node')).toBeInTheDocument()
|
||||
expect(screen.getByText('answer')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,67 @@
|
||||
import type { AnswerNodeType } from '../types'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { createNode } from '@/app/components/workflow/__tests__/fixtures'
|
||||
import { renderNodeComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { useWorkflow } from '@/app/components/workflow/hooks'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Node from '../node'
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useWorkflow: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const mockUseWorkflow = vi.mocked(useWorkflow)
|
||||
|
||||
const createNodeData = (overrides: Partial<AnswerNodeType> = {}): AnswerNodeType => ({
|
||||
title: 'Answer',
|
||||
desc: '',
|
||||
type: BlockEnum.Answer,
|
||||
variables: [],
|
||||
answer: 'Plain answer',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('AnswerNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseWorkflow.mockReturnValue({
|
||||
getBeforeNodesInSameBranchIncludeParent: () => [],
|
||||
} as unknown as ReturnType<typeof useWorkflow>)
|
||||
})
|
||||
|
||||
// The node should render the localized panel title and plain answer text.
|
||||
describe('Rendering', () => {
|
||||
it('should render the answer title and text content', () => {
|
||||
renderNodeComponent(Node, createNodeData())
|
||||
|
||||
expect(screen.getByText('workflow.nodes.answer.answer')).toBeInTheDocument()
|
||||
expect(screen.getByText('Plain answer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render referenced variables inside the readonly content', () => {
|
||||
mockUseWorkflow.mockReturnValue({
|
||||
getBeforeNodesInSameBranchIncludeParent: () => [
|
||||
createNode({
|
||||
id: 'source-node',
|
||||
data: {
|
||||
type: BlockEnum.Code,
|
||||
title: 'Source Node',
|
||||
},
|
||||
}),
|
||||
],
|
||||
} as unknown as ReturnType<typeof useWorkflow>)
|
||||
|
||||
renderNodeComponent(Node, createNodeData({
|
||||
answer: 'Hello {{#source-node.name#}}',
|
||||
}))
|
||||
|
||||
expect(screen.getByText('Hello')).toBeInTheDocument()
|
||||
expect(screen.getByText('Source Node')).toBeInTheDocument()
|
||||
expect(screen.getByText('name')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,6 @@
|
||||
import { VarType } from '../../types'
|
||||
import { extractFunctionParams, extractReturnType } from './code-parser'
|
||||
import { CodeLanguage } from './types'
|
||||
import { VarType } from '../../../types'
|
||||
import { extractFunctionParams, extractReturnType } from '../code-parser'
|
||||
import { CodeLanguage } from '../types'
|
||||
|
||||
const SAMPLE_CODES = {
|
||||
python3: {
|
||||
@ -0,0 +1,101 @@
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import type { OnSelectBlock } from '@/app/components/workflow/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import DataSourceEmptyNode from '../index'
|
||||
|
||||
const mockUseReplaceDataSourceNode = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useReplaceDataSourceNode: mockUseReplaceDataSourceNode,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/block-selector', () => ({
|
||||
default: ({
|
||||
onSelect,
|
||||
trigger,
|
||||
}: {
|
||||
onSelect: OnSelectBlock
|
||||
trigger: ((open?: boolean) => ReactNode) | ReactNode
|
||||
}) => (
|
||||
<div>
|
||||
{typeof trigger === 'function' ? trigger(false) : trigger}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(BlockEnum.DataSource, {
|
||||
plugin_id: 'plugin-id',
|
||||
provider_type: 'datasource',
|
||||
provider_name: 'file',
|
||||
datasource_name: 'local-file',
|
||||
datasource_label: 'Local File',
|
||||
title: 'Local File',
|
||||
})}
|
||||
>
|
||||
select data source
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
type DataSourceEmptyNodeProps = ComponentProps<typeof DataSourceEmptyNode>
|
||||
|
||||
const createNodeProps = (): DataSourceEmptyNodeProps => ({
|
||||
id: 'data-source-empty-node',
|
||||
data: {
|
||||
width: 240,
|
||||
height: 88,
|
||||
},
|
||||
type: 'default',
|
||||
selected: false,
|
||||
zIndex: 0,
|
||||
isConnectable: true,
|
||||
xPos: 0,
|
||||
yPos: 0,
|
||||
dragging: false,
|
||||
dragHandle: undefined,
|
||||
} as unknown as DataSourceEmptyNodeProps)
|
||||
|
||||
describe('DataSourceEmptyNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseReplaceDataSourceNode.mockReturnValue({
|
||||
handleReplaceNode: vi.fn(),
|
||||
})
|
||||
})
|
||||
|
||||
// The empty datasource node should render the add trigger and forward selector choices.
|
||||
describe('Rendering and Selection', () => {
|
||||
it('should render the datasource add trigger', () => {
|
||||
render(
|
||||
<DataSourceEmptyNode {...createNodeProps()} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.dataSource.add')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.blocks.datasource')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should forward block selections to the replace hook', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleReplaceNode = vi.fn()
|
||||
mockUseReplaceDataSourceNode.mockReturnValue({
|
||||
handleReplaceNode,
|
||||
})
|
||||
|
||||
render(
|
||||
<DataSourceEmptyNode {...createNodeProps()} />,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'select data source' }))
|
||||
|
||||
expect(handleReplaceNode).toHaveBeenCalledWith(BlockEnum.DataSource, {
|
||||
plugin_id: 'plugin-id',
|
||||
provider_type: 'datasource',
|
||||
provider_name: 'file',
|
||||
datasource_name: 'local-file',
|
||||
datasource_label: 'Local File',
|
||||
title: 'Local File',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,76 @@
|
||||
import type { DataSourceNodeType } from '../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Node from '../node'
|
||||
|
||||
const mockInstallPluginButton = vi.hoisted(() => vi.fn(({ uniqueIdentifier }: { uniqueIdentifier: string }) => (
|
||||
<button type="button">{uniqueIdentifier}</button>
|
||||
)))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks/use-node-plugin-installation', () => ({
|
||||
useNodePluginInstallation: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/install-plugin-button', () => ({
|
||||
InstallPluginButton: mockInstallPluginButton,
|
||||
}))
|
||||
|
||||
const mockUseNodePluginInstallation = vi.mocked(useNodePluginInstallation)
|
||||
|
||||
const createNodeData = (overrides: Partial<DataSourceNodeType> = {}): DataSourceNodeType => ({
|
||||
title: 'Datasource',
|
||||
desc: '',
|
||||
type: BlockEnum.DataSource,
|
||||
plugin_id: 'plugin-id',
|
||||
provider_type: 'datasource',
|
||||
provider_name: 'file',
|
||||
datasource_name: 'local-file',
|
||||
datasource_label: 'Local File',
|
||||
datasource_parameters: {},
|
||||
datasource_configurations: {},
|
||||
plugin_unique_identifier: 'plugin-id@1.0.0',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('DataSourceNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseNodePluginInstallation.mockReturnValue({
|
||||
isChecking: false,
|
||||
isMissing: false,
|
||||
uniqueIdentifier: undefined,
|
||||
canInstall: false,
|
||||
onInstallSuccess: vi.fn(),
|
||||
shouldDim: false,
|
||||
})
|
||||
})
|
||||
|
||||
// The node should only expose install affordances when the backing plugin is missing and installable.
|
||||
describe('Plugin Installation', () => {
|
||||
it('should render the install button when the datasource plugin is missing', () => {
|
||||
mockUseNodePluginInstallation.mockReturnValue({
|
||||
isChecking: false,
|
||||
isMissing: true,
|
||||
uniqueIdentifier: 'plugin-id@1.0.0',
|
||||
canInstall: true,
|
||||
onInstallSuccess: vi.fn(),
|
||||
shouldDim: true,
|
||||
})
|
||||
|
||||
render(<Node id="data-source-node" data={createNodeData()} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'plugin-id@1.0.0' })).toBeInTheDocument()
|
||||
expect(mockInstallPluginButton).toHaveBeenCalledWith(expect.objectContaining({
|
||||
uniqueIdentifier: 'plugin-id@1.0.0',
|
||||
extraIdentifiers: ['plugin-id', 'file'],
|
||||
}), undefined)
|
||||
})
|
||||
|
||||
it('should render nothing when installation is unavailable', () => {
|
||||
const { container } = render(<Node id="data-source-node" data={createNodeData()} />)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,93 @@
|
||||
import type { EndNodeType } from '../types'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { createNode, createStartNode } from '@/app/components/workflow/__tests__/fixtures'
|
||||
import { renderNodeComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import {
|
||||
useIsChatMode,
|
||||
useWorkflow,
|
||||
useWorkflowVariables,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Node from '../node'
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useWorkflow: vi.fn(),
|
||||
useWorkflowVariables: vi.fn(),
|
||||
useIsChatMode: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const mockUseWorkflow = vi.mocked(useWorkflow)
|
||||
const mockUseWorkflowVariables = vi.mocked(useWorkflowVariables)
|
||||
const mockUseIsChatMode = vi.mocked(useIsChatMode)
|
||||
|
||||
const createNodeData = (overrides: Partial<EndNodeType> = {}): EndNodeType => ({
|
||||
title: 'End',
|
||||
desc: '',
|
||||
type: BlockEnum.End,
|
||||
outputs: [{
|
||||
variable: 'answer',
|
||||
value_selector: ['source-node', 'answer'],
|
||||
}],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('EndNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseWorkflow.mockReturnValue({
|
||||
getBeforeNodesInSameBranch: () => [
|
||||
createStartNode(),
|
||||
createNode({
|
||||
id: 'source-node',
|
||||
data: {
|
||||
type: BlockEnum.Code,
|
||||
title: 'Source Node',
|
||||
},
|
||||
}),
|
||||
],
|
||||
} as unknown as ReturnType<typeof useWorkflow>)
|
||||
mockUseWorkflowVariables.mockReturnValue({
|
||||
getNodeAvailableVars: () => [],
|
||||
getCurrentVariableType: () => 'string',
|
||||
} as unknown as ReturnType<typeof useWorkflowVariables>)
|
||||
mockUseIsChatMode.mockReturnValue(false)
|
||||
})
|
||||
|
||||
// The node should surface only resolved outputs and ignore empty selectors.
|
||||
describe('Rendering', () => {
|
||||
it('should render resolved output labels for referenced nodes', () => {
|
||||
renderNodeComponent(Node, createNodeData())
|
||||
|
||||
expect(screen.getByText('Source Node')).toBeInTheDocument()
|
||||
expect(screen.getByText('answer')).toBeInTheDocument()
|
||||
expect(screen.getByText('String')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fall back to the start node when the selector node cannot be found', () => {
|
||||
renderNodeComponent(Node, createNodeData({
|
||||
outputs: [{
|
||||
variable: 'answer',
|
||||
value_selector: ['missing-node', 'answer'],
|
||||
}],
|
||||
}))
|
||||
|
||||
expect(screen.getByText('Start')).toBeInTheDocument()
|
||||
expect(screen.getByText('answer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render nothing when every output selector is empty', () => {
|
||||
const { container } = renderNodeComponent(Node, createNodeData({
|
||||
outputs: [{
|
||||
variable: 'answer',
|
||||
value_selector: [],
|
||||
}],
|
||||
}))
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,94 @@
|
||||
import type { NodeProps } from 'reactflow'
|
||||
import type { CommonNodeType } from '@/app/components/workflow/types'
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import { createNode } from '@/app/components/workflow/__tests__/fixtures'
|
||||
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import {
|
||||
useAvailableBlocks,
|
||||
useIsChatMode,
|
||||
useNodesInteractions,
|
||||
useNodesReadOnly,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import IterationStartNode, { IterationStartNodeDumb } from '../index'
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useAvailableBlocks: vi.fn(),
|
||||
useNodesInteractions: vi.fn(),
|
||||
useNodesReadOnly: vi.fn(),
|
||||
useIsChatMode: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const mockUseAvailableBlocks = vi.mocked(useAvailableBlocks)
|
||||
const mockUseNodesInteractions = vi.mocked(useNodesInteractions)
|
||||
const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly)
|
||||
const mockUseIsChatMode = vi.mocked(useIsChatMode)
|
||||
|
||||
const createAvailableBlocksResult = (): ReturnType<typeof useAvailableBlocks> => ({
|
||||
getAvailableBlocks: vi.fn(() => ({
|
||||
availablePrevBlocks: [],
|
||||
availableNextBlocks: [],
|
||||
})),
|
||||
availablePrevBlocks: [],
|
||||
availableNextBlocks: [],
|
||||
})
|
||||
|
||||
const FlowNode = (props: NodeProps<CommonNodeType>) => (
|
||||
<IterationStartNode {...props} />
|
||||
)
|
||||
|
||||
const renderFlowNode = () =>
|
||||
renderWorkflowFlowComponent(<div />, {
|
||||
nodes: [createNode({
|
||||
id: 'iteration-start-node',
|
||||
type: 'iterationStartNode',
|
||||
data: {
|
||||
title: 'Iteration Start',
|
||||
desc: '',
|
||||
type: BlockEnum.IterationStart,
|
||||
},
|
||||
})],
|
||||
edges: [],
|
||||
reactFlowProps: {
|
||||
nodeTypes: { iterationStartNode: FlowNode },
|
||||
},
|
||||
canvasStyle: {
|
||||
width: 400,
|
||||
height: 300,
|
||||
},
|
||||
})
|
||||
|
||||
describe('IterationStartNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAvailableBlocks.mockReturnValue(createAvailableBlocksResult())
|
||||
mockUseNodesInteractions.mockReturnValue({
|
||||
handleNodeAdd: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useNodesInteractions>)
|
||||
mockUseNodesReadOnly.mockReturnValue({
|
||||
getNodesReadOnly: () => false,
|
||||
} as unknown as ReturnType<typeof useNodesReadOnly>)
|
||||
mockUseIsChatMode.mockReturnValue(false)
|
||||
})
|
||||
|
||||
// The start marker should provide the source handle in flow mode and omit it in dumb mode.
|
||||
describe('Rendering', () => {
|
||||
it('should render the source handle in the ReactFlow context', async () => {
|
||||
const { container } = renderFlowNode()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-handleid="source"]')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render the dumb variant without any source handle', () => {
|
||||
const { container } = render(<IterationStartNodeDumb />)
|
||||
|
||||
expect(container.querySelector('[data-handleid="source"]')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,12 +1,12 @@
|
||||
import type { KnowledgeBaseNodeType } from './types'
|
||||
import type { KnowledgeBaseNodeType } from '../types'
|
||||
import type { Model, ModelItem } 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 nodeDefault from './default'
|
||||
import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from './types'
|
||||
import nodeDefault from '../default'
|
||||
import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from '../types'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { KnowledgeBaseNodeType } from './types'
|
||||
import type { KnowledgeBaseNodeType } from '../types'
|
||||
import type { ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { CommonNodeType } from '@/app/components/workflow/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
@ -8,12 +8,12 @@ import {
|
||||
ModelTypeEnum,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Node from './node'
|
||||
import Node from '../node'
|
||||
import {
|
||||
ChunkStructureEnum,
|
||||
IndexMethodEnum,
|
||||
RetrievalSearchMethodEnum,
|
||||
} from './types'
|
||||
} from '../types'
|
||||
|
||||
const mockUseModelList = vi.hoisted(() => vi.fn())
|
||||
const mockUseSettingsDisplay = vi.hoisted(() => vi.fn())
|
||||
@ -36,11 +36,11 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', asy
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./hooks/use-settings-display', () => ({
|
||||
vi.mock('../hooks/use-settings-display', () => ({
|
||||
useSettingsDisplay: mockUseSettingsDisplay,
|
||||
}))
|
||||
|
||||
vi.mock('./hooks/use-embedding-model-status', () => ({
|
||||
vi.mock('../hooks/use-embedding-model-status', () => ({
|
||||
useEmbeddingModelStatus: mockUseEmbeddingModelStatus,
|
||||
}))
|
||||
|
||||
@ -2,8 +2,8 @@ import type { ReactNode } from 'react'
|
||||
import type { PanelProps } from '@/types/workflow'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import Panel from './panel'
|
||||
import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from './types'
|
||||
import Panel from '../panel'
|
||||
import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from '../types'
|
||||
|
||||
const mockUseModelList = vi.hoisted(() => vi.fn())
|
||||
const mockUseQuery = vi.hoisted(() => vi.fn())
|
||||
@ -35,7 +35,7 @@ vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useNodesReadOnly: () => ({ nodesReadOnly: false }),
|
||||
}))
|
||||
|
||||
vi.mock('./hooks/use-config', () => ({
|
||||
vi.mock('../hooks/use-config', () => ({
|
||||
useConfig: () => ({
|
||||
handleChunkStructureChange: vi.fn(),
|
||||
handleIndexMethodChange: vi.fn(),
|
||||
@ -54,7 +54,7 @@ vi.mock('./hooks/use-config', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./hooks/use-embedding-model-status', () => ({
|
||||
vi.mock('../hooks/use-embedding-model-status', () => ({
|
||||
useEmbeddingModelStatus: mockUseEmbeddingModelStatus,
|
||||
}))
|
||||
|
||||
@ -92,19 +92,19 @@ vi.mock('@/app/components/datasets/settings/summary-index-setting', () => ({
|
||||
default: mockSummaryIndexSetting,
|
||||
}))
|
||||
|
||||
vi.mock('./components/chunk-structure', () => ({
|
||||
vi.mock('../components/chunk-structure', () => ({
|
||||
default: mockChunkStructure,
|
||||
}))
|
||||
|
||||
vi.mock('./components/index-method', () => ({
|
||||
vi.mock('../components/index-method', () => ({
|
||||
default: () => <div data-testid="index-method" />,
|
||||
}))
|
||||
|
||||
vi.mock('./components/embedding-model', () => ({
|
||||
vi.mock('../components/embedding-model', () => ({
|
||||
default: mockEmbeddingModel,
|
||||
}))
|
||||
|
||||
vi.mock('./components/retrieval-setting', () => ({
|
||||
vi.mock('../components/retrieval-setting', () => ({
|
||||
default: () => <div data-testid="retrieval-setting" />,
|
||||
}))
|
||||
|
||||
@ -0,0 +1,93 @@
|
||||
import type { KnowledgeBaseNodeType } from '../types'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
|
||||
import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from '../types'
|
||||
import useSingleRunFormParams from '../use-single-run-form-params'
|
||||
|
||||
const createPayload = (overrides: Partial<KnowledgeBaseNodeType> = {}): KnowledgeBaseNodeType => ({
|
||||
title: 'Knowledge Base',
|
||||
desc: '',
|
||||
type: BlockEnum.KnowledgeBase,
|
||||
index_chunk_variable_selector: ['chunks', 'results'],
|
||||
chunk_structure: ChunkStructureEnum.general,
|
||||
indexing_technique: IndexMethodEnum.QUALIFIED,
|
||||
embedding_model: 'text-embedding-3-large',
|
||||
embedding_model_provider: 'openai',
|
||||
keyword_number: 10,
|
||||
retrieval_model: {
|
||||
search_method: RetrievalSearchMethodEnum.semantic,
|
||||
reranking_enable: false,
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('useSingleRunFormParams', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The hook should expose the single query form and map chunk dependencies for single-run execution.
|
||||
describe('Forms', () => {
|
||||
it('should build the query form with the current run input value', () => {
|
||||
const { result } = renderHook(() => useSingleRunFormParams({
|
||||
id: 'knowledge-base-1',
|
||||
payload: createPayload(),
|
||||
runInputData: { query: 'what is dify' },
|
||||
getInputVars: vi.fn(),
|
||||
setRunInputData: vi.fn(),
|
||||
toVarInputs: vi.fn(),
|
||||
}))
|
||||
|
||||
expect(result.current.forms).toHaveLength(1)
|
||||
expect(result.current.forms[0].inputs).toEqual([{
|
||||
label: 'workflow.nodes.common.inputVars',
|
||||
variable: 'query',
|
||||
type: InputVarType.paragraph,
|
||||
required: true,
|
||||
}])
|
||||
expect(result.current.forms[0].values).toEqual({ query: 'what is dify' })
|
||||
})
|
||||
|
||||
it('should update run input data when the query changes', () => {
|
||||
const setRunInputData = vi.fn()
|
||||
const { result } = renderHook(() => useSingleRunFormParams({
|
||||
id: 'knowledge-base-1',
|
||||
payload: createPayload(),
|
||||
runInputData: { query: 'old query' },
|
||||
getInputVars: vi.fn(),
|
||||
setRunInputData,
|
||||
toVarInputs: vi.fn(),
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.forms[0].onChange({ query: 'new query' })
|
||||
})
|
||||
|
||||
expect(setRunInputData).toHaveBeenCalledWith({ query: 'new query' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dependencies', () => {
|
||||
it('should expose the chunk selector as the only dependent variable', () => {
|
||||
const payload = createPayload({
|
||||
index_chunk_variable_selector: ['node-1', 'chunks'],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useSingleRunFormParams({
|
||||
id: 'knowledge-base-1',
|
||||
payload,
|
||||
runInputData: {},
|
||||
getInputVars: vi.fn(),
|
||||
setRunInputData: vi.fn(),
|
||||
toVarInputs: vi.fn(),
|
||||
}))
|
||||
|
||||
expect(result.current.getDependentVars()).toEqual([['node-1', 'chunks']])
|
||||
expect(result.current.getDependentVar('query')).toEqual(['node-1', 'chunks'])
|
||||
expect(result.current.getDependentVar('other')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,4 +1,4 @@
|
||||
import type { KnowledgeBaseNodeType } from './types'
|
||||
import type { KnowledgeBaseNodeType } from '../types'
|
||||
import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
@ -9,14 +9,14 @@ import {
|
||||
ChunkStructureEnum,
|
||||
IndexMethodEnum,
|
||||
RetrievalSearchMethodEnum,
|
||||
} from './types'
|
||||
} from '../types'
|
||||
import {
|
||||
getKnowledgeBaseValidationIssue,
|
||||
getKnowledgeBaseValidationMessage,
|
||||
isHighQualitySearchMethod,
|
||||
isKnowledgeBaseEmbeddingIssue,
|
||||
KnowledgeBaseValidationIssueCode,
|
||||
} from './utils'
|
||||
} from '../utils'
|
||||
|
||||
const makeEmbeddingModelList = (status: ModelStatusEnum): Model[] => {
|
||||
return [
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -0,0 +1,513 @@
|
||||
import type { KnowledgeBaseNodeType } from '../../types'
|
||||
import { act } from '@testing-library/react'
|
||||
import {
|
||||
createNode,
|
||||
createNodeDataFactory,
|
||||
} from '@/app/components/workflow/__tests__/fixtures'
|
||||
import { renderWorkflowFlowHook } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { RerankingModeEnum } from '@/models/datasets'
|
||||
import {
|
||||
ChunkStructureEnum,
|
||||
HybridSearchModeEnum,
|
||||
IndexMethodEnum,
|
||||
RetrievalSearchMethodEnum,
|
||||
WeightedScoreEnum,
|
||||
} from '../../types'
|
||||
import { useConfig } from '../use-config'
|
||||
|
||||
const mockHandleNodeDataUpdateWithSyncDraft = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useNodeDataUpdate: () => ({
|
||||
handleNodeDataUpdateWithSyncDraft: mockHandleNodeDataUpdateWithSyncDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createNodeData = createNodeDataFactory<KnowledgeBaseNodeType>({
|
||||
title: 'Knowledge Base',
|
||||
desc: '',
|
||||
type: 'knowledge-base' as KnowledgeBaseNodeType['type'],
|
||||
index_chunk_variable_selector: ['chunks', 'results'],
|
||||
chunk_structure: ChunkStructureEnum.general,
|
||||
indexing_technique: IndexMethodEnum.QUALIFIED,
|
||||
embedding_model: 'text-embedding-3-large',
|
||||
embedding_model_provider: 'openai',
|
||||
keyword_number: 3,
|
||||
retrieval_model: {
|
||||
search_method: RetrievalSearchMethodEnum.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_mode: RerankingModeEnum.RerankingModel,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
},
|
||||
summary_index_setting: {
|
||||
enable: false,
|
||||
summary_prompt: 'existing prompt',
|
||||
},
|
||||
})
|
||||
|
||||
const renderConfigHook = (nodeData: KnowledgeBaseNodeType) =>
|
||||
renderWorkflowFlowHook(() => useConfig('knowledge-base-node'), {
|
||||
nodes: [
|
||||
createNode({
|
||||
id: 'knowledge-base-node',
|
||||
data: nodeData,
|
||||
}),
|
||||
],
|
||||
edges: [],
|
||||
})
|
||||
|
||||
describe('useConfig', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should preserve the current chunk variable selector when the chunk structure does not change', () => {
|
||||
const { result } = renderConfigHook(createNodeData())
|
||||
|
||||
act(() => {
|
||||
result.current.handleChunkStructureChange(ChunkStructureEnum.general)
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
chunk_structure: ChunkStructureEnum.general,
|
||||
index_chunk_variable_selector: ['chunks', 'results'],
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should reset chunk variables and keep a high-quality search method when switching chunk structures', () => {
|
||||
const { result } = renderConfigHook(createNodeData({
|
||||
retrieval_model: {
|
||||
search_method: RetrievalSearchMethodEnum.keywordSearch,
|
||||
reranking_enable: false,
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
},
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleChunkStructureChange(ChunkStructureEnum.parent_child)
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
chunk_structure: ChunkStructureEnum.parent_child,
|
||||
indexing_technique: IndexMethodEnum.QUALIFIED,
|
||||
index_chunk_variable_selector: [],
|
||||
retrieval_model: expect.objectContaining({
|
||||
search_method: RetrievalSearchMethodEnum.keywordSearch,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should preserve semantic search when switching to a structured chunk mode from a high-quality search method', () => {
|
||||
const { result } = renderConfigHook(createNodeData({
|
||||
retrieval_model: {
|
||||
search_method: RetrievalSearchMethodEnum.semantic,
|
||||
reranking_enable: false,
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
},
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleChunkStructureChange(ChunkStructureEnum.question_answer)
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
chunk_structure: ChunkStructureEnum.question_answer,
|
||||
retrieval_model: expect.objectContaining({
|
||||
search_method: RetrievalSearchMethodEnum.semantic,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should update the index method and keyword number', () => {
|
||||
const { result } = renderConfigHook(createNodeData())
|
||||
|
||||
act(() => {
|
||||
result.current.handleIndexMethodChange(IndexMethodEnum.ECONOMICAL)
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
indexing_technique: IndexMethodEnum.ECONOMICAL,
|
||||
retrieval_model: expect.objectContaining({
|
||||
search_method: RetrievalSearchMethodEnum.keywordSearch,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleIndexMethodChange(IndexMethodEnum.QUALIFIED)
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
indexing_technique: IndexMethodEnum.QUALIFIED,
|
||||
retrieval_model: expect.objectContaining({
|
||||
search_method: RetrievalSearchMethodEnum.semantic,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleKeywordNumberChange(9)
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: {
|
||||
keyword_number: 9,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should create default weights when embedding weights are missing and default reranking mode when switching away from hybrid', () => {
|
||||
const { result } = renderConfigHook(createNodeData({
|
||||
retrieval_model: {
|
||||
search_method: RetrievalSearchMethodEnum.semantic,
|
||||
reranking_enable: false,
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
},
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleEmbeddingModelChange({
|
||||
embeddingModel: 'text-embedding-3-small',
|
||||
embeddingModelProvider: 'openai',
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
retrieval_model: expect.objectContaining({
|
||||
weights: expect.objectContaining({
|
||||
vector_setting: expect.objectContaining({
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-3-small',
|
||||
}),
|
||||
keyword_setting: expect.objectContaining({
|
||||
keyword_weight: 0.3,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleRetrievalSearchMethodChange(RetrievalSearchMethodEnum.fullText)
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
retrieval_model: expect.objectContaining({
|
||||
search_method: RetrievalSearchMethodEnum.fullText,
|
||||
reranking_mode: RerankingModeEnum.RerankingModel,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should update embedding model weights and retrieval search method defaults', () => {
|
||||
const { result } = renderConfigHook(createNodeData({
|
||||
retrieval_model: {
|
||||
search_method: RetrievalSearchMethodEnum.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_mode: RerankingModeEnum.RerankingModel,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
weights: {
|
||||
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,
|
||||
},
|
||||
},
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
},
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleEmbeddingModelChange({
|
||||
embeddingModel: 'text-embedding-3-small',
|
||||
embeddingModelProvider: 'openai',
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
embedding_model: 'text-embedding-3-small',
|
||||
embedding_model_provider: 'openai',
|
||||
retrieval_model: expect.objectContaining({
|
||||
weights: expect.objectContaining({
|
||||
vector_setting: expect.objectContaining({
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-3-small',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleRetrievalSearchMethodChange(RetrievalSearchMethodEnum.hybrid)
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
retrieval_model: expect.objectContaining({
|
||||
search_method: RetrievalSearchMethodEnum.hybrid,
|
||||
reranking_mode: RerankingModeEnum.RerankingModel,
|
||||
reranking_enable: true,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should seed hybrid weights and propagate retrieval tuning updates', () => {
|
||||
const { result } = renderConfigHook(createNodeData({
|
||||
retrieval_model: {
|
||||
search_method: RetrievalSearchMethodEnum.hybrid,
|
||||
reranking_enable: false,
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
},
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleHybridSearchModeChange(HybridSearchModeEnum.WeightedScore)
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
retrieval_model: expect.objectContaining({
|
||||
reranking_mode: HybridSearchModeEnum.WeightedScore,
|
||||
reranking_enable: false,
|
||||
weights: expect.objectContaining({
|
||||
vector_setting: expect.objectContaining({
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-3-large',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleRerankingModelEnabledChange(true)
|
||||
result.current.handleWeighedScoreChange({ value: [0.6, 0.4] })
|
||||
result.current.handleRerankingModelChange({
|
||||
reranking_provider_name: 'cohere',
|
||||
reranking_model_name: 'rerank-v3',
|
||||
})
|
||||
result.current.handleTopKChange(8)
|
||||
result.current.handleScoreThresholdChange(0.75)
|
||||
result.current.handleScoreThresholdEnabledChange(true)
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenNthCalledWith(2, {
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
retrieval_model: expect.objectContaining({
|
||||
reranking_enable: true,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenNthCalledWith(3, {
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
retrieval_model: expect.objectContaining({
|
||||
weights: expect.objectContaining({
|
||||
weight_type: WeightedScoreEnum.Customized,
|
||||
vector_setting: expect.objectContaining({
|
||||
vector_weight: 0.6,
|
||||
}),
|
||||
keyword_setting: expect.objectContaining({
|
||||
keyword_weight: 0.4,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenNthCalledWith(4, {
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
retrieval_model: expect.objectContaining({
|
||||
reranking_model: {
|
||||
reranking_provider_name: 'cohere',
|
||||
reranking_model_name: 'rerank-v3',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
})
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenNthCalledWith(5, {
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
retrieval_model: expect.objectContaining({
|
||||
top_k: 8,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenNthCalledWith(6, {
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
retrieval_model: expect.objectContaining({
|
||||
score_threshold: 0.75,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenNthCalledWith(7, {
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
retrieval_model: expect.objectContaining({
|
||||
score_threshold_enabled: true,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should reuse existing hybrid weights and allow empty embedding defaults', () => {
|
||||
const { result } = renderConfigHook(createNodeData({
|
||||
embedding_model: undefined,
|
||||
embedding_model_provider: undefined,
|
||||
retrieval_model: {
|
||||
search_method: RetrievalSearchMethodEnum.hybrid,
|
||||
reranking_enable: false,
|
||||
reranking_mode: RerankingModeEnum.WeightedScore,
|
||||
weights: {
|
||||
weight_type: WeightedScoreEnum.Customized,
|
||||
vector_setting: {
|
||||
vector_weight: 0.9,
|
||||
embedding_provider_name: 'existing-provider',
|
||||
embedding_model_name: 'existing-model',
|
||||
},
|
||||
keyword_setting: {
|
||||
keyword_weight: 0.1,
|
||||
},
|
||||
},
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
},
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleHybridSearchModeChange(HybridSearchModeEnum.RerankingModel)
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
retrieval_model: expect.objectContaining({
|
||||
reranking_mode: HybridSearchModeEnum.RerankingModel,
|
||||
reranking_enable: true,
|
||||
weights: expect.objectContaining({
|
||||
vector_setting: expect.objectContaining({
|
||||
embedding_provider_name: 'existing-provider',
|
||||
embedding_model_name: 'existing-model',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleEmbeddingModelChange({
|
||||
embeddingModel: 'fallback-model',
|
||||
embeddingModelProvider: '',
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: expect.objectContaining({
|
||||
embedding_model: 'fallback-model',
|
||||
embedding_model_provider: '',
|
||||
retrieval_model: expect.objectContaining({
|
||||
weights: expect.objectContaining({
|
||||
vector_setting: expect.objectContaining({
|
||||
embedding_provider_name: '',
|
||||
embedding_model_name: 'fallback-model',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('should normalize input variables and merge summary index settings', () => {
|
||||
const { result } = renderConfigHook(createNodeData())
|
||||
|
||||
act(() => {
|
||||
result.current.handleInputVariableChange('chunks')
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: {
|
||||
index_chunk_variable_selector: [],
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleInputVariableChange(['payload', 'chunks'])
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: {
|
||||
index_chunk_variable_selector: ['payload', 'chunks'],
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleSummaryIndexSettingChange({
|
||||
enable: true,
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenLastCalledWith({
|
||||
id: 'knowledge-base-node',
|
||||
data: {
|
||||
summary_index_setting: {
|
||||
enable: true,
|
||||
summary_prompt: 'existing prompt',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,81 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import {
|
||||
createCredentialState,
|
||||
createModel,
|
||||
createModelItem,
|
||||
createProviderMeta,
|
||||
} from '@/app/components/workflow/__tests__/model-provider-fixtures'
|
||||
import { useEmbeddingModelStatus } from '../use-embedding-model-status'
|
||||
|
||||
const mockUseCredentialPanelState = vi.hoisted(() => vi.fn())
|
||||
const mockUseProviderContext = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state', () => ({
|
||||
useCredentialPanelState: mockUseCredentialPanelState,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: mockUseProviderContext,
|
||||
}))
|
||||
|
||||
describe('useEmbeddingModelStatus', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
modelProviders: [createProviderMeta({
|
||||
supported_model_types: [ModelTypeEnum.textEmbedding],
|
||||
})],
|
||||
})
|
||||
mockUseCredentialPanelState.mockReturnValue(createCredentialState())
|
||||
})
|
||||
|
||||
// The hook should resolve provider and model metadata before deriving the final status.
|
||||
describe('Resolution', () => {
|
||||
it('should return the matched provider, current model, and active status', () => {
|
||||
const embeddingModelList = [createModel()]
|
||||
|
||||
const { result } = renderHook(() => useEmbeddingModelStatus({
|
||||
embeddingModel: 'text-embedding-3-large',
|
||||
embeddingModelProvider: 'openai',
|
||||
embeddingModelList,
|
||||
}))
|
||||
|
||||
expect(result.current.providerMeta?.provider).toBe('openai')
|
||||
expect(result.current.modelProvider?.provider).toBe('openai')
|
||||
expect(result.current.currentModel?.model).toBe('text-embedding-3-large')
|
||||
expect(result.current.status).toBe('active')
|
||||
})
|
||||
|
||||
it('should return incompatible when the provider exists but the selected model is missing', () => {
|
||||
const embeddingModelList = [
|
||||
createModel({
|
||||
models: [createModelItem({ model: 'another-model' })],
|
||||
}),
|
||||
]
|
||||
|
||||
const { result } = renderHook(() => useEmbeddingModelStatus({
|
||||
embeddingModel: 'text-embedding-3-large',
|
||||
embeddingModelProvider: 'openai',
|
||||
embeddingModelList,
|
||||
}))
|
||||
|
||||
expect(result.current.providerMeta?.provider).toBe('openai')
|
||||
expect(result.current.currentModel).toBeUndefined()
|
||||
expect(result.current.status).toBe('incompatible')
|
||||
})
|
||||
|
||||
it('should return empty when no embedding model is configured', () => {
|
||||
const { result } = renderHook(() => useEmbeddingModelStatus({
|
||||
embeddingModel: undefined,
|
||||
embeddingModelProvider: undefined,
|
||||
embeddingModelList: [],
|
||||
}))
|
||||
|
||||
expect(result.current.providerMeta).toBeUndefined()
|
||||
expect(result.current.modelProvider).toBeUndefined()
|
||||
expect(result.current.currentModel).toBeUndefined()
|
||||
expect(result.current.status).toBe('empty')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,26 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import {
|
||||
IndexMethodEnum,
|
||||
RetrievalSearchMethodEnum,
|
||||
} from '../../types'
|
||||
import { useSettingsDisplay } from '../use-settings-display'
|
||||
|
||||
describe('useSettingsDisplay', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The display map should expose translated labels for all index and retrieval settings.
|
||||
describe('Translations', () => {
|
||||
it('should return translated labels for each supported setting key', () => {
|
||||
const { result } = renderHook(() => useSettingsDisplay())
|
||||
|
||||
expect(result.current[IndexMethodEnum.QUALIFIED]).toBe('datasetCreation.stepTwo.qualified')
|
||||
expect(result.current[IndexMethodEnum.ECONOMICAL]).toBe('datasetSettings.form.indexMethodEconomy')
|
||||
expect(result.current[RetrievalSearchMethodEnum.semantic]).toBe('dataset.retrieval.semantic_search.title')
|
||||
expect(result.current[RetrievalSearchMethodEnum.fullText]).toBe('dataset.retrieval.full_text_search.title')
|
||||
expect(result.current[RetrievalSearchMethodEnum.hybrid]).toBe('dataset.retrieval.hybrid_search.title')
|
||||
expect(result.current[RetrievalSearchMethodEnum.keywordSearch]).toBe('dataset.retrieval.keyword_search.title')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,7 +1,7 @@
|
||||
import type { LLMNodeType } from './types'
|
||||
import type { LLMNodeType } from '../types'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { EditionType, PromptRole } from '../../types'
|
||||
import nodeDefault from './default'
|
||||
import { EditionType, PromptRole } from '../../../types'
|
||||
import nodeDefault from '../default'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { LLMNodeType } from './types'
|
||||
import type { LLMNodeType } from '../types'
|
||||
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { ProviderContextState } from '@/context/provider-context'
|
||||
import type { PanelProps } from '@/types/workflow'
|
||||
@ -14,8 +14,8 @@ import {
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { BlockEnum } from '../../types'
|
||||
import Panel from './panel'
|
||||
import { BlockEnum } from '../../../types'
|
||||
import Panel from '../panel'
|
||||
|
||||
const mockUseConfig = vi.fn()
|
||||
|
||||
@ -23,7 +23,7 @@ vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContextSelector: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./use-config', () => ({
|
||||
vi.mock('../use-config', () => ({
|
||||
default: (...args: unknown[]) => mockUseConfig(...args),
|
||||
}))
|
||||
|
||||
@ -31,19 +31,19 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-param
|
||||
default: () => <div data-testid="model-parameter-modal" />,
|
||||
}))
|
||||
|
||||
vi.mock('./components/config-prompt', () => ({
|
||||
vi.mock('../components/config-prompt', () => ({
|
||||
default: () => <div data-testid="config-prompt" />,
|
||||
}))
|
||||
|
||||
vi.mock('../_base/components/config-vision', () => ({
|
||||
vi.mock('../../_base/components/config-vision', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../_base/components/memory-config', () => ({
|
||||
vi.mock('../../_base/components/memory-config', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../_base/components/variable/var-reference-picker', () => ({
|
||||
vi.mock('../../_base/components/variable/var-reference-picker', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
@ -55,11 +55,11 @@ vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-list', ()
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('./components/reasoning-format-config', () => ({
|
||||
vi.mock('../components/reasoning-format-config', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('./components/structure-output', () => ({
|
||||
vi.mock('../components/structure-output', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { getLLMModelIssue, isLLMModelProviderInstalled, LLMModelIssueCode } from './utils'
|
||||
import { getLLMModelIssue, isLLMModelProviderInstalled, LLMModelIssueCode } from '../utils'
|
||||
|
||||
describe('llm utils', () => {
|
||||
describe('getLLMModelIssue', () => {
|
||||
@ -0,0 +1,94 @@
|
||||
import type { NodeProps } from 'reactflow'
|
||||
import type { CommonNodeType } from '@/app/components/workflow/types'
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import { createNode } from '@/app/components/workflow/__tests__/fixtures'
|
||||
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import {
|
||||
useAvailableBlocks,
|
||||
useIsChatMode,
|
||||
useNodesInteractions,
|
||||
useNodesReadOnly,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import LoopStartNode, { LoopStartNodeDumb } from '../index'
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useAvailableBlocks: vi.fn(),
|
||||
useNodesInteractions: vi.fn(),
|
||||
useNodesReadOnly: vi.fn(),
|
||||
useIsChatMode: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const mockUseAvailableBlocks = vi.mocked(useAvailableBlocks)
|
||||
const mockUseNodesInteractions = vi.mocked(useNodesInteractions)
|
||||
const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly)
|
||||
const mockUseIsChatMode = vi.mocked(useIsChatMode)
|
||||
|
||||
const createAvailableBlocksResult = (): ReturnType<typeof useAvailableBlocks> => ({
|
||||
getAvailableBlocks: vi.fn(() => ({
|
||||
availablePrevBlocks: [],
|
||||
availableNextBlocks: [],
|
||||
})),
|
||||
availablePrevBlocks: [],
|
||||
availableNextBlocks: [],
|
||||
})
|
||||
|
||||
const FlowNode = (props: NodeProps<CommonNodeType>) => (
|
||||
<LoopStartNode {...props} />
|
||||
)
|
||||
|
||||
const renderFlowNode = () =>
|
||||
renderWorkflowFlowComponent(<div />, {
|
||||
nodes: [createNode({
|
||||
id: 'loop-start-node',
|
||||
type: 'loopStartNode',
|
||||
data: {
|
||||
title: 'Loop Start',
|
||||
desc: '',
|
||||
type: BlockEnum.LoopStart,
|
||||
},
|
||||
})],
|
||||
edges: [],
|
||||
reactFlowProps: {
|
||||
nodeTypes: { loopStartNode: FlowNode },
|
||||
},
|
||||
canvasStyle: {
|
||||
width: 400,
|
||||
height: 300,
|
||||
},
|
||||
})
|
||||
|
||||
describe('LoopStartNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAvailableBlocks.mockReturnValue(createAvailableBlocksResult())
|
||||
mockUseNodesInteractions.mockReturnValue({
|
||||
handleNodeAdd: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useNodesInteractions>)
|
||||
mockUseNodesReadOnly.mockReturnValue({
|
||||
getNodesReadOnly: () => false,
|
||||
} as unknown as ReturnType<typeof useNodesReadOnly>)
|
||||
mockUseIsChatMode.mockReturnValue(false)
|
||||
})
|
||||
|
||||
// The loop start marker should match iteration start behavior in both real and dumb render paths.
|
||||
describe('Rendering', () => {
|
||||
it('should render the source handle in the ReactFlow context', async () => {
|
||||
const { container } = renderFlowNode()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-handleid="source"]')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render the dumb variant without any source handle', () => {
|
||||
const { container } = render(<LoopStartNodeDumb />)
|
||||
|
||||
expect(container.querySelector('[data-handleid="source"]')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,58 @@
|
||||
import type { StartNodeType } from '../types'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { renderNodeComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
|
||||
import Node from '../node'
|
||||
|
||||
const createNodeData = (overrides: Partial<StartNodeType> = {}): StartNodeType => ({
|
||||
title: 'Start',
|
||||
desc: '',
|
||||
type: BlockEnum.Start,
|
||||
variables: [{
|
||||
label: 'Question',
|
||||
variable: 'query',
|
||||
type: InputVarType.textInput,
|
||||
required: true,
|
||||
}],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('StartNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Start variables should render required metadata and gracefully disappear when empty.
|
||||
describe('Rendering', () => {
|
||||
it('should render configured input variables and required markers', () => {
|
||||
renderNodeComponent(Node, createNodeData({
|
||||
variables: [
|
||||
{
|
||||
label: 'Question',
|
||||
variable: 'query',
|
||||
type: InputVarType.textInput,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
label: 'Count',
|
||||
variable: 'count',
|
||||
type: InputVarType.number,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
expect(screen.getByText('query')).toBeInTheDocument()
|
||||
expect(screen.getByText('count')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.start.required')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render nothing when there are no start variables', () => {
|
||||
const { container } = renderNodeComponent(Node, createNodeData({
|
||||
variables: [],
|
||||
}))
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,46 @@
|
||||
import type { ScheduleTriggerNodeType } from '../types'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { renderNodeComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Node from '../node'
|
||||
import { getNextExecutionTime } from '../utils/execution-time-calculator'
|
||||
|
||||
const createNodeData = (overrides: Partial<ScheduleTriggerNodeType> = {}): ScheduleTriggerNodeType => ({
|
||||
title: 'Schedule Trigger',
|
||||
desc: '',
|
||||
type: BlockEnum.TriggerSchedule,
|
||||
mode: 'visual',
|
||||
frequency: 'daily',
|
||||
timezone: 'UTC',
|
||||
visual_config: {
|
||||
time: '11:30 AM',
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('TriggerScheduleNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The node should surface the computed next execution time for both valid and invalid schedules.
|
||||
describe('Rendering', () => {
|
||||
it('should render the next execution label and computed execution time', () => {
|
||||
const data = createNodeData()
|
||||
|
||||
renderNodeComponent(Node, data)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.triggerSchedule.nextExecutionTime')).toBeInTheDocument()
|
||||
expect(screen.getByText(getNextExecutionTime(data))).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the placeholder when cron mode has an invalid expression', () => {
|
||||
renderNodeComponent(Node, createNodeData({
|
||||
mode: 'cron',
|
||||
cron_expression: 'invalid cron',
|
||||
}))
|
||||
|
||||
expect(screen.getByText('--')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,7 +1,7 @@
|
||||
import type { ScheduleTriggerNodeType } from '../types'
|
||||
import { BlockEnum } from '../../../types'
|
||||
import { isValidCronExpression, parseCronExpression } from './cron-parser'
|
||||
import { getNextExecutionTime, getNextExecutionTimes } from './execution-time-calculator'
|
||||
import type { ScheduleTriggerNodeType } from '../../types'
|
||||
import { BlockEnum } from '../../../../types'
|
||||
import { isValidCronExpression, parseCronExpression } from '../cron-parser'
|
||||
import { getNextExecutionTime, getNextExecutionTimes } from '../execution-time-calculator'
|
||||
|
||||
// Comprehensive integration tests for cron-parser and execution-time-calculator compatibility
|
||||
describe('cron-parser + execution-time-calculator integration', () => {
|
||||
@ -0,0 +1,47 @@
|
||||
import type { WebhookTriggerNodeType } from '../types'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { renderNodeComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Node from '../node'
|
||||
|
||||
const createNodeData = (overrides: Partial<WebhookTriggerNodeType> = {}): WebhookTriggerNodeType => ({
|
||||
title: 'Webhook Trigger',
|
||||
desc: '',
|
||||
type: BlockEnum.TriggerWebhook,
|
||||
method: 'POST',
|
||||
content_type: 'application/json',
|
||||
headers: [],
|
||||
params: [],
|
||||
body: [],
|
||||
async_mode: false,
|
||||
status_code: 200,
|
||||
response_body: '',
|
||||
variables: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('TriggerWebhookNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// The node should expose the webhook URL and keep a clear fallback for empty data.
|
||||
describe('Rendering', () => {
|
||||
it('should render the webhook url when it exists', () => {
|
||||
renderNodeComponent(Node, createNodeData({
|
||||
webhook_url: 'https://example.com/webhook',
|
||||
}))
|
||||
|
||||
expect(screen.getByText('URL')).toBeInTheDocument()
|
||||
expect(screen.getByText('https://example.com/webhook')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the placeholder when the webhook url is empty', () => {
|
||||
renderNodeComponent(Node, createNodeData({
|
||||
webhook_url: '',
|
||||
}))
|
||||
|
||||
expect(screen.getByText('--')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user