mirror of
https://github.com/langgenius/dify.git
synced 2026-03-27 17:19:55 +08:00
Co-authored-by: CodingOnStar <hanxujiang@dify.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: lif <1835304752@qq.com> Co-authored-by: hjlarry <hjlarry@163.com> Co-authored-by: Stephen Zhou <hi@hyoban.cc> Co-authored-by: tmimmanuel <14046872+tmimmanuel@users.noreply.github.com> Co-authored-by: Desel72 <pedroluiscolmenares722@gmail.com> Co-authored-by: Renzo <170978465+RenzoMXD@users.noreply.github.com> Co-authored-by: Krishna Chaitanya <krishnabkc15@gmail.com> Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
261 lines
8.8 KiB
TypeScript
261 lines
8.8 KiB
TypeScript
import { render, screen } from '@testing-library/react'
|
|
import CandidateNodeMain from '../candidate-node-main'
|
|
import { CUSTOM_NODE } from '../constants'
|
|
import { CUSTOM_NOTE_NODE } from '../note-node/constants'
|
|
import { BlockEnum } from '../types'
|
|
import { createNode } from './fixtures'
|
|
|
|
const mockUseEventListener = vi.hoisted(() => vi.fn())
|
|
const mockUseStoreApi = vi.hoisted(() => vi.fn())
|
|
const mockUseReactFlow = vi.hoisted(() => vi.fn())
|
|
const mockUseViewport = vi.hoisted(() => vi.fn())
|
|
const mockUseStore = vi.hoisted(() => vi.fn())
|
|
const mockUseWorkflowStore = vi.hoisted(() => vi.fn())
|
|
const mockUseHooks = vi.hoisted(() => vi.fn())
|
|
const mockCustomNode = vi.hoisted(() => vi.fn())
|
|
const mockCustomNoteNode = vi.hoisted(() => vi.fn())
|
|
const mockGetIterationStartNode = vi.hoisted(() => vi.fn())
|
|
const mockGetLoopStartNode = vi.hoisted(() => vi.fn())
|
|
|
|
vi.mock('ahooks', () => ({
|
|
useEventListener: (...args: unknown[]) => mockUseEventListener(...args),
|
|
}))
|
|
|
|
vi.mock('reactflow', () => ({
|
|
useStoreApi: () => mockUseStoreApi(),
|
|
useReactFlow: () => mockUseReactFlow(),
|
|
useViewport: () => mockUseViewport(),
|
|
Position: {
|
|
Left: 'left',
|
|
Right: 'right',
|
|
},
|
|
}))
|
|
|
|
vi.mock('@/app/components/workflow/store', () => ({
|
|
useStore: (selector: (state: { mousePosition: {
|
|
pageX: number
|
|
pageY: number
|
|
elementX: number
|
|
elementY: number
|
|
} }) => unknown) => mockUseStore(selector),
|
|
useWorkflowStore: () => mockUseWorkflowStore(),
|
|
}))
|
|
|
|
vi.mock('@/app/components/workflow/hooks', () => ({
|
|
useNodesInteractions: () => mockUseHooks().useNodesInteractions(),
|
|
useNodesSyncDraft: () => mockUseHooks().useNodesSyncDraft(),
|
|
useWorkflowHistory: () => mockUseHooks().useWorkflowHistory(),
|
|
useAutoGenerateWebhookUrl: () => mockUseHooks().useAutoGenerateWebhookUrl(),
|
|
WorkflowHistoryEvent: {
|
|
NodeAdd: 'NodeAdd',
|
|
NoteAdd: 'NoteAdd',
|
|
},
|
|
}))
|
|
|
|
vi.mock('@/app/components/workflow/nodes', () => ({
|
|
__esModule: true,
|
|
default: (props: { id: string }) => {
|
|
mockCustomNode(props)
|
|
return <div data-testid="candidate-custom-node">{props.id}</div>
|
|
},
|
|
}))
|
|
|
|
vi.mock('@/app/components/workflow/note-node', () => ({
|
|
__esModule: true,
|
|
default: (props: { id: string }) => {
|
|
mockCustomNoteNode(props)
|
|
return <div data-testid="candidate-note-node">{props.id}</div>
|
|
},
|
|
}))
|
|
|
|
vi.mock('@/app/components/workflow/utils', () => ({
|
|
getIterationStartNode: (...args: unknown[]) => mockGetIterationStartNode(...args),
|
|
getLoopStartNode: (...args: unknown[]) => mockGetLoopStartNode(...args),
|
|
}))
|
|
|
|
describe('CandidateNodeMain', () => {
|
|
const mockSetNodes = vi.fn()
|
|
const mockHandleNodeSelect = vi.fn()
|
|
const mockSaveStateToHistory = vi.fn()
|
|
const mockHandleSyncWorkflowDraft = vi.fn()
|
|
const mockAutoGenerateWebhookUrl = vi.fn()
|
|
const mockWorkflowStoreSetState = vi.fn()
|
|
const createNodesInteractions = () => ({
|
|
handleNodeSelect: mockHandleNodeSelect,
|
|
})
|
|
const createWorkflowHistory = () => ({
|
|
saveStateToHistory: mockSaveStateToHistory,
|
|
})
|
|
const createNodesSyncDraft = () => ({
|
|
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
|
|
})
|
|
const createAutoGenerateWebhookUrl = () => mockAutoGenerateWebhookUrl
|
|
const eventHandlers: Partial<Record<'click' | 'contextmenu', (event: { preventDefault: () => void }) => void>> = {}
|
|
let nodes = [createNode({ id: 'existing-node' })]
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
nodes = [createNode({ id: 'existing-node' })]
|
|
eventHandlers.click = undefined
|
|
eventHandlers.contextmenu = undefined
|
|
|
|
mockUseEventListener.mockImplementation((event: 'click' | 'contextmenu', handler: (event: { preventDefault: () => void }) => void) => {
|
|
eventHandlers[event] = handler
|
|
})
|
|
mockUseStoreApi.mockReturnValue({
|
|
getState: () => ({
|
|
getNodes: () => nodes,
|
|
setNodes: mockSetNodes,
|
|
}),
|
|
})
|
|
mockUseReactFlow.mockReturnValue({
|
|
screenToFlowPosition: ({ x, y }: { x: number, y: number }) => ({ x: x + 10, y: y + 20 }),
|
|
})
|
|
mockUseViewport.mockReturnValue({ zoom: 1.5 })
|
|
mockUseStore.mockImplementation((selector: (state: { mousePosition: {
|
|
pageX: number
|
|
pageY: number
|
|
elementX: number
|
|
elementY: number
|
|
} }) => unknown) => selector({
|
|
mousePosition: {
|
|
pageX: 100,
|
|
pageY: 200,
|
|
elementX: 30,
|
|
elementY: 40,
|
|
},
|
|
}))
|
|
mockUseWorkflowStore.mockReturnValue({
|
|
setState: mockWorkflowStoreSetState,
|
|
})
|
|
mockUseHooks.mockReturnValue({
|
|
useNodesInteractions: createNodesInteractions,
|
|
useWorkflowHistory: createWorkflowHistory,
|
|
useNodesSyncDraft: createNodesSyncDraft,
|
|
useAutoGenerateWebhookUrl: createAutoGenerateWebhookUrl,
|
|
})
|
|
mockHandleSyncWorkflowDraft.mockImplementation((_isSync: boolean, _force: boolean, options?: { onSuccess?: () => void }) => {
|
|
options?.onSuccess?.()
|
|
})
|
|
mockGetIterationStartNode.mockReturnValue(createNode({ id: 'iteration-start' }))
|
|
mockGetLoopStartNode.mockReturnValue(createNode({ id: 'loop-start' }))
|
|
})
|
|
|
|
it('should render the candidate node and commit a webhook node on click', () => {
|
|
const candidateNode = createNode({
|
|
id: 'candidate-webhook',
|
|
type: CUSTOM_NODE,
|
|
data: {
|
|
type: BlockEnum.TriggerWebhook,
|
|
title: 'Webhook Candidate',
|
|
_isCandidate: true,
|
|
},
|
|
})
|
|
|
|
const { container } = render(<CandidateNodeMain candidateNode={candidateNode} />)
|
|
|
|
expect(screen.getByTestId('candidate-custom-node')).toHaveTextContent('candidate-webhook')
|
|
expect(container.firstChild).toHaveStyle({
|
|
left: '30px',
|
|
top: '40px',
|
|
transform: 'scale(1.5)',
|
|
})
|
|
|
|
eventHandlers.click?.({ preventDefault: vi.fn() })
|
|
|
|
expect(mockSetNodes).toHaveBeenCalledWith(expect.arrayContaining([
|
|
expect.objectContaining({ id: 'existing-node' }),
|
|
expect.objectContaining({
|
|
id: 'candidate-webhook',
|
|
position: { x: 110, y: 220 },
|
|
data: expect.objectContaining({ _isCandidate: false }),
|
|
}),
|
|
]))
|
|
expect(mockSaveStateToHistory).toHaveBeenCalledWith('NodeAdd', { nodeId: 'candidate-webhook' })
|
|
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ candidateNode: undefined })
|
|
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true, true, expect.objectContaining({
|
|
onSuccess: expect.any(Function),
|
|
}))
|
|
expect(mockAutoGenerateWebhookUrl).toHaveBeenCalledWith('candidate-webhook')
|
|
expect(mockHandleNodeSelect).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should save note candidates as notes and select the inserted note', () => {
|
|
const candidateNode = createNode({
|
|
id: 'candidate-note',
|
|
type: CUSTOM_NOTE_NODE,
|
|
data: {
|
|
type: BlockEnum.Code,
|
|
title: 'Note Candidate',
|
|
_isCandidate: true,
|
|
},
|
|
})
|
|
|
|
render(<CandidateNodeMain candidateNode={candidateNode} />)
|
|
|
|
expect(screen.getByTestId('candidate-note-node')).toHaveTextContent('candidate-note')
|
|
|
|
eventHandlers.click?.({ preventDefault: vi.fn() })
|
|
|
|
expect(mockSaveStateToHistory).toHaveBeenCalledWith('NoteAdd', { nodeId: 'candidate-note' })
|
|
expect(mockHandleNodeSelect).toHaveBeenCalledWith('candidate-note')
|
|
})
|
|
|
|
it('should append iteration and loop start helper nodes for control-flow candidates', () => {
|
|
const iterationNode = createNode({
|
|
id: 'candidate-iteration',
|
|
type: CUSTOM_NODE,
|
|
data: {
|
|
type: BlockEnum.Iteration,
|
|
title: 'Iteration Candidate',
|
|
_isCandidate: true,
|
|
},
|
|
})
|
|
const loopNode = createNode({
|
|
id: 'candidate-loop',
|
|
type: CUSTOM_NODE,
|
|
data: {
|
|
type: BlockEnum.Loop,
|
|
title: 'Loop Candidate',
|
|
_isCandidate: true,
|
|
},
|
|
})
|
|
|
|
const { rerender } = render(<CandidateNodeMain candidateNode={iterationNode} />)
|
|
|
|
eventHandlers.click?.({ preventDefault: vi.fn() })
|
|
expect(mockGetIterationStartNode).toHaveBeenCalledWith('candidate-iteration')
|
|
expect(mockSetNodes.mock.calls[0][0]).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ id: 'candidate-iteration' }),
|
|
expect.objectContaining({ id: 'iteration-start' }),
|
|
]))
|
|
|
|
rerender(<CandidateNodeMain candidateNode={loopNode} />)
|
|
eventHandlers.click?.({ preventDefault: vi.fn() })
|
|
|
|
expect(mockGetLoopStartNode).toHaveBeenCalledWith('candidate-loop')
|
|
expect(mockSetNodes.mock.calls[1][0]).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ id: 'candidate-loop' }),
|
|
expect.objectContaining({ id: 'loop-start' }),
|
|
]))
|
|
})
|
|
|
|
it('should clear the candidate node on contextmenu', () => {
|
|
const candidateNode = createNode({
|
|
id: 'candidate-context',
|
|
type: CUSTOM_NODE,
|
|
data: {
|
|
type: BlockEnum.Code,
|
|
title: 'Context Candidate',
|
|
_isCandidate: true,
|
|
},
|
|
})
|
|
|
|
render(<CandidateNodeMain candidateNode={candidateNode} />)
|
|
|
|
eventHandlers.contextmenu?.({ preventDefault: vi.fn() })
|
|
|
|
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ candidateNode: undefined })
|
|
})
|
|
})
|