mirror of
https://github.com/langgenius/dify.git
synced 2026-11-13 14:51:39 +08:00
test(web): exercise workflow configuration and controls with real stores (#44471)
This commit is contained in:
1 parent
f0daa16723
commit
796cc1bfbb
4 files changed
+354
-579
No files matched your search
@@ -1,168 +1,144 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import type { Node } from '../../types'
|
||||
import { act } from '@testing-library/react'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { seedModelProviderQueries } from '@/test/console/model-provider'
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { createNode } from '../../__tests__/fixtures'
|
||||
import { renderWorkflowCanvasHook } from '../../__tests__/workflow-test-env'
|
||||
import { useLocateNode } from '../use-locate-node'
|
||||
import { useNodesInteractions } from '../use-nodes-interactions'
|
||||
|
||||
const mockHandleNodeSelect = vi.hoisted(() => vi.fn())
|
||||
const mockScrollToWorkflowNode = vi.hoisted(() => vi.fn())
|
||||
const mockSearchParams = vi.hoisted(() => new URLSearchParams())
|
||||
const mockToastSuccess = vi.hoisted(() => vi.fn())
|
||||
const mockToastError = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useSearchParams: () => mockSearchParams,
|
||||
const { searchParams, success, error } = vi.hoisted(() => ({
|
||||
searchParams: new URLSearchParams(),
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/next/navigation', () => ({ useSearchParams: () => searchParams }))
|
||||
vi.mock('@/app/notifications', () => ({ toast: { success, error } }))
|
||||
|
||||
vi.mock('@/app/notifications', () => ({
|
||||
toast: Object.assign(vi.fn(), {
|
||||
success: mockToastSuccess,
|
||||
error: mockToastError,
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
update: vi.fn(),
|
||||
promise: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (
|
||||
selector: (source: Record<string, string>) => string,
|
||||
options?: Record<string, unknown>,
|
||||
) => {
|
||||
const key = selector(
|
||||
new Proxy({}, { get: (_, property: string) => property }) as Record<string, string>,
|
||||
)
|
||||
if (options?.nodeId) return `${key}:${options.nodeId}`
|
||||
if (options?.title) return `${key}:${options.title}`
|
||||
return key
|
||||
function renderLocate(nodes: Node[]) {
|
||||
const { queryClient } = createConsoleQueryWrapper({ appDslVersion: '0.6.0' })
|
||||
seedModelProviderQueries(queryClient)
|
||||
return renderWorkflowCanvasHook(
|
||||
({ loadedNodes }) => {
|
||||
useLocateNode(loadedNodes)
|
||||
return { flow: useReactFlow(), ...useNodesInteractions() }
|
||||
},
|
||||
}),
|
||||
}))
|
||||
{ nodes, queryClient, initialProps: { loadedNodes: nodes } },
|
||||
)
|
||||
}
|
||||
|
||||
vi.mock('../use-nodes-interactions', () => ({
|
||||
useNodesInteractions: () => ({
|
||||
handleNodeSelect: mockHandleNodeSelect,
|
||||
}),
|
||||
}))
|
||||
const selectedIds = (view: ReturnType<typeof renderLocate>) =>
|
||||
view.result.current.flow
|
||||
.getNodes()
|
||||
.filter((node) => node.selected)
|
||||
.map((node) => node.id)
|
||||
|
||||
vi.mock('../../utils/node-navigation', () => ({
|
||||
scrollToWorkflowNode: (nodeId: string) => mockScrollToWorkflowNode(nodeId),
|
||||
}))
|
||||
// The document event is the public boundary consumed by the canvas scroll listener.
|
||||
let scrollRequests: string[]
|
||||
const onScroll = (event: Event) => {
|
||||
scrollRequests.push((event as CustomEvent<{ nodeId: string }>).detail.nodeId)
|
||||
}
|
||||
|
||||
describe('useLocateNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
mockSearchParams.delete('node_id')
|
||||
searchParams.delete('node_id')
|
||||
scrollRequests = []
|
||||
document.addEventListener('workflow:scroll-to-node', onScroll)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.removeEventListener('workflow:scroll-to-node', onScroll)
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('does nothing when node_id param is absent', () => {
|
||||
const nodes = [createNode({ id: 'n1' })]
|
||||
renderHook(() => useLocateNode(nodes))
|
||||
it('preserves selection when node_id is absent', () => {
|
||||
const view = renderLocate([
|
||||
createNode({ id: 'existing', selected: true, data: { selected: true } }),
|
||||
])
|
||||
act(() => vi.advanceTimersByTime(500))
|
||||
|
||||
expect(mockHandleNodeSelect).not.toHaveBeenCalled()
|
||||
expect(mockToastSuccess).not.toHaveBeenCalled()
|
||||
expect(mockToastError).not.toHaveBeenCalled()
|
||||
expect(selectedIds(view)).toEqual(['existing'])
|
||||
expect(success).not.toHaveBeenCalled()
|
||||
expect(error).not.toHaveBeenCalled()
|
||||
expect(scrollRequests).toEqual([])
|
||||
})
|
||||
|
||||
it('does nothing when nodes are empty', () => {
|
||||
mockSearchParams.set('node_id', 'n1')
|
||||
renderHook(() => useLocateNode([]))
|
||||
it('waits for nodes before reporting a missing target', () => {
|
||||
searchParams.set('node_id', 'target')
|
||||
const view = renderLocate([])
|
||||
act(() => vi.advanceTimersByTime(500))
|
||||
|
||||
expect(mockHandleNodeSelect).not.toHaveBeenCalled()
|
||||
expect(mockToastError).not.toHaveBeenCalled()
|
||||
expect(view.result.current.flow.getNodes()).toEqual([])
|
||||
expect(success).not.toHaveBeenCalled()
|
||||
expect(error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('selects, scrolls to, and shows success toast when node is found', () => {
|
||||
mockSearchParams.set('node_id', 'target-node')
|
||||
const nodes = [createNode({ id: 'target-node', data: { title: 'My Node' } })]
|
||||
it('selects the target instead of the previous node and requests scrolling after the delay', () => {
|
||||
searchParams.set('node_id', 'target')
|
||||
const view = renderLocate([
|
||||
createNode({ id: 'previous', selected: true, data: { selected: true } }),
|
||||
createNode({ id: 'target', data: { title: 'My Node' } }),
|
||||
])
|
||||
|
||||
renderHook(() => useLocateNode(nodes))
|
||||
|
||||
expect(mockHandleNodeSelect).toHaveBeenCalledWith('target-node')
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('panel.locateNodeSuccess:My Node')
|
||||
|
||||
// Scroll happens after a 200ms delay
|
||||
expect(mockScrollToWorkflowNode).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(200)
|
||||
expect(mockScrollToWorkflowNode).toHaveBeenCalledWith('target-node')
|
||||
expect(selectedIds(view)).toEqual(['target'])
|
||||
expect(view.result.current.flow.getNode('target')?.data.selected).toBe(true)
|
||||
expect(view.result.current.flow.getNode('previous')?.data.selected).toBe(false)
|
||||
expect(success).toHaveBeenCalledWith('workflow.panel.locateNodeSuccess:{"title":"My Node"}')
|
||||
expect(scrollRequests).toEqual([])
|
||||
act(() => vi.advanceTimersByTime(200))
|
||||
expect(scrollRequests).toEqual(['target'])
|
||||
})
|
||||
|
||||
it('shows error toast after debounce when node is not found', () => {
|
||||
mockSearchParams.set('node_id', 'missing-node')
|
||||
const nodes = [createNode({ id: 'other-node' })]
|
||||
it('reports a missing target after nodes settle without changing selection', () => {
|
||||
searchParams.set('node_id', 'missing')
|
||||
const view = renderLocate([createNode({ id: 'other', selected: true })])
|
||||
expect(error).not.toHaveBeenCalled()
|
||||
act(() => vi.advanceTimersByTime(500))
|
||||
|
||||
renderHook(() => useLocateNode(nodes))
|
||||
|
||||
// Not immediately reported
|
||||
expect(mockToastError).not.toHaveBeenCalled()
|
||||
|
||||
// After 500ms debounce, reports not found
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(mockToastError).toHaveBeenCalledWith('panel.locateNodeNotFound:missing-node')
|
||||
expect(error).toHaveBeenCalledWith('workflow.panel.locateNodeNotFound:{"nodeId":"missing"}')
|
||||
expect(selectedIds(view)).toEqual(['other'])
|
||||
expect(scrollRequests).toEqual([])
|
||||
})
|
||||
|
||||
it('does not report not-found prematurely when nodes are still loading', () => {
|
||||
mockSearchParams.set('node_id', 'target-node')
|
||||
const initialNodes = [createNode({ id: 'other-node' })]
|
||||
it('selects a target that arrives before the not-found debounce expires', () => {
|
||||
searchParams.set('node_id', 'target')
|
||||
const initial = [createNode({ id: 'other' })]
|
||||
const view = renderLocate(initial)
|
||||
act(() => vi.advanceTimersByTime(300))
|
||||
const loadedNodes = [...initial, createNode({ id: 'target', data: { title: 'Found!' } })]
|
||||
act(() => view.setCanvas({ nodes: loadedNodes }))
|
||||
view.rerender({ loadedNodes })
|
||||
|
||||
const { rerender } = renderHook(({ nodes }) => useLocateNode(nodes), {
|
||||
initialProps: { nodes: initialNodes },
|
||||
})
|
||||
|
||||
// Before debounce fires, more nodes arrive including the target
|
||||
vi.advanceTimersByTime(300)
|
||||
rerender({
|
||||
nodes: [
|
||||
createNode({ id: 'other-node' }),
|
||||
createNode({ id: 'target-node', data: { title: 'Found!' } }),
|
||||
],
|
||||
})
|
||||
|
||||
// Should have located the node, not reported error
|
||||
expect(mockToastError).not.toHaveBeenCalled()
|
||||
expect(mockHandleNodeSelect).toHaveBeenCalledWith('target-node')
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('panel.locateNodeSuccess:Found!')
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(mockToastError).not.toHaveBeenCalled()
|
||||
expect(mockScrollToWorkflowNode).toHaveBeenCalledExactlyOnceWith('target-node')
|
||||
expect(selectedIds(view)).toEqual(['target'])
|
||||
expect(success).toHaveBeenCalledWith('workflow.panel.locateNodeSuccess:{"title":"Found!"}')
|
||||
act(() => vi.advanceTimersByTime(500))
|
||||
expect(error).not.toHaveBeenCalled()
|
||||
expect(scrollRequests).toEqual(['target'])
|
||||
})
|
||||
|
||||
it('locates only once even if nodes update afterwards', () => {
|
||||
mockSearchParams.set('node_id', 'target-node')
|
||||
const nodes1 = [createNode({ id: 'target-node' })]
|
||||
it('does not steal selection back when nodes update after locating', () => {
|
||||
searchParams.set('node_id', 'target')
|
||||
const view = renderLocate([createNode({ id: 'target' }), createNode({ id: 'other' })])
|
||||
expect(selectedIds(view)).toEqual(['target'])
|
||||
act(() => vi.advanceTimersByTime(200))
|
||||
|
||||
const { rerender } = renderHook(({ nodes }) => useLocateNode(nodes), {
|
||||
initialProps: { nodes: nodes1 },
|
||||
})
|
||||
act(() => view.result.current.handleNodeSelect('other'))
|
||||
view.rerender({ loadedNodes: view.result.current.flow.getNodes() })
|
||||
act(() => vi.advanceTimersByTime(500))
|
||||
|
||||
expect(mockHandleNodeSelect).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Simulate a nodes update after locate has already happened
|
||||
rerender({
|
||||
nodes: [createNode({ id: 'target-node' }), createNode({ id: 'extra' })],
|
||||
})
|
||||
|
||||
expect(mockHandleNodeSelect).toHaveBeenCalledTimes(1)
|
||||
expect(mockToastSuccess).toHaveBeenCalledTimes(1)
|
||||
expect(selectedIds(view)).toEqual(['other'])
|
||||
expect(success).toHaveBeenCalledTimes(1)
|
||||
expect(scrollRequests).toEqual(['target'])
|
||||
})
|
||||
|
||||
it('clears scroll timeout on unmount', () => {
|
||||
mockSearchParams.set('node_id', 'target-node')
|
||||
const nodes = [createNode({ id: 'target-node' })]
|
||||
|
||||
const { unmount } = renderHook(() => useLocateNode(nodes))
|
||||
|
||||
// Unmount before the 200ms scroll timer fires
|
||||
unmount()
|
||||
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(mockScrollToWorkflowNode).not.toHaveBeenCalled()
|
||||
it('cancels pending scrolling when the editor unmounts', () => {
|
||||
searchParams.set('node_id', 'target')
|
||||
const view = renderLocate([createNode({ id: 'target' })])
|
||||
expect(selectedIds(view)).toEqual(['target'])
|
||||
view.unmount()
|
||||
act(() => vi.advanceTimersByTime(500))
|
||||
expect(scrollRequests).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,149 +1,94 @@
|
||||
import { act } from '@testing-library/react'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import type { Shape as HooksStoreShape } from '../../hooks-store/store'
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { createLoopNode, createNode } from '../../__tests__/fixtures'
|
||||
import { renderWorkflowFlowHook } from '../../__tests__/workflow-test-env'
|
||||
import { useWorkflowOrganize } from '../use-workflow-organize'
|
||||
|
||||
const mockSetViewport = vi.hoisted(() => vi.fn())
|
||||
const mockHandleSyncWorkflowDraft = vi.hoisted(() => vi.fn())
|
||||
const mockSaveStateToHistory = vi.hoisted(() => vi.fn())
|
||||
const mockGetLayoutForChildNodes = vi.hoisted(() => vi.fn())
|
||||
const mockGetLayoutByELK = vi.hoisted(() => vi.fn())
|
||||
|
||||
const runtimeState = vi.hoisted(() => ({
|
||||
nodes: [] as ReturnType<typeof createNode>[],
|
||||
edges: [] as { id: string; source: string; target: string }[],
|
||||
nodesReadOnly: false,
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('reactflow')>()
|
||||
return {
|
||||
...actual,
|
||||
useReactFlow: () => ({ ...actual.useReactFlow(), setViewport: mockSetViewport }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../use-workflow', () => ({
|
||||
useNodesReadOnly: () => ({
|
||||
getNodesReadOnly: () => runtimeState.nodesReadOnly,
|
||||
nodesReadOnly: runtimeState.nodesReadOnly,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
handleSyncWorkflowDraft: (...args: unknown[]) => mockHandleSyncWorkflowDraft(...args),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-workflow-history', () => ({
|
||||
useWorkflowHistory: () => ({
|
||||
saveStateToHistory: (...args: unknown[]) => mockSaveStateToHistory(...args),
|
||||
}),
|
||||
WorkflowHistoryEvent: {
|
||||
LayoutOrganize: 'LayoutOrganize',
|
||||
},
|
||||
const { getLayoutForChildNodes, getLayoutByELK } = vi.hoisted(() => ({
|
||||
getLayoutForChildNodes: vi.fn(),
|
||||
getLayoutByELK: vi.fn(),
|
||||
}))
|
||||
|
||||
// ELK owns layout calculation; applying its result, permissions and persistence stay real.
|
||||
vi.mock('../../utils/elk-layout', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../utils/elk-layout')>()),
|
||||
getLayoutForChildNodes: (...args: unknown[]) => mockGetLayoutForChildNodes(...args),
|
||||
getLayoutByELK: (...args: unknown[]) => mockGetLayoutByELK(...args),
|
||||
getLayoutForChildNodes,
|
||||
getLayoutByELK,
|
||||
}))
|
||||
|
||||
const renderOrganize = (readOnly = false) => {
|
||||
const nodes = [
|
||||
createLoopNode({ id: 'loop-node', width: 200, height: 160 }),
|
||||
createNode({
|
||||
id: 'loop-child',
|
||||
parentId: 'loop-node',
|
||||
position: { x: 20, y: 20 },
|
||||
width: 100,
|
||||
height: 60,
|
||||
}),
|
||||
createNode({ id: 'top-node', position: { x: 400, y: 0 }, width: 240, height: 100 }),
|
||||
]
|
||||
const doSyncWorkflowDraft = vi
|
||||
.fn<HooksStoreShape['doSyncWorkflowDraft']>()
|
||||
.mockResolvedValue(undefined)
|
||||
const view = renderWorkflowFlowHook(() => ({ ...useWorkflowOrganize(), flow: useReactFlow() }), {
|
||||
nodes,
|
||||
historyStore: { nodes, edges: [] },
|
||||
initialStoreState: { canvasReadOnly: readOnly },
|
||||
hooksStoreProps: { doSyncWorkflowDraft },
|
||||
reactFlowProps: { fitView: false, defaultViewport: { x: 40, y: 60, zoom: 1.2 } },
|
||||
})
|
||||
return { ...view, doSyncWorkflowDraft }
|
||||
}
|
||||
|
||||
describe('useWorkflowOrganize', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
runtimeState.nodesReadOnly = false
|
||||
runtimeState.nodes = []
|
||||
runtimeState.edges = []
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('resizes containers, lays out nodes, and syncs draft when editable', async () => {
|
||||
runtimeState.nodes = [
|
||||
createLoopNode({
|
||||
id: 'loop-node',
|
||||
width: 200,
|
||||
height: 160,
|
||||
}),
|
||||
createNode({
|
||||
id: 'loop-child',
|
||||
parentId: 'loop-node',
|
||||
position: { x: 20, y: 20 },
|
||||
width: 100,
|
||||
height: 60,
|
||||
}),
|
||||
createNode({
|
||||
id: 'top-node',
|
||||
position: { x: 400, y: 0 },
|
||||
}),
|
||||
]
|
||||
runtimeState.edges = []
|
||||
mockGetLayoutForChildNodes.mockResolvedValue({
|
||||
getLayoutForChildNodes.mockResolvedValue({
|
||||
bounds: { minX: 0, minY: 0, maxX: 320, maxY: 220 },
|
||||
nodes: new Map([['loop-child', { x: 40, y: 60, width: 100, height: 60 }]]),
|
||||
})
|
||||
mockGetLayoutByELK.mockResolvedValue({
|
||||
getLayoutByELK.mockResolvedValue({
|
||||
nodes: new Map([
|
||||
['loop-node', { x: 10, y: 20, width: 360, height: 260, layer: 0 }],
|
||||
['top-node', { x: 500, y: 30, width: 240, height: 100, layer: 0 }],
|
||||
]),
|
||||
})
|
||||
|
||||
const { result } = renderWorkflowFlowHook(
|
||||
() => ({ ...useWorkflowOrganize(), graph: useStoreApi() }),
|
||||
{ nodes: runtimeState.nodes },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleLayout()
|
||||
})
|
||||
act(() => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
const nextNodes = result.current.graph.getState().getNodes()
|
||||
expect(nextNodes.find((node: { id: string }) => node.id === 'loop-node')).toEqual(
|
||||
expect.objectContaining({
|
||||
width: 440,
|
||||
height: 340,
|
||||
position: { x: 10, y: 20 },
|
||||
}),
|
||||
)
|
||||
expect(nextNodes.find((node: { id: string }) => node.id === 'loop-child')).toEqual(
|
||||
expect.objectContaining({
|
||||
position: { x: 100, y: 120 },
|
||||
}),
|
||||
)
|
||||
expect(mockSetViewport).toHaveBeenCalledWith({ x: 0, y: 0, zoom: 0.7 })
|
||||
expect(mockSaveStateToHistory).toHaveBeenCalledWith('LayoutOrganize')
|
||||
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips layout when nodes are read-only', async () => {
|
||||
runtimeState.nodesReadOnly = true
|
||||
runtimeState.nodes = [createNode({ id: 'n1' })]
|
||||
it('applies layout and container bounds to the canvas and records the resulting graph', async () => {
|
||||
const { result, store, doSyncWorkflowDraft } = renderOrganize()
|
||||
await act(async () => result.current.handleLayout())
|
||||
|
||||
const { result } = renderWorkflowFlowHook(
|
||||
() => ({ ...useWorkflowOrganize(), graph: useStoreApi() }),
|
||||
{ nodes: runtimeState.nodes },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleLayout()
|
||||
expect(result.current.flow.getNode('loop-node')).toMatchObject({
|
||||
width: 440,
|
||||
height: 340,
|
||||
position: { x: 10, y: 20 },
|
||||
data: { width: 440, height: 340 },
|
||||
})
|
||||
expect(result.current.flow.getNode('loop-child')).toMatchObject({
|
||||
position: { x: 100, y: 120 },
|
||||
})
|
||||
expect(result.current.flow.getNode('top-node')).toMatchObject({ position: { x: 500, y: 100 } })
|
||||
expect(result.current.flow.getViewport()).toEqual({ x: 0, y: 0, zoom: 0.7 })
|
||||
await waitFor(() =>
|
||||
expect(store.getState().workflowHistory.workflowHistoryEvent).toBe('LayoutOrganize'),
|
||||
)
|
||||
expect(store.getState().workflowHistory.nodes).toMatchObject(result.current.flow.getNodes())
|
||||
act(() => store.getState().flushPendingSync())
|
||||
expect(doSyncWorkflowDraft).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
expect(mockGetLayoutForChildNodes).not.toHaveBeenCalled()
|
||||
expect(mockGetLayoutByELK).not.toHaveBeenCalled()
|
||||
expect(result.current.graph.getState().getNodes()).toMatchObject(runtimeState.nodes)
|
||||
expect(mockSaveStateToHistory).not.toHaveBeenCalled()
|
||||
expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
expect(mockSetViewport).not.toHaveBeenCalled()
|
||||
it('preserves the graph, viewport, history and draft when read only', async () => {
|
||||
const { result, store, doSyncWorkflowDraft } = renderOrganize(true)
|
||||
const before = result.current.flow.getNodes()
|
||||
const history = store.getState().workflowHistory
|
||||
await act(async () => result.current.handleLayout())
|
||||
act(() => store.getState().flushPendingSync())
|
||||
|
||||
expect(result.current.flow.getNodes()).toEqual(before)
|
||||
expect(result.current.flow.getViewport()).toEqual({ x: 40, y: 60, zoom: 1.2 })
|
||||
expect(store.getState().workflowHistory).toEqual(history)
|
||||
expect(doSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,41 +1,15 @@
|
||||
import type { Shape as HooksStoreShape } from '../../../hooks-store/store'
|
||||
import type { InputVar } from '../../../types'
|
||||
import type { StartNodeType } from '../types'
|
||||
import type { InputVar, ValueSelector } from '@/app/components/workflow/types'
|
||||
import { act } from '@testing-library/react'
|
||||
import { renderWorkflowHook as renderHook } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { BlockEnum, ChangeType, InputVarType } from '@/app/components/workflow/types'
|
||||
import { useNodes, useReactFlow } from 'reactflow'
|
||||
import { createEdge, createNode, createStartNode } from '../../../__tests__/fixtures'
|
||||
import { renderWorkflowCanvasHook } from '../../../__tests__/workflow-test-env'
|
||||
import { BlockEnum, ChangeType, InputVarType } from '../../../types'
|
||||
import useConfig from '../use-config'
|
||||
|
||||
const mockUseWorkflow = vi.hoisted(() => vi.fn())
|
||||
let chatflowMode = false
|
||||
const mockUseNodeCrud = vi.hoisted(() => vi.fn())
|
||||
const mockUseInspectVarsCrud = vi.hoisted(() => vi.fn())
|
||||
const mockNotify = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../../../hooks/use-workflow', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../../hooks/use-workflow')>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useWorkflow: () => mockUseWorkflow(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockUseNodeCrud(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../../../hooks/use-inspect-vars-crud', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockUseInspectVarsCrud(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/notifications', () => ({
|
||||
__esModule: true,
|
||||
toast: {
|
||||
error: (message: string) => mockNotify({ type: 'error', message }),
|
||||
},
|
||||
}))
|
||||
const { notify } = vi.hoisted(() => ({ notify: vi.fn() }))
|
||||
vi.mock('@/app/notifications', () => ({ toast: { error: notify } }))
|
||||
|
||||
const createInputVar = (overrides: Partial<InputVar> = {}): InputVar => ({
|
||||
label: 'Question',
|
||||
@@ -45,242 +19,155 @@ const createInputVar = (overrides: Partial<InputVar> = {}): InputVar => ({
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createPayload = (overrides: Partial<StartNodeType> = {}): StartNodeType => ({
|
||||
title: 'Start',
|
||||
desc: '',
|
||||
type: BlockEnum.Start,
|
||||
variables: [
|
||||
const renderConfig = () => {
|
||||
const variables = [
|
||||
createInputVar(),
|
||||
createInputVar({
|
||||
label: 'Age',
|
||||
variable: 'age',
|
||||
type: InputVarType.number,
|
||||
required: false,
|
||||
createInputVar({ label: 'Age', variable: 'age', type: InputVarType.number, required: false }),
|
||||
]
|
||||
const start = createStartNode({ id: 'start-node', data: { variables } })
|
||||
const nodes = [
|
||||
start,
|
||||
createNode({
|
||||
id: 'consumer',
|
||||
data: {
|
||||
variables: [
|
||||
{ variable: 'question', value_selector: ['start-node', 'query'] },
|
||||
{ variable: 'age', value_selector: ['start-node', 'age'] },
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
...overrides,
|
||||
})
|
||||
]
|
||||
// Inspector persistence is supplied by the editor owner through the hooks store.
|
||||
const renameInspectVarName = vi
|
||||
.fn<HooksStoreShape['renameInspectVarName']>()
|
||||
.mockResolvedValue(undefined)
|
||||
const deleteInspectVar = vi.fn<HooksStoreShape['deleteInspectVar']>().mockResolvedValue(undefined)
|
||||
const deleteNodeInspectorVars = vi
|
||||
.fn<HooksStoreShape['deleteNodeInspectorVars']>()
|
||||
.mockResolvedValue(undefined)
|
||||
const view = renderWorkflowCanvasHook(
|
||||
() => {
|
||||
const startNode = useNodes<StartNodeType>().find((node) => node.id === start.id)!
|
||||
return { ...useConfig(start.id, startNode.data), flow: useReactFlow() }
|
||||
},
|
||||
{
|
||||
nodes,
|
||||
edges: [createEdge({ source: start.id, target: 'consumer' })],
|
||||
hooksStoreProps: { renameInspectVarName, deleteInspectVar, deleteNodeInspectorVars },
|
||||
initialStoreState: {
|
||||
nodesWithInspectVars: [
|
||||
{
|
||||
nodeId: start.id,
|
||||
nodePayload: start.data,
|
||||
nodeType: BlockEnum.Start,
|
||||
title: 'Start',
|
||||
vars: [
|
||||
{
|
||||
id: 'inspect-query',
|
||||
type: 'node',
|
||||
name: 'query',
|
||||
description: '',
|
||||
selector: [start.id, 'query'],
|
||||
value_type: 'string',
|
||||
value: 'hello',
|
||||
edited: false,
|
||||
visible: true,
|
||||
is_truncated: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
return { ...view, variables, renameInspectVarName, deleteInspectVar, deleteNodeInspectorVars }
|
||||
}
|
||||
|
||||
const removal = { index: 0, payload: { type: ChangeType.remove, payload: { beforeKey: 'query' } } }
|
||||
|
||||
describe('start/use-config', () => {
|
||||
const mockSetInputs = vi.fn()
|
||||
const mockHandleOutVarRenameChange = vi.fn()
|
||||
const mockIsVarUsedInNodes = vi.fn()
|
||||
const mockRemoveUsedVarInNodes = vi.fn()
|
||||
const mockDeleteNodeInspectorVars = vi.fn()
|
||||
const mockRenameInspectVarName = vi.fn()
|
||||
const mockDeleteInspectVar = vi.fn()
|
||||
const toastSpy = mockNotify
|
||||
let currentInputs: StartNodeType
|
||||
it('renames the saved variable and its downstream references', () => {
|
||||
const { result, variables, renameInspectVarName } = renderConfig()
|
||||
const renamed = [createInputVar({ variable: 'prompt' }), variables[1]!]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
currentInputs = createPayload()
|
||||
|
||||
mockUseWorkflow.mockReturnValue({
|
||||
handleOutVarRenameChange: mockHandleOutVarRenameChange,
|
||||
isVarUsedInNodes: mockIsVarUsedInNodes,
|
||||
removeUsedVarInNodes: mockRemoveUsedVarInNodes,
|
||||
})
|
||||
chatflowMode = false
|
||||
mockUseNodeCrud.mockImplementation(() => ({
|
||||
inputs: currentInputs,
|
||||
setInputs: mockSetInputs,
|
||||
}))
|
||||
mockUseInspectVarsCrud.mockReturnValue({
|
||||
deleteNodeInspectorVars: mockDeleteNodeInspectorVars,
|
||||
renameInspectVarName: mockRenameInspectVarName,
|
||||
nodesWithInspectVars: [
|
||||
{
|
||||
nodeId: 'start-node',
|
||||
vars: [{ id: 'inspect-query', name: 'query' }],
|
||||
},
|
||||
],
|
||||
deleteInspectVar: mockDeleteInspectVar,
|
||||
})
|
||||
mockIsVarUsedInNodes.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('should rename variables and sync downstream variable references', () => {
|
||||
const { result } = renderHook(() => useConfig('start-node', currentInputs), {
|
||||
resource: { kind: 'app', appId: 'app-1', mode: chatflowMode ? 'advanced-chat' : 'workflow' },
|
||||
})
|
||||
const renamedList = [
|
||||
createInputVar({
|
||||
label: 'Question',
|
||||
variable: 'prompt',
|
||||
}),
|
||||
createInputVar({
|
||||
label: 'Age',
|
||||
variable: 'age',
|
||||
type: InputVarType.number,
|
||||
required: false,
|
||||
}),
|
||||
]
|
||||
|
||||
act(() => {
|
||||
result.current.handleVarListChange(renamedList, {
|
||||
act(() =>
|
||||
result.current.handleVarListChange(renamed, {
|
||||
index: 0,
|
||||
payload: {
|
||||
type: ChangeType.changeVarName,
|
||||
payload: {
|
||||
beforeKey: 'query',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variables: renamedList,
|
||||
payload: { type: ChangeType.changeVarName, payload: { beforeKey: 'query' } },
|
||||
}),
|
||||
)
|
||||
expect(mockHandleOutVarRenameChange).toHaveBeenCalledWith(
|
||||
'start-node',
|
||||
['start-node', 'query'],
|
||||
['start-node', 'prompt'],
|
||||
)
|
||||
expect(mockRenameInspectVarName).toHaveBeenCalledWith('start-node', 'query', 'prompt')
|
||||
expect(result.current.readOnly).toBe(false)
|
||||
expect(result.current.isChatflow).toBe(false)
|
||||
|
||||
expect(result.current.inputs.variables).toEqual(renamed)
|
||||
expect(result.current.flow.getNode('consumer')?.data.variables).toEqual([
|
||||
{ variable: 'question', value_selector: ['start-node', 'prompt'] },
|
||||
{ variable: 'age', value_selector: ['start-node', 'age'] },
|
||||
])
|
||||
expect(renameInspectVarName).toHaveBeenCalledExactlyOnceWith('start-node', 'query', 'prompt')
|
||||
})
|
||||
|
||||
it('should block removal when the variable is still in use and confirm the deletion later', () => {
|
||||
mockIsVarUsedInNodes.mockReturnValue(true)
|
||||
const { result } = renderHook(() => useConfig('start-node', currentInputs), {
|
||||
resource: { kind: 'app', appId: 'app-1', mode: chatflowMode ? 'advanced-chat' : 'workflow' },
|
||||
})
|
||||
const nextList = [currentInputs.variables[1]!]
|
||||
it('keeps a referenced variable until confirmation, then removes it and clears its references', () => {
|
||||
const { result, variables, deleteInspectVar } = renderConfig()
|
||||
act(() => result.current.handleVarListChange([variables[1]!], removal))
|
||||
|
||||
act(() => {
|
||||
result.current.handleVarListChange(nextList, {
|
||||
index: 0,
|
||||
payload: {
|
||||
type: ChangeType.remove,
|
||||
payload: {
|
||||
beforeKey: 'query',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
expect(result.current.isShowRemoveVarConfirm).toBe(true)
|
||||
expect(result.current.inputs.variables).toEqual(variables)
|
||||
expect(deleteInspectVar).not.toHaveBeenCalled()
|
||||
|
||||
expect(mockDeleteInspectVar).not.toHaveBeenCalled()
|
||||
expect(mockSetInputs).not.toHaveBeenCalled()
|
||||
act(() => result.current.onRemoveVarConfirm())
|
||||
|
||||
expect(result.current.inputs.variables).toEqual([variables[1]])
|
||||
expect(result.current.flow.getNode('consumer')?.data.variables).toEqual([
|
||||
{ variable: 'question', value_selector: [] },
|
||||
{ variable: 'age', value_selector: ['start-node', 'age'] },
|
||||
])
|
||||
expect(deleteInspectVar).toHaveBeenCalledExactlyOnceWith('start-node', 'inspect-query')
|
||||
expect(result.current.isShowRemoveVarConfirm).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves the graph and inspector value when removal is cancelled', () => {
|
||||
const { result, variables, deleteInspectVar } = renderConfig()
|
||||
const before = result.current.flow.getNodes()
|
||||
act(() => result.current.handleVarListChange([variables[1]!], removal))
|
||||
expect(result.current.isShowRemoveVarConfirm).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.onRemoveVarConfirm()
|
||||
})
|
||||
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variables: [expect.objectContaining({ variable: 'age' })],
|
||||
}),
|
||||
)
|
||||
expect(mockDeleteInspectVar).toHaveBeenCalledExactlyOnceWith('start-node', 'inspect-query')
|
||||
expect(mockRemoveUsedVarInNodes).toHaveBeenCalledWith(['start-node', 'query'] as ValueSelector)
|
||||
expect(result.current.isShowRemoveVarConfirm).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the variable and inspector value when removal is cancelled', () => {
|
||||
mockIsVarUsedInNodes.mockReturnValue(true)
|
||||
const { result } = renderHook(() => useConfig('start-node', currentInputs))
|
||||
act(() =>
|
||||
result.current.handleVarListChange([currentInputs.variables[1]!], {
|
||||
index: 0,
|
||||
payload: { type: ChangeType.remove, payload: { beforeKey: 'query' } },
|
||||
}),
|
||||
)
|
||||
act(() => result.current.hideRemoveVarConfirm())
|
||||
|
||||
expect(result.current.isShowRemoveVarConfirm).toBe(false)
|
||||
expect(mockSetInputs).not.toHaveBeenCalled()
|
||||
expect(mockDeleteInspectVar).not.toHaveBeenCalled()
|
||||
expect(mockRemoveUsedVarInNodes).not.toHaveBeenCalled()
|
||||
expect(result.current.flow.getNodes()).toEqual(before)
|
||||
expect(deleteInspectVar).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should validate duplicate variables and labels before adding a new variable', () => {
|
||||
const { result } = renderHook(() => useConfig('start-node', currentInputs), {
|
||||
resource: { kind: 'app', appId: 'app-1', mode: chatflowMode ? 'advanced-chat' : 'workflow' },
|
||||
})
|
||||
|
||||
let added = true
|
||||
it('rejects a duplicate key without changing the node, then saves a unique variable', () => {
|
||||
const { result, variables } = renderConfig()
|
||||
act(() => {
|
||||
added = result.current.handleAddVariable(
|
||||
createInputVar({
|
||||
label: 'Different Label',
|
||||
variable: 'query',
|
||||
}),
|
||||
expect(result.current.handleAddVariable(createInputVar({ label: 'Different label' }))).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
expect(result.current.inputs.variables).toEqual(variables)
|
||||
expect(notify).toHaveBeenCalledWith(expect.stringContaining('varKeyError.keyAlreadyExists'))
|
||||
|
||||
expect(added).toBe(false)
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'error',
|
||||
message: expect.stringContaining('varKeyError.keyAlreadyExists'),
|
||||
}),
|
||||
)
|
||||
|
||||
mockSetInputs.mockClear()
|
||||
let addedUnique = false
|
||||
const locale = createInputVar({ label: 'Locale', variable: 'locale', required: false })
|
||||
act(() => {
|
||||
addedUnique = result.current.handleAddVariable(
|
||||
createInputVar({
|
||||
label: 'Locale',
|
||||
variable: 'locale',
|
||||
required: false,
|
||||
}),
|
||||
)
|
||||
expect(result.current.handleAddVariable(locale)).toBe(true)
|
||||
})
|
||||
|
||||
expect(addedUnique).toBe(true)
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variables: expect.arrayContaining([expect.objectContaining({ variable: 'locale' })]),
|
||||
}),
|
||||
)
|
||||
expect(result.current.inputs.variables).toEqual([...variables, locale])
|
||||
})
|
||||
|
||||
it('should clear inspector vars for non-remove list updates and reject duplicate labels', () => {
|
||||
const { result } = renderHook(() => useConfig('start-node', currentInputs), {
|
||||
resource: { kind: 'app', appId: 'app-1', mode: chatflowMode ? 'advanced-chat' : 'workflow' },
|
||||
})
|
||||
const typeEditedList = [
|
||||
createInputVar({
|
||||
label: 'Question',
|
||||
variable: 'query',
|
||||
type: InputVarType.paragraph,
|
||||
}),
|
||||
currentInputs.variables[1]!,
|
||||
]
|
||||
it('saves a type edit and clears inspector values, but rejects a duplicate label', () => {
|
||||
const { result, variables, deleteNodeInspectorVars } = renderConfig()
|
||||
const edited = [createInputVar({ type: InputVarType.paragraph }), variables[1]!]
|
||||
act(() => result.current.handleVarListChange(edited))
|
||||
|
||||
expect(result.current.inputs.variables).toEqual(edited)
|
||||
expect(deleteNodeInspectorVars).toHaveBeenCalledExactlyOnceWith('start-node')
|
||||
|
||||
act(() => {
|
||||
result.current.handleVarListChange(typeEditedList)
|
||||
expect(
|
||||
result.current.handleAddVariable(createInputVar({ label: 'Age', variable: 'new_age' })),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
expect(mockSetInputs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variables: typeEditedList,
|
||||
}),
|
||||
)
|
||||
expect(mockDeleteNodeInspectorVars).toHaveBeenCalledWith('start-node')
|
||||
|
||||
toastSpy.mockClear()
|
||||
let added = true
|
||||
act(() => {
|
||||
added = result.current.handleAddVariable(
|
||||
createInputVar({
|
||||
label: 'Age',
|
||||
variable: 'new_age',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
expect(added).toBe(false)
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'error',
|
||||
message: expect.stringContaining('varKeyError.keyAlreadyExists'),
|
||||
}),
|
||||
)
|
||||
expect(result.current.inputs.variables).toEqual(edited)
|
||||
expect(notify).toHaveBeenCalledWith(expect.stringContaining('varKeyError.keyAlreadyExists'))
|
||||
})
|
||||
})
|
||||
+53
-86
@@ -1,108 +1,75 @@
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import { screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createNode } from '../../../__tests__/fixtures'
|
||||
import { renderWorkflowFlowComponent } from '../../../__tests__/workflow-test-env'
|
||||
import ZoomInOut from '../zoom-in-out'
|
||||
|
||||
const { mockZoomIn, mockZoomOut, mockZoomTo, mockFitView, mockViewport } = vi.hoisted(() => ({
|
||||
mockZoomIn: vi.fn(),
|
||||
mockZoomOut: vi.fn(),
|
||||
mockZoomTo: vi.fn(),
|
||||
mockFitView: vi.fn(),
|
||||
mockViewport: { zoom: 1 },
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useReactFlow: () => ({
|
||||
zoomIn: mockZoomIn,
|
||||
zoomOut: mockZoomOut,
|
||||
zoomTo: mockZoomTo,
|
||||
fitView: mockFitView,
|
||||
}),
|
||||
useViewport: () => mockViewport,
|
||||
}))
|
||||
|
||||
const getZoomControls = () => ({
|
||||
zoomOutTrigger: screen.getByRole('button', { name: 'workflow.operator.zoomOut' }),
|
||||
label: screen.getByRole('button', { name: /^\d+%$/ }),
|
||||
zoomInTrigger: screen.getByRole('button', { name: 'workflow.operator.zoomIn' }),
|
||||
})
|
||||
const renderZoom = (zoom = 1) =>
|
||||
renderWorkflowFlowComponent(<ZoomInOut />, {
|
||||
nodes: [
|
||||
createNode({ id: 'left', position: { x: 0, y: 0 }, width: 240, height: 80 }),
|
||||
createNode({ id: 'right', position: { x: 1600, y: 400 }, width: 240, height: 80 }),
|
||||
],
|
||||
reactFlowProps: {
|
||||
fitView: false,
|
||||
minZoom: 0.25,
|
||||
maxZoom: 2,
|
||||
defaultViewport: { x: 0, y: 0, zoom },
|
||||
},
|
||||
})
|
||||
|
||||
const zoomLabel = () => screen.getByRole('button', { name: /^\d+%$/ })
|
||||
const openZoomMenu = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.click(getZoomControls().label)
|
||||
await user.click(zoomLabel())
|
||||
return within(await screen.findByRole('menu'))
|
||||
}
|
||||
|
||||
describe('workflow preview zoom controls', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockViewport.zoom = 1
|
||||
it('zooms out and in and displays the resulting viewport scale', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderZoom()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.operator.zoomOut' }))
|
||||
await waitFor(() => expect(zoomLabel()).toHaveAccessibleName('83%'))
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.operator.zoomIn' }))
|
||||
await waitFor(() => expect(zoomLabel()).toHaveAccessibleName('100%'))
|
||||
})
|
||||
|
||||
// The inline controls should call the reactflow zoom handlers when the viewport is within range.
|
||||
describe('Inline Controls', () => {
|
||||
it('should zoom out and zoom in when the viewport is within the supported range', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ZoomInOut />)
|
||||
it.each([
|
||||
{ zoom: 0.25, control: 'workflow.operator.zoomOut', label: '25%' },
|
||||
{ zoom: 2, control: 'workflow.operator.zoomIn', label: '200%' },
|
||||
])('disables $control at $label', async ({ zoom, control, label }) => {
|
||||
const user = userEvent.setup()
|
||||
renderZoom(zoom)
|
||||
const button = screen.getByRole('button', { name: control })
|
||||
|
||||
const { zoomOutTrigger, zoomInTrigger } = getZoomControls()
|
||||
|
||||
await user.click(zoomOutTrigger)
|
||||
await user.click(zoomInTrigger)
|
||||
|
||||
expect(mockZoomOut).toHaveBeenCalledTimes(1)
|
||||
expect(mockZoomIn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should block zooming out when the viewport is already at the minimum scale', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockViewport.zoom = 0.25
|
||||
render(<ZoomInOut />)
|
||||
|
||||
await user.click(getZoomControls().zoomOutTrigger)
|
||||
|
||||
expect(mockZoomOut).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should block zooming in when the viewport is already at the maximum scale', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockViewport.zoom = 2
|
||||
render(<ZoomInOut />)
|
||||
|
||||
await user.click(getZoomControls().zoomInTrigger)
|
||||
|
||||
expect(mockZoomIn).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(button).toBeDisabled()
|
||||
await user.click(button)
|
||||
expect(zoomLabel()).toHaveAccessibleName(label)
|
||||
})
|
||||
|
||||
// Preset menu actions should route to the correct reactflow commands.
|
||||
describe('Preset Menu', () => {
|
||||
it.each([
|
||||
['25%', 0.25],
|
||||
['50%', 0.5],
|
||||
['75%', 0.75],
|
||||
['100%', 1],
|
||||
['200%', 2],
|
||||
])('should zoom to %s when selecting that preset', async (label, zoom) => {
|
||||
it.each(['25%', '50%', '75%', '100%', '200%'])(
|
||||
'applies the %s preset from a click',
|
||||
async (label) => {
|
||||
const user = userEvent.setup()
|
||||
render(<ZoomInOut />)
|
||||
renderZoom(label === '100%' ? 0.75 : 1)
|
||||
const menu = await openZoomMenu(user)
|
||||
|
||||
const portal = await openZoomMenu(user)
|
||||
await user.click(menu.getByRole('menuitem', { name: new RegExp(`^${label}`) }))
|
||||
|
||||
await user.click(portal.getByRole('menuitem', { name: new RegExp(label) }))
|
||||
await waitFor(() => expect(zoomLabel()).toHaveAccessibleName(label))
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
|
||||
expect(mockZoomTo).toHaveBeenCalledWith(zoom)
|
||||
expect(mockFitView).not.toHaveBeenCalled()
|
||||
})
|
||||
it('fits the graph from the menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderZoom()
|
||||
const menu = await openZoomMenu(user)
|
||||
|
||||
it('should fit the viewport when selecting the fit option', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ZoomInOut />)
|
||||
await user.click(menu.getByRole('menuitem', { name: /^workflow\.operator\.zoomToFit/ }))
|
||||
|
||||
const portal = await openZoomMenu(user)
|
||||
|
||||
await user.click(portal.getByRole('menuitem', { name: /workflow\.operator\.zoomToFit/ }))
|
||||
|
||||
expect(mockFitView).toHaveBeenCalledTimes(1)
|
||||
expect(mockZoomTo).not.toHaveBeenCalled()
|
||||
})
|
||||
await waitFor(() => expect(zoomLabel()).toHaveAccessibleName('25%'))
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user