test(skill): add comprehensive unit tests for file-tree domain

This commit is contained in:
yyh
2026-02-07 16:53:58 +08:00
parent f5a29b69a8
commit a761ab5cee
31 changed files with 6645 additions and 0 deletions

View File

@ -0,0 +1,165 @@
import type { App, AppSSO } from '@/types/app'
import type { AppAssetTreeResponse, AppAssetTreeView } from '@/types/app-asset'
import { renderHook } from '@testing-library/react'
import { useStore as useAppStore } from '@/app/components/app/store'
import {
useExistingSkillNames,
useSkillAssetNodeMap,
useSkillAssetTreeData,
} from './use-skill-asset-tree'
const { mockUseGetAppAssetTree } = vi.hoisted(() => ({
mockUseGetAppAssetTree: vi.fn(),
}))
vi.mock('@/service/use-app-asset', () => ({
useGetAppAssetTree: (...args: unknown[]) => mockUseGetAppAssetTree(...args),
}))
const createTreeNode = (
overrides: Partial<AppAssetTreeView> & Pick<AppAssetTreeView, 'id' | 'node_type' | 'name'>,
): AppAssetTreeView => ({
id: overrides.id,
node_type: overrides.node_type,
name: overrides.name,
path: overrides.path ?? `/${overrides.name}`,
extension: overrides.extension ?? '',
size: overrides.size ?? 0,
children: overrides.children ?? [],
})
describe('useSkillAssetTree', () => {
beforeEach(() => {
vi.clearAllMocks()
useAppStore.setState({
appDetail: { id: 'app-1' } as App & Partial<AppSSO>,
})
mockUseGetAppAssetTree.mockReturnValue({
data: null,
isPending: false,
error: null,
})
})
// Scenario: should pass app id from app store to the data query hook.
describe('useSkillAssetTreeData', () => {
it('should request tree data with current app id', () => {
const expectedResult = { data: { children: [] }, isPending: false }
mockUseGetAppAssetTree.mockReturnValue(expectedResult)
const { result } = renderHook(() => useSkillAssetTreeData())
expect(mockUseGetAppAssetTree).toHaveBeenCalledWith('app-1')
expect(result.current).toBe(expectedResult)
})
it('should request tree data with empty app id when app detail is missing', () => {
useAppStore.setState({ appDetail: undefined })
renderHook(() => useSkillAssetTreeData())
expect(mockUseGetAppAssetTree).toHaveBeenCalledWith('')
})
})
// Scenario: should expose a select transform that builds node lookup maps.
describe('useSkillAssetNodeMap', () => {
it('should build a map including nested nodes', () => {
renderHook(() => useSkillAssetNodeMap())
const options = mockUseGetAppAssetTree.mock.calls[0][1] as {
select: (data: AppAssetTreeResponse) => Map<string, AppAssetTreeView>
}
const map = options.select({
children: [
createTreeNode({
id: 'folder-1',
node_type: 'folder',
name: 'skill-a',
children: [
createTreeNode({
id: 'file-1',
node_type: 'file',
name: 'README.md',
extension: 'md',
}),
],
}),
],
})
expect(map.get('folder-1')?.name).toBe('skill-a')
expect(map.get('file-1')?.name).toBe('README.md')
expect(map.size).toBe(2)
})
it('should return an empty map when tree response has no children', () => {
renderHook(() => useSkillAssetNodeMap())
const options = mockUseGetAppAssetTree.mock.calls[0][1] as {
select: (data: AppAssetTreeResponse) => Map<string, AppAssetTreeView>
}
const map = options.select({} as AppAssetTreeResponse)
expect(map.size).toBe(0)
})
})
// Scenario: should expose root-level existing skill folder names.
describe('useExistingSkillNames', () => {
it('should collect only root folder names', () => {
renderHook(() => useExistingSkillNames())
const options = mockUseGetAppAssetTree.mock.calls[0][1] as {
select: (data: AppAssetTreeResponse) => Set<string>
}
const names = options.select({
children: [
createTreeNode({
id: 'folder-1',
node_type: 'folder',
name: 'skill-a',
children: [
createTreeNode({
id: 'folder-2',
node_type: 'folder',
name: 'nested-folder',
}),
],
}),
createTreeNode({
id: 'file-1',
node_type: 'file',
name: 'README.md',
extension: 'md',
}),
createTreeNode({
id: 'folder-3',
node_type: 'folder',
name: 'skill-b',
}),
],
})
expect(names.has('skill-a')).toBe(true)
expect(names.has('skill-b')).toBe(true)
expect(names.has('nested-folder')).toBe(false)
expect(names.size).toBe(2)
})
it('should return an empty set when tree response has no children', () => {
renderHook(() => useExistingSkillNames())
const options = mockUseGetAppAssetTree.mock.calls[0][1] as {
select: (data: AppAssetTreeResponse) => Set<string>
}
const names = options.select({} as AppAssetTreeResponse)
expect(names.size).toBe(0)
})
})
})

View File

@ -0,0 +1,168 @@
import type { ReactNode } from 'react'
import type { App, AppSSO } from '@/types/app'
import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { consoleQuery } from '@/service/client'
import {
useSkillTreeCollaboration,
useSkillTreeUpdateEmitter,
} from './use-skill-tree-collaboration'
const {
mockEmitTreeUpdate,
mockOnTreeUpdate,
mockUnsubscribe,
} = vi.hoisted(() => ({
mockEmitTreeUpdate: vi.fn(),
mockOnTreeUpdate: vi.fn(),
mockUnsubscribe: vi.fn(),
}))
vi.mock('@/app/components/workflow/collaboration/skills/skill-collaboration-manager', () => ({
skillCollaborationManager: {
emitTreeUpdate: mockEmitTreeUpdate,
onTreeUpdate: mockOnTreeUpdate,
},
}))
const createWrapper = (queryClient: QueryClient) => {
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
describe('useSkillTreeCollaboration', () => {
beforeEach(() => {
vi.clearAllMocks()
useAppStore.setState({
appDetail: { id: 'app-1' } as App & Partial<AppSSO>,
})
const currentFeatures = useGlobalPublicStore.getState().systemFeatures
useGlobalPublicStore.setState({
systemFeatures: {
...currentFeatures,
enable_collaboration_mode: true,
},
})
mockOnTreeUpdate.mockReturnValue(mockUnsubscribe)
})
// Scenario: update emitter sends events only when collaboration is enabled and app id exists.
describe('useSkillTreeUpdateEmitter', () => {
it('should emit tree update with app id and payload', () => {
const { result } = renderHook(() => useSkillTreeUpdateEmitter())
act(() => {
result.current({ source: 'test' })
})
expect(mockEmitTreeUpdate).toHaveBeenCalledWith('app-1', { source: 'test' })
})
it('should not emit tree update when collaboration is disabled', () => {
const currentFeatures = useGlobalPublicStore.getState().systemFeatures
useGlobalPublicStore.setState({
systemFeatures: {
...currentFeatures,
enable_collaboration_mode: false,
},
})
const { result } = renderHook(() => useSkillTreeUpdateEmitter())
act(() => {
result.current({ source: 'disabled' })
})
expect(mockEmitTreeUpdate).not.toHaveBeenCalled()
})
it('should not emit tree update when app id is missing', () => {
useAppStore.setState({ appDetail: undefined })
const { result } = renderHook(() => useSkillTreeUpdateEmitter())
act(() => {
result.current({ source: 'no-app' })
})
expect(mockEmitTreeUpdate).not.toHaveBeenCalled()
})
})
// Scenario: collaboration hook subscribes to updates and invalidates tree query cache.
describe('useSkillTreeCollaboration', () => {
it('should subscribe to tree updates and invalidate app tree query when updates arrive', async () => {
let treeUpdateCallback: ((payload: Record<string, unknown>) => void) | null = null
mockOnTreeUpdate.mockImplementation((_appId: string, callback: (payload: Record<string, unknown>) => void) => {
treeUpdateCallback = callback
return mockUnsubscribe
})
const queryClient = new QueryClient()
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries')
renderHook(() => useSkillTreeCollaboration(), {
wrapper: createWrapper(queryClient),
})
expect(mockOnTreeUpdate).toHaveBeenCalledWith('app-1', expect.any(Function))
act(() => {
treeUpdateCallback?.({ reason: 'remote' })
})
await waitFor(() => {
expect(invalidateQueriesSpy).toHaveBeenCalledWith({
queryKey: consoleQuery.appAsset.tree.queryKey({ input: { params: { appId: 'app-1' } } }),
})
})
})
it('should clean up tree update subscription on unmount', () => {
const queryClient = new QueryClient()
const { unmount } = renderHook(() => useSkillTreeCollaboration(), {
wrapper: createWrapper(queryClient),
})
unmount()
expect(mockUnsubscribe).toHaveBeenCalledTimes(1)
})
it('should skip subscription when collaboration is disabled', () => {
const currentFeatures = useGlobalPublicStore.getState().systemFeatures
useGlobalPublicStore.setState({
systemFeatures: {
...currentFeatures,
enable_collaboration_mode: false,
},
})
const queryClient = new QueryClient()
renderHook(() => useSkillTreeCollaboration(), {
wrapper: createWrapper(queryClient),
})
expect(mockOnTreeUpdate).not.toHaveBeenCalled()
})
it('should skip subscription when app id is missing', () => {
useAppStore.setState({ appDetail: undefined })
const queryClient = new QueryClient()
renderHook(() => useSkillTreeCollaboration(), {
wrapper: createWrapper(queryClient),
})
expect(mockOnTreeUpdate).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,278 @@
import type { ReactNode } from 'react'
import type { App, AppSSO } from '@/types/app'
import { act, renderHook } from '@testing-library/react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { WorkflowContext } from '@/app/components/workflow/context'
import { createWorkflowStore } from '@/app/components/workflow/store'
import { ROOT_ID } from '../../../constants'
import { useFileDrop } from './use-file-drop'
const {
mockUploadMutateAsync,
mockPrepareSkillUploadFile,
mockEmitTreeUpdate,
mockToastNotify,
} = vi.hoisted(() => ({
mockUploadMutateAsync: vi.fn(),
mockPrepareSkillUploadFile: vi.fn(),
mockEmitTreeUpdate: vi.fn(),
mockToastNotify: vi.fn(),
}))
vi.mock('@/service/use-app-asset', () => ({
useUploadFileWithPresignedUrl: () => ({
mutateAsync: mockUploadMutateAsync,
isPending: false,
}),
}))
vi.mock('../../../utils/skill-upload-utils', () => ({
prepareSkillUploadFile: mockPrepareSkillUploadFile,
}))
vi.mock('../data/use-skill-tree-collaboration', () => ({
useSkillTreeUpdateEmitter: () => mockEmitTreeUpdate,
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: mockToastNotify,
},
}))
type MockDataTransferItem = {
kind: string
getAsFile: () => File | null
webkitGetAsEntry: () => { isDirectory: boolean } | null
}
type MockDragEvent = {
preventDefault: ReturnType<typeof vi.fn>
stopPropagation: ReturnType<typeof vi.fn>
dataTransfer: {
types: string[]
items: DataTransferItem[]
dropEffect: 'none' | 'copy' | 'move' | 'link'
}
}
const createWrapper = (store: ReturnType<typeof createWorkflowStore>) => {
return ({ children }: { children: ReactNode }) => (
<WorkflowContext.Provider value={store}>
{children}
</WorkflowContext.Provider>
)
}
const createDataTransferItem = (params: {
file?: File | null
kind?: string
isDirectory?: boolean
} = {}): DataTransferItem => {
const {
file = null,
kind = 'file',
isDirectory,
} = params
const item: MockDataTransferItem = {
kind,
getAsFile: () => file,
webkitGetAsEntry: () => {
if (typeof isDirectory === 'boolean')
return { isDirectory }
return null
},
}
return item as unknown as DataTransferItem
}
const createDragEvent = (params: {
types?: string[]
items?: DataTransferItem[]
} = {}): MockDragEvent => {
return {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
dataTransfer: {
types: params.types ?? ['Files'],
items: params.items ?? [],
dropEffect: 'none',
},
}
}
describe('useFileDrop', () => {
beforeEach(() => {
vi.clearAllMocks()
useAppStore.setState({
appDetail: { id: 'app-1' } as App & Partial<AppSSO>,
})
mockPrepareSkillUploadFile.mockImplementation(async (file: File) => file)
mockUploadMutateAsync.mockResolvedValue(undefined)
})
// Scenario: drag-over updates upload drag state for valid external file drags.
describe('Drag Over', () => {
it('should set upload drag state when file drag enters root target', () => {
const store = createWorkflowStore({})
const { result } = renderHook(() => useFileDrop(), { wrapper: createWrapper(store) })
const event = createDragEvent()
act(() => {
result.current.handleDragOver(event as unknown as React.DragEvent, {
folderId: null,
isFolder: false,
})
})
expect(event.preventDefault).toHaveBeenCalledTimes(1)
expect(event.stopPropagation).toHaveBeenCalledTimes(1)
expect(event.dataTransfer.dropEffect).toBe('copy')
expect(store.getState().currentDragType).toBe('upload')
expect(store.getState().dragOverFolderId).toBe(ROOT_ID)
})
it('should ignore drag-over when dragged payload does not contain files', () => {
const store = createWorkflowStore({})
const { result } = renderHook(() => useFileDrop(), { wrapper: createWrapper(store) })
const event = createDragEvent({ types: ['text/plain'] })
act(() => {
result.current.handleDragOver(event as unknown as React.DragEvent, {
folderId: 'folder-1',
isFolder: true,
})
})
expect(store.getState().currentDragType).toBeNull()
expect(store.getState().dragOverFolderId).toBeNull()
expect(event.dataTransfer.dropEffect).toBe('none')
})
})
// Scenario: directory drops are rejected and do not trigger upload mutations.
describe('Folder Drop Rejection', () => {
it('should reject dropped folders and show an error toast', async () => {
const store = createWorkflowStore({})
store.getState().setCurrentDragType('upload')
store.getState().setDragOverFolderId('folder-1')
const { result } = renderHook(() => useFileDrop(), { wrapper: createWrapper(store) })
const event = createDragEvent({
items: [createDataTransferItem({ isDirectory: true })],
})
await act(async () => {
await result.current.handleDrop(event as unknown as React.DragEvent, 'folder-1')
})
expect(mockPrepareSkillUploadFile).not.toHaveBeenCalled()
expect(mockUploadMutateAsync).not.toHaveBeenCalled()
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'error',
message: 'workflow.skillSidebar.menu.folderDropNotSupported',
})
expect(store.getState().currentDragType).toBeNull()
expect(store.getState().dragOverFolderId).toBeNull()
})
it('should upload valid files while rejecting directories in a mixed drop payload', async () => {
const store = createWorkflowStore({})
const { result } = renderHook(() => useFileDrop(), { wrapper: createWrapper(store) })
const file = new File(['gamma'], 'gamma.md', { type: 'text/markdown' })
const event = createDragEvent({
items: [
createDataTransferItem({ isDirectory: true }),
createDataTransferItem({ file }),
],
})
await act(async () => {
await result.current.handleDrop(event as unknown as React.DragEvent, 'folder-mixed')
})
expect(mockPrepareSkillUploadFile).toHaveBeenCalledTimes(1)
expect(mockPrepareSkillUploadFile).toHaveBeenCalledWith(file)
expect(mockUploadMutateAsync).toHaveBeenCalledTimes(1)
expect(mockUploadMutateAsync).toHaveBeenCalledWith({
appId: 'app-1',
file,
parentId: 'folder-mixed',
})
expect(mockEmitTreeUpdate).toHaveBeenCalledTimes(1)
expect(mockToastNotify).toHaveBeenNthCalledWith(1, {
type: 'error',
message: 'workflow.skillSidebar.menu.folderDropNotSupported',
})
expect(mockToastNotify).toHaveBeenNthCalledWith(2, {
type: 'success',
message: 'workflow.skillSidebar.menu.filesUploaded:{"count":1}',
})
})
})
// Scenario: successful drops upload prepared files and emit collaboration updates.
describe('Upload Success', () => {
it('should upload dropped files and show success toast when upload succeeds', async () => {
const store = createWorkflowStore({})
const { result } = renderHook(() => useFileDrop(), { wrapper: createWrapper(store) })
const firstFile = new File(['alpha'], 'alpha.md', { type: 'text/markdown' })
const secondFile = new File(['beta'], 'beta.txt', { type: 'text/plain' })
const event = createDragEvent({
items: [
createDataTransferItem({ file: firstFile }),
createDataTransferItem({ file: secondFile }),
],
})
await act(async () => {
await result.current.handleDrop(event as unknown as React.DragEvent, 'folder-9')
})
expect(mockPrepareSkillUploadFile).toHaveBeenCalledTimes(2)
expect(mockPrepareSkillUploadFile).toHaveBeenNthCalledWith(1, firstFile)
expect(mockPrepareSkillUploadFile).toHaveBeenNthCalledWith(2, secondFile)
expect(mockUploadMutateAsync).toHaveBeenCalledTimes(2)
expect(mockUploadMutateAsync).toHaveBeenNthCalledWith(1, {
appId: 'app-1',
file: firstFile,
parentId: 'folder-9',
})
expect(mockUploadMutateAsync).toHaveBeenNthCalledWith(2, {
appId: 'app-1',
file: secondFile,
parentId: 'folder-9',
})
expect(mockEmitTreeUpdate).toHaveBeenCalledTimes(1)
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'success',
message: 'workflow.skillSidebar.menu.filesUploaded:{"count":2}',
})
})
})
// Scenario: failed uploads surface an error toast and skip collaboration updates.
describe('Upload Error', () => {
it('should show error toast when upload fails', async () => {
const store = createWorkflowStore({})
const { result } = renderHook(() => useFileDrop(), { wrapper: createWrapper(store) })
const file = new File(['content'], 'failed.md', { type: 'text/markdown' })
const event = createDragEvent({
items: [createDataTransferItem({ file })],
})
mockUploadMutateAsync.mockRejectedValueOnce(new Error('upload failed'))
await act(async () => {
await result.current.handleDrop(event as unknown as React.DragEvent, 'folder-err')
})
expect(mockUploadMutateAsync).toHaveBeenCalledTimes(1)
expect(mockEmitTreeUpdate).not.toHaveBeenCalled()
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'error',
message: 'workflow.skillSidebar.menu.uploadError',
})
})
})
})

View File

@ -0,0 +1,242 @@
import type { ReactNode } from 'react'
import type { NodeApi } from 'react-arborist'
import type { TreeNodeData } from '../../../type'
import type { AppAssetTreeView } from '@/types/app-asset'
import { act, renderHook } from '@testing-library/react'
import { WorkflowContext } from '@/app/components/workflow/context'
import { createWorkflowStore } from '@/app/components/workflow/store'
import { INTERNAL_NODE_DRAG_TYPE } from '../../../constants'
import { useFolderFileDrop } from './use-folder-file-drop'
const {
mockHandleDragOver,
mockHandleDrop,
} = vi.hoisted(() => ({
mockHandleDragOver: vi.fn(),
mockHandleDrop: vi.fn(),
}))
vi.mock('./use-unified-drag', () => ({
useUnifiedDrag: () => ({
handleDragOver: mockHandleDragOver,
handleDrop: mockHandleDrop,
}),
}))
const createWrapper = (store: ReturnType<typeof createWorkflowStore>) => {
return ({ children }: { children: ReactNode }) => (
<WorkflowContext.Provider value={store}>
{children}
</WorkflowContext.Provider>
)
}
const createNode = (params: {
id?: string
nodeType: 'file' | 'folder'
isOpen?: boolean
}): NodeApi<TreeNodeData> => {
const node = {
data: {
id: params.id ?? 'node-1',
node_type: params.nodeType,
name: params.nodeType === 'folder' ? 'folder-a' : 'README.md',
path: '/node-1',
extension: params.nodeType === 'folder' ? '' : 'md',
size: 1,
children: [],
},
isOpen: params.isOpen ?? false,
open: vi.fn(),
}
return node as unknown as NodeApi<TreeNodeData>
}
const createDragEvent = (types: string[]): React.DragEvent => {
return {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
dataTransfer: {
types,
items: [],
dropEffect: 'none',
} as unknown as DataTransfer,
} as unknown as React.DragEvent
}
const EMPTY_TREE_CHILDREN: AppAssetTreeView[] = []
describe('useFolderFileDrop', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
// Scenario: derive drag-over state from workflow store and folder identity.
describe('isDragOver', () => {
it('should be true when node is folder and dragOverFolderId matches node id', () => {
const store = createWorkflowStore({})
store.getState().setDragOverFolderId('folder-1')
const node = createNode({ id: 'folder-1', nodeType: 'folder' })
const { result } = renderHook(() => useFolderFileDrop({
node,
treeChildren: EMPTY_TREE_CHILDREN,
}), {
wrapper: createWrapper(store),
})
expect(result.current.isDragOver).toBe(true)
})
it('should be false when node is not a folder even if dragOverFolderId matches', () => {
const store = createWorkflowStore({})
store.getState().setDragOverFolderId('file-1')
const node = createNode({ id: 'file-1', nodeType: 'file' })
const { result } = renderHook(() => useFolderFileDrop({
node,
treeChildren: EMPTY_TREE_CHILDREN,
}), {
wrapper: createWrapper(store),
})
expect(result.current.isDragOver).toBe(false)
})
})
// Scenario: drag handlers delegate only for supported drag events on folder nodes.
describe('drag handlers', () => {
it('should delegate drag over and drop for supported file drag events', () => {
const store = createWorkflowStore({})
const node = createNode({ id: 'folder-2', nodeType: 'folder' })
const { result } = renderHook(() => useFolderFileDrop({
node,
treeChildren: EMPTY_TREE_CHILDREN,
}), {
wrapper: createWrapper(store),
})
const dragOverEvent = createDragEvent(['Files'])
const dropEvent = createDragEvent(['Files'])
act(() => {
result.current.dragHandlers.onDragOver(dragOverEvent)
result.current.dragHandlers.onDrop(dropEvent)
})
expect(mockHandleDragOver).toHaveBeenCalledWith(dragOverEvent, {
folderId: 'folder-2',
isFolder: true,
})
expect(mockHandleDrop).toHaveBeenCalledWith(dropEvent, 'folder-2')
})
it('should ignore unsupported drag events', () => {
const store = createWorkflowStore({})
const node = createNode({ id: 'folder-3', nodeType: 'folder' })
const { result } = renderHook(() => useFolderFileDrop({
node,
treeChildren: EMPTY_TREE_CHILDREN,
}), {
wrapper: createWrapper(store),
})
const unsupportedEvent = createDragEvent(['text/plain'])
act(() => {
result.current.dragHandlers.onDragEnter(unsupportedEvent)
result.current.dragHandlers.onDragOver(unsupportedEvent)
result.current.dragHandlers.onDragLeave(unsupportedEvent)
})
expect(mockHandleDragOver).not.toHaveBeenCalled()
expect(mockHandleDrop).not.toHaveBeenCalled()
})
it('should support internal node drag type in drag over handler', () => {
const store = createWorkflowStore({})
const node = createNode({ id: 'folder-4', nodeType: 'folder' })
const { result } = renderHook(() => useFolderFileDrop({
node,
treeChildren: EMPTY_TREE_CHILDREN,
}), {
wrapper: createWrapper(store),
})
const internalDragEvent = createDragEvent([INTERNAL_NODE_DRAG_TYPE])
act(() => {
result.current.dragHandlers.onDragOver(internalDragEvent)
})
expect(mockHandleDragOver).toHaveBeenCalledWith(internalDragEvent, {
folderId: 'folder-4',
isFolder: true,
})
})
})
// Scenario: auto-expand lifecycle should blink first, expand later, and cleanup when drag state changes.
describe('auto expand and blink', () => {
it('should blink after delay and auto-expand folder after longer delay', () => {
const store = createWorkflowStore({})
store.getState().setDragOverFolderId('folder-5')
const node = createNode({ id: 'folder-5', nodeType: 'folder', isOpen: false })
const { result } = renderHook(() => useFolderFileDrop({
node,
treeChildren: EMPTY_TREE_CHILDREN,
}), {
wrapper: createWrapper(store),
})
expect(result.current.isBlinking).toBe(false)
act(() => {
vi.advanceTimersByTime(1000)
})
expect(result.current.isBlinking).toBe(true)
act(() => {
vi.advanceTimersByTime(1000)
})
expect(result.current.isBlinking).toBe(false)
expect(node.open).toHaveBeenCalledTimes(1)
})
it('should cancel auto-expand when drag over state is cleared before expand delay', () => {
const store = createWorkflowStore({})
store.getState().setDragOverFolderId('folder-6')
const node = createNode({ id: 'folder-6', nodeType: 'folder', isOpen: false })
const { result } = renderHook(() => useFolderFileDrop({
node,
treeChildren: EMPTY_TREE_CHILDREN,
}), {
wrapper: createWrapper(store),
})
act(() => {
vi.advanceTimersByTime(1000)
})
expect(result.current.isBlinking).toBe(true)
act(() => {
store.getState().setDragOverFolderId(null)
})
expect(result.current.isBlinking).toBe(false)
act(() => {
vi.advanceTimersByTime(2000)
})
expect(node.open).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,237 @@
import type { ReactNode } from 'react'
import type { App, AppSSO } from '@/types/app'
import type { AppAssetTreeView } from '@/types/app-asset'
import { act, renderHook } from '@testing-library/react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { WorkflowContext } from '@/app/components/workflow/context'
import { createWorkflowStore } from '@/app/components/workflow/store'
import { INTERNAL_NODE_DRAG_TYPE, ROOT_ID } from '../../../constants'
import { useRootFileDrop } from './use-root-file-drop'
const { mockUploadMutateAsync, uploadHookState } = vi.hoisted(() => ({
mockUploadMutateAsync: vi.fn(),
uploadHookState: { isPending: false },
}))
vi.mock('@/service/use-app-asset', () => ({
useUploadFileWithPresignedUrl: () => ({
mutateAsync: mockUploadMutateAsync,
isPending: uploadHookState.isPending,
}),
}))
type DragEventOptions = {
types: string[]
items?: DataTransferItem[]
}
const createDragEvent = ({ types, items = [] }: DragEventOptions): React.DragEvent => {
return {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
dataTransfer: {
types,
items,
dropEffect: 'none',
} as unknown as DataTransfer,
} as unknown as React.DragEvent
}
const createWrapper = (store: ReturnType<typeof createWorkflowStore>) => {
return ({ children }: { children: ReactNode }) => (
<WorkflowContext.Provider value={store}>
{children}
</WorkflowContext.Provider>
)
}
const EMPTY_TREE_CHILDREN: AppAssetTreeView[] = []
describe('useRootFileDrop', () => {
beforeEach(() => {
vi.clearAllMocks()
uploadHookState.isPending = false
useAppStore.setState({
appDetail: { id: 'app-1' } as App & Partial<AppSSO>,
})
})
describe('handleRootDragOver', () => {
it('should set root upload drag state when files are dragged over root', () => {
const store = createWorkflowStore({})
const { result } = renderHook(() => useRootFileDrop({ treeChildren: EMPTY_TREE_CHILDREN }), {
wrapper: createWrapper(store),
})
const dragEvent = createDragEvent({ types: ['Files'] })
act(() => {
result.current.handleRootDragOver(dragEvent)
})
expect(store.getState().currentDragType).toBe('upload')
expect(store.getState().dragOverFolderId).toBe(ROOT_ID)
})
it('should skip dragOver handling when drag source is not files', () => {
const store = createWorkflowStore({})
const { result } = renderHook(() => useRootFileDrop({ treeChildren: EMPTY_TREE_CHILDREN }), {
wrapper: createWrapper(store),
})
const dragEvent = createDragEvent({ types: [INTERNAL_NODE_DRAG_TYPE] })
act(() => {
result.current.handleRootDragOver(dragEvent)
})
expect(store.getState().currentDragType).toBeNull()
expect(store.getState().dragOverFolderId).toBeNull()
})
})
describe('drag counter behavior', () => {
it('should keep drag state until nested drag leaves reach zero', () => {
const store = createWorkflowStore({})
const { result } = renderHook(() => useRootFileDrop({ treeChildren: EMPTY_TREE_CHILDREN }), {
wrapper: createWrapper(store),
})
const fileDragEvent = createDragEvent({ types: ['Files'] })
act(() => {
result.current.handleRootDragOver(fileDragEvent)
})
expect(store.getState().currentDragType).toBe('upload')
expect(store.getState().dragOverFolderId).toBe(ROOT_ID)
act(() => {
result.current.handleRootDragEnter(fileDragEvent)
result.current.handleRootDragEnter(fileDragEvent)
})
act(() => {
result.current.handleRootDragLeave(fileDragEvent)
})
expect(store.getState().currentDragType).toBe('upload')
expect(store.getState().dragOverFolderId).toBe(ROOT_ID)
act(() => {
result.current.handleRootDragLeave(fileDragEvent)
})
expect(store.getState().currentDragType).toBeNull()
expect(store.getState().dragOverFolderId).toBeNull()
})
it('should not increment counter when dragEnter is not a supported drag event', () => {
const store = createWorkflowStore({})
const { result } = renderHook(() => useRootFileDrop({ treeChildren: EMPTY_TREE_CHILDREN }), {
wrapper: createWrapper(store),
})
const fileDragEvent = createDragEvent({ types: ['Files'] })
const unsupportedDragEvent = createDragEvent({ types: ['text/plain'] })
act(() => {
result.current.handleRootDragOver(fileDragEvent)
})
act(() => {
result.current.handleRootDragEnter(unsupportedDragEvent)
})
act(() => {
result.current.handleRootDragLeave(fileDragEvent)
})
expect(store.getState().currentDragType).toBeNull()
expect(store.getState().dragOverFolderId).toBeNull()
})
it('should not decrement counter when dragLeave is not a supported drag event', () => {
const store = createWorkflowStore({})
const { result } = renderHook(() => useRootFileDrop({ treeChildren: EMPTY_TREE_CHILDREN }), {
wrapper: createWrapper(store),
})
const fileDragEvent = createDragEvent({ types: ['Files'] })
const unsupportedDragEvent = createDragEvent({ types: ['text/plain'] })
act(() => {
result.current.handleRootDragOver(fileDragEvent)
result.current.handleRootDragEnter(fileDragEvent)
result.current.handleRootDragEnter(fileDragEvent)
})
act(() => {
result.current.handleRootDragLeave(unsupportedDragEvent)
})
act(() => {
result.current.handleRootDragLeave(fileDragEvent)
})
expect(store.getState().currentDragType).toBe('upload')
expect(store.getState().dragOverFolderId).toBe(ROOT_ID)
act(() => {
result.current.handleRootDragLeave(fileDragEvent)
})
expect(store.getState().currentDragType).toBeNull()
expect(store.getState().dragOverFolderId).toBeNull()
})
})
describe('counter reset', () => {
it('should clear counter when resetRootDragCounter is called', () => {
const store = createWorkflowStore({})
const { result } = renderHook(() => useRootFileDrop({ treeChildren: EMPTY_TREE_CHILDREN }), {
wrapper: createWrapper(store),
})
const fileDragEvent = createDragEvent({ types: ['Files'] })
act(() => {
result.current.handleRootDragOver(fileDragEvent)
result.current.handleRootDragEnter(fileDragEvent)
result.current.handleRootDragEnter(fileDragEvent)
})
act(() => {
result.current.resetRootDragCounter()
})
act(() => {
result.current.handleRootDragLeave(fileDragEvent)
})
expect(store.getState().currentDragType).toBeNull()
expect(store.getState().dragOverFolderId).toBeNull()
})
it('should reset counter after drop and clear drag state', () => {
const store = createWorkflowStore({})
const { result } = renderHook(() => useRootFileDrop({ treeChildren: EMPTY_TREE_CHILDREN }), {
wrapper: createWrapper(store),
})
const beforeDropEvent = createDragEvent({ types: ['Files'], items: [] })
const afterDropEvent = createDragEvent({ types: ['Files'], items: [] })
act(() => {
result.current.handleRootDragOver(beforeDropEvent)
result.current.handleRootDragEnter(beforeDropEvent)
result.current.handleRootDragEnter(beforeDropEvent)
result.current.handleRootDrop(beforeDropEvent)
})
expect(store.getState().currentDragType).toBeNull()
expect(store.getState().dragOverFolderId).toBeNull()
expect(beforeDropEvent.preventDefault).toHaveBeenCalled()
expect(beforeDropEvent.stopPropagation).toHaveBeenCalled()
act(() => {
result.current.handleRootDragOver(afterDropEvent)
})
expect(store.getState().currentDragType).toBe('upload')
expect(store.getState().dragOverFolderId).toBe(ROOT_ID)
act(() => {
result.current.handleRootDragLeave(afterDropEvent)
})
expect(store.getState().currentDragType).toBeNull()
expect(store.getState().dragOverFolderId).toBeNull()
})
})
})

View File

@ -0,0 +1,187 @@
import type { ReactNode } from 'react'
import type { App, AppSSO } from '@/types/app'
import { act, renderHook } from '@testing-library/react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { WorkflowContext } from '@/app/components/workflow/context'
import { createWorkflowStore } from '@/app/components/workflow/store'
import { INTERNAL_NODE_DRAG_TYPE } from '../../../constants'
import { useUnifiedDrag } from './use-unified-drag'
const { mockUploadMutateAsync, uploadHookState } = vi.hoisted(() => ({
mockUploadMutateAsync: vi.fn(),
uploadHookState: { isPending: false },
}))
vi.mock('@/service/use-app-asset', () => ({
useUploadFileWithPresignedUrl: () => ({
mutateAsync: mockUploadMutateAsync,
isPending: uploadHookState.isPending,
}),
}))
type DragEventOptions = {
types: string[]
items?: DataTransferItem[]
}
const createDragEvent = ({ types, items = [] }: DragEventOptions): React.DragEvent => {
return {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
dataTransfer: {
types,
items,
dropEffect: 'none',
} as unknown as DataTransfer,
} as unknown as React.DragEvent
}
const createWrapper = (store: ReturnType<typeof createWorkflowStore>) => {
return ({ children }: { children: ReactNode }) => (
<WorkflowContext.Provider value={store}>
{children}
</WorkflowContext.Provider>
)
}
describe('useUnifiedDrag', () => {
beforeEach(() => {
vi.clearAllMocks()
uploadHookState.isPending = false
useAppStore.setState({
appDetail: { id: 'app-1' } as App & Partial<AppSSO>,
})
})
describe('handleDragOver', () => {
it('should update drag state when drag source contains files', () => {
const store = createWorkflowStore({})
const { result } = renderHook(() => useUnifiedDrag(), {
wrapper: createWrapper(store),
})
const dragEvent = createDragEvent({ types: ['Files'] })
act(() => {
result.current.handleDragOver(dragEvent, { folderId: 'folder-1', isFolder: true })
})
expect(store.getState().currentDragType).toBe('upload')
expect(store.getState().dragOverFolderId).toBe('folder-1')
expect(dragEvent.dataTransfer.dropEffect).toBe('copy')
})
it('should ignore dragOver when drag source does not contain files', () => {
const store = createWorkflowStore({})
const { result } = renderHook(() => useUnifiedDrag(), {
wrapper: createWrapper(store),
})
const dragEvent = createDragEvent({ types: [INTERNAL_NODE_DRAG_TYPE] })
act(() => {
result.current.handleDragOver(dragEvent, { folderId: 'folder-1', isFolder: true })
})
expect(store.getState().currentDragType).toBeNull()
expect(store.getState().dragOverFolderId).toBeNull()
expect(dragEvent.dataTransfer.dropEffect).toBe('none')
})
})
describe('handleDragLeave', () => {
it('should clear drag state when drag source contains files', () => {
const store = createWorkflowStore({})
const { result } = renderHook(() => useUnifiedDrag(), {
wrapper: createWrapper(store),
})
const dragEvent = createDragEvent({ types: ['Files'] })
act(() => {
result.current.handleDragOver(dragEvent, { folderId: 'folder-1', isFolder: true })
})
expect(store.getState().currentDragType).toBe('upload')
expect(store.getState().dragOverFolderId).toBe('folder-1')
act(() => {
result.current.handleDragLeave(dragEvent)
})
expect(store.getState().currentDragType).toBeNull()
expect(store.getState().dragOverFolderId).toBeNull()
})
it('should ignore dragLeave when drag source does not contain files', () => {
const store = createWorkflowStore({})
store.getState().setCurrentDragType('upload')
store.getState().setDragOverFolderId('folder-1')
const { result } = renderHook(() => useUnifiedDrag(), {
wrapper: createWrapper(store),
})
const dragEvent = createDragEvent({ types: [INTERNAL_NODE_DRAG_TYPE] })
act(() => {
result.current.handleDragLeave(dragEvent)
})
expect(store.getState().currentDragType).toBe('upload')
expect(store.getState().dragOverFolderId).toBe('folder-1')
})
})
describe('handleDrop', () => {
it('should delegate drop handling when drag source contains files', async () => {
const store = createWorkflowStore({})
store.getState().setCurrentDragType('upload')
store.getState().setDragOverFolderId('folder-1')
const { result } = renderHook(() => useUnifiedDrag(), {
wrapper: createWrapper(store),
})
const dragEvent = createDragEvent({ types: ['Files'], items: [] })
await act(async () => {
await result.current.handleDrop(dragEvent, null)
})
expect(store.getState().currentDragType).toBeNull()
expect(store.getState().dragOverFolderId).toBeNull()
expect(dragEvent.preventDefault).toHaveBeenCalledTimes(1)
expect(dragEvent.stopPropagation).toHaveBeenCalledTimes(1)
})
it('should return undefined and skip drop handling when drag source does not contain files', async () => {
const store = createWorkflowStore({})
store.getState().setCurrentDragType('upload')
store.getState().setDragOverFolderId('folder-1')
const { result } = renderHook(() => useUnifiedDrag(), {
wrapper: createWrapper(store),
})
const dragEvent = createDragEvent({ types: [INTERNAL_NODE_DRAG_TYPE], items: [] })
let dropResult: Promise<void> | undefined
await act(async () => {
dropResult = result.current.handleDrop(dragEvent, null)
})
expect(dropResult).toBeUndefined()
expect(store.getState().currentDragType).toBe('upload')
expect(store.getState().dragOverFolderId).toBe('folder-1')
expect(dragEvent.preventDefault).not.toHaveBeenCalled()
expect(dragEvent.stopPropagation).not.toHaveBeenCalled()
})
})
describe('isUploading', () => {
it('should expose uploading state from file drop hook', () => {
uploadHookState.isPending = true
const store = createWorkflowStore({})
const { result } = renderHook(() => useUnifiedDrag(), {
wrapper: createWrapper(store),
})
expect(result.current.isUploading).toBe(true)
})
})
})

View File

@ -0,0 +1,145 @@
import { act, renderHook } from '@testing-library/react'
import { useDelayedClick } from './use-delayed-click'
describe('useDelayedClick', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('Single Click', () => {
it('should call onSingleClick after the delay when clicked once', () => {
const onSingleClick = vi.fn()
const onDoubleClick = vi.fn()
const { result } = renderHook(() => useDelayedClick({
delay: 200,
onSingleClick,
onDoubleClick,
}))
act(() => {
result.current.handleClick()
})
act(() => {
vi.advanceTimersByTime(199)
})
expect(onSingleClick).not.toHaveBeenCalled()
expect(onDoubleClick).not.toHaveBeenCalled()
act(() => {
vi.advanceTimersByTime(1)
})
expect(onSingleClick).toHaveBeenCalledTimes(1)
expect(onDoubleClick).not.toHaveBeenCalled()
})
it('should schedule only one single click when clicked twice before delay ends', () => {
const onSingleClick = vi.fn()
const onDoubleClick = vi.fn()
const { result } = renderHook(() => useDelayedClick({
delay: 200,
onSingleClick,
onDoubleClick,
}))
act(() => {
result.current.handleClick()
})
act(() => {
vi.advanceTimersByTime(100)
})
act(() => {
result.current.handleClick()
})
act(() => {
vi.advanceTimersByTime(199)
})
expect(onSingleClick).not.toHaveBeenCalled()
act(() => {
vi.advanceTimersByTime(1)
})
expect(onSingleClick).toHaveBeenCalledTimes(1)
expect(onDoubleClick).not.toHaveBeenCalled()
})
})
describe('Double Click', () => {
it('should cancel pending single click and call onDoubleClick when double-clicked', () => {
const onSingleClick = vi.fn()
const onDoubleClick = vi.fn()
const { result } = renderHook(() => useDelayedClick({
delay: 200,
onSingleClick,
onDoubleClick,
}))
act(() => {
result.current.handleClick()
})
act(() => {
vi.advanceTimersByTime(50)
})
act(() => {
result.current.handleDoubleClick()
})
expect(onDoubleClick).toHaveBeenCalledTimes(1)
expect(onSingleClick).not.toHaveBeenCalled()
act(() => {
vi.advanceTimersByTime(300)
})
expect(onSingleClick).not.toHaveBeenCalled()
})
it('should call onDoubleClick when no single-click timeout is pending', () => {
const onSingleClick = vi.fn()
const onDoubleClick = vi.fn()
const { result } = renderHook(() => useDelayedClick({
onSingleClick,
onDoubleClick,
}))
act(() => {
result.current.handleDoubleClick()
})
expect(onDoubleClick).toHaveBeenCalledTimes(1)
expect(onSingleClick).not.toHaveBeenCalled()
})
})
describe('Cleanup', () => {
it('should clear pending timeout on unmount', () => {
const onSingleClick = vi.fn()
const onDoubleClick = vi.fn()
const { result, unmount } = renderHook(() => useDelayedClick({
delay: 200,
onSingleClick,
onDoubleClick,
}))
act(() => {
result.current.handleClick()
})
unmount()
act(() => {
vi.advanceTimersByTime(300)
})
expect(onSingleClick).not.toHaveBeenCalled()
expect(onDoubleClick).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,190 @@
import type { RefObject } from 'react'
import type { TreeApi } from 'react-arborist'
import type { TreeNodeData } from '../../../type'
import { act, renderHook } from '@testing-library/react'
import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils/common'
import { useSkillShortcuts } from './use-skill-shortcuts'
const {
mockUseKeyPress,
mockCutNodes,
mockHasClipboard,
registeredShortcutHandlers,
} = vi.hoisted(() => ({
mockUseKeyPress: vi.fn(),
mockCutNodes: vi.fn(),
mockHasClipboard: vi.fn(() => false),
registeredShortcutHandlers: {} as Record<string, (event: KeyboardEvent) => void>,
}))
vi.mock('ahooks', () => ({
useKeyPress: (hotkey: string, callback: (event: KeyboardEvent) => void) => {
mockUseKeyPress(hotkey, callback)
registeredShortcutHandlers[hotkey] = callback
},
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
cutNodes: mockCutNodes,
hasClipboard: mockHasClipboard,
}),
}),
}))
const createTreeRef = (selectedIds: string[]): RefObject<TreeApi<TreeNodeData> | null> => {
return {
current: {
selectedNodes: selectedIds.map(id => ({ id })),
} as unknown as TreeApi<TreeNodeData>,
}
}
const createShortcutEvent = (target: HTMLElement): KeyboardEvent => {
return {
target,
preventDefault: vi.fn(),
} as unknown as KeyboardEvent
}
describe('useSkillShortcuts', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.keys(registeredShortcutHandlers).forEach((shortcut) => {
delete registeredShortcutHandlers[shortcut]
})
mockHasClipboard.mockReturnValue(false)
})
// Scenario: register platform-aware cut and paste shortcuts on mount.
describe('shortcut registration', () => {
it('should register cut and paste key combinations', () => {
const treeRef = createTreeRef([])
renderHook(() => useSkillShortcuts({ treeRef }))
const ctrlKey = getKeyboardKeyCodeBySystem('ctrl')
expect(mockUseKeyPress).toHaveBeenCalledTimes(2)
expect(registeredShortcutHandlers[`${ctrlKey}.x`]).toBeTypeOf('function')
expect(registeredShortcutHandlers[`${ctrlKey}.v`]).toBeTypeOf('function')
})
})
// Scenario: cut shortcut depends on target context, selection state, and enabled state.
describe('cut shortcut', () => {
it('should cut selected nodes when keyboard event originates in tree container', () => {
const treeRef = createTreeRef(['file-1', 'file-2'])
renderHook(() => useSkillShortcuts({ treeRef }))
const container = document.createElement('div')
container.setAttribute('data-skill-tree-container', '')
const target = document.createElement('button')
container.appendChild(target)
const event = createShortcutEvent(target)
const cutShortcut = `${getKeyboardKeyCodeBySystem('ctrl')}.x`
act(() => {
registeredShortcutHandlers[cutShortcut](event)
})
expect(event.preventDefault).toHaveBeenCalledTimes(1)
expect(mockCutNodes).toHaveBeenCalledWith(['file-1', 'file-2'])
})
it('should cut selected nodes even when event target is outside tree container', () => {
const treeRef = createTreeRef(['file-3'])
renderHook(() => useSkillShortcuts({ treeRef }))
const outsideTarget = document.createElement('button')
const event = createShortcutEvent(outsideTarget)
const cutShortcut = `${getKeyboardKeyCodeBySystem('ctrl')}.x`
act(() => {
registeredShortcutHandlers[cutShortcut](event)
})
expect(event.preventDefault).toHaveBeenCalledTimes(1)
expect(mockCutNodes).toHaveBeenCalledWith(['file-3'])
})
it('should ignore cut shortcut when target is an input area', () => {
const treeRef = createTreeRef(['file-1'])
renderHook(() => useSkillShortcuts({ treeRef }))
const input = document.createElement('input')
const event = createShortcutEvent(input)
const cutShortcut = `${getKeyboardKeyCodeBySystem('ctrl')}.x`
act(() => {
registeredShortcutHandlers[cutShortcut](event)
})
expect(event.preventDefault).not.toHaveBeenCalled()
expect(mockCutNodes).not.toHaveBeenCalled()
})
it('should ignore cut shortcut when shortcuts are disabled', () => {
const treeRef = createTreeRef(['file-1'])
const { rerender } = renderHook(
({ enabled }) => useSkillShortcuts({ treeRef, enabled }),
{ initialProps: { enabled: true } },
)
rerender({ enabled: false })
const container = document.createElement('div')
container.setAttribute('data-skill-tree-container', '')
const target = document.createElement('button')
container.appendChild(target)
const event = createShortcutEvent(target)
const cutShortcut = `${getKeyboardKeyCodeBySystem('ctrl')}.x`
act(() => {
registeredShortcutHandlers[cutShortcut](event)
})
expect(event.preventDefault).not.toHaveBeenCalled()
expect(mockCutNodes).not.toHaveBeenCalled()
})
})
// Scenario: paste shortcut dispatches global paste event only when clipboard has content.
describe('paste shortcut', () => {
it('should dispatch paste event when clipboard has content and shortcut should be handled', () => {
mockHasClipboard.mockReturnValue(true)
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent')
const treeRef = createTreeRef(['file-1'])
renderHook(() => useSkillShortcuts({ treeRef }))
const target = document.createElement('button')
const event = createShortcutEvent(target)
const pasteShortcut = `${getKeyboardKeyCodeBySystem('ctrl')}.v`
act(() => {
registeredShortcutHandlers[pasteShortcut](event)
})
expect(event.preventDefault).toHaveBeenCalledTimes(1)
expect(dispatchEventSpy).toHaveBeenCalledTimes(1)
expect(dispatchEventSpy.mock.calls[0][0].type).toBe('skill:paste')
})
it('should ignore paste shortcut when clipboard is empty', () => {
mockHasClipboard.mockReturnValue(false)
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent')
const treeRef = createTreeRef(['file-1'])
renderHook(() => useSkillShortcuts({ treeRef }))
const target = document.createElement('button')
const event = createShortcutEvent(target)
const pasteShortcut = `${getKeyboardKeyCodeBySystem('ctrl')}.v`
act(() => {
registeredShortcutHandlers[pasteShortcut](event)
})
expect(event.preventDefault).not.toHaveBeenCalled()
expect(dispatchEventSpy).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,237 @@
import type { NodeApi } from 'react-arborist'
import type { TreeNodeData } from '../../../type'
import { act, renderHook } from '@testing-library/react'
import { useTreeNodeHandlers } from './use-tree-node-handlers'
const {
mockClearArtifactSelection,
mockOpenTab,
mockSetContextMenu,
} = vi.hoisted(() => ({
mockClearArtifactSelection: vi.fn(),
mockOpenTab: vi.fn(),
mockSetContextMenu: vi.fn(),
}))
vi.mock('es-toolkit/function', () => ({
throttle: (fn: () => void) => fn,
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
clearArtifactSelection: mockClearArtifactSelection,
openTab: mockOpenTab,
setContextMenu: mockSetContextMenu,
}),
}),
}))
const createNode = (params: {
id?: string
nodeType: 'file' | 'folder'
}) => {
const id = params.id ?? 'node-1'
return {
data: {
id,
node_type: params.nodeType,
name: params.nodeType === 'folder' ? 'folder-a' : 'README.md',
path: `/${id}`,
extension: params.nodeType === 'folder' ? '' : 'md',
size: 1,
children: [],
},
toggle: vi.fn(),
select: vi.fn(),
selectMulti: vi.fn(),
selectContiguous: vi.fn(),
isOpen: false,
} as unknown as NodeApi<TreeNodeData>
}
const createMouseEvent = (params: {
shiftKey?: boolean
ctrlKey?: boolean
metaKey?: boolean
clientX?: number
clientY?: number
} = {}) => {
return {
stopPropagation: vi.fn(),
preventDefault: vi.fn(),
shiftKey: params.shiftKey ?? false,
ctrlKey: params.ctrlKey ?? false,
metaKey: params.metaKey ?? false,
clientX: params.clientX ?? 0,
clientY: params.clientY ?? 0,
} as unknown as React.MouseEvent
}
const createKeyboardEvent = (key: string) => {
return {
key,
preventDefault: vi.fn(),
} as unknown as React.KeyboardEvent
}
describe('useTreeNodeHandlers', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
// Scenario: click behavior differs for folders/files and modifier keys.
describe('handleClick', () => {
it('should select contiguous node and toggle folder on shift-click', () => {
const node = createNode({ nodeType: 'folder' })
const { result } = renderHook(() => useTreeNodeHandlers({ node }))
const event = createMouseEvent({ shiftKey: true })
act(() => {
result.current.handleClick(event)
})
expect(event.stopPropagation).toHaveBeenCalledTimes(1)
expect(node.selectContiguous).toHaveBeenCalledTimes(1)
expect(node.toggle).toHaveBeenCalledTimes(1)
expect(node.select).not.toHaveBeenCalled()
expect(node.selectMulti).not.toHaveBeenCalled()
})
it('should open file preview tab on plain click after delayed click timeout', () => {
const node = createNode({ id: 'file-1', nodeType: 'file' })
const { result } = renderHook(() => useTreeNodeHandlers({ node }))
const event = createMouseEvent()
act(() => {
result.current.handleClick(event)
})
expect(node.select).toHaveBeenCalledTimes(1)
expect(mockOpenTab).not.toHaveBeenCalled()
act(() => {
vi.advanceTimersByTime(200)
})
expect(mockClearArtifactSelection).toHaveBeenCalledTimes(1)
expect(mockOpenTab).toHaveBeenCalledWith('file-1', { pinned: false })
})
it('should not trigger file preview tab on ctrl-click', () => {
const node = createNode({ id: 'file-2', nodeType: 'file' })
const { result } = renderHook(() => useTreeNodeHandlers({ node }))
const event = createMouseEvent({ ctrlKey: true })
act(() => {
result.current.handleClick(event)
vi.advanceTimersByTime(250)
})
expect(node.selectMulti).toHaveBeenCalledTimes(1)
expect(mockOpenTab).not.toHaveBeenCalled()
expect(mockClearArtifactSelection).not.toHaveBeenCalled()
})
})
// Scenario: double-click and toggle handlers route to folder toggle or pinned file open.
describe('double click and toggle', () => {
it('should toggle folder on double click', () => {
const node = createNode({ nodeType: 'folder' })
const { result } = renderHook(() => useTreeNodeHandlers({ node }))
const event = createMouseEvent()
act(() => {
result.current.handleDoubleClick(event)
})
expect(event.stopPropagation).toHaveBeenCalledTimes(1)
expect(node.toggle).toHaveBeenCalledTimes(1)
expect(mockOpenTab).not.toHaveBeenCalled()
})
it('should open file as pinned tab on double click', () => {
const node = createNode({ id: 'file-3', nodeType: 'file' })
const { result } = renderHook(() => useTreeNodeHandlers({ node }))
const event = createMouseEvent()
act(() => {
result.current.handleDoubleClick(event)
})
expect(event.stopPropagation).toHaveBeenCalledTimes(1)
expect(mockClearArtifactSelection).toHaveBeenCalledTimes(1)
expect(mockOpenTab).toHaveBeenCalledWith('file-3', { pinned: true })
})
it('should toggle node when toggle handler is invoked', () => {
const node = createNode({ nodeType: 'folder' })
const { result } = renderHook(() => useTreeNodeHandlers({ node }))
const event = createMouseEvent()
act(() => {
result.current.handleToggle(event)
})
expect(event.stopPropagation).toHaveBeenCalledTimes(1)
expect(node.toggle).toHaveBeenCalledTimes(1)
})
})
// Scenario: context menu and keyboard handlers update menu state and open/toggle actions.
describe('context menu and keyboard', () => {
it('should select node and set context menu payload on right click', () => {
const node = createNode({ id: 'folder-1', nodeType: 'folder' })
const { result } = renderHook(() => useTreeNodeHandlers({ node }))
const event = createMouseEvent({ clientX: 120, clientY: 45 })
act(() => {
result.current.handleContextMenu(event)
})
expect(event.preventDefault).toHaveBeenCalledTimes(1)
expect(event.stopPropagation).toHaveBeenCalledTimes(1)
expect(node.select).toHaveBeenCalledTimes(1)
expect(mockSetContextMenu).toHaveBeenCalledWith({
top: 45,
left: 120,
type: 'node',
nodeId: 'folder-1',
isFolder: true,
})
})
it('should toggle folder on Enter key', () => {
const node = createNode({ nodeType: 'folder' })
const { result } = renderHook(() => useTreeNodeHandlers({ node }))
const event = createKeyboardEvent('Enter')
act(() => {
result.current.handleKeyDown(event)
})
expect(event.preventDefault).toHaveBeenCalledTimes(1)
expect(node.toggle).toHaveBeenCalledTimes(1)
expect(mockOpenTab).not.toHaveBeenCalled()
})
it('should open file as pinned tab on Space key', () => {
const node = createNode({ id: 'file-4', nodeType: 'file' })
const { result } = renderHook(() => useTreeNodeHandlers({ node }))
const event = createKeyboardEvent(' ')
act(() => {
result.current.handleKeyDown(event)
})
expect(event.preventDefault).toHaveBeenCalledTimes(1)
expect(mockClearArtifactSelection).toHaveBeenCalledTimes(1)
expect(mockOpenTab).toHaveBeenCalledWith('file-4', { pinned: true })
})
})
})

View File

@ -0,0 +1,427 @@
import type { StoreApi } from 'zustand'
import type { SkillEditorSliceShape, UploadStatus } from '@/app/components/workflow/store/workflow/skill-editor/types'
import type { BatchUploadNodeInput } from '@/types/app-asset'
import { act, renderHook } from '@testing-library/react'
import { useCreateOperations } from './use-create-operations'
type UploadMutationPayload = {
appId: string
file: File
parentId?: string | null
}
type BatchUploadMutationPayload = {
appId: string
tree: BatchUploadNodeInput[]
files: Map<string, File>
parentId?: string | null
onProgress?: (uploaded: number, total: number) => void
}
type UploadProgress = {
uploaded: number
total: number
failed: number
}
const mocks = vi.hoisted(() => ({
createFolderPending: false,
uploadPending: false,
batchPending: false,
uploadMutateAsync: vi.fn<(payload: UploadMutationPayload) => Promise<void>>(),
batchMutateAsync: vi.fn<(payload: BatchUploadMutationPayload) => Promise<unknown>>(),
prepareSkillUploadFile: vi.fn<(file: File) => Promise<File>>(),
emitTreeUpdate: vi.fn<() => void>(),
}))
vi.mock('@/service/use-app-asset', () => ({
useCreateAppAssetFolder: () => ({
isPending: mocks.createFolderPending,
}),
useUploadFileWithPresignedUrl: () => ({
mutateAsync: mocks.uploadMutateAsync,
isPending: mocks.uploadPending,
}),
useBatchUpload: () => ({
mutateAsync: mocks.batchMutateAsync,
isPending: mocks.batchPending,
}),
}))
vi.mock('../../../utils/skill-upload-utils', () => ({
prepareSkillUploadFile: mocks.prepareSkillUploadFile,
}))
vi.mock('../data/use-skill-tree-collaboration', () => ({
useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
}))
const createStoreApi = () => {
const startCreateNode = vi.fn<(nodeType: 'file' | 'folder', parentId: string | null) => void>()
const setUploadStatus = vi.fn<(status: UploadStatus) => void>()
const setUploadProgress = vi.fn<(progress: UploadProgress) => void>()
const state = {
startCreateNode,
setUploadStatus,
setUploadProgress,
} as Pick<SkillEditorSliceShape, 'startCreateNode' | 'setUploadStatus' | 'setUploadProgress'>
const storeApi = {
getState: () => state,
} as unknown as StoreApi<SkillEditorSliceShape>
return {
storeApi,
startCreateNode,
setUploadStatus,
setUploadProgress,
}
}
const createInputChangeEvent = (files: File[] | null) => {
return {
target: {
files,
value: 'selected',
},
} as unknown as React.ChangeEvent<HTMLInputElement>
}
const withRelativePath = (file: File, relativePath: string): File => {
Object.defineProperty(file, 'webkitRelativePath', {
value: relativePath,
configurable: true,
})
return file
}
describe('useCreateOperations', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.createFolderPending = false
mocks.uploadPending = false
mocks.batchPending = false
mocks.prepareSkillUploadFile.mockImplementation(async file => file)
mocks.uploadMutateAsync.mockResolvedValue(undefined)
mocks.batchMutateAsync.mockResolvedValue([])
})
// Scenario: loading state should combine all create-related pending flags.
describe('State', () => {
it('should expose isCreating false when no mutation is pending', () => {
const { storeApi } = createStoreApi()
const { result } = renderHook(() => useCreateOperations({
parentId: 'folder-1',
appId: 'app-1',
storeApi,
onClose: vi.fn(),
}))
expect(result.current.isCreating).toBe(false)
expect(result.current.fileInputRef.current).toBeNull()
expect(result.current.folderInputRef.current).toBeNull()
})
it('should expose isCreating true when any mutation is pending', () => {
const { storeApi } = createStoreApi()
mocks.createFolderPending = true
const { result } = renderHook(() => useCreateOperations({
parentId: 'folder-1',
appId: 'app-1',
storeApi,
onClose: vi.fn(),
}))
expect(result.current.isCreating).toBe(true)
})
})
// Scenario: new node handlers should initialize create mode and close menu.
describe('New node handlers', () => {
it('should start inline file creation when handleNewFile is called', () => {
const { storeApi, startCreateNode } = createStoreApi()
const onClose = vi.fn()
const { result } = renderHook(() => useCreateOperations({
parentId: 'parent-1',
appId: 'app-1',
storeApi,
onClose,
}))
act(() => {
result.current.handleNewFile()
})
expect(startCreateNode).toHaveBeenCalledWith('file', 'parent-1')
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should start inline folder creation when handleNewFolder is called', () => {
const { storeApi, startCreateNode } = createStoreApi()
const onClose = vi.fn()
const { result } = renderHook(() => useCreateOperations({
parentId: null,
appId: 'app-1',
storeApi,
onClose,
}))
act(() => {
result.current.handleNewFolder()
})
expect(startCreateNode).toHaveBeenCalledWith('folder', null)
expect(onClose).toHaveBeenCalledTimes(1)
})
})
// Scenario: file upload handler should process empty, success, partial-failure, and preparation-failure branches.
describe('handleFileChange', () => {
it('should close menu and no-op when no files are selected', async () => {
const { storeApi, setUploadStatus, setUploadProgress } = createStoreApi()
const onClose = vi.fn()
const event = createInputChangeEvent([])
const { result } = renderHook(() => useCreateOperations({
parentId: 'parent-empty',
appId: 'app-empty',
storeApi,
onClose,
}))
await act(async () => {
await result.current.handleFileChange(event)
})
expect(setUploadStatus).not.toHaveBeenCalled()
expect(setUploadProgress).not.toHaveBeenCalled()
expect(mocks.uploadMutateAsync).not.toHaveBeenCalled()
expect(mocks.emitTreeUpdate).not.toHaveBeenCalled()
expect(onClose).toHaveBeenCalledTimes(1)
expect(event.target.value).toBe('selected')
})
it('should upload all files and set success status when all uploads succeed', async () => {
const { storeApi, setUploadStatus, setUploadProgress } = createStoreApi()
const onClose = vi.fn()
const first = new File(['first'], 'first.md', { type: 'text/markdown' })
const second = new File(['second'], 'second.txt', { type: 'text/plain' })
const event = createInputChangeEvent([first, second])
const { result } = renderHook(() => useCreateOperations({
parentId: 'folder-success',
appId: 'app-success',
storeApi,
onClose,
}))
await act(async () => {
await result.current.handleFileChange(event)
})
expect(mocks.prepareSkillUploadFile).toHaveBeenNthCalledWith(1, first)
expect(mocks.prepareSkillUploadFile).toHaveBeenNthCalledWith(2, second)
expect(mocks.uploadMutateAsync).toHaveBeenCalledTimes(2)
expect(mocks.uploadMutateAsync).toHaveBeenNthCalledWith(1, {
appId: 'app-success',
file: first,
parentId: 'folder-success',
})
expect(mocks.uploadMutateAsync).toHaveBeenNthCalledWith(2, {
appId: 'app-success',
file: second,
parentId: 'folder-success',
})
expect(setUploadStatus).toHaveBeenNthCalledWith(1, 'uploading')
expect(setUploadStatus).toHaveBeenNthCalledWith(2, 'success')
expect(setUploadProgress).toHaveBeenCalledWith({ uploaded: 0, total: 2, failed: 0 })
expect(setUploadProgress).toHaveBeenCalledWith({ uploaded: 1, total: 2, failed: 0 })
expect(setUploadProgress).toHaveBeenCalledWith({ uploaded: 2, total: 2, failed: 0 })
expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1)
expect(event.target.value).toBe('')
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should set partial_error when some file uploads fail but still emit updates for uploaded files', async () => {
const { storeApi, setUploadStatus, setUploadProgress } = createStoreApi()
const onClose = vi.fn()
const okFile = new File(['ok'], 'ok.md', { type: 'text/markdown' })
const failedFile = new File(['nope'], 'nope.md', { type: 'text/markdown' })
const event = createInputChangeEvent([okFile, failedFile])
mocks.uploadMutateAsync
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('upload failed'))
const { result } = renderHook(() => useCreateOperations({
parentId: 'folder-partial',
appId: 'app-partial',
storeApi,
onClose,
}))
await act(async () => {
await result.current.handleFileChange(event)
})
expect(setUploadStatus).toHaveBeenNthCalledWith(1, 'uploading')
expect(setUploadStatus).toHaveBeenNthCalledWith(2, 'partial_error')
expect(setUploadProgress).toHaveBeenCalledWith({ uploaded: 1, total: 2, failed: 1 })
expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1)
expect(event.target.value).toBe('')
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should set partial_error and skip API upload when file preparation fails', async () => {
const { storeApi, setUploadStatus } = createStoreApi()
const onClose = vi.fn()
const file = new File(['broken'], 'broken.md', { type: 'text/markdown' })
const event = createInputChangeEvent([file])
mocks.prepareSkillUploadFile.mockRejectedValueOnce(new Error('prepare failed'))
const { result } = renderHook(() => useCreateOperations({
parentId: null,
appId: 'app-prepare-error',
storeApi,
onClose,
}))
await act(async () => {
await result.current.handleFileChange(event)
})
expect(mocks.uploadMutateAsync).not.toHaveBeenCalled()
expect(setUploadStatus).toHaveBeenNthCalledWith(1, 'uploading')
expect(setUploadStatus).toHaveBeenNthCalledWith(2, 'partial_error')
expect(mocks.emitTreeUpdate).not.toHaveBeenCalled()
expect(event.target.value).toBe('')
expect(onClose).toHaveBeenCalledTimes(1)
})
})
// Scenario: folder upload handler should build nested tree payload and handle success/failure branches.
describe('handleFolderChange', () => {
it('should close menu and no-op when no folder files are selected', async () => {
const { storeApi, setUploadStatus, setUploadProgress } = createStoreApi()
const onClose = vi.fn()
const event = createInputChangeEvent([])
const { result } = renderHook(() => useCreateOperations({
parentId: 'parent-empty-folder',
appId: 'app-empty-folder',
storeApi,
onClose,
}))
await act(async () => {
await result.current.handleFolderChange(event)
})
expect(setUploadStatus).not.toHaveBeenCalled()
expect(setUploadProgress).not.toHaveBeenCalled()
expect(mocks.batchMutateAsync).not.toHaveBeenCalled()
expect(mocks.emitTreeUpdate).not.toHaveBeenCalled()
expect(onClose).toHaveBeenCalledTimes(1)
expect(event.target.value).toBe('selected')
})
it('should batch upload folder files, update progress callback, and emit success update', async () => {
const { storeApi, setUploadStatus, setUploadProgress } = createStoreApi()
const onClose = vi.fn()
const fileA = withRelativePath(new File(['a'], 'a.md', { type: 'text/markdown' }), 'docs/a.md')
const fileB = withRelativePath(new File(['b'], 'b.txt', { type: 'text/plain' }), 'docs/nested/b.txt')
const rootFile = new File(['root'], 'root.md', { type: 'text/markdown' })
const event = createInputChangeEvent([fileA, fileB, rootFile])
mocks.batchMutateAsync.mockImplementationOnce(async ({ onProgress }) => {
onProgress?.(1, 3)
onProgress?.(3, 3)
return []
})
const { result } = renderHook(() => useCreateOperations({
parentId: 'folder-parent',
appId: 'app-folder',
storeApi,
onClose,
}))
await act(async () => {
await result.current.handleFolderChange(event)
})
expect(mocks.batchMutateAsync).toHaveBeenCalledTimes(1)
const batchPayload = mocks.batchMutateAsync.mock.calls[0][0]
expect(batchPayload.appId).toBe('app-folder')
expect(batchPayload.parentId).toBe('folder-parent')
expect(batchPayload.tree).toEqual([
{
name: 'docs',
node_type: 'folder',
children: [
{
name: 'a.md',
node_type: 'file',
size: fileA.size,
},
{
name: 'nested',
node_type: 'folder',
children: [
{
name: 'b.txt',
node_type: 'file',
size: fileB.size,
},
],
},
],
},
{
name: 'root.md',
node_type: 'file',
size: rootFile.size,
},
])
expect([...batchPayload.files.keys()]).toEqual(['docs/a.md', 'docs/nested/b.txt', 'root.md'])
expect(batchPayload.files.get('docs/a.md')).toBe(fileA)
expect(batchPayload.files.get('docs/nested/b.txt')).toBe(fileB)
expect(batchPayload.files.get('root.md')).toBe(rootFile)
expect(setUploadStatus).toHaveBeenNthCalledWith(1, 'uploading')
expect(setUploadStatus).toHaveBeenNthCalledWith(2, 'success')
expect(setUploadProgress).toHaveBeenCalledWith({ uploaded: 1, total: 3, failed: 0 })
expect(setUploadProgress).toHaveBeenCalledWith({ uploaded: 3, total: 3, failed: 0 })
expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1)
expect(event.target.value).toBe('')
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should set partial_error when batch upload fails', async () => {
const { storeApi, setUploadStatus } = createStoreApi()
const onClose = vi.fn()
const file = withRelativePath(new File(['f'], 'f.md', { type: 'text/markdown' }), 'folder/f.md')
const event = createInputChangeEvent([file])
mocks.batchMutateAsync.mockRejectedValueOnce(new Error('batch failed'))
const { result } = renderHook(() => useCreateOperations({
parentId: null,
appId: 'app-folder-error',
storeApi,
onClose,
}))
await act(async () => {
await result.current.handleFolderChange(event)
})
expect(setUploadStatus).toHaveBeenNthCalledWith(1, 'uploading')
expect(setUploadStatus).toHaveBeenNthCalledWith(2, 'partial_error')
expect(mocks.emitTreeUpdate).not.toHaveBeenCalled()
expect(event.target.value).toBe('')
expect(onClose).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -0,0 +1,173 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { useDownloadOperation } from './use-download-operation'
type DownloadRequest = {
params: {
appId: string
nodeId: string
}
}
type DownloadResponse = {
download_url: string
}
type Deferred<T> = {
promise: Promise<T>
resolve: (value: T) => void
reject: (reason?: unknown) => void
}
const createDeferred = <T,>(): Deferred<T> => {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
const {
mockGetFileDownloadUrl,
mockDownloadUrl,
mockToastNotify,
} = vi.hoisted(() => ({
mockGetFileDownloadUrl: vi.fn<(request: DownloadRequest) => Promise<DownloadResponse>>(),
mockDownloadUrl: vi.fn<(payload: { url: string, fileName?: string }) => void>(),
mockToastNotify: vi.fn<(payload: { type: string, message: string }) => void>(),
}))
vi.mock('@/service/client', () => ({
consoleClient: {
appAsset: {
getFileDownloadUrl: mockGetFileDownloadUrl,
},
},
}))
vi.mock('@/utils/download', () => ({
downloadUrl: mockDownloadUrl,
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: mockToastNotify,
},
}))
describe('useDownloadOperation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetFileDownloadUrl.mockResolvedValue({ download_url: 'https://example.com/file.txt' })
})
// Scenario: hook should no-op when required identifiers are missing.
describe('Guards', () => {
it('should not call download API when appId or nodeId is missing', async () => {
const onClose = vi.fn()
const { result } = renderHook(() => useDownloadOperation({
appId: '',
nodeId: '',
onClose,
}))
await act(async () => {
await result.current.handleDownload()
})
expect(onClose).not.toHaveBeenCalled()
expect(mockGetFileDownloadUrl).not.toHaveBeenCalled()
expect(mockDownloadUrl).not.toHaveBeenCalled()
expect(result.current.isDownloading).toBe(false)
})
})
// Scenario: successful downloads should fetch URL and trigger browser download.
describe('Success', () => {
it('should download file when API call succeeds', async () => {
const onClose = vi.fn()
const { result } = renderHook(() => useDownloadOperation({
appId: 'app-1',
nodeId: 'node-1',
fileName: 'notes.md',
onClose,
}))
await act(async () => {
await result.current.handleDownload()
})
expect(onClose).toHaveBeenCalledTimes(1)
expect(mockGetFileDownloadUrl).toHaveBeenCalledWith({
params: {
appId: 'app-1',
nodeId: 'node-1',
},
})
expect(mockDownloadUrl).toHaveBeenCalledWith({
url: 'https://example.com/file.txt',
fileName: 'notes.md',
})
expect(mockToastNotify).not.toHaveBeenCalled()
expect(result.current.isDownloading).toBe(false)
})
it('should set isDownloading true while download request is pending', async () => {
const deferred = createDeferred<DownloadResponse>()
mockGetFileDownloadUrl.mockReturnValueOnce(deferred.promise)
const onClose = vi.fn()
const { result } = renderHook(() => useDownloadOperation({
appId: 'app-2',
nodeId: 'node-2',
onClose,
}))
act(() => {
void result.current.handleDownload()
})
await waitFor(() => {
expect(result.current.isDownloading).toBe(true)
})
await act(async () => {
deferred.resolve({ download_url: 'https://example.com/slow.txt' })
await deferred.promise
})
expect(onClose).toHaveBeenCalledTimes(1)
expect(mockDownloadUrl).toHaveBeenCalledWith({
url: 'https://example.com/slow.txt',
fileName: undefined,
})
expect(result.current.isDownloading).toBe(false)
})
})
// Scenario: failed downloads should notify users and reset loading state.
describe('Error handling', () => {
it('should show error toast when download API fails', async () => {
mockGetFileDownloadUrl.mockRejectedValueOnce(new Error('network failure'))
const onClose = vi.fn()
const { result } = renderHook(() => useDownloadOperation({
appId: 'app-3',
nodeId: 'node-3',
onClose,
}))
await act(async () => {
await result.current.handleDownload()
})
expect(onClose).toHaveBeenCalledTimes(1)
expect(mockDownloadUrl).not.toHaveBeenCalled()
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'error',
message: 'workflow.skillSidebar.menu.downloadError',
})
expect(result.current.isDownloading).toBe(false)
})
})
})

View File

@ -0,0 +1,335 @@
import type { RefObject } from 'react'
import type { NodeApi, TreeApi } from 'react-arborist'
import type { StoreApi } from 'zustand'
import type { TreeNodeData } from '../../../type'
import type { SkillEditorSliceShape } from '@/app/components/workflow/store/workflow/skill-editor/types'
import type { AppAssetTreeResponse } from '@/types/app-asset'
import { renderHook } from '@testing-library/react'
import { useFileOperations } from './use-file-operations'
type AppStoreState = {
appDetail?: {
id: string
} | null
}
type CreateOpsResult = {
fileInputRef: React.RefObject<HTMLInputElement | null>
folderInputRef: React.RefObject<HTMLInputElement | null>
handleNewFile: () => void
handleNewFolder: () => void
handleFileChange: (e: React.ChangeEvent<HTMLInputElement>) => Promise<void>
handleFolderChange: (e: React.ChangeEvent<HTMLInputElement>) => Promise<void>
isCreating: boolean
}
type ModifyOpsResult = {
showDeleteConfirm: boolean
handleRename: () => void
handleDeleteClick: () => void
handleDeleteConfirm: () => Promise<void>
handleDeleteCancel: () => void
isDeleting: boolean
}
type DownloadOpsResult = {
handleDownload: () => Promise<void>
isDownloading: boolean
}
const createDefaultCreateOps = (): CreateOpsResult => ({
fileInputRef: { current: null } as React.RefObject<HTMLInputElement | null>,
folderInputRef: { current: null } as React.RefObject<HTMLInputElement | null>,
handleNewFile: vi.fn(),
handleNewFolder: vi.fn(),
handleFileChange: vi.fn(async () => undefined),
handleFolderChange: vi.fn(async () => undefined),
isCreating: false,
})
const createDefaultModifyOps = (): ModifyOpsResult => ({
showDeleteConfirm: false,
handleRename: vi.fn(),
handleDeleteClick: vi.fn(),
handleDeleteConfirm: vi.fn(async () => undefined),
handleDeleteCancel: vi.fn(),
isDeleting: false,
})
const createDefaultDownloadOps = (): DownloadOpsResult => ({
handleDownload: vi.fn(async () => undefined),
isDownloading: false,
})
const mocks = vi.hoisted(() => {
const workflowStore = {} as StoreApi<SkillEditorSliceShape>
const fileInputRef = { current: null } as React.RefObject<HTMLInputElement | null>
const folderInputRef = { current: null } as React.RefObject<HTMLInputElement | null>
return {
appStoreState: {
appDetail: { id: 'app-1' },
} as AppStoreState,
workflowStore,
treeData: {
children: [],
} as AppAssetTreeResponse,
toApiParentId: vi.fn<(folderId: string | null | undefined) => string | null>(),
createOpsHook: vi.fn<(options: {
parentId: string | null
appId: string
storeApi: StoreApi<SkillEditorSliceShape>
onClose: () => void
}) => CreateOpsResult>(),
modifyOpsHook: vi.fn<(options: {
nodeId: string
node?: NodeApi<TreeNodeData>
treeRef?: RefObject<TreeApi<TreeNodeData> | null>
appId: string
storeApi: StoreApi<SkillEditorSliceShape>
treeData?: AppAssetTreeResponse
onClose: () => void
}) => ModifyOpsResult>(),
downloadOpsHook: vi.fn<(options: {
appId: string
nodeId: string
fileName?: string
onClose: () => void
}) => DownloadOpsResult>(),
createOpsResult: {
fileInputRef,
folderInputRef,
handleNewFile: vi.fn<() => void>(),
handleNewFolder: vi.fn<() => void>(),
handleFileChange: vi.fn<(e: React.ChangeEvent<HTMLInputElement>) => Promise<void>>(async () => undefined),
handleFolderChange: vi.fn<(e: React.ChangeEvent<HTMLInputElement>) => Promise<void>>(async () => undefined),
isCreating: false,
} as CreateOpsResult,
modifyOpsResult: {
showDeleteConfirm: false,
handleRename: vi.fn<() => void>(),
handleDeleteClick: vi.fn<() => void>(),
handleDeleteConfirm: vi.fn<() => Promise<void>>(async () => undefined),
handleDeleteCancel: vi.fn<() => void>(),
isDeleting: false,
} as ModifyOpsResult,
downloadOpsResult: {
handleDownload: vi.fn<() => Promise<void>>(async () => undefined),
isDownloading: false,
} as DownloadOpsResult,
}
})
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: AppStoreState) => unknown) => selector(mocks.appStoreState),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => mocks.workflowStore,
}))
vi.mock('../data/use-skill-asset-tree', () => ({
useSkillAssetTreeData: () => ({
data: mocks.treeData,
}),
}))
vi.mock('../../../utils/tree-utils', () => ({
toApiParentId: mocks.toApiParentId,
}))
vi.mock('./use-create-operations', () => ({
useCreateOperations: (options: {
parentId: string | null
appId: string
storeApi: StoreApi<SkillEditorSliceShape>
onClose: () => void
}) => mocks.createOpsHook(options),
}))
vi.mock('./use-modify-operations', () => ({
useModifyOperations: (options: {
nodeId: string
node?: NodeApi<TreeNodeData>
treeRef?: RefObject<TreeApi<TreeNodeData> | null>
appId: string
storeApi: StoreApi<SkillEditorSliceShape>
treeData?: AppAssetTreeResponse
onClose: () => void
}) => mocks.modifyOpsHook(options),
}))
vi.mock('./use-download-operation', () => ({
useDownloadOperation: (options: {
appId: string
nodeId: string
fileName?: string
onClose: () => void
}) => mocks.downloadOpsHook(options),
}))
const createNodeApi = (id: string, name: string): NodeApi<TreeNodeData> => {
return {
data: {
id,
node_type: 'file',
name,
path: `/${id}`,
extension: 'md',
size: 1,
children: [],
},
} as unknown as NodeApi<TreeNodeData>
}
describe('useFileOperations', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.appStoreState.appDetail = { id: 'app-1' }
mocks.treeData = { children: [] }
mocks.toApiParentId.mockReturnValue('parent-api-id')
mocks.createOpsResult = createDefaultCreateOps()
mocks.modifyOpsResult = createDefaultModifyOps()
mocks.downloadOpsResult = createDefaultDownloadOps()
mocks.createOpsHook.mockImplementation(() => mocks.createOpsResult)
mocks.modifyOpsHook.mockImplementation(() => mocks.modifyOpsResult)
mocks.downloadOpsHook.mockImplementation(() => mocks.downloadOpsResult)
})
// Scenario: node id and wiring should prioritize selected node over explicit id.
describe('Hook wiring', () => {
it('should use node data id and pass expected options to child operation hooks', () => {
const node = createNodeApi('node-from-node', 'from-node.md')
const treeRef = { current: null } as RefObject<TreeApi<TreeNodeData> | null>
const onClose = vi.fn()
const { result } = renderHook(() => useFileOperations({
nodeId: 'explicit-node',
node,
treeRef,
onClose,
}))
expect(mocks.toApiParentId).toHaveBeenCalledWith('node-from-node')
expect(mocks.createOpsHook).toHaveBeenCalledWith({
parentId: 'parent-api-id',
appId: 'app-1',
storeApi: mocks.workflowStore,
onClose,
})
expect(mocks.modifyOpsHook).toHaveBeenCalledWith({
nodeId: 'node-from-node',
node,
treeRef,
appId: 'app-1',
storeApi: mocks.workflowStore,
treeData: mocks.treeData,
onClose,
})
expect(mocks.downloadOpsHook).toHaveBeenCalledWith({
appId: 'app-1',
nodeId: 'node-from-node',
fileName: 'from-node.md',
onClose,
})
expect(result.current.handleNewFile).toBe(mocks.createOpsResult.handleNewFile)
expect(result.current.handleRename).toBe(mocks.modifyOpsResult.handleRename)
expect(result.current.handleDownload).toBe(mocks.downloadOpsResult.handleDownload)
})
it('should fallback to explicit nodeId when node is not provided', () => {
const onClose = vi.fn()
renderHook(() => useFileOperations({
nodeId: 'explicit-only',
onClose,
}))
expect(mocks.toApiParentId).toHaveBeenCalledWith('explicit-only')
expect(mocks.downloadOpsHook).toHaveBeenCalledWith({
appId: 'app-1',
nodeId: 'explicit-only',
fileName: undefined,
onClose,
})
})
it('should fallback to empty nodeId when both node and explicit nodeId are missing', () => {
const onClose = vi.fn()
renderHook(() => useFileOperations({
onClose,
}))
expect(mocks.toApiParentId).toHaveBeenCalledWith('')
expect(mocks.modifyOpsHook).toHaveBeenCalledWith({
nodeId: '',
node: undefined,
treeRef: undefined,
appId: 'app-1',
storeApi: mocks.workflowStore,
treeData: mocks.treeData,
onClose,
})
})
})
// Scenario: returned values should pass through child hook outputs and aggregate loading state.
describe('Return shape', () => {
it('should expose all operation handlers and refs from composed hooks', () => {
const onClose = vi.fn()
const { result } = renderHook(() => useFileOperations({ onClose }))
expect(result.current.fileInputRef).toBe(mocks.createOpsResult.fileInputRef)
expect(result.current.folderInputRef).toBe(mocks.createOpsResult.folderInputRef)
expect(result.current.handleNewFile).toBe(mocks.createOpsResult.handleNewFile)
expect(result.current.handleNewFolder).toBe(mocks.createOpsResult.handleNewFolder)
expect(result.current.handleFileChange).toBe(mocks.createOpsResult.handleFileChange)
expect(result.current.handleFolderChange).toBe(mocks.createOpsResult.handleFolderChange)
expect(result.current.showDeleteConfirm).toBe(mocks.modifyOpsResult.showDeleteConfirm)
expect(result.current.handleRename).toBe(mocks.modifyOpsResult.handleRename)
expect(result.current.handleDeleteClick).toBe(mocks.modifyOpsResult.handleDeleteClick)
expect(result.current.handleDeleteConfirm).toBe(mocks.modifyOpsResult.handleDeleteConfirm)
expect(result.current.handleDeleteCancel).toBe(mocks.modifyOpsResult.handleDeleteCancel)
expect(result.current.handleDownload).toBe(mocks.downloadOpsResult.handleDownload)
expect(result.current.isDeleting).toBe(mocks.modifyOpsResult.isDeleting)
expect(result.current.isDownloading).toBe(mocks.downloadOpsResult.isDownloading)
})
it('should compute isLoading as false when all child hooks are idle', () => {
const { result } = renderHook(() => useFileOperations({ onClose: vi.fn() }))
expect(result.current.isLoading).toBe(false)
})
it.each([
{
name: 'create operation is pending',
isCreating: true,
isDeleting: false,
isDownloading: false,
},
{
name: 'delete operation is pending',
isCreating: false,
isDeleting: true,
isDownloading: false,
},
{
name: 'download operation is pending',
isCreating: false,
isDeleting: false,
isDownloading: true,
},
])('should compute isLoading as true when $name', ({ isCreating, isDeleting, isDownloading }) => {
mocks.createOpsResult.isCreating = isCreating
mocks.modifyOpsResult.isDeleting = isDeleting
mocks.downloadOpsResult.isDownloading = isDownloading
const { result } = renderHook(() => useFileOperations({ onClose: vi.fn() }))
expect(result.current.isLoading).toBe(true)
})
})
})

View File

@ -0,0 +1,338 @@
import type { RefObject } from 'react'
import type { NodeApi, TreeApi } from 'react-arborist'
import type { StoreApi } from 'zustand'
import type { TreeNodeData } from '../../../type'
import type { SkillEditorSliceShape } from '@/app/components/workflow/store/workflow/skill-editor/types'
import type { AppAssetTreeResponse } from '@/types/app-asset'
import { act, renderHook } from '@testing-library/react'
import { useModifyOperations } from './use-modify-operations'
type DeleteMutationPayload = {
appId: string
nodeId: string
}
const mocks = vi.hoisted(() => ({
deletePending: false,
deleteMutateAsync: vi.fn<(payload: DeleteMutationPayload) => Promise<void>>(),
emitTreeUpdate: vi.fn<() => void>(),
toastNotify: vi.fn<(payload: { type: string, message: string }) => void>(),
getAllDescendantFileIds: vi.fn<(nodeId: string, nodes: TreeNodeData[]) => string[]>(),
}))
vi.mock('@/service/use-app-asset', () => ({
useDeleteAppAssetNode: () => ({
mutateAsync: mocks.deleteMutateAsync,
isPending: mocks.deletePending,
}),
}))
vi.mock('../data/use-skill-tree-collaboration', () => ({
useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
}))
vi.mock('../../../utils/tree-utils', () => ({
getAllDescendantFileIds: mocks.getAllDescendantFileIds,
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: mocks.toastNotify,
},
}))
const createTreeNodeData = (id: string, nodeType: 'file' | 'folder', children: TreeNodeData[] = []): TreeNodeData => ({
id,
node_type: nodeType,
name: nodeType === 'folder' ? `folder-${id}` : `${id}.md`,
path: `/${id}`,
extension: nodeType === 'folder' ? '' : 'md',
size: 1,
children,
})
const createNodeApi = (nodeType: 'file' | 'folder', id = 'node-1') => {
const edit = vi.fn()
const node = {
data: createTreeNodeData(id, nodeType),
edit,
} as unknown as NodeApi<TreeNodeData>
return { node, edit }
}
const createTreeRef = (targetNode: NodeApi<TreeNodeData> | null) => {
const get = vi.fn<(nodeId: string) => NodeApi<TreeNodeData> | null>().mockReturnValue(targetNode)
const treeRef = {
current: {
get,
},
} as unknown as RefObject<TreeApi<TreeNodeData> | null>
return { treeRef, get }
}
const createStoreApi = () => {
const closeTab = vi.fn<(fileId: string) => void>()
const clearDraftContent = vi.fn<(fileId: string) => void>()
const state = {
closeTab,
clearDraftContent,
} as Pick<SkillEditorSliceShape, 'closeTab' | 'clearDraftContent'>
const storeApi = {
getState: () => state,
} as unknown as StoreApi<SkillEditorSliceShape>
return {
storeApi,
closeTab,
clearDraftContent,
}
}
describe('useModifyOperations', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.deletePending = false
mocks.deleteMutateAsync.mockResolvedValue(undefined)
mocks.getAllDescendantFileIds.mockReturnValue([])
})
// Scenario: loading state should match mutation pending status.
describe('State', () => {
it('should expose mutation pending state as isDeleting', () => {
mocks.deletePending = true
const { storeApi } = createStoreApi()
const { result } = renderHook(() => useModifyOperations({
nodeId: 'node-1',
appId: 'app-1',
storeApi,
onClose: vi.fn(),
}))
expect(result.current.isDeleting).toBe(true)
})
})
// Scenario: rename action should prefer treeRef editing and fallback to node editing.
describe('Rename', () => {
it('should edit node from treeRef when treeRef is available', () => {
const { storeApi } = createStoreApi()
const onClose = vi.fn()
const { node: treeNode, edit: treeNodeEdit } = createNodeApi('file', 'tree-node')
const { treeRef, get } = createTreeRef(treeNode)
const { node: fallbackNode, edit: fallbackEdit } = createNodeApi('file', 'fallback-node')
const { result } = renderHook(() => useModifyOperations({
nodeId: 'tree-node',
node: fallbackNode,
treeRef,
appId: 'app-1',
storeApi,
onClose,
}))
act(() => {
result.current.handleRename()
})
expect(get).toHaveBeenCalledWith('tree-node')
expect(treeNodeEdit).toHaveBeenCalledTimes(1)
expect(fallbackEdit).not.toHaveBeenCalled()
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should fallback to provided node edit when treeRef is absent', () => {
const { storeApi } = createStoreApi()
const onClose = vi.fn()
const { node, edit } = createNodeApi('folder', 'folder-2')
const { result } = renderHook(() => useModifyOperations({
nodeId: 'folder-2',
node,
appId: 'app-1',
storeApi,
onClose,
}))
act(() => {
result.current.handleRename()
})
expect(edit).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledTimes(1)
})
})
// Scenario: delete confirm dialog toggles with click/cancel handlers.
describe('Delete dialog state', () => {
it('should open and close delete confirmation dialog', () => {
const { storeApi } = createStoreApi()
const { result } = renderHook(() => useModifyOperations({
nodeId: 'node-1',
appId: 'app-1',
storeApi,
onClose: vi.fn(),
}))
expect(result.current.showDeleteConfirm).toBe(false)
act(() => {
result.current.handleDeleteClick()
})
expect(result.current.showDeleteConfirm).toBe(true)
act(() => {
result.current.handleDeleteCancel()
})
expect(result.current.showDeleteConfirm).toBe(false)
})
})
// Scenario: successful deletes should close tabs/drafts and emit collaboration updates.
describe('Delete success', () => {
it('should delete file node, clear descendants and current file tabs, and show file success toast', async () => {
const { storeApi, closeTab, clearDraftContent } = createStoreApi()
const onClose = vi.fn()
const { node } = createNodeApi('file', 'file-7')
const treeData: AppAssetTreeResponse = {
children: [createTreeNodeData('root-folder', 'folder')],
}
mocks.getAllDescendantFileIds.mockReturnValue(['desc-1', 'desc-2'])
const { result } = renderHook(() => useModifyOperations({
nodeId: 'file-7',
node,
appId: 'app-77',
storeApi,
treeData,
onClose,
}))
act(() => {
result.current.handleDeleteClick()
})
await act(async () => {
await result.current.handleDeleteConfirm()
})
expect(mocks.getAllDescendantFileIds).toHaveBeenCalledWith('file-7', treeData.children)
expect(mocks.deleteMutateAsync).toHaveBeenCalledWith({
appId: 'app-77',
nodeId: 'file-7',
})
expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1)
expect(closeTab).toHaveBeenNthCalledWith(1, 'desc-1')
expect(closeTab).toHaveBeenNthCalledWith(2, 'desc-2')
expect(closeTab).toHaveBeenNthCalledWith(3, 'file-7')
expect(clearDraftContent).toHaveBeenNthCalledWith(1, 'desc-1')
expect(clearDraftContent).toHaveBeenNthCalledWith(2, 'desc-2')
expect(clearDraftContent).toHaveBeenNthCalledWith(3, 'file-7')
expect(mocks.toastNotify).toHaveBeenCalledWith({
type: 'success',
message: 'workflow.skillSidebar.menu.fileDeleted',
})
expect(result.current.showDeleteConfirm).toBe(false)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should delete folder node and skip closing the folder tab itself', async () => {
const { storeApi, closeTab, clearDraftContent } = createStoreApi()
const { node } = createNodeApi('folder', 'folder-9')
const treeData: AppAssetTreeResponse = {
children: [createTreeNodeData('root-folder', 'folder')],
}
mocks.getAllDescendantFileIds.mockReturnValue(['file-in-folder'])
const { result } = renderHook(() => useModifyOperations({
nodeId: 'folder-9',
node,
appId: 'app-9',
storeApi,
treeData,
onClose: vi.fn(),
}))
await act(async () => {
await result.current.handleDeleteConfirm()
})
expect(closeTab).toHaveBeenCalledTimes(1)
expect(closeTab).toHaveBeenCalledWith('file-in-folder')
expect(clearDraftContent).toHaveBeenCalledTimes(1)
expect(clearDraftContent).toHaveBeenCalledWith('file-in-folder')
expect(closeTab).not.toHaveBeenCalledWith('folder-9')
expect(clearDraftContent).not.toHaveBeenCalledWith('folder-9')
expect(mocks.toastNotify).toHaveBeenCalledWith({
type: 'success',
message: 'workflow.skillSidebar.menu.deleted',
})
})
})
// Scenario: failed deletes should surface proper error toasts and always close dialog.
describe('Delete errors', () => {
it('should show folder delete error toast on failure', async () => {
mocks.deleteMutateAsync.mockRejectedValueOnce(new Error('delete failed'))
const { storeApi, closeTab, clearDraftContent } = createStoreApi()
const onClose = vi.fn()
const { node } = createNodeApi('folder', 'folder-err')
const treeData: AppAssetTreeResponse = {
children: [createTreeNodeData('top', 'folder')],
}
const { result } = renderHook(() => useModifyOperations({
nodeId: 'folder-err',
node,
appId: 'app-err',
storeApi,
treeData,
onClose,
}))
await act(async () => {
await result.current.handleDeleteConfirm()
})
expect(mocks.emitTreeUpdate).not.toHaveBeenCalled()
expect(closeTab).not.toHaveBeenCalled()
expect(clearDraftContent).not.toHaveBeenCalled()
expect(mocks.toastNotify).toHaveBeenCalledWith({
type: 'error',
message: 'workflow.skillSidebar.menu.deleteError',
})
expect(result.current.showDeleteConfirm).toBe(false)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should show file delete error toast and skip descendant lookup when treeData is missing', async () => {
mocks.deleteMutateAsync.mockRejectedValueOnce(new Error('delete failed'))
const { storeApi } = createStoreApi()
const { node } = createNodeApi('file', 'file-err')
const { result } = renderHook(() => useModifyOperations({
nodeId: 'file-err',
node,
appId: 'app-err',
storeApi,
onClose: vi.fn(),
}))
await act(async () => {
await result.current.handleDeleteConfirm()
})
expect(mocks.getAllDescendantFileIds).not.toHaveBeenCalled()
expect(mocks.toastNotify).toHaveBeenCalledWith({
type: 'error',
message: 'workflow.skillSidebar.menu.fileDeleteError',
})
})
})
})

View File

@ -0,0 +1,135 @@
import { act, renderHook } from '@testing-library/react'
import { useNodeMove } from './use-node-move'
type AppStoreState = {
appDetail?: {
id: string
} | null
}
type MoveMutationPayload = {
appId: string
nodeId: string
payload: {
parent_id: string | null
}
}
const mocks = vi.hoisted(() => ({
appStoreState: {
appDetail: { id: 'app-1' },
} as AppStoreState,
movePending: false,
moveMutateAsync: vi.fn<(payload: MoveMutationPayload) => Promise<void>>(),
emitTreeUpdate: vi.fn<() => void>(),
toastNotify: vi.fn<(payload: { type: string, message: string }) => void>(),
toApiParentId: vi.fn<(folderId: string | null | undefined) => string | null>(),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: AppStoreState) => unknown) => selector(mocks.appStoreState),
}))
vi.mock('@/service/use-app-asset', () => ({
useMoveAppAssetNode: () => ({
mutateAsync: mocks.moveMutateAsync,
isPending: mocks.movePending,
}),
}))
vi.mock('../data/use-skill-tree-collaboration', () => ({
useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
}))
vi.mock('../../../utils/tree-utils', () => ({
toApiParentId: mocks.toApiParentId,
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: mocks.toastNotify,
},
}))
describe('useNodeMove', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.appStoreState.appDetail = { id: 'app-1' }
mocks.movePending = false
mocks.moveMutateAsync.mockResolvedValue(undefined)
mocks.toApiParentId.mockImplementation(folderId => folderId ?? null)
})
// Scenario: loading state should mirror mutation pending state.
describe('State', () => {
it('should expose mutation pending state as isMoving', () => {
mocks.movePending = true
const { result } = renderHook(() => useNodeMove())
expect(result.current.isMoving).toBe(true)
})
})
// Scenario: successful move should call API, emit update, and show success toast.
describe('Success', () => {
it('should move node and emit collaboration update when API succeeds', async () => {
mocks.toApiParentId.mockReturnValueOnce('parent-api-id')
const { result } = renderHook(() => useNodeMove())
await act(async () => {
await result.current.executeMoveNode('node-11', 'folder-22')
})
expect(mocks.toApiParentId).toHaveBeenCalledWith('folder-22')
expect(mocks.moveMutateAsync).toHaveBeenCalledWith({
appId: 'app-1',
nodeId: 'node-11',
payload: {
parent_id: 'parent-api-id',
},
})
expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1)
expect(mocks.toastNotify).toHaveBeenCalledWith({
type: 'success',
message: 'workflow.skillSidebar.menu.moved',
})
})
it('should use empty appId when app detail is unavailable', async () => {
mocks.appStoreState.appDetail = undefined
mocks.toApiParentId.mockReturnValueOnce(null)
const { result } = renderHook(() => useNodeMove())
await act(async () => {
await result.current.executeMoveNode('node-99', null)
})
expect(mocks.moveMutateAsync).toHaveBeenCalledWith({
appId: '',
nodeId: 'node-99',
payload: {
parent_id: null,
},
})
})
})
// Scenario: failed move should surface an error toast and skip update emission.
describe('Error handling', () => {
it('should show error toast when move fails', async () => {
mocks.moveMutateAsync.mockRejectedValueOnce(new Error('move failed'))
const { result } = renderHook(() => useNodeMove())
await act(async () => {
await result.current.executeMoveNode('node-7', 'folder-7')
})
expect(mocks.emitTreeUpdate).not.toHaveBeenCalled()
expect(mocks.toastNotify).toHaveBeenCalledWith({
type: 'error',
message: 'workflow.skillSidebar.menu.moveError',
})
})
})
})

View File

@ -0,0 +1,126 @@
import { act, renderHook } from '@testing-library/react'
import { useNodeReorder } from './use-node-reorder'
type AppStoreState = {
appDetail?: {
id: string
} | null
}
type ReorderMutationPayload = {
appId: string
nodeId: string
payload: {
after_node_id: string | null
}
}
const mocks = vi.hoisted(() => ({
appStoreState: {
appDetail: { id: 'app-10' },
} as AppStoreState,
reorderPending: false,
reorderMutateAsync: vi.fn<(payload: ReorderMutationPayload) => Promise<void>>(),
emitTreeUpdate: vi.fn<() => void>(),
toastNotify: vi.fn<(payload: { type: string, message: string }) => void>(),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: AppStoreState) => unknown) => selector(mocks.appStoreState),
}))
vi.mock('@/service/use-app-asset', () => ({
useReorderAppAssetNode: () => ({
mutateAsync: mocks.reorderMutateAsync,
isPending: mocks.reorderPending,
}),
}))
vi.mock('../data/use-skill-tree-collaboration', () => ({
useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: mocks.toastNotify,
},
}))
describe('useNodeReorder', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.appStoreState.appDetail = { id: 'app-10' }
mocks.reorderPending = false
mocks.reorderMutateAsync.mockResolvedValue(undefined)
})
// Scenario: loading state should mirror reorder mutation status.
describe('State', () => {
it('should expose mutation pending state as isReordering', () => {
mocks.reorderPending = true
const { result } = renderHook(() => useNodeReorder())
expect(result.current.isReordering).toBe(true)
})
})
// Scenario: successful reorder should call API, emit update, and notify success.
describe('Success', () => {
it('should reorder node with provided afterNodeId', async () => {
const { result } = renderHook(() => useNodeReorder())
await act(async () => {
await result.current.executeReorderNode('node-1', 'node-0')
})
expect(mocks.reorderMutateAsync).toHaveBeenCalledWith({
appId: 'app-10',
nodeId: 'node-1',
payload: {
after_node_id: 'node-0',
},
})
expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1)
expect(mocks.toastNotify).toHaveBeenCalledWith({
type: 'success',
message: 'workflow.skillSidebar.menu.moved',
})
})
it('should use empty appId when app detail is missing', async () => {
mocks.appStoreState.appDetail = null
const { result } = renderHook(() => useNodeReorder())
await act(async () => {
await result.current.executeReorderNode('node-2', null)
})
expect(mocks.reorderMutateAsync).toHaveBeenCalledWith({
appId: '',
nodeId: 'node-2',
payload: {
after_node_id: null,
},
})
})
})
// Scenario: failed reorder should not emit update and should show error toast.
describe('Error handling', () => {
it('should show error toast when reorder fails', async () => {
mocks.reorderMutateAsync.mockRejectedValueOnce(new Error('reorder failed'))
const { result } = renderHook(() => useNodeReorder())
await act(async () => {
await result.current.executeReorderNode('node-3', 'node-1')
})
expect(mocks.emitTreeUpdate).not.toHaveBeenCalled()
expect(mocks.toastNotify).toHaveBeenCalledWith({
type: 'error',
message: 'workflow.skillSidebar.menu.moveError',
})
})
})
})

View File

@ -0,0 +1,381 @@
import type { RefObject } from 'react'
import type { TreeApi } from 'react-arborist'
import type { TreeNodeData } from '../../../type'
import type { AppAssetTreeResponse } from '@/types/app-asset'
import { act, renderHook, waitFor } from '@testing-library/react'
import { usePasteOperation } from './use-paste-operation'
type MoveMutationPayload = {
appId: string
nodeId: string
payload: {
parent_id: string | null
}
}
type Deferred<T> = {
promise: Promise<T>
resolve: (value: T) => void
reject: (reason?: unknown) => void
}
const createDeferred = <T,>(): Deferred<T> => {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
type WorkflowStoreState = {
clipboard: {
operation: 'cut'
nodeIds: Set<string>
} | null
selectedTreeNodeId: string | null
clearClipboard: () => void
}
type AppStoreState = {
appDetail?: {
id: string
} | null
}
const mocks = vi.hoisted(() => ({
workflowState: {
clipboard: null,
selectedTreeNodeId: null,
clearClipboard: vi.fn<() => void>(),
} as WorkflowStoreState,
appStoreState: {
appDetail: { id: 'app-1' },
} as AppStoreState,
movePending: false,
moveMutateAsync: vi.fn<(payload: MoveMutationPayload) => Promise<void>>(),
emitTreeUpdate: vi.fn<() => void>(),
toastNotify: vi.fn<(payload: { type: string, message: string }) => void>(),
getTargetFolderIdFromSelection: vi.fn<(selectedId: string | null, nodes: TreeNodeData[]) => string>(),
toApiParentId: vi.fn<(folderId: string | null | undefined) => string | null>(),
findNodeById: vi.fn<(nodes: TreeNodeData[], nodeId: string) => TreeNodeData | null>(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => mocks.workflowState,
}),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: AppStoreState) => unknown) => selector(mocks.appStoreState),
}))
vi.mock('@/service/use-app-asset', () => ({
useMoveAppAssetNode: () => ({
mutateAsync: mocks.moveMutateAsync,
isPending: mocks.movePending,
}),
}))
vi.mock('../data/use-skill-tree-collaboration', () => ({
useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
}))
vi.mock('../../../utils/tree-utils', () => ({
getTargetFolderIdFromSelection: mocks.getTargetFolderIdFromSelection,
toApiParentId: mocks.toApiParentId,
findNodeById: mocks.findNodeById,
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: mocks.toastNotify,
},
}))
const createTreeNode = (id: string, nodeType: 'file' | 'folder'): TreeNodeData => ({
id,
node_type: nodeType,
name: nodeType === 'folder' ? `folder-${id}` : `${id}.md`,
path: `/${id}`,
extension: nodeType === 'folder' ? '' : 'md',
size: 1,
children: [],
})
const createTreeRef = (selectedId?: string): RefObject<TreeApi<TreeNodeData> | null> => {
const selectedNodes = selectedId ? [{ id: selectedId }] : []
return {
current: {
selectedNodes,
},
} as unknown as RefObject<TreeApi<TreeNodeData> | null>
}
describe('usePasteOperation', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.workflowState.clipboard = null
mocks.workflowState.selectedTreeNodeId = null
mocks.appStoreState.appDetail = { id: 'app-1' }
mocks.movePending = false
mocks.moveMutateAsync.mockResolvedValue(undefined)
mocks.getTargetFolderIdFromSelection.mockReturnValue('target-folder')
mocks.toApiParentId.mockReturnValue('target-parent')
mocks.findNodeById.mockReturnValue(null)
})
// Scenario: isPasting output should reflect mutation pending state.
describe('State', () => {
it('should expose mutation pending state as isPasting', () => {
mocks.movePending = true
const treeRef = createTreeRef('selected')
const { result } = renderHook(() => usePasteOperation({ treeRef }))
expect(result.current.isPasting).toBe(true)
})
})
// Scenario: guard clauses should skip paste work when clipboard is unavailable.
describe('Guards', () => {
it('should no-op when clipboard is empty', async () => {
const treeRef = createTreeRef('selected')
const { result } = renderHook(() => usePasteOperation({ treeRef }))
await act(async () => {
await result.current.handlePaste()
})
expect(mocks.getTargetFolderIdFromSelection).not.toHaveBeenCalled()
expect(mocks.moveMutateAsync).not.toHaveBeenCalled()
expect(mocks.toastNotify).not.toHaveBeenCalled()
})
it('should no-op when clipboard has no node ids', async () => {
mocks.workflowState.clipboard = {
operation: 'cut',
nodeIds: new Set(),
}
const treeRef = createTreeRef('selected')
const { result } = renderHook(() => usePasteOperation({ treeRef }))
await act(async () => {
await result.current.handlePaste()
})
expect(mocks.getTargetFolderIdFromSelection).not.toHaveBeenCalled()
expect(mocks.moveMutateAsync).not.toHaveBeenCalled()
})
it('should reject moving folder into itself and show error toast', async () => {
mocks.workflowState.clipboard = {
operation: 'cut',
nodeIds: new Set(['folder-1']),
}
mocks.getTargetFolderIdFromSelection.mockReturnValueOnce('folder-1')
mocks.findNodeById.mockReturnValueOnce(createTreeNode('folder-1', 'folder'))
const treeRef = createTreeRef('folder-1')
const treeData: AppAssetTreeResponse = {
children: [createTreeNode('folder-1', 'folder')],
}
const { result } = renderHook(() => usePasteOperation({ treeRef, treeData }))
await act(async () => {
await result.current.handlePaste()
})
expect(mocks.moveMutateAsync).not.toHaveBeenCalled()
expect(mocks.toastNotify).toHaveBeenCalledWith({
type: 'error',
message: 'workflow.skillSidebar.menu.cannotMoveToSelf',
})
})
})
// Scenario: successful cut-paste should move all nodes and clear clipboard.
describe('Success', () => {
it('should move cut nodes, clear clipboard, and emit update', async () => {
mocks.workflowState.clipboard = {
operation: 'cut',
nodeIds: new Set(['node-1', 'node-2']),
}
const treeRef = createTreeRef('selected-node')
const treeData: AppAssetTreeResponse = {
children: [createTreeNode('node-1', 'file'), createTreeNode('node-2', 'file')],
}
const { result } = renderHook(() => usePasteOperation({ treeRef, treeData }))
await act(async () => {
await result.current.handlePaste()
})
expect(mocks.getTargetFolderIdFromSelection).toHaveBeenCalledWith('selected-node', treeData.children)
expect(mocks.toApiParentId).toHaveBeenCalledWith('target-folder')
expect(mocks.moveMutateAsync).toHaveBeenCalledTimes(2)
expect(mocks.moveMutateAsync).toHaveBeenNthCalledWith(1, {
appId: 'app-1',
nodeId: 'node-1',
payload: {
parent_id: 'target-parent',
},
})
expect(mocks.moveMutateAsync).toHaveBeenNthCalledWith(2, {
appId: 'app-1',
nodeId: 'node-2',
payload: {
parent_id: 'target-parent',
},
})
expect(mocks.workflowState.clearClipboard).toHaveBeenCalledTimes(1)
expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1)
expect(mocks.toastNotify).toHaveBeenCalledWith({
type: 'success',
message: 'workflow.skillSidebar.menu.moved',
})
})
it('should fallback to selectedTreeNodeId when tree has no selected node', async () => {
mocks.workflowState.clipboard = {
operation: 'cut',
nodeIds: new Set(['node-store']),
}
mocks.workflowState.selectedTreeNodeId = 'store-selected'
const treeRef = createTreeRef()
const treeData: AppAssetTreeResponse = {
children: [createTreeNode('node-store', 'file')],
}
const { result } = renderHook(() => usePasteOperation({ treeRef, treeData }))
await act(async () => {
await result.current.handlePaste()
})
expect(mocks.getTargetFolderIdFromSelection).toHaveBeenCalledWith('store-selected', treeData.children)
expect(mocks.moveMutateAsync).toHaveBeenCalledTimes(1)
})
})
// Scenario: failed paste should keep clipboard and show error toast.
describe('Error handling', () => {
it('should show move error toast when API call fails', async () => {
mocks.workflowState.clipboard = {
operation: 'cut',
nodeIds: new Set(['node-error']),
}
mocks.moveMutateAsync.mockRejectedValueOnce(new Error('move failed'))
const treeRef = createTreeRef('target')
const treeData: AppAssetTreeResponse = {
children: [createTreeNode('node-error', 'file')],
}
const { result } = renderHook(() => usePasteOperation({ treeRef, treeData }))
await act(async () => {
await result.current.handlePaste()
})
expect(mocks.workflowState.clearClipboard).not.toHaveBeenCalled()
expect(mocks.emitTreeUpdate).not.toHaveBeenCalled()
expect(mocks.toastNotify).toHaveBeenCalledWith({
type: 'error',
message: 'workflow.skillSidebar.menu.moveError',
})
})
it('should prevent re-entrant paste while a paste is in progress', async () => {
mocks.workflowState.clipboard = {
operation: 'cut',
nodeIds: new Set(['node-slow']),
}
const deferred = createDeferred<void>()
mocks.moveMutateAsync.mockReturnValueOnce(deferred.promise)
const treeRef = createTreeRef('target')
const treeData: AppAssetTreeResponse = {
children: [createTreeNode('node-slow', 'file')],
}
const { result } = renderHook(() => usePasteOperation({ treeRef, treeData }))
act(() => {
void result.current.handlePaste()
void result.current.handlePaste()
})
expect(mocks.moveMutateAsync).toHaveBeenCalledTimes(1)
await act(async () => {
deferred.resolve(undefined)
await deferred.promise
})
})
})
// Scenario: enabled flag should control window event listener lifecycle.
describe('Window event integration', () => {
it('should register and cleanup paste listener when enabled', () => {
const addListenerSpy = vi.spyOn(window, 'addEventListener')
const removeListenerSpy = vi.spyOn(window, 'removeEventListener')
const treeRef = createTreeRef('selected')
const { unmount } = renderHook(() => usePasteOperation({ treeRef, enabled: true }))
const addCall = addListenerSpy.mock.calls.find(call => String(call[0]) === 'skill:paste')
expect(addCall).toBeDefined()
unmount()
const removeCall = removeListenerSpy.mock.calls.find(call => String(call[0]) === 'skill:paste')
expect(removeCall).toBeDefined()
expect(removeCall?.[1]).toBe(addCall?.[1])
addListenerSpy.mockRestore()
removeListenerSpy.mockRestore()
})
it('should trigger paste handler when skill:paste event is dispatched and enabled', async () => {
mocks.workflowState.clipboard = {
operation: 'cut',
nodeIds: new Set(['node-event']),
}
const treeRef = createTreeRef('selected')
const treeData: AppAssetTreeResponse = {
children: [createTreeNode('node-event', 'file')],
}
renderHook(() => usePasteOperation({ treeRef, treeData, enabled: true }))
act(() => {
window.dispatchEvent(new Event('skill:paste'))
})
await waitFor(() => {
expect(mocks.moveMutateAsync).toHaveBeenCalledTimes(1)
})
})
it('should ignore skill:paste event when disabled', async () => {
mocks.workflowState.clipboard = {
operation: 'cut',
nodeIds: new Set(['node-disabled']),
}
const treeRef = createTreeRef('selected')
const treeData: AppAssetTreeResponse = {
children: [createTreeNode('node-disabled', 'file')],
}
renderHook(() => usePasteOperation({ treeRef, treeData, enabled: false }))
act(() => {
window.dispatchEvent(new Event('skill:paste'))
})
await waitFor(() => {
expect(mocks.moveMutateAsync).not.toHaveBeenCalled()
})
})
})
})