mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 18:08:07 +08:00
test(skill): add comprehensive unit tests for file-tree domain
This commit is contained in:
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user