mirror of
https://github.com/langgenius/dify.git
synced 2026-06-09 01:11:11 +08:00
Co-authored-by: JzoNg <jzongcode@gmail.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
476 lines
15 KiB
TypeScript
476 lines
15 KiB
TypeScript
import type { Edge, Node } from '../types'
|
|
import { ContextMenu } from '@langgenius/dify-ui/context-menu'
|
|
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
|
import { useEffect } from 'react'
|
|
import { useNodes } from 'reactflow'
|
|
import { PipelineInputVarType } from '@/models/pipeline'
|
|
import { SelectionContextmenu } from '../selection-contextmenu'
|
|
import { useWorkflowStore } from '../store'
|
|
import { BlockEnum } from '../types'
|
|
import { useWorkflowHistoryStore } from '../workflow-history-store'
|
|
import { createEdge, createNode } from './fixtures'
|
|
import { renderWorkflowFlowComponent } from './workflow-test-env'
|
|
|
|
let latestNodes: Node[] = []
|
|
let latestHistoryEvent: string | undefined
|
|
const mockGetNodesReadOnly = vi.fn()
|
|
const mockHandleNodesCopy = vi.fn()
|
|
const mockHandleNodesDuplicate = vi.fn()
|
|
const mockHandleNodesDelete = vi.fn()
|
|
const mockHandleCreateSnippet = vi.fn()
|
|
const mockCreateSnippetDialogRender = vi.fn()
|
|
|
|
vi.mock('@/app/components/snippets/hooks/use-create-snippet', async () => {
|
|
const React = await vi.importActual<typeof import('react')>('react')
|
|
|
|
return {
|
|
useCreateSnippet: () => {
|
|
const [isOpen, setIsOpen] = React.useState(false)
|
|
|
|
return {
|
|
createSnippetMutation: { isPending: false },
|
|
handleCloseCreateSnippetDialog: () => setIsOpen(false),
|
|
handleCreateSnippet: mockHandleCreateSnippet,
|
|
handleOpenCreateSnippetDialog: () => setIsOpen(true),
|
|
isCreateSnippetDialogOpen: isOpen,
|
|
isCreatingSnippet: false,
|
|
}
|
|
},
|
|
}
|
|
})
|
|
|
|
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
|
|
default: (props: {
|
|
isOpen: boolean
|
|
selectedGraph?: { nodes: Node[], edges: Edge[], viewport: { x: number, y: number, zoom: number } }
|
|
inputFields?: Array<{ variable: string }>
|
|
}) => {
|
|
mockCreateSnippetDialogRender(props)
|
|
|
|
return props.isOpen ? <div data-testid="create-snippet-dialog" /> : null
|
|
},
|
|
}))
|
|
|
|
vi.mock('../hooks', async () => {
|
|
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
|
|
return {
|
|
...actual,
|
|
useNodesReadOnly: () => ({
|
|
getNodesReadOnly: mockGetNodesReadOnly,
|
|
}),
|
|
useNodesInteractions: () => ({
|
|
handleNodesCopy: mockHandleNodesCopy,
|
|
handleNodesDuplicate: mockHandleNodesDuplicate,
|
|
handleNodesDelete: mockHandleNodesDelete,
|
|
}),
|
|
}
|
|
})
|
|
|
|
const RuntimeProbe = () => {
|
|
latestNodes = useNodes() as Node[]
|
|
const { store } = useWorkflowHistoryStore()
|
|
|
|
useEffect(() => {
|
|
latestHistoryEvent = store.getState().workflowHistoryEvent
|
|
return store.subscribe((state) => {
|
|
latestHistoryEvent = state.workflowHistoryEvent
|
|
})
|
|
}, [store])
|
|
|
|
return null
|
|
}
|
|
|
|
const hooksStoreProps = {
|
|
doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined),
|
|
}
|
|
|
|
const SelectionMenuHarness = () => {
|
|
const workflowStore = useWorkflowStore()
|
|
|
|
return (
|
|
<ContextMenu open>
|
|
<SelectionContextmenu
|
|
onClose={() => workflowStore.getState().setContextMenuTarget(undefined)}
|
|
/>
|
|
</ContextMenu>
|
|
)
|
|
}
|
|
|
|
const renderSelectionMenu = (options?: {
|
|
nodes?: Node[]
|
|
edges?: Edge[]
|
|
initialStoreState?: Record<string, unknown>
|
|
}) => {
|
|
latestNodes = []
|
|
latestHistoryEvent = undefined
|
|
|
|
const nodes = options?.nodes ?? []
|
|
const edges = options?.edges ?? []
|
|
|
|
return renderWorkflowFlowComponent(
|
|
<div id="workflow-container" style={{ width: 800, height: 600 }}>
|
|
<RuntimeProbe />
|
|
<SelectionMenuHarness />
|
|
</div>,
|
|
{
|
|
nodes,
|
|
edges,
|
|
hooksStoreProps,
|
|
historyStore: { nodes, edges },
|
|
initialStoreState: options?.initialStoreState,
|
|
reactFlowProps: { fitView: false },
|
|
},
|
|
)
|
|
}
|
|
|
|
describe('SelectionContextmenu', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
latestNodes = []
|
|
latestHistoryEvent = undefined
|
|
mockGetNodesReadOnly.mockReset()
|
|
mockGetNodesReadOnly.mockReturnValue(false)
|
|
mockHandleNodesCopy.mockReset()
|
|
mockHandleNodesDuplicate.mockReset()
|
|
mockHandleNodesDelete.mockReset()
|
|
mockHandleCreateSnippet.mockReset()
|
|
mockCreateSnippetDialogRender.mockReset()
|
|
})
|
|
|
|
it('should not render when selection context menu target is absent', () => {
|
|
renderSelectionMenu()
|
|
|
|
expect(screen.queryByText('operator.vertical')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('should render menu items when selection context menu target is present', async () => {
|
|
const nodes = [
|
|
createNode({ id: 'n1', selected: true, width: 80, height: 40 }),
|
|
createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }),
|
|
]
|
|
const { store } = renderSelectionMenu({ nodes })
|
|
|
|
act(() => {
|
|
store.setState({ contextMenuTarget: { type: 'selection' } })
|
|
})
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('selection-contextmenu-item-left')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should render and execute copy/duplicate/delete operations', async () => {
|
|
const nodes = [
|
|
createNode({ id: 'n1', selected: true, width: 80, height: 40 }),
|
|
createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }),
|
|
]
|
|
const { store } = renderSelectionMenu({ nodes })
|
|
|
|
act(() => {
|
|
store.setState({ contextMenuTarget: { type: 'selection' } })
|
|
})
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('menuitem', { name: /common.copy/ })).toBeInTheDocument()
|
|
})
|
|
|
|
fireEvent.click(screen.getByRole('menuitem', { name: /common.copy/ }))
|
|
expect(mockHandleNodesCopy).toHaveBeenCalledTimes(1)
|
|
expect(store.getState().contextMenuTarget).toBeUndefined()
|
|
|
|
act(() => {
|
|
store.setState({ contextMenuTarget: { type: 'selection' } })
|
|
})
|
|
fireEvent.click(screen.getByRole('menuitem', { name: /common.duplicate/ }))
|
|
expect(mockHandleNodesDuplicate).toHaveBeenCalledTimes(1)
|
|
expect(store.getState().contextMenuTarget).toBeUndefined()
|
|
|
|
act(() => {
|
|
store.setState({ contextMenuTarget: { type: 'selection' } })
|
|
})
|
|
fireEvent.click(screen.getByRole('menuitem', { name: /operation.delete/ }))
|
|
expect(mockHandleNodesDelete).toHaveBeenCalledTimes(1)
|
|
expect(store.getState().contextMenuTarget).toBeUndefined()
|
|
})
|
|
|
|
it('should open create snippet dialog with selected graph from the top menu item', async () => {
|
|
const nodes = [
|
|
createNode({ id: 'n1', selected: true, width: 80, height: 40 }),
|
|
createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }),
|
|
createNode({ id: 'n3', selected: false, position: { x: 260, y: 0 }, width: 80, height: 40 }),
|
|
]
|
|
const edges = [
|
|
createEdge({ source: 'n1', target: 'n2' }),
|
|
createEdge({ source: 'n2', target: 'n3' }),
|
|
]
|
|
const { store } = renderSelectionMenu({ nodes, edges })
|
|
|
|
act(() => {
|
|
store.setState({ contextMenuTarget: { type: 'selection' } })
|
|
})
|
|
|
|
fireEvent.click(await screen.findByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ }))
|
|
|
|
expect(screen.getByTestId('create-snippet-dialog')).toBeInTheDocument()
|
|
expect(store.getState().contextMenuTarget).toBeUndefined()
|
|
|
|
const dialogProps = mockCreateSnippetDialogRender.mock.calls.at(-1)?.[0]
|
|
expect(dialogProps.selectedGraph.nodes.map((node: Node) => node.id)).toEqual(['n1', 'n2'])
|
|
expect(dialogProps.selectedGraph.nodes.every((node: Node) => node.selected === false)).toBe(true)
|
|
expect(dialogProps.selectedGraph.edges).toHaveLength(1)
|
|
expect(dialogProps.selectedGraph.viewport).toEqual({ x: 490, y: 380, zoom: 1 })
|
|
expect(dialogProps.selectedGraph.edges[0]).toEqual(expect.objectContaining({
|
|
source: 'n1',
|
|
target: 'n2',
|
|
selected: false,
|
|
}))
|
|
})
|
|
|
|
it('should add input fields for variable references outside of the selected graph', async () => {
|
|
const nodes = [
|
|
createNode({
|
|
id: 'n1',
|
|
selected: true,
|
|
width: 80,
|
|
height: 40,
|
|
data: {
|
|
prompt_template: 'Use {{#source-node.topic#}} and {{#n2.answer#}}',
|
|
query_variable_selector: ['source-node', 'topic'],
|
|
env_reference: '{{#env.API_KEY#}}',
|
|
},
|
|
}),
|
|
createNode({
|
|
id: 'n2',
|
|
selected: true,
|
|
position: { x: 140, y: 0 },
|
|
width: 80,
|
|
height: 40,
|
|
}),
|
|
]
|
|
const { store } = renderSelectionMenu({ nodes })
|
|
|
|
act(() => {
|
|
store.setState({ contextMenuTarget: { type: 'selection' } })
|
|
})
|
|
|
|
fireEvent.click(await screen.findByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ }))
|
|
|
|
const dialogProps = mockCreateSnippetDialogRender.mock.calls.at(-1)?.[0]
|
|
expect(dialogProps.inputFields).toEqual([
|
|
{
|
|
label: 'topic',
|
|
variable: 'topic',
|
|
type: PipelineInputVarType.textInput,
|
|
required: true,
|
|
},
|
|
{
|
|
label: 'API_KEY',
|
|
variable: 'API_KEY',
|
|
type: PipelineInputVarType.textInput,
|
|
required: true,
|
|
},
|
|
])
|
|
expect(dialogProps.selectedGraph.nodes[0].data.prompt_template).toBe('Use {{#start.topic#}} and {{#n2.answer#}}')
|
|
expect(dialogProps.selectedGraph.nodes[0].data.query_variable_selector).toEqual(['start', 'topic'])
|
|
expect(dialogProps.selectedGraph.nodes[0].data.env_reference).toBe('{{#start.API_KEY#}}')
|
|
})
|
|
|
|
it.each([
|
|
BlockEnum.Answer,
|
|
BlockEnum.End,
|
|
BlockEnum.Start,
|
|
])('should hide create snippet when selection contains %s node', async (nodeType) => {
|
|
const nodes = [
|
|
createNode({ id: 'n1', selected: true, width: 80, height: 40, data: { type: nodeType } }),
|
|
createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }),
|
|
]
|
|
const { store } = renderSelectionMenu({ nodes })
|
|
|
|
act(() => {
|
|
store.setState({ contextMenuTarget: { type: 'selection' } })
|
|
})
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('menuitem', { name: /common.copy/ })).toBeInTheDocument()
|
|
})
|
|
expect(screen.queryByRole('menuitem', { name: /Create Snippet|snippet\.createDialogTitle/ })).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('should stay hidden when only one node is selected', async () => {
|
|
const nodes = [
|
|
createNode({ id: 'n1', selected: true, width: 80, height: 40 }),
|
|
]
|
|
|
|
const { store } = renderSelectionMenu({ nodes })
|
|
|
|
act(() => {
|
|
store.setState({ contextMenuTarget: { type: 'selection' } })
|
|
})
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should align selected nodes to the left and save history', async () => {
|
|
vi.useFakeTimers()
|
|
const nodes = [
|
|
createNode({ id: 'n1', selected: true, position: { x: 20, y: 40 }, width: 40, height: 20 }),
|
|
createNode({ id: 'n2', selected: true, position: { x: 140, y: 90 }, width: 60, height: 30 }),
|
|
]
|
|
|
|
const { store } = renderSelectionMenu({
|
|
nodes,
|
|
edges: [createEdge({ source: 'n1', target: 'n2' })],
|
|
initialStoreState: {
|
|
helpLineHorizontal: { y: 10 } as never,
|
|
helpLineVertical: { x: 10 } as never,
|
|
},
|
|
})
|
|
|
|
act(() => {
|
|
store.setState({ contextMenuTarget: { type: 'selection' } })
|
|
})
|
|
|
|
fireEvent.click(screen.getByTestId('selection-contextmenu-item-left'))
|
|
|
|
expect(latestNodes.find(node => node.id === 'n1')?.position.x).toBe(20)
|
|
expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(20)
|
|
expect(store.getState().contextMenuTarget).toBeUndefined()
|
|
expect(store.getState().helpLineHorizontal).toBeUndefined()
|
|
expect(store.getState().helpLineVertical).toBeUndefined()
|
|
|
|
act(() => {
|
|
store.getState().flushPendingSync()
|
|
vi.advanceTimersByTime(600)
|
|
})
|
|
|
|
expect(hooksStoreProps.doSyncWorkflowDraft).toHaveBeenCalled()
|
|
expect(latestHistoryEvent).toBe('NodeDragStop')
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('should distribute selected nodes horizontally', async () => {
|
|
const nodes = [
|
|
createNode({ id: 'n1', selected: true, position: { x: 0, y: 10 }, width: 20, height: 20 }),
|
|
createNode({ id: 'n2', selected: true, position: { x: 100, y: 20 }, width: 20, height: 20 }),
|
|
createNode({ id: 'n3', selected: true, position: { x: 300, y: 30 }, width: 20, height: 20 }),
|
|
]
|
|
|
|
const { store } = renderSelectionMenu({
|
|
nodes,
|
|
})
|
|
|
|
act(() => {
|
|
store.setState({ contextMenuTarget: { type: 'selection' } })
|
|
})
|
|
|
|
fireEvent.click(screen.getByTestId('selection-contextmenu-item-distributeHorizontal'))
|
|
|
|
expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(150)
|
|
})
|
|
|
|
it('should ignore child nodes when the selected container is aligned', async () => {
|
|
const nodes = [
|
|
createNode({
|
|
id: 'container',
|
|
selected: true,
|
|
position: { x: 200, y: 0 },
|
|
width: 100,
|
|
height: 80,
|
|
data: { _children: [{ nodeId: 'child', nodeType: 'code' as never }] },
|
|
}),
|
|
createNode({
|
|
id: 'child',
|
|
selected: true,
|
|
position: { x: 210, y: 10 },
|
|
width: 30,
|
|
height: 20,
|
|
}),
|
|
createNode({
|
|
id: 'other',
|
|
selected: true,
|
|
position: { x: 40, y: 60 },
|
|
width: 40,
|
|
height: 20,
|
|
}),
|
|
]
|
|
|
|
const { store } = renderSelectionMenu({
|
|
nodes,
|
|
})
|
|
|
|
act(() => {
|
|
store.setState({ contextMenuTarget: { type: 'selection' } })
|
|
})
|
|
|
|
fireEvent.click(screen.getByTestId('selection-contextmenu-item-left'))
|
|
|
|
expect(latestNodes.find(node => node.id === 'container')?.position.x).toBe(40)
|
|
expect(latestNodes.find(node => node.id === 'other')?.position.x).toBe(40)
|
|
expect(latestNodes.find(node => node.id === 'child')?.position.x).toBe(210)
|
|
})
|
|
|
|
it('should cancel when align bounds cannot be resolved', () => {
|
|
const nodes = [
|
|
createNode({ id: 'n1', selected: true }),
|
|
createNode({ id: 'n2', selected: true, position: { x: 80, y: 20 } }),
|
|
]
|
|
|
|
const { store } = renderSelectionMenu({ nodes })
|
|
|
|
act(() => {
|
|
store.setState({ contextMenuTarget: { type: 'selection' } })
|
|
})
|
|
|
|
fireEvent.click(screen.getByTestId('selection-contextmenu-item-left'))
|
|
|
|
expect(store.getState().contextMenuTarget).toBeUndefined()
|
|
})
|
|
|
|
it('should cancel without aligning when nodes are read only', () => {
|
|
mockGetNodesReadOnly.mockReturnValue(true)
|
|
const nodes = [
|
|
createNode({ id: 'n1', selected: true, width: 40, height: 20 }),
|
|
createNode({ id: 'n2', selected: true, position: { x: 80, y: 20 }, width: 40, height: 20 }),
|
|
]
|
|
|
|
const { store } = renderSelectionMenu({ nodes })
|
|
|
|
act(() => {
|
|
store.setState({ contextMenuTarget: { type: 'selection' } })
|
|
})
|
|
|
|
fireEvent.click(screen.getByTestId('selection-contextmenu-item-left'))
|
|
|
|
expect(store.getState().contextMenuTarget).toBeUndefined()
|
|
expect(latestNodes.find(node => node.id === 'n1')?.position.x).toBe(0)
|
|
expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(80)
|
|
})
|
|
|
|
it('should cancel when alignable nodes shrink to one item', () => {
|
|
const nodes = [
|
|
createNode({
|
|
id: 'container',
|
|
selected: true,
|
|
width: 40,
|
|
height: 20,
|
|
data: { _children: [{ nodeId: 'child', nodeType: 'code' as never }] },
|
|
}),
|
|
createNode({ id: 'child', selected: true, position: { x: 80, y: 20 }, width: 40, height: 20 }),
|
|
]
|
|
|
|
const { store } = renderSelectionMenu({ nodes })
|
|
|
|
act(() => {
|
|
store.setState({ contextMenuTarget: { type: 'selection' } })
|
|
})
|
|
|
|
fireEvent.click(screen.getByTestId('selection-contextmenu-item-left'))
|
|
|
|
expect(store.getState().contextMenuTarget).toBeUndefined()
|
|
expect(latestNodes.find(node => node.id === 'container')?.position.x).toBe(0)
|
|
expect(latestNodes.find(node => node.id === 'child')?.position.x).toBe(80)
|
|
})
|
|
})
|