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

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

View File

@@ -0,0 +1,202 @@
import type { SandboxFileDownloadTicket, SandboxFileTreeNode } from '@/types/sandbox-file'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import ArtifactsSection from './artifacts-section'
type MockStoreState = {
appId: string | undefined
selectedArtifactPath: string | null
}
const mocks = vi.hoisted(() => ({
storeState: {
appId: 'app-1',
selectedArtifactPath: null,
} as MockStoreState,
treeData: undefined as SandboxFileTreeNode[] | undefined,
hasFiles: false,
isLoading: false,
isDownloading: false,
selectArtifact: vi.fn(),
fetchDownloadUrl: vi.fn<(path: string) => Promise<SandboxFileDownloadTicket>>(),
downloadUrl: vi.fn(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: MockStoreState) => unknown) => selector(mocks.storeState),
useWorkflowStore: () => ({
getState: () => ({
selectArtifact: mocks.selectArtifact,
}),
}),
}))
vi.mock('@/service/use-sandbox-file', () => ({
useSandboxFilesTree: () => ({
data: mocks.treeData,
hasFiles: mocks.hasFiles,
isLoading: mocks.isLoading,
}),
useDownloadSandboxFile: () => ({
mutateAsync: mocks.fetchDownloadUrl,
isPending: mocks.isDownloading,
}),
}))
vi.mock('@/utils/download', () => ({
downloadUrl: (...args: unknown[]) => mocks.downloadUrl(...args),
}))
const createNode = (overrides: Partial<SandboxFileTreeNode> = {}): SandboxFileTreeNode => ({
id: 'node-1',
name: 'report.txt',
path: 'report.txt',
node_type: 'file',
size: 1,
mtime: 1700000000,
extension: 'txt',
children: [],
...overrides,
})
describe('ArtifactsSection', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.storeState.appId = 'app-1'
mocks.storeState.selectedArtifactPath = null
mocks.treeData = undefined
mocks.hasFiles = false
mocks.isLoading = false
mocks.isDownloading = false
mocks.fetchDownloadUrl.mockResolvedValue({
download_url: 'https://example.com/download/report.txt',
expires_in: 3600,
export_id: 'abc123def4567890',
})
})
// Covers collapsed header rendering and visual indicators.
describe('Rendering', () => {
it('should render collapsed header and apply custom className', () => {
const { container } = render(<ArtifactsSection className="px-2" />)
expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.artifacts\.openArtifacts/i })).toHaveAttribute('aria-expanded', 'false')
expect(screen.getByText('workflow.skillSidebar.artifacts.title')).toBeInTheDocument()
expect(container.firstChild).toHaveClass('px-2')
})
it('should show blue dot when collapsed and files exist', () => {
mocks.hasFiles = true
mocks.treeData = [createNode()]
const { container } = render(<ArtifactsSection />)
expect(container.querySelector('.bg-state-accent-solid')).toBeInTheDocument()
})
it('should show spinner when file tree is loading', () => {
mocks.isLoading = true
const { container } = render(<ArtifactsSection />)
expect(container.querySelector('.animate-spin')).toBeInTheDocument()
})
})
// Covers expanded branches for empty and loading states.
describe('Expanded content', () => {
it('should render empty state when expanded and there are no files', () => {
render(<ArtifactsSection />)
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.artifacts\.openArtifacts/i }))
expect(screen.getByText('workflow.skillSidebar.artifacts.emptyState')).toBeInTheDocument()
})
it('should not render empty state content while loading even when expanded', () => {
mocks.isLoading = true
render(<ArtifactsSection />)
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.artifacts\.openArtifacts/i }))
expect(screen.queryByText('workflow.skillSidebar.artifacts.emptyState')).not.toBeInTheDocument()
})
})
// Covers real tree integration for selecting and downloading artifacts.
describe('Artifacts tree interactions', () => {
it('should render file rows and select artifact path when a file is clicked', () => {
const selectedFile = createNode({ id: 'selected', name: 'a.txt', path: 'a.txt' })
const otherFile = createNode({ id: 'other', name: 'b.txt', path: 'b.txt' })
mocks.hasFiles = true
mocks.treeData = [selectedFile, otherFile]
mocks.storeState.selectedArtifactPath = 'a.txt'
render(<ArtifactsSection />)
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.artifacts\.openArtifacts/i }))
expect(screen.getByRole('button', { name: 'a.txt' })).toHaveAttribute('aria-selected', 'true')
fireEvent.click(screen.getByRole('button', { name: 'b.txt' }))
expect(mocks.selectArtifact).toHaveBeenCalledTimes(1)
expect(mocks.selectArtifact).toHaveBeenCalledWith('b.txt')
})
it('should request download URL and trigger browser download when file download succeeds', async () => {
const file = createNode({ name: 'export.csv', path: 'export.csv', extension: 'csv' })
mocks.hasFiles = true
mocks.treeData = [file]
mocks.fetchDownloadUrl.mockResolvedValue({
download_url: 'https://example.com/download/export.csv',
expires_in: 3600,
export_id: 'fedcba9876543210',
})
render(<ArtifactsSection />)
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.artifacts\.openArtifacts/i }))
fireEvent.click(screen.getByRole('button', { name: 'Download export.csv' }))
await waitFor(() => {
expect(mocks.fetchDownloadUrl).toHaveBeenCalledWith('export.csv')
})
await waitFor(() => {
expect(mocks.downloadUrl).toHaveBeenCalledWith({
url: 'https://example.com/download/export.csv',
fileName: 'export.csv',
})
})
})
it('should log error and skip browser download when download request fails', async () => {
const file = createNode({ name: 'broken.bin', path: 'broken.bin', extension: 'bin' })
const error = new Error('request failed')
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
mocks.hasFiles = true
mocks.treeData = [file]
mocks.fetchDownloadUrl.mockRejectedValue(error)
render(<ArtifactsSection />)
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.artifacts\.openArtifacts/i }))
fireEvent.click(screen.getByRole('button', { name: 'Download broken.bin' }))
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalledWith('Download failed:', error)
})
expect(mocks.downloadUrl).not.toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
it('should disable download buttons when a download request is pending', () => {
const file = createNode({ name: 'asset.png', path: 'asset.png', extension: 'png' })
mocks.hasFiles = true
mocks.treeData = [file]
mocks.isDownloading = true
render(<ArtifactsSection />)
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.artifacts\.openArtifacts/i }))
expect(screen.getByRole('button', { name: 'Download asset.png' })).toBeDisabled()
})
})
})

View File

@@ -0,0 +1,189 @@
import type { SandboxFileTreeNode } from '@/types/sandbox-file'
import { fireEvent, render, screen } from '@testing-library/react'
import ArtifactsTree from './artifacts-tree'
const createNode = (overrides: Partial<SandboxFileTreeNode> = {}): SandboxFileTreeNode => ({
id: 'node-1',
name: 'report.txt',
path: 'report.txt',
node_type: 'file',
size: 1,
mtime: 1700000000,
extension: 'txt',
children: [],
...overrides,
})
describe('ArtifactsTree', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Covers guard branches when no tree data is available.
describe('Rendering', () => {
it('should render nothing when data is undefined', () => {
const { container } = render(<ArtifactsTree data={undefined} onDownload={vi.fn()} />)
expect(container.firstChild).toBeNull()
})
it('should render nothing when data is empty', () => {
const { container } = render(<ArtifactsTree data={[]} onDownload={vi.fn()} />)
expect(container.firstChild).toBeNull()
})
it('should reveal and hide children when folder row is toggled', () => {
const child = createNode({
id: 'node-child',
name: 'nested.txt',
path: 'docs/nested.txt',
})
const folder = createNode({
id: 'node-folder',
name: 'docs',
path: 'docs',
node_type: 'folder',
extension: null,
children: [child],
})
render(<ArtifactsTree data={[folder]} onDownload={vi.fn()} />)
const folderButton = screen.getByRole('button', { name: 'docs folder' })
expect(folderButton).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByRole('button', { name: 'nested.txt' })).not.toBeInTheDocument()
fireEvent.click(folderButton)
expect(folderButton).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByRole('button', { name: 'nested.txt' })).toBeInTheDocument()
fireEvent.click(folderButton)
expect(folderButton).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByRole('button', { name: 'nested.txt' })).not.toBeInTheDocument()
})
})
// Covers keyboard-driven expansion/selection behavior.
describe('Keyboard interactions', () => {
it('should toggle a folder when Enter and Space keys are pressed', () => {
const folder = createNode({
id: 'node-folder',
name: 'assets',
path: 'assets',
node_type: 'folder',
extension: null,
})
render(<ArtifactsTree data={[folder]} onDownload={vi.fn()} />)
const folderButton = screen.getByRole('button', { name: 'assets folder' })
fireEvent.keyDown(folderButton, { key: 'Enter' })
expect(folderButton).toHaveAttribute('aria-expanded', 'true')
fireEvent.keyDown(folderButton, { key: ' ' })
expect(folderButton).toHaveAttribute('aria-expanded', 'false')
})
it('should call onSelect when Enter is pressed on a file row', () => {
const file = createNode({ name: 'guide.md', path: 'guide.md', extension: 'md' })
const onSelect = vi.fn()
render(
<ArtifactsTree
data={[file]}
onDownload={vi.fn()}
onSelect={onSelect}
/>,
)
fireEvent.keyDown(screen.getByRole('button', { name: 'guide.md' }), { key: 'Enter' })
expect(onSelect).toHaveBeenCalledTimes(1)
expect(onSelect).toHaveBeenCalledWith(file)
})
})
// Covers selection state and click behavior for file rows.
describe('Selection', () => {
it('should call onSelect when a file row is clicked', () => {
const file = createNode({ name: 'main.py', path: 'src/main.py', extension: 'py' })
const onSelect = vi.fn()
render(
<ArtifactsTree
data={[file]}
onDownload={vi.fn()}
onSelect={onSelect}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'main.py' }))
expect(onSelect).toHaveBeenCalledTimes(1)
expect(onSelect).toHaveBeenCalledWith(file)
})
it('should mark only the matching file path as selected', () => {
const selectedFile = createNode({ id: 'selected', name: 'a.txt', path: 'a.txt' })
const otherFile = createNode({ id: 'other', name: 'b.txt', path: 'b.txt' })
render(
<ArtifactsTree
data={[selectedFile, otherFile]}
onDownload={vi.fn()}
selectedPath="a.txt"
/>,
)
expect(screen.getByRole('button', { name: 'a.txt' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('button', { name: 'b.txt' })).toHaveAttribute('aria-selected', 'false')
})
})
// Covers download events including stopPropagation and disabled state.
describe('Download', () => {
it('should call onDownload without triggering onSelect when download button is clicked', () => {
const file = createNode({ name: 'archive.zip', path: 'archive.zip', extension: 'zip' })
const onDownload = vi.fn()
const onSelect = vi.fn()
render(
<ArtifactsTree
data={[file]}
onDownload={onDownload}
onSelect={onSelect}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Download archive.zip' }))
expect(onDownload).toHaveBeenCalledTimes(1)
expect(onDownload).toHaveBeenCalledWith(file)
expect(onSelect).not.toHaveBeenCalled()
})
it('should disable download buttons when isDownloading is true', () => {
const file = createNode({ name: 'asset.png', path: 'asset.png', extension: 'png' })
const onDownload = vi.fn()
render(
<ArtifactsTree
data={[file]}
onDownload={onDownload}
isDownloading
/>,
)
const downloadButton = screen.getByRole('button', { name: 'Download asset.png' })
expect(downloadButton).toBeDisabled()
fireEvent.click(downloadButton)
expect(onDownload).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,116 @@
import type { AppAssetTreeView } from '@/types/app-asset'
import { render, screen } from '@testing-library/react'
import { ROOT_ID } from '../../constants'
import DragActionTooltip from './drag-action-tooltip'
type MockWorkflowState = {
dragOverFolderId: string | null
}
const mocks = vi.hoisted(() => ({
storeState: {
dragOverFolderId: null,
} as MockWorkflowState,
nodeMap: undefined as Map<string, AppAssetTreeView> | undefined,
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: MockWorkflowState) => unknown) => selector(mocks.storeState),
}))
vi.mock('../../hooks/file-tree/data/use-skill-asset-tree', () => ({
useSkillAssetNodeMap: () => ({ data: mocks.nodeMap }),
}))
const createNode = (overrides: Partial<AppAssetTreeView> = {}): AppAssetTreeView => ({
id: 'folder-1',
node_type: 'folder',
name: 'assets',
path: '/assets',
extension: '',
size: 0,
children: [],
...overrides,
})
const setDragOverFolderId = (value: string | null) => {
mocks.storeState.dragOverFolderId = value
}
const setNodeMap = (nodes: AppAssetTreeView[] = []) => {
mocks.nodeMap = new Map(nodes.map(node => [node.id, node]))
}
describe('DragActionTooltip', () => {
beforeEach(() => {
vi.clearAllMocks()
setDragOverFolderId(null)
setNodeMap([])
})
// Tooltip should only render while dragging over a valid target.
describe('Rendering', () => {
it('should render nothing when dragOverFolderId is null', () => {
// Arrange
setDragOverFolderId(null)
// Act
const { container } = render(<DragActionTooltip action="upload" />)
// Assert
expect(container.firstChild).toBeNull()
})
it('should render upload action and root folder label for root target', () => {
// Arrange
setDragOverFolderId(ROOT_ID)
// Act
render(<DragActionTooltip action="upload" />)
// Assert
expect(screen.getByText(/workflow\.skillSidebar\.dragAction\.uploadTo/i)).toBeInTheDocument()
expect(screen.getByText('workflow.skillSidebar.rootFolder')).toBeInTheDocument()
})
})
// Target path resolution should normalize node paths.
describe('Path resolution', () => {
it('should strip leading slash from node path for move action', () => {
// Arrange
setDragOverFolderId('folder-1')
setNodeMap([createNode({ id: 'folder-1', path: '/skills/assets' })])
// Act
render(<DragActionTooltip action="move" />)
// Assert
expect(screen.getByText(/workflow\.skillSidebar\.dragAction\.moveTo/i)).toBeInTheDocument()
expect(screen.getByText('skills/assets')).toBeInTheDocument()
})
it('should keep path unchanged when it does not start with slash', () => {
// Arrange
setDragOverFolderId('folder-1')
setNodeMap([createNode({ id: 'folder-1', path: 'relative/path' })])
// Act
render(<DragActionTooltip action="move" />)
// Assert
expect(screen.getByText('relative/path')).toBeInTheDocument()
})
it('should render nothing when target node path is missing', () => {
// Arrange
setDragOverFolderId('missing-folder')
setNodeMap([createNode({ id: 'folder-1' })])
// Act
const { container } = render(<DragActionTooltip action="upload" />)
// Assert
expect(container.firstChild).toBeNull()
})
})
})

View File

@@ -0,0 +1,545 @@
import type { ReactNode, Ref } from 'react'
import type { AppAssetTreeView } from '@/types/app-asset'
import { fireEvent, render, screen } from '@testing-library/react'
import { CONTEXT_MENU_TYPE, ROOT_ID } from '../../constants'
import FileTree from './file-tree'
type MockWorkflowState = {
expandedFolderIds: Set<string>
activeTabId: string | null
dragOverFolderId: string | null
currentDragType: 'move' | 'upload' | null
fileTreeSearchTerm: string
}
type MockWorkflowActions = {
toggleFolder: (id: string) => void
openTab: (id: string, options: { pinned: boolean }) => void
setSelectedNodeIds: (ids: string[]) => void
clearSelection: () => void
setContextMenu: (menu: { top: number, left: number, type: string } | null) => void
setDragInsertTarget: (target: { parentId: string | null, index: number } | null) => void
setFileTreeSearchTerm: (term: string) => void
}
type MockAssetTreeHookResult = {
data: { children: AppAssetTreeView[] } | undefined
isLoading: boolean
error: Error | null
dataUpdatedAt: number
}
type MockInlineCreateNodeResult = {
treeNodes: AppAssetTreeView[]
handleRename: (payload: { id: string, name: string }) => void
searchMatch: (node: { data: { name: string } }, term: string) => boolean
hasPendingCreate: boolean
}
type MockTreeApi = {
deselectAll: () => void
state: {
nodes: {
drag: {
id: string | null
destinationParentId: string | null
destinationIndex: number | null
}
}
}
store: {
subscribe: (listener: () => void) => () => void
}
root: {
id: string
children: Array<{ id: string }>
}
dragDestinationIndex: number | null | undefined
}
type CapturedTreeProps = {
onToggle: (id: string) => void
onSelect: (nodes: Array<{ id: string }>) => void
onActivate: (node: { data: { id: string, node_type: 'file' | 'folder' }, toggle: () => void }) => void
onMove: (args: {
dragIds: string[]
parentId: string | null
index: number
dragNodes: Array<{ id: string, data: { node_type: 'file' | 'folder' }, parent: { id: string, isRoot?: boolean } | null }>
parentNode: { id: string, children: Array<{ id: string }> } | undefined
}) => void
disableDrop: (args: {
parentNode: { id: string, data: { node_type: 'file' | 'folder' }, children: Array<{ id: string }> }
dragNodes: Array<{ id: string, data: { node_type: 'file' | 'folder' } }>
index: number
}) => boolean
}
function createNode(overrides: Partial<AppAssetTreeView> = {}): AppAssetTreeView {
return {
id: overrides.id ?? 'file-1',
node_type: overrides.node_type ?? 'file',
name: overrides.name ?? 'guide.md',
path: overrides.path ?? '/guide.md',
extension: overrides.extension ?? 'md',
size: overrides.size ?? 1,
children: overrides.children ?? [],
}
}
function createTreeApiMock(): MockTreeApi {
return {
deselectAll: vi.fn(),
state: {
nodes: {
drag: {
id: null,
destinationParentId: null,
destinationIndex: null,
},
},
},
store: {
subscribe: vi.fn(() => vi.fn()),
},
root: {
id: 'root',
children: [],
},
dragDestinationIndex: null,
}
}
function createRootDropHandlersMock() {
return {
handleRootDragEnter: vi.fn(),
handleRootDragLeave: vi.fn(),
handleRootDragOver: vi.fn(),
handleRootDrop: vi.fn(),
resetRootDragCounter: vi.fn(),
}
}
function createInlineCreateNodeMock(): MockInlineCreateNodeResult {
return {
treeNodes: [createNode()],
handleRename: vi.fn(),
searchMatch: vi.fn(() => true),
hasPendingCreate: false,
}
}
const mocks = vi.hoisted(() => ({
storeState: {
expandedFolderIds: new Set<string>(),
activeTabId: null,
dragOverFolderId: null,
currentDragType: null,
fileTreeSearchTerm: '',
} as MockWorkflowState,
actions: {
toggleFolder: vi.fn(),
openTab: vi.fn(),
setSelectedNodeIds: vi.fn(),
clearSelection: vi.fn(),
setContextMenu: vi.fn(),
setDragInsertTarget: vi.fn(),
setFileTreeSearchTerm: vi.fn(),
} as MockWorkflowActions,
skillAssetTreeData: {
data: { children: [createNode()] },
isLoading: false,
error: null,
dataUpdatedAt: 1,
} as MockAssetTreeHookResult,
inlineCreateNode: createInlineCreateNodeMock(),
rootDropHandlers: createRootDropHandlersMock(),
executeMoveNode: vi.fn(),
executeReorderNode: vi.fn(),
useSkillTreeCollaboration: vi.fn(),
useSkillShortcuts: vi.fn(),
useSyncTreeWithActiveTab: vi.fn(),
usePasteOperation: vi.fn(),
treeApi: createTreeApiMock(),
treeProps: null as CapturedTreeProps | null,
isMutating: 0,
containerSize: { height: 320 } as { height: number } | undefined,
isDescendantOf: vi.fn<(parentId: string, nodeId: string, treeChildren: AppAssetTreeView[]) => boolean>(() => false),
}))
vi.mock('react-arborist', async () => {
const React = await vi.importActual<typeof import('react')>('react')
type MockTreeComponentProps = {
children?: ReactNode
} & Record<string, unknown>
const Tree = React.forwardRef((props: MockTreeComponentProps, ref: Ref<unknown>) => {
mocks.treeProps = props as unknown as CapturedTreeProps
if (typeof ref === 'function')
ref(mocks.treeApi)
else if (ref)
(ref as { current: unknown }).current = mocks.treeApi
return <div data-testid="arborist-tree" />
})
return { Tree }
})
vi.mock('@tanstack/react-query', async () => {
const actual = await vi.importActual<typeof import('@tanstack/react-query')>('@tanstack/react-query')
return {
...actual,
useIsMutating: () => mocks.isMutating,
}
})
vi.mock('ahooks', () => ({
useSize: () => mocks.containerSize,
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: MockWorkflowState) => unknown) => selector(mocks.storeState),
useWorkflowStore: () => ({
getState: () => mocks.actions,
}),
}))
vi.mock('../../hooks/file-tree/data/use-skill-asset-tree', () => ({
useSkillAssetTreeData: () => mocks.skillAssetTreeData,
}))
vi.mock('../../hooks/file-tree/data/use-skill-tree-collaboration', () => ({
useSkillTreeCollaboration: () => mocks.useSkillTreeCollaboration(),
}))
vi.mock('../../hooks/file-tree/dnd/use-root-file-drop', () => ({
useRootFileDrop: () => mocks.rootDropHandlers,
}))
vi.mock('../../hooks/file-tree/interaction/use-inline-create-node', () => ({
useInlineCreateNode: () => mocks.inlineCreateNode,
}))
vi.mock('../../hooks/file-tree/interaction/use-skill-shortcuts', () => ({
useSkillShortcuts: (args: unknown) => mocks.useSkillShortcuts(args),
}))
vi.mock('../../hooks/file-tree/interaction/use-sync-tree-with-active-tab', () => ({
useSyncTreeWithActiveTab: (args: unknown) => mocks.useSyncTreeWithActiveTab(args),
}))
vi.mock('../../hooks/file-tree/operations/use-node-move', () => ({
useNodeMove: () => ({ executeMoveNode: mocks.executeMoveNode }),
}))
vi.mock('../../hooks/file-tree/operations/use-node-reorder', () => ({
useNodeReorder: () => ({ executeReorderNode: mocks.executeReorderNode }),
}))
vi.mock('../../hooks/file-tree/operations/use-paste-operation', () => ({
usePasteOperation: (args: unknown) => mocks.usePasteOperation(args),
}))
vi.mock('../../utils/tree-utils', () => ({
isDescendantOf: (parentId: string, nodeId: string, treeChildren: AppAssetTreeView[]) =>
mocks.isDescendantOf(parentId, nodeId, treeChildren),
}))
vi.mock('./search-result-list', () => ({
default: ({ searchTerm }: { searchTerm: string }) => (
<div data-testid="search-result-list">{searchTerm}</div>
),
}))
vi.mock('./drag-action-tooltip', () => ({
default: ({ action }: { action: string }) => (
<div data-testid="drag-action-tooltip">{action}</div>
),
}))
vi.mock('./upload-status-tooltip', () => ({
default: ({ fallback }: { fallback?: ReactNode }) => (
<div data-testid="upload-status-tooltip">{fallback}</div>
),
}))
vi.mock('./tree-context-menu', () => ({
default: () => <div data-testid="tree-context-menu" />,
}))
function getCapturedTreeProps(): CapturedTreeProps {
if (!mocks.treeProps)
throw new Error('Tree props were not captured')
return mocks.treeProps
}
function getTreeDropZone(): HTMLElement {
const tree = screen.getByTestId('arborist-tree')
const dropZone = tree.parentElement
if (!dropZone)
throw new Error('Tree drop zone not found')
return dropZone
}
function resetMockState() {
mocks.storeState.expandedFolderIds = new Set<string>()
mocks.storeState.activeTabId = null
mocks.storeState.dragOverFolderId = null
mocks.storeState.currentDragType = null
mocks.storeState.fileTreeSearchTerm = ''
mocks.skillAssetTreeData = {
data: { children: [createNode()] },
isLoading: false,
error: null,
dataUpdatedAt: 1,
}
mocks.inlineCreateNode = createInlineCreateNodeMock()
mocks.rootDropHandlers = createRootDropHandlersMock()
mocks.executeMoveNode = vi.fn()
mocks.executeReorderNode = vi.fn()
mocks.treeApi = createTreeApiMock()
mocks.treeProps = null
mocks.isMutating = 0
mocks.containerSize = { height: 320 }
mocks.isDescendantOf = vi.fn(() => false)
}
describe('FileTree', () => {
beforeEach(() => {
vi.clearAllMocks()
resetMockState()
})
describe('Tree states', () => {
it('should render loading state when tree data is loading', () => {
mocks.skillAssetTreeData.isLoading = true
mocks.skillAssetTreeData.data = undefined
render(<FileTree />)
expect(screen.getByRole('status', { name: /appApi\.loading/i })).toBeInTheDocument()
})
it('should render error state when tree query fails', () => {
mocks.skillAssetTreeData.error = new Error('request failed')
render(<FileTree />)
expect(screen.getByText('workflow.skillSidebar.loadError')).toBeInTheDocument()
})
it('should render empty state and root drop tip when tree has no children', () => {
mocks.skillAssetTreeData.data = { children: [] }
mocks.inlineCreateNode.treeNodes = []
render(<FileTree />)
expect(screen.getByText('workflow.skillSidebar.empty')).toBeInTheDocument()
expect(screen.getByText('workflow.skillSidebar.dropTip')).toBeInTheDocument()
})
it('should render search no result state and reset filter action', () => {
mocks.storeState.fileTreeSearchTerm = 'missing-keyword'
mocks.skillAssetTreeData.data = {
children: [createNode({ name: 'existing.txt', extension: 'txt', path: '/existing.txt' })],
}
mocks.inlineCreateNode.treeNodes = mocks.skillAssetTreeData.data.children
render(<FileTree />)
expect(screen.getByText('workflow.skillSidebar.searchNoResults')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.resetFilter/i }))
expect(mocks.actions.setFileTreeSearchTerm).toHaveBeenCalledWith('')
})
it('should render search result list when search term has matches', () => {
mocks.storeState.fileTreeSearchTerm = 'guide'
mocks.skillAssetTreeData.data = {
children: [createNode({ name: 'guide.md' })],
}
mocks.inlineCreateNode.treeNodes = mocks.skillAssetTreeData.data.children
render(<FileTree />)
expect(screen.getByTestId('search-result-list')).toHaveTextContent('guide')
expect(screen.queryByTestId('arborist-tree')).not.toBeInTheDocument()
})
it('should render normal tree view with root drag highlight and drag action tooltip', () => {
mocks.storeState.dragOverFolderId = ROOT_ID
mocks.storeState.currentDragType = 'move'
mocks.isMutating = 1
render(<FileTree />)
const treeContainer = document.querySelector('[data-skill-tree-container]')
expect(treeContainer).toHaveClass('pointer-events-none')
const dropZone = getTreeDropZone()
expect(dropZone).toHaveClass('bg-state-accent-hover')
expect(screen.getByTestId('drag-action-tooltip')).toHaveTextContent('move')
expect(screen.queryByTestId('upload-status-tooltip')).not.toBeInTheDocument()
expect(screen.getByTestId('tree-context-menu')).toBeInTheDocument()
})
})
describe('Container interactions', () => {
it('should deselect tree and clear store selection when blank area is clicked', () => {
render(<FileTree />)
fireEvent.click(getTreeDropZone())
expect(mocks.treeApi.deselectAll).toHaveBeenCalledTimes(1)
expect(mocks.actions.clearSelection).toHaveBeenCalledTimes(1)
})
it('should open blank context menu with pointer position on right click', () => {
render(<FileTree />)
fireEvent.contextMenu(getTreeDropZone(), { clientX: 64, clientY: 128 })
expect(mocks.treeApi.deselectAll).toHaveBeenCalledTimes(1)
expect(mocks.actions.clearSelection).toHaveBeenCalledTimes(1)
expect(mocks.actions.setContextMenu).toHaveBeenCalledWith({
top: 128,
left: 64,
type: CONTEXT_MENU_TYPE.BLANK,
})
})
it('should forward root drag events to root file drop handlers', () => {
render(<FileTree />)
const dropZone = getTreeDropZone()
fireEvent.dragEnter(dropZone)
fireEvent.dragOver(dropZone)
fireEvent.dragLeave(dropZone)
fireEvent.drop(dropZone)
expect(mocks.rootDropHandlers.handleRootDragEnter).toHaveBeenCalledTimes(1)
expect(mocks.rootDropHandlers.handleRootDragOver).toHaveBeenCalledTimes(1)
expect(mocks.rootDropHandlers.handleRootDragLeave).toHaveBeenCalledTimes(1)
expect(mocks.rootDropHandlers.handleRootDrop).toHaveBeenCalledTimes(1)
})
})
describe('Tree callbacks', () => {
it('should open file tab when file node is activated and toggle folder node', () => {
render(<FileTree />)
const treeProps = getCapturedTreeProps()
const folderToggle = vi.fn()
treeProps.onActivate({
data: { id: 'file-9', node_type: 'file' },
toggle: vi.fn(),
})
treeProps.onActivate({
data: { id: 'folder-9', node_type: 'folder' },
toggle: folderToggle,
})
expect(mocks.actions.openTab).toHaveBeenCalledWith('file-9', { pinned: true })
expect(folderToggle).toHaveBeenCalledTimes(1)
})
it('should update expanded and selected ids from tree callbacks', () => {
render(<FileTree />)
const treeProps = getCapturedTreeProps()
treeProps.onToggle('folder-1')
treeProps.onSelect([{ id: 'file-1' }, { id: 'file-2' }])
expect(mocks.actions.toggleFolder).toHaveBeenCalledWith('folder-1')
expect(mocks.actions.setSelectedNodeIds).toHaveBeenCalledWith(['file-1', 'file-2'])
})
it('should disable drop for invalid targets and allow valid folder drops', () => {
render(<FileTree />)
const treeProps = getCapturedTreeProps()
const dropToFile = treeProps.disableDrop({
parentNode: { id: 'file-parent', data: { node_type: 'file' }, children: [] },
dragNodes: [{ id: 'drag-1', data: { node_type: 'file' } }],
index: 0,
})
const dropToSelf = treeProps.disableDrop({
parentNode: { id: 'folder-self', data: { node_type: 'folder' }, children: [] },
dragNodes: [{ id: 'folder-self', data: { node_type: 'folder' } }],
index: 0,
})
mocks.isDescendantOf = vi.fn(() => true)
const circularDrop = treeProps.disableDrop({
parentNode: { id: 'folder-child', data: { node_type: 'folder' }, children: [] },
dragNodes: [{ id: 'folder-parent', data: { node_type: 'folder' } }],
index: 0,
})
mocks.isDescendantOf = vi.fn(() => false)
const validDrop = treeProps.disableDrop({
parentNode: { id: 'folder-target', data: { node_type: 'folder' }, children: [] },
dragNodes: [{ id: 'file-3', data: { node_type: 'file' } }],
index: 0,
})
expect(dropToFile).toBe(true)
expect(dropToSelf).toBe(true)
expect(circularDrop).toBe(true)
expect(validDrop).toBe(false)
})
it('should reorder node when drag is insert-line within same parent', () => {
mocks.treeApi.dragDestinationIndex = 2
render(<FileTree />)
const treeProps = getCapturedTreeProps()
treeProps.onMove({
dragIds: ['file-b'],
parentId: 'folder-1',
index: 2,
dragNodes: [{
id: 'file-b',
data: { node_type: 'file' },
parent: { id: 'folder-1', isRoot: false },
}],
parentNode: {
id: 'folder-1',
children: [{ id: 'file-a' }, { id: 'file-b' }, { id: 'file-c' }],
},
})
expect(mocks.executeReorderNode).toHaveBeenCalledWith('file-b', 'file-a')
expect(mocks.executeMoveNode).not.toHaveBeenCalled()
})
it('should move node when destination parent differs or insert line is absent', () => {
mocks.treeApi.dragDestinationIndex = null
render(<FileTree />)
const treeProps = getCapturedTreeProps()
treeProps.onMove({
dragIds: ['file-1'],
parentId: 'folder-2',
index: 0,
dragNodes: [{
id: 'file-1',
data: { node_type: 'file' },
parent: { id: 'folder-1', isRoot: false },
}],
parentNode: {
id: 'folder-2',
children: [{ id: 'file-4' }],
},
})
expect(mocks.executeMoveNode).toHaveBeenCalledWith('file-1', 'folder-2')
expect(mocks.executeReorderNode).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,133 @@
import type { MenuItemProps } from './menu-item'
import { fireEvent, render, screen } from '@testing-library/react'
import MenuItem from './menu-item'
const MockIcon = (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />
const defaultProps: MenuItemProps = {
icon: MockIcon,
label: 'Rename',
onClick: vi.fn(),
}
const renderMenuItem = (overrides: Partial<MenuItemProps> = {}) => {
const props = { ...defaultProps, ...overrides }
return {
...render(<MenuItem {...props} />),
onClick: props.onClick,
}
}
describe('MenuItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Menu item should render its interactive label and style variants.
describe('Rendering', () => {
it('should render a button with the provided label', () => {
// Arrange
renderMenuItem()
// Assert
expect(screen.getByRole('button', { name: /rename/i })).toBeInTheDocument()
})
it('should apply destructive variant styles when variant is destructive', () => {
// Arrange
renderMenuItem({ variant: 'destructive', label: 'Delete' })
// Act
const button = screen.getByRole('button', { name: /delete/i })
// Assert
expect(button).toHaveClass('group')
expect(button).toHaveClass('hover:bg-state-destructive-hover')
})
})
// Optional props should alter the visible content.
describe('Props', () => {
it('should render keyboard shortcut hints when kbd has values', () => {
// Arrange
renderMenuItem({ kbd: ['k'] })
// Assert
expect(screen.getByText('k')).toBeInTheDocument()
})
it('should not render keyboard shortcut hints when kbd is empty', () => {
// Arrange
renderMenuItem({ kbd: [] })
// Assert
expect(screen.queryByText('k')).not.toBeInTheDocument()
})
it('should show tooltip content when hovering the tooltip trigger', async () => {
// Arrange
const tooltipText = 'Show help'
const { container } = renderMenuItem({ tooltip: tooltipText })
const tooltipIcon = container.querySelector('svg.text-text-quaternary')
// Act
expect(tooltipIcon).toBeTruthy()
fireEvent.mouseEnter(tooltipIcon!)
// Assert
expect(await screen.findByText(tooltipText)).toBeInTheDocument()
})
})
// Click handling should call actions without leaking events upward.
describe('Interactions', () => {
it('should call onClick and stop click propagation when button is clicked', () => {
// Arrange
const outerClick = vi.fn()
const onClick = vi.fn()
render(
<div onClick={outerClick}>
<MenuItem {...defaultProps} onClick={onClick} />
</div>,
)
// Act
fireEvent.click(screen.getByRole('button', { name: /rename/i }))
// Assert
expect(onClick).toHaveBeenCalledTimes(1)
expect(outerClick).not.toHaveBeenCalled()
})
it('should not trigger onClick when tooltip icon is clicked', () => {
// Arrange
const onClick = vi.fn()
const { container } = renderMenuItem({ onClick, tooltip: 'Help' })
const tooltipIcon = container.querySelector('svg.text-text-quaternary')
// Act
expect(tooltipIcon).toBeTruthy()
fireEvent.click(tooltipIcon!)
// Assert
expect(onClick).not.toHaveBeenCalled()
})
})
// Disabled state should block interaction.
describe('Edge cases', () => {
it('should disable the button and ignore click when disabled is true', () => {
// Arrange
const onClick = vi.fn()
renderMenuItem({ onClick, disabled: true })
const button = screen.getByRole('button', { name: /rename/i })
// Act
fireEvent.click(button)
// Assert
expect(button).toBeDisabled()
expect(onClick).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,252 @@
import type { ReactElement, RefObject } from 'react'
import type { NodeApi, TreeApi } from 'react-arborist'
import type { TreeNodeData } from '../../type'
import { fireEvent, render, screen } from '@testing-library/react'
import { NODE_MENU_TYPE } from '../../constants'
import NodeMenu from './node-menu'
type MockWorkflowState = {
selectedNodeIds: Set<string>
hasClipboard: () => boolean
}
type MockFileOperations = {
fileInputRef: RefObject<HTMLInputElement | null>
folderInputRef: RefObject<HTMLInputElement | null>
showDeleteConfirm: boolean
isLoading: boolean
isDeleting: boolean
handleDownload: () => void
handleNewFile: () => void
handleNewFolder: () => void
handleFileChange: () => void
handleFolderChange: () => void
handleRename: () => void
handleDeleteClick: () => void
handleDeleteConfirm: () => void
handleDeleteCancel: () => void
}
type RenderNodeMenuProps = {
type?: 'root' | 'folder' | 'file'
nodeId?: string
onClose?: () => void
treeRef?: RefObject<TreeApi<TreeNodeData> | null>
node?: NodeApi<TreeNodeData>
}
function createFileOperationsMock(): MockFileOperations {
return ({
fileInputRef: { current: null },
folderInputRef: { current: null },
showDeleteConfirm: false,
isLoading: false,
isDeleting: false,
handleDownload: vi.fn(),
handleNewFile: vi.fn(),
handleNewFolder: vi.fn(),
handleFileChange: vi.fn(),
handleFolderChange: vi.fn(),
handleRename: vi.fn(),
handleDeleteClick: vi.fn(),
handleDeleteConfirm: vi.fn(),
handleDeleteCancel: vi.fn(),
})
}
const mocks = vi.hoisted(() => ({
storeState: {
selectedNodeIds: new Set<string>(),
hasClipboard: () => false,
} as MockWorkflowState,
cutNodes: vi.fn(),
fileOperations: createFileOperationsMock(),
}))
vi.mock('next/dynamic', () => ({
default: () => {
const MockImportSkillModal = ({ isOpen, onClose }: { isOpen: boolean, onClose: () => void }): ReactElement | null => {
if (!isOpen)
return null
return (
<div data-testid="import-skill-modal">
<button type="button" onClick={onClose}>close-import-modal</button>
</div>
)
}
return MockImportSkillModal
},
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: MockWorkflowState) => unknown) => selector(mocks.storeState),
useWorkflowStore: () => ({
getState: () => ({
cutNodes: mocks.cutNodes,
}),
}),
}))
vi.mock('../../hooks/file-tree/operations/use-file-operations', () => ({
useFileOperations: () => mocks.fileOperations,
}))
const renderNodeMenu = ({
type = NODE_MENU_TYPE.FOLDER,
nodeId = 'node-1',
onClose = vi.fn(),
treeRef,
node,
}: RenderNodeMenuProps = {}) => {
render(
<NodeMenu
type={type}
nodeId={nodeId}
onClose={onClose}
treeRef={treeRef}
node={node}
/>,
)
return { onClose }
}
describe('NodeMenu', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.storeState.selectedNodeIds = new Set<string>()
mocks.storeState.hasClipboard = () => false
mocks.fileOperations = createFileOperationsMock()
})
describe('Rendering', () => {
it('should render root folder actions and hide file-only actions', () => {
renderNodeMenu({ type: NODE_MENU_TYPE.ROOT })
expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.newFile/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.newFolder/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.uploadFile/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.uploadFolder/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.importSkills/i })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /workflow\.skillSidebar\.menu\.cut/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /workflow\.skillSidebar\.menu\.rename/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /workflow\.skillSidebar\.menu\.delete/i })).not.toBeInTheDocument()
})
it('should render file actions and hide folder-only actions', () => {
renderNodeMenu({ type: NODE_MENU_TYPE.FILE })
expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.download/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.cut/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.rename/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.delete/i })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /workflow\.skillSidebar\.menu\.newFile/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /workflow\.skillSidebar\.menu\.newFolder/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /workflow\.skillSidebar\.menu\.paste/i })).not.toBeInTheDocument()
})
it('should disable menu actions when file operations are loading', () => {
mocks.fileOperations.isLoading = true
renderNodeMenu({ type: NODE_MENU_TYPE.FOLDER })
expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.newFile/i })).toBeDisabled()
expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.newFolder/i })).toBeDisabled()
expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.uploadFile/i })).toBeDisabled()
})
})
describe('Menu actions', () => {
it('should trigger create operations when clicking new file and new folder', () => {
renderNodeMenu({ type: NODE_MENU_TYPE.FOLDER })
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.newFile/i }))
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.newFolder/i }))
expect(mocks.fileOperations.handleNewFile).toHaveBeenCalledTimes(1)
expect(mocks.fileOperations.handleNewFolder).toHaveBeenCalledTimes(1)
})
it('should trigger hidden file and folder input clicks from upload actions', () => {
const clickSpy = vi.spyOn(HTMLInputElement.prototype, 'click')
renderNodeMenu({ type: NODE_MENU_TYPE.FOLDER })
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.uploadFile/i }))
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.uploadFolder/i }))
expect(clickSpy).toHaveBeenCalledTimes(2)
})
it('should cut selected nodes and close menu when cut is clicked', () => {
mocks.storeState.selectedNodeIds = new Set(['file-1', 'file-2'])
const { onClose } = renderNodeMenu({ type: NODE_MENU_TYPE.FILE, nodeId: 'fallback-id' })
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.cut/i }))
expect(mocks.cutNodes).toHaveBeenCalledTimes(1)
expect(mocks.cutNodes).toHaveBeenCalledWith(['file-1', 'file-2'])
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should cut current node id when no multi-selection exists', () => {
const { onClose } = renderNodeMenu({ type: NODE_MENU_TYPE.FILE, nodeId: 'file-3' })
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.cut/i }))
expect(mocks.cutNodes).toHaveBeenCalledWith(['file-3'])
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should dispatch paste event and close when paste is clicked', () => {
mocks.storeState.hasClipboard = () => true
const pasteListener = vi.fn()
window.addEventListener('skill:paste', pasteListener)
const { onClose } = renderNodeMenu({ type: NODE_MENU_TYPE.FOLDER })
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.paste/i }))
expect(pasteListener).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledTimes(1)
window.removeEventListener('skill:paste', pasteListener)
})
it('should call download, rename, and delete handlers for file menu actions', () => {
renderNodeMenu({ type: NODE_MENU_TYPE.FILE })
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.download/i }))
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.rename/i }))
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.delete/i }))
expect(mocks.fileOperations.handleDownload).toHaveBeenCalledTimes(1)
expect(mocks.fileOperations.handleRename).toHaveBeenCalledTimes(1)
expect(mocks.fileOperations.handleDeleteClick).toHaveBeenCalledTimes(1)
})
})
describe('Dialogs', () => {
it('should open and close import modal from root menu', () => {
renderNodeMenu({ type: NODE_MENU_TYPE.ROOT })
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.importSkills/i }))
expect(screen.getByTestId('import-skill-modal')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /close-import-modal/i }))
expect(screen.queryByTestId('import-skill-modal')).not.toBeInTheDocument()
})
it('should render delete confirmation content for files and forward confirm callbacks', () => {
mocks.fileOperations.showDeleteConfirm = true
renderNodeMenu({ type: NODE_MENU_TYPE.FILE })
expect(screen.getByText('workflow.skillSidebar.menu.fileDeleteConfirmTitle')).toBeInTheDocument()
expect(screen.getByText('workflow.skillSidebar.menu.fileDeleteConfirmContent')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.confirm/i }))
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
expect(mocks.fileOperations.handleDeleteConfirm).toHaveBeenCalledTimes(1)
expect(mocks.fileOperations.handleDeleteCancel).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -0,0 +1,171 @@
import type { AppAssetTreeView } from '@/types/app-asset'
import { fireEvent, render, screen } from '@testing-library/react'
import SearchResultList from './search-result-list'
type MockWorkflowState = {
activeTabId: string | null
}
const mocks = vi.hoisted(() => ({
storeState: {
activeTabId: null,
} as MockWorkflowState,
clearArtifactSelection: vi.fn(),
openTab: vi.fn(),
revealFile: vi.fn(),
setFileTreeSearchTerm: vi.fn(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: MockWorkflowState) => unknown) => selector(mocks.storeState),
useWorkflowStore: () => ({
getState: () => ({
clearArtifactSelection: mocks.clearArtifactSelection,
openTab: mocks.openTab,
revealFile: mocks.revealFile,
setFileTreeSearchTerm: mocks.setFileTreeSearchTerm,
}),
}),
}))
const createNode = (overrides: Partial<AppAssetTreeView> = {}): AppAssetTreeView => ({
id: 'node-1',
node_type: 'file',
name: 'readme.md',
path: '/readme.md',
extension: 'md',
size: 10,
children: [],
...overrides,
})
const setActiveTabId = (activeTabId: string | null) => {
mocks.storeState.activeTabId = activeTabId
}
describe('SearchResultList', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
setActiveTabId(null)
})
// Search results should render only matching items with path hints when available.
describe('Rendering', () => {
it('should render matching nodes and parent path when search term matches', () => {
const treeChildren = [
createNode({ id: 'file-1', name: 'readme.md', path: '/src/readme.md' }),
createNode({ id: 'file-2', name: 'guide.txt', path: '/guide.txt', extension: 'txt' }),
]
render(<SearchResultList searchTerm="read" treeChildren={treeChildren} />)
expect(screen.getByText('readme.md')).toBeInTheDocument()
expect(screen.queryByText('guide.txt')).not.toBeInTheDocument()
expect(screen.getByText('src')).toBeInTheDocument()
})
it('should render active row style when node id matches active tab id', () => {
setActiveTabId('file-1')
const treeChildren = [createNode({ id: 'file-1', name: 'readme.md' })]
render(<SearchResultList searchTerm="read" treeChildren={treeChildren} />)
const row = screen.getByText('readme.md').closest('[role="button"]')
expect(row).toHaveClass('bg-state-base-active')
})
it('should render no rows when search term is empty', () => {
const treeChildren = [createNode({ id: 'file-1', name: 'readme.md' })]
render(<SearchResultList searchTerm="" treeChildren={treeChildren} />)
expect(screen.queryByText('readme.md')).not.toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
})
// File and folder actions should dispatch the correct store operations.
describe('Interactions', () => {
it('should open file preview when file row is single clicked', () => {
vi.useFakeTimers()
const treeChildren = [createNode({ id: 'file-1', name: 'readme.md' })]
render(<SearchResultList searchTerm="read" treeChildren={treeChildren} />)
const row = screen.getByText('readme.md').closest('[role="button"]')
if (!row)
throw new Error('Expected row element for readme.md')
fireEvent.click(row)
expect(mocks.openTab).not.toHaveBeenCalled()
vi.runAllTimers()
expect(mocks.clearArtifactSelection).toHaveBeenCalledTimes(1)
expect(mocks.openTab).toHaveBeenCalledTimes(1)
expect(mocks.openTab).toHaveBeenCalledWith('file-1', { pinned: false })
})
it('should open file pinned when file row is double clicked', () => {
vi.useFakeTimers()
const treeChildren = [createNode({ id: 'file-1', name: 'readme.md' })]
render(<SearchResultList searchTerm="read" treeChildren={treeChildren} />)
const row = screen.getByText('readme.md').closest('[role="button"]')
if (!row)
throw new Error('Expected row element for readme.md')
fireEvent.doubleClick(row)
vi.runAllTimers()
expect(mocks.clearArtifactSelection).toHaveBeenCalledTimes(1)
expect(mocks.openTab).toHaveBeenCalledTimes(1)
expect(mocks.openTab).toHaveBeenCalledWith('file-1', { pinned: true })
})
it('should reveal folder and clear search when folder row is clicked', () => {
const treeChildren = [
createNode({
id: 'folder-1',
node_type: 'folder',
name: 'src',
path: '/src',
extension: '',
children: [createNode({ id: 'file-1', name: 'readme.md', path: '/src/readme.md' })],
}),
]
render(<SearchResultList searchTerm="src" treeChildren={treeChildren} />)
const row = screen.getByText('src').closest('[role="button"]')
if (!row)
throw new Error('Expected row element for src')
fireEvent.click(row)
expect(mocks.revealFile).toHaveBeenCalledTimes(1)
expect(mocks.revealFile).toHaveBeenCalledWith(['folder-1'])
expect(mocks.setFileTreeSearchTerm).toHaveBeenCalledTimes(1)
expect(mocks.setFileTreeSearchTerm).toHaveBeenCalledWith('')
expect(mocks.openTab).not.toHaveBeenCalled()
})
it('should open file pinned when Enter key is pressed on a file row', () => {
const treeChildren = [createNode({ id: 'file-1', name: 'readme.md' })]
render(<SearchResultList searchTerm="read" treeChildren={treeChildren} />)
const row = screen.getByText('readme.md').closest('[role="button"]')
if (!row)
throw new Error('Expected row element for readme.md')
fireEvent.keyDown(row, { key: 'Enter' })
expect(mocks.clearArtifactSelection).toHaveBeenCalledTimes(1)
expect(mocks.openTab).toHaveBeenCalledTimes(1)
expect(mocks.openTab).toHaveBeenCalledWith('file-1', { pinned: true })
})
})
})

View File

@@ -0,0 +1,174 @@
import type { ReactNode } from 'react'
import type { ContextMenuState } from '@/app/components/workflow/store/workflow/skill-editor/types'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { CONTEXT_MENU_TYPE, NODE_MENU_TYPE, ROOT_ID } from '../../constants'
import TreeContextMenu from './tree-context-menu'
type MockWorkflowState = {
contextMenu: ContextMenuState | null
}
type FloatingOptions = {
open: boolean
onOpenChange: (open: boolean) => void
position: {
x: number
y: number
}
}
const mocks = vi.hoisted(() => ({
storeState: {
contextMenu: null,
} as MockWorkflowState,
setContextMenu: vi.fn(),
floatingOptions: null as FloatingOptions | null,
getFloatingProps: vi.fn(() => ({ 'data-floating-props': 'applied' })),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: MockWorkflowState) => unknown) => selector(mocks.storeState),
useWorkflowStore: () => ({
getState: () => ({
setContextMenu: mocks.setContextMenu,
}),
}),
}))
vi.mock('@floating-ui/react', () => ({
FloatingPortal: ({ children }: { children: ReactNode }) => (
<div data-testid="floating-portal">{children}</div>
),
}))
vi.mock('@/app/components/base/portal-to-follow-elem/use-context-menu-floating', () => ({
useContextMenuFloating: (options: FloatingOptions) => {
mocks.floatingOptions = options
return {
refs: {
setFloating: vi.fn(),
},
floatingStyles: {
left: `${options.position.x}px`,
top: `${options.position.y}px`,
},
getFloatingProps: mocks.getFloatingProps,
isPositioned: true,
}
},
}))
vi.mock('./node-menu', () => ({
default: ({
type,
nodeId,
onClose,
}: {
type: string
nodeId?: string
onClose: () => void
}) => (
<div data-testid="node-menu" data-type={type} data-node-id={nodeId ?? ''}>
<button type="button" onClick={onClose}>close</button>
</div>
),
}))
const setContextMenuState = (contextMenu: ContextMenuState | null) => {
mocks.storeState.contextMenu = contextMenu
}
describe('TreeContextMenu', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.floatingOptions = null
setContextMenuState(null)
})
// Rendering should depend on context-menu state in the workflow store.
describe('Rendering', () => {
it('should render nothing when context menu state is null', () => {
render(<TreeContextMenu treeRef={{ current: null }} />)
expect(screen.queryByTestId('node-menu')).not.toBeInTheDocument()
expect(screen.queryByTestId('floating-portal')).not.toBeInTheDocument()
})
it('should render file menu with node id when node context is on a file', () => {
setContextMenuState({
top: 40,
left: 24,
type: CONTEXT_MENU_TYPE.NODE,
nodeId: 'file-1',
isFolder: false,
})
render(<TreeContextMenu treeRef={{ current: null }} />)
const menu = screen.getByTestId('node-menu')
expect(menu).toHaveAttribute('data-type', NODE_MENU_TYPE.FILE)
expect(menu).toHaveAttribute('data-node-id', 'file-1')
expect(menu.parentElement).toHaveStyle({
left: '24px',
top: '40px',
visibility: 'visible',
})
expect(mocks.getFloatingProps).toHaveBeenCalledTimes(1)
expect(mocks.floatingOptions?.open).toBe(true)
expect(mocks.floatingOptions?.position).toEqual({ x: 24, y: 40 })
})
it('should render root menu with root id when context is blank area', () => {
setContextMenuState({
top: 100,
left: 80,
type: CONTEXT_MENU_TYPE.BLANK,
})
render(<TreeContextMenu treeRef={{ current: null }} />)
const menu = screen.getByTestId('node-menu')
expect(menu).toHaveAttribute('data-type', NODE_MENU_TYPE.ROOT)
expect(menu).toHaveAttribute('data-node-id', ROOT_ID)
})
})
// Close events from floating layer and menu should reset store context menu.
describe('Closing behavior', () => {
it('should clear context menu when floating layer requests close', () => {
setContextMenuState({
top: 12,
left: 16,
type: CONTEXT_MENU_TYPE.NODE,
nodeId: 'file-1',
isFolder: false,
})
render(<TreeContextMenu treeRef={{ current: null }} />)
act(() => {
mocks.floatingOptions?.onOpenChange(false)
})
expect(mocks.setContextMenu).toHaveBeenCalledTimes(1)
expect(mocks.setContextMenu).toHaveBeenCalledWith(null)
})
it('should clear context menu when node menu closes', () => {
setContextMenuState({
top: 12,
left: 16,
type: CONTEXT_MENU_TYPE.NODE,
nodeId: 'file-1',
isFolder: false,
})
render(<TreeContextMenu treeRef={{ current: null }} />)
fireEvent.click(screen.getByRole('button', { name: 'close' }))
expect(mocks.setContextMenu).toHaveBeenCalledTimes(1)
expect(mocks.setContextMenu).toHaveBeenCalledWith(null)
})
})
})

View File

@@ -0,0 +1,51 @@
import { render } from '@testing-library/react'
import TreeGuideLines from './tree-guide-lines'
describe('TreeGuideLines', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering behavior for root-level and nested nodes.
describe('Rendering', () => {
it('should render nothing when level is 0', () => {
// Arrange
const { container } = render(<TreeGuideLines level={0} />)
// Assert
expect(container.firstChild).toBeNull()
})
it('should render one guideline per level with default spacing', () => {
// Arrange
const { container } = render(<TreeGuideLines level={3} />)
// Act
const guides = container.querySelectorAll('.border-divider-subtle')
// Assert
expect(guides).toHaveLength(3)
expect(guides[0]).toHaveStyle({ left: '10px' })
expect(guides[1]).toHaveStyle({ left: '30px' })
expect(guides[2]).toHaveStyle({ left: '50px' })
})
})
// Custom spacing props should influence guideline position.
describe('Props', () => {
it('should apply custom indentSize and lineOffset when provided', () => {
// Arrange
const { container } = render(
<TreeGuideLines level={2} indentSize={24} lineOffset={4} />,
)
// Act
const guides = container.querySelectorAll('.border-divider-subtle')
// Assert
expect(guides).toHaveLength(2)
expect(guides[0]).toHaveStyle({ left: '20px' })
expect(guides[1]).toHaveStyle({ left: '44px' })
})
})
})

View File

@@ -0,0 +1,122 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { TreeNodeIcon } from './tree-node-icon'
const mocks = vi.hoisted(() => ({
getFileIconType: vi.fn(() => 'document'),
}))
vi.mock('../../utils/file-utils', () => ({
getFileIconType: mocks.getFileIconType,
}))
describe('TreeNodeIcon', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Folder nodes should render toggle affordances and icon state.
describe('Folder nodes', () => {
it('should render an open-folder icon when folder is expanded', () => {
// Arrange
render(
<TreeNodeIcon
isFolder
isOpen
fileName="assets"
isDirty={false}
/>,
)
// Act
const toggleButton = screen.getByRole('button', { name: /workflow\.skillSidebar\.toggleFolder/i })
const icon = toggleButton.querySelector('svg')
// Assert
expect(toggleButton).toBeInTheDocument()
expect(icon).toHaveClass('text-text-accent')
expect(mocks.getFileIconType).not.toHaveBeenCalled()
})
it('should render a closed-folder icon when folder is collapsed', () => {
// Arrange
render(
<TreeNodeIcon
isFolder
isOpen={false}
fileName="assets"
isDirty={false}
/>,
)
// Act
const toggleButton = screen.getByRole('button', { name: /workflow\.skillSidebar\.toggleFolder/i })
const icon = toggleButton.querySelector('svg')
// Assert
expect(icon).toHaveClass('text-text-secondary')
})
it('should call onToggle when folder icon button is clicked', () => {
// Arrange
const onToggle = vi.fn()
render(
<TreeNodeIcon
isFolder
isOpen={false}
fileName="assets"
isDirty={false}
onToggle={onToggle}
/>,
)
// Act
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.toggleFolder/i }))
// Assert
expect(onToggle).toHaveBeenCalledTimes(1)
})
})
// File nodes should resolve icon type and optionally show dirty indicator.
describe('File nodes', () => {
it('should resolve file icon type and show dirty marker when file is dirty', () => {
// Arrange
const { container } = render(
<TreeNodeIcon
isFolder={false}
isOpen={false}
fileName="guide.md"
extension="md"
isDirty
/>,
)
// Act
const dirtyMarker = container.querySelector('.bg-text-warning-secondary')
// Assert
expect(screen.queryByRole('button', { name: /workflow\.skillSidebar\.toggleFolder/i })).not.toBeInTheDocument()
expect(mocks.getFileIconType).toHaveBeenCalledWith('guide.md', 'md')
expect(dirtyMarker).toBeInTheDocument()
})
it('should hide dirty marker when file is clean', () => {
// Arrange
const { container } = render(
<TreeNodeIcon
isFolder={false}
isOpen={false}
fileName="README"
isDirty={false}
/>,
)
// Act
const dirtyMarker = container.querySelector('.bg-text-warning-secondary')
// Assert
expect(mocks.getFileIconType).toHaveBeenCalledWith('README', undefined)
expect(dirtyMarker).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,339 @@
import type { NodeApi, NodeRendererProps, TreeApi } from 'react-arborist'
import type { TreeNodeData } from '../../type'
import { fireEvent, render, screen } from '@testing-library/react'
import TreeNode from './tree-node'
type MockWorkflowSelectorState = {
dirtyContents: Set<string>
contextMenu: {
nodeId?: string
} | null
isCutNode: (nodeId: string) => boolean
}
type NodeState = {
id: string
nodeType: 'file' | 'folder'
name: string
extension: string
isSelected: boolean
isOpen: boolean
isDragging: boolean
willReceiveDrop: boolean
isEditing: boolean
level: number
}
const workflowState = vi.hoisted(() => ({
dirtyContents: new Set<string>(),
cutNodeIds: new Set<string>(),
contextMenuNodeId: null as string | null,
dragOverFolderId: null as string | null,
}))
const storeActions = vi.hoisted(() => ({
setCurrentDragType: vi.fn(),
setDragOverFolderId: vi.fn(),
}))
const handlerMocks = vi.hoisted(() => ({
handleClick: vi.fn(),
handleDoubleClick: vi.fn(),
handleToggle: vi.fn(),
handleContextMenu: vi.fn(),
handleKeyDown: vi.fn(),
}))
const dndMocks = vi.hoisted(() => ({
isDragOver: false,
isBlinking: false,
onDragEnter: vi.fn(),
onDragOver: vi.fn(),
onDrop: vi.fn(),
onDragLeave: vi.fn(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: MockWorkflowSelectorState) => unknown) => selector({
dirtyContents: workflowState.dirtyContents,
contextMenu: workflowState.contextMenuNodeId
? { nodeId: workflowState.contextMenuNodeId }
: null,
isCutNode: (nodeId: string) => workflowState.cutNodeIds.has(nodeId),
}),
useWorkflowStore: () => ({
getState: () => ({
dragOverFolderId: workflowState.dragOverFolderId,
setCurrentDragType: (type: 'move' | null) => {
storeActions.setCurrentDragType(type)
},
setDragOverFolderId: (folderId: string | null) => {
workflowState.dragOverFolderId = folderId
storeActions.setDragOverFolderId(folderId)
},
}),
}),
}))
vi.mock('../../hooks/file-tree/interaction/use-tree-node-handlers', () => ({
useTreeNodeHandlers: () => ({
handleClick: handlerMocks.handleClick,
handleDoubleClick: handlerMocks.handleDoubleClick,
handleToggle: handlerMocks.handleToggle,
handleContextMenu: handlerMocks.handleContextMenu,
handleKeyDown: handlerMocks.handleKeyDown,
}),
}))
vi.mock('../../hooks/file-tree/dnd/use-folder-file-drop', () => ({
useFolderFileDrop: () => ({
isDragOver: dndMocks.isDragOver,
isBlinking: dndMocks.isBlinking,
dragHandlers: {
onDragEnter: dndMocks.onDragEnter,
onDragOver: dndMocks.onDragOver,
onDrop: dndMocks.onDrop,
onDragLeave: dndMocks.onDragLeave,
},
}),
}))
vi.mock('./node-menu', () => ({
default: ({ type, onClose }: { type: string, onClose: () => void }) => (
<div data-testid="node-menu" data-type={type}>
<button type="button" onClick={onClose}>close-menu</button>
</div>
),
}))
const createNode = (overrides: Partial<NodeState> = {}): NodeApi<TreeNodeData> => {
const resolved: NodeState = {
id: overrides.id ?? 'file-1',
nodeType: overrides.nodeType ?? 'file',
name: overrides.name ?? 'readme.md',
extension: overrides.extension ?? 'md',
isSelected: overrides.isSelected ?? false,
isOpen: overrides.isOpen ?? false,
isDragging: overrides.isDragging ?? false,
willReceiveDrop: overrides.willReceiveDrop ?? false,
isEditing: overrides.isEditing ?? false,
level: overrides.level ?? 0,
}
return {
data: {
id: resolved.id,
node_type: resolved.nodeType,
name: resolved.name,
path: `/${resolved.name}`,
extension: resolved.nodeType === 'folder' ? '' : resolved.extension,
size: 0,
children: [],
},
isSelected: resolved.isSelected,
isOpen: resolved.isOpen,
isDragging: resolved.isDragging,
willReceiveDrop: resolved.willReceiveDrop,
isEditing: resolved.isEditing,
level: resolved.level,
} as unknown as NodeApi<TreeNodeData>
}
const buildProps = (nodeOverrides: Partial<NodeState> = {}): NodeRendererProps<TreeNodeData> & {
treeChildren: TreeNodeData[]
} => ({
node: createNode(nodeOverrides),
style: {},
tree: {} as TreeApi<TreeNodeData>,
dragHandle: vi.fn(),
treeChildren: [],
})
describe('TreeNode', () => {
beforeEach(() => {
vi.clearAllMocks()
workflowState.dirtyContents.clear()
workflowState.cutNodeIds.clear()
workflowState.contextMenuNodeId = null
workflowState.dragOverFolderId = null
dndMocks.isDragOver = false
dndMocks.isBlinking = false
})
// Core rendering should reflect selection, folder expansion, and store-driven visual states.
describe('Rendering', () => {
it('should render file node with context-menu highlight and action button label', () => {
workflowState.contextMenuNodeId = 'file-1'
const props = buildProps({ id: 'file-1', name: 'readme.md', nodeType: 'file' })
render(<TreeNode {...props} />)
const treeItem = screen.getByRole('treeitem')
expect(treeItem).toHaveAttribute('aria-selected', 'false')
expect(treeItem).not.toHaveAttribute('aria-expanded')
expect(treeItem).toHaveClass('bg-state-base-hover')
expect(screen.getByText('readme.md')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.moreActions/i })).toBeInTheDocument()
})
it('should render selected open folder with folder expansion aria state', () => {
const props = buildProps({
id: 'folder-1',
name: 'src',
nodeType: 'folder',
isSelected: true,
isOpen: true,
})
render(<TreeNode {...props} />)
const treeItem = screen.getByRole('treeitem')
expect(treeItem).toHaveAttribute('aria-selected', 'true')
expect(treeItem).toHaveAttribute('aria-expanded', 'true')
expect(treeItem).toHaveClass('bg-state-base-active')
})
it('should apply drag-over, blinking, and cut styles when states are active', () => {
dndMocks.isDragOver = true
dndMocks.isBlinking = true
workflowState.cutNodeIds.add('folder-1')
const props = buildProps({
id: 'folder-1',
nodeType: 'folder',
name: 'src',
})
render(<TreeNode {...props} />)
const treeItem = screen.getByRole('treeitem')
expect(treeItem).toHaveClass('ring-state-accent-solid')
expect(treeItem).toHaveClass('animate-drag-blink')
expect(treeItem).toHaveClass('opacity-50')
})
})
// User interactions on the node surface should forward to handler hooks and DnD hooks.
describe('Event wiring', () => {
it('should call click and double-click handlers from main content interactions', () => {
const props = buildProps({ id: 'file-1', name: 'readme.md', nodeType: 'file' })
render(<TreeNode {...props} />)
const label = screen.getByText('readme.md')
fireEvent.click(label)
fireEvent.doubleClick(label)
expect(handlerMocks.handleClick).toHaveBeenCalled()
expect(handlerMocks.handleDoubleClick).toHaveBeenCalled()
})
it('should call keyboard and context-menu handlers on tree item', () => {
const props = buildProps({ id: 'file-1', name: 'readme.md', nodeType: 'file' })
render(<TreeNode {...props} />)
const treeItem = screen.getByRole('treeitem')
fireEvent.keyDown(treeItem, { key: 'Enter' })
fireEvent.contextMenu(treeItem)
expect(handlerMocks.handleKeyDown).toHaveBeenCalledTimes(1)
expect(handlerMocks.handleContextMenu).toHaveBeenCalledTimes(1)
})
it('should attach folder drag handlers only when node is a folder', () => {
const folderProps = buildProps({ id: 'folder-1', name: 'src', nodeType: 'folder' })
const { rerender } = render(<TreeNode {...folderProps} />)
const folderTreeItem = screen.getByRole('treeitem')
fireEvent.dragEnter(folderTreeItem)
fireEvent.dragOver(folderTreeItem)
fireEvent.drop(folderTreeItem)
fireEvent.dragLeave(folderTreeItem)
expect(dndMocks.onDragEnter).toHaveBeenCalledTimes(1)
expect(dndMocks.onDragOver).toHaveBeenCalledTimes(1)
expect(dndMocks.onDrop).toHaveBeenCalledTimes(1)
expect(dndMocks.onDragLeave).toHaveBeenCalledTimes(1)
vi.clearAllMocks()
const fileProps = buildProps({ id: 'file-2', name: 'guide.md', nodeType: 'file' })
rerender(<TreeNode {...fileProps} />)
const fileTreeItem = screen.getByRole('treeitem')
fireEvent.dragEnter(fileTreeItem)
fireEvent.dragOver(fileTreeItem)
fireEvent.drop(fileTreeItem)
fireEvent.dragLeave(fileTreeItem)
expect(dndMocks.onDragEnter).not.toHaveBeenCalled()
expect(dndMocks.onDragOver).not.toHaveBeenCalled()
expect(dndMocks.onDrop).not.toHaveBeenCalled()
expect(dndMocks.onDragLeave).not.toHaveBeenCalled()
})
it('should open and close dropdown menu when more actions button is toggled', () => {
const props = buildProps({ id: 'file-1', name: 'readme.md', nodeType: 'file' })
render(<TreeNode {...props} />)
expect(screen.queryByTestId('node-menu')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.menu\.moreActions/i }))
expect(screen.getByTestId('node-menu')).toHaveAttribute('data-type', 'file')
fireEvent.click(screen.getByRole('button', { name: 'close-menu' }))
expect(screen.queryByTestId('node-menu')).not.toBeInTheDocument()
})
})
// Effects should synchronize external drag status transitions into workflow store state.
describe('Drag state synchronization effects', () => {
it('should set drag type on drag start and clear drag state on drag end', () => {
const initialProps = buildProps({ id: 'file-1', nodeType: 'file', isDragging: false })
const { rerender } = render(<TreeNode {...initialProps} />)
const draggingProps = buildProps({ id: 'file-1', nodeType: 'file', isDragging: true })
rerender(<TreeNode {...draggingProps} />)
expect(storeActions.setCurrentDragType).toHaveBeenCalledWith('move')
const notDraggingProps = buildProps({ id: 'file-1', nodeType: 'file', isDragging: false })
rerender(<TreeNode {...notDraggingProps} />)
expect(storeActions.setCurrentDragType).toHaveBeenCalledWith(null)
expect(storeActions.setDragOverFolderId).toHaveBeenCalledWith(null)
})
it('should sync drag-over folder id when folder willReceiveDrop changes', () => {
const initialProps = buildProps({
id: 'folder-1',
nodeType: 'folder',
willReceiveDrop: false,
})
const { rerender } = render(<TreeNode {...initialProps} />)
const receiveDropProps = buildProps({
id: 'folder-1',
nodeType: 'folder',
willReceiveDrop: true,
})
rerender(<TreeNode {...receiveDropProps} />)
expect(storeActions.setDragOverFolderId).toHaveBeenCalledWith('folder-1')
const stopReceiveDropProps = buildProps({
id: 'folder-1',
nodeType: 'folder',
willReceiveDrop: false,
})
rerender(<TreeNode {...stopReceiveDropProps} />)
expect(storeActions.setDragOverFolderId).toHaveBeenCalledWith(null)
})
})
})

View File

@@ -0,0 +1,187 @@
import { fireEvent, render, screen } from '@testing-library/react'
import UploadStatusTooltip from './upload-status-tooltip'
type MockWorkflowState = {
uploadStatus: 'idle' | 'uploading' | 'success' | 'partial_error'
uploadProgress: {
uploaded: number
total: number
failed: number
}
}
const mocks = vi.hoisted(() => ({
storeState: {
uploadStatus: 'idle',
uploadProgress: { uploaded: 0, total: 0, failed: 0 },
} as MockWorkflowState,
resetUpload: vi.fn(),
storeApi: {
getState: () => ({
resetUpload: mocks.resetUpload,
}),
},
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: MockWorkflowState) => unknown) => selector(mocks.storeState),
useWorkflowStore: () => mocks.storeApi,
}))
const setUploadState = (overrides: Partial<MockWorkflowState> = {}) => {
mocks.storeState.uploadStatus = overrides.uploadStatus ?? 'idle'
mocks.storeState.uploadProgress = overrides.uploadProgress ?? { uploaded: 0, total: 0, failed: 0 }
}
describe('UploadStatusTooltip', () => {
beforeEach(() => {
vi.clearAllMocks()
setUploadState()
})
afterEach(() => {
vi.useRealTimers()
})
// Different upload states should render different user-facing feedback.
describe('Rendering', () => {
it('should render fallback content when upload status is idle', () => {
// Arrange
setUploadState({ uploadStatus: 'idle' })
// Act
render(<UploadStatusTooltip fallback={<span>Idle fallback</span>} />)
// Assert
expect(screen.getByText('Idle fallback')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /common\.operation\.close/i })).not.toBeInTheDocument()
})
it('should render uploading text and progress width when upload is in progress', () => {
// Arrange
setUploadState({
uploadStatus: 'uploading',
uploadProgress: { uploaded: 2, total: 5, failed: 0 },
})
// Act
const { container } = render(<UploadStatusTooltip />)
const progressBar = container.querySelector('.bg-components-progress-bar-progress')
// Assert
expect(screen.getByText(/workflow\.skillSidebar\.uploadingItems/i)).toBeInTheDocument()
expect(screen.getByText(/"uploaded":2/)).toBeInTheDocument()
expect(progressBar).toBeInTheDocument()
expect(progressBar).toHaveStyle({ width: '40%' })
})
it('should clamp uploading progress width to 0% when total is 0', () => {
// Arrange
setUploadState({
uploadStatus: 'uploading',
uploadProgress: { uploaded: 2, total: 0, failed: 0 },
})
// Act
const { container } = render(<UploadStatusTooltip />)
const progressBar = container.querySelector('.bg-components-progress-bar-progress')
// Assert
expect(progressBar).toHaveStyle({ width: '0%' })
})
it('should render success title and detail when upload succeeds', () => {
// Arrange
setUploadState({
uploadStatus: 'success',
uploadProgress: { uploaded: 3, total: 3, failed: 0 },
})
// Act
render(<UploadStatusTooltip />)
// Assert
expect(screen.getByText('workflow.skillSidebar.uploadSuccess')).toBeInTheDocument()
expect(screen.getByText(/workflow\.skillSidebar\.uploadSuccessDetail/i)).toBeInTheDocument()
})
it('should render partial error title and detail when upload partially fails', () => {
// Arrange
setUploadState({
uploadStatus: 'partial_error',
uploadProgress: { uploaded: 2, total: 5, failed: 3 },
})
// Act
render(<UploadStatusTooltip />)
// Assert
expect(screen.getByText('workflow.skillSidebar.uploadPartialError')).toBeInTheDocument()
expect(screen.getByText(/workflow\.skillSidebar\.uploadPartialErrorDetail/i)).toBeInTheDocument()
})
})
// User action should dismiss the tooltip via store action.
describe('Interactions', () => {
it('should call resetUpload when close button is clicked', () => {
// Arrange
setUploadState({
uploadStatus: 'partial_error',
uploadProgress: { uploaded: 2, total: 5, failed: 3 },
})
render(<UploadStatusTooltip />)
// Act
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.close/i }))
// Assert
expect(mocks.resetUpload).toHaveBeenCalledTimes(1)
})
})
// Success state uses a timer and should clean it up correctly.
describe('Success timer', () => {
it('should reset upload automatically after success display duration', () => {
// Arrange
vi.useFakeTimers()
setUploadState({
uploadStatus: 'success',
uploadProgress: { uploaded: 1, total: 1, failed: 0 },
})
render(<UploadStatusTooltip />)
// Act
vi.advanceTimersByTime(1999)
// Assert
expect(mocks.resetUpload).not.toHaveBeenCalled()
// Act
vi.advanceTimersByTime(1)
// Assert
expect(mocks.resetUpload).toHaveBeenCalledTimes(1)
})
it('should clear pending success timer when status changes before timeout', () => {
// Arrange
vi.useFakeTimers()
setUploadState({
uploadStatus: 'success',
uploadProgress: { uploaded: 1, total: 1, failed: 0 },
})
const { rerender } = render(<UploadStatusTooltip fallback={<span>v1</span>} />)
// Act
setUploadState({
uploadStatus: 'uploading',
uploadProgress: { uploaded: 1, total: 3, failed: 0 },
})
rerender(<UploadStatusTooltip fallback={<span>v2</span>} />)
vi.advanceTimersByTime(3000)
// Assert
expect(mocks.resetUpload).not.toHaveBeenCalled()
})
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,100 @@
import type { ComponentProps } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import FileTabItem from './file-tab-item'
type FileTabItemProps = ComponentProps<typeof FileTabItem>
const createProps = (overrides: Partial<FileTabItemProps> = {}) => {
const onClick = vi.fn()
const onClose = vi.fn()
const onDoubleClick = vi.fn()
const props: FileTabItemProps = {
fileId: 'file-1',
name: 'readme.md',
extension: 'md',
isActive: false,
isDirty: false,
isPreview: false,
onClick,
onClose,
onDoubleClick,
...overrides,
}
return { props, onClick, onClose, onDoubleClick }
}
describe('FileTabItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering behavior for the tab label and close action.
describe('Rendering', () => {
it('should render the file tab button and close button', () => {
const { props } = createProps()
render(<FileTabItem {...props} />)
expect(screen.getByRole('button', { name: /readme\.md/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /common\.operation\.close/i })).toBeInTheDocument()
})
it('should style the file name as preview when isPreview is true', () => {
const { props } = createProps({ isPreview: true })
render(<FileTabItem {...props} />)
expect(screen.getByText('readme.md')).toHaveClass('italic')
})
})
// Pointer interactions should trigger the corresponding callbacks.
describe('Interactions', () => {
it('should call onClick with file id when the tab is clicked', () => {
const { props, onClick } = createProps()
render(<FileTabItem {...props} />)
fireEvent.click(screen.getByRole('button', { name: /readme\.md/i }))
expect(onClick).toHaveBeenCalledTimes(1)
expect(onClick).toHaveBeenCalledWith('file-1')
})
it('should call onDoubleClick with file id when preview tab is double clicked', () => {
const { props, onDoubleClick } = createProps({ isPreview: true })
render(<FileTabItem {...props} />)
fireEvent.doubleClick(screen.getByRole('button', { name: /readme\.md/i }))
expect(onDoubleClick).toHaveBeenCalledTimes(1)
expect(onDoubleClick).toHaveBeenCalledWith('file-1')
})
it('should not call onDoubleClick when tab is not in preview mode', () => {
const { props, onDoubleClick } = createProps({ isPreview: false })
render(<FileTabItem {...props} />)
fireEvent.doubleClick(screen.getByRole('button', { name: /readme\.md/i }))
expect(onDoubleClick).not.toHaveBeenCalled()
})
it('should call onClose and stop propagation when close button is clicked', () => {
const parentClick = vi.fn()
const { props, onClose } = createProps()
render(
<div onClick={parentClick}>
<FileTabItem {...props} />
</div>,
)
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.close/i }))
expect(onClose).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledWith('file-1')
expect(parentClick).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,239 @@
import type { AppAssetTreeView } from '@/types/app-asset'
import { fireEvent, render, screen } from '@testing-library/react'
import { makeArtifactTabId, START_TAB_ID } from '../../constants'
import FileTabs from './file-tabs'
type MockWorkflowState = {
openTabIds: string[]
activeTabId: string | null
previewTabId: string | null
dirtyContents: Set<string>
dirtyMetadataIds: Set<string>
}
const mocks = vi.hoisted(() => ({
storeState: {
openTabIds: [],
activeTabId: '__start__',
previewTabId: null,
dirtyContents: new Set<string>(),
dirtyMetadataIds: new Set<string>(),
} as MockWorkflowState,
nodeMap: undefined as Map<string, AppAssetTreeView> | undefined,
activateTab: vi.fn(),
pinTab: vi.fn(),
closeTab: vi.fn(),
clearDraftContent: vi.fn(),
clearFileMetadata: vi.fn(),
clearArtifactSelection: vi.fn(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: MockWorkflowState) => unknown) => selector(mocks.storeState),
useWorkflowStore: () => ({
getState: () => ({
activateTab: mocks.activateTab,
pinTab: mocks.pinTab,
closeTab: mocks.closeTab,
clearDraftContent: mocks.clearDraftContent,
clearFileMetadata: mocks.clearFileMetadata,
clearArtifactSelection: mocks.clearArtifactSelection,
}),
}),
}))
vi.mock('../../hooks/file-tree/data/use-skill-asset-tree', () => ({
useSkillAssetNodeMap: () => ({ data: mocks.nodeMap }),
}))
const createNode = (overrides: Partial<AppAssetTreeView> = {}): AppAssetTreeView => ({
id: 'file-1',
node_type: 'file',
name: 'guide.md',
path: '/guide.md',
extension: 'md',
size: 10,
children: [],
...overrides,
})
const setMockState = (overrides: Partial<MockWorkflowState> = {}) => {
mocks.storeState.openTabIds = overrides.openTabIds ?? []
mocks.storeState.activeTabId = overrides.activeTabId ?? START_TAB_ID
mocks.storeState.previewTabId = overrides.previewTabId ?? null
mocks.storeState.dirtyContents = overrides.dirtyContents ?? new Set<string>()
mocks.storeState.dirtyMetadataIds = overrides.dirtyMetadataIds ?? new Set<string>()
}
const setMockNodeMap = (nodes: AppAssetTreeView[] = []) => {
mocks.nodeMap = new Map(nodes.map(node => [node.id, node]))
}
describe('FileTabs', () => {
beforeEach(() => {
vi.clearAllMocks()
setMockState()
setMockNodeMap([])
})
// Rendering behavior for start tab, file tabs, and fallback naming.
describe('Rendering', () => {
it('should render start tab and tabs for regular and artifact files', () => {
const artifactTabId = makeArtifactTabId('/assets/logo.png')
setMockState({
openTabIds: ['file-1', artifactTabId],
activeTabId: 'file-1',
})
setMockNodeMap([
createNode({ id: 'file-1', name: 'guide.md' }),
])
render(<FileTabs />)
expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.startTab/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /guide\.md/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /logo\.png/i })).toBeInTheDocument()
})
it('should fall back to file id when node is missing from node map', () => {
setMockState({
openTabIds: ['missing-file-id'],
activeTabId: 'missing-file-id',
})
setMockNodeMap([])
render(<FileTabs />)
expect(screen.getByRole('button', { name: /missing-file-id/i })).toBeInTheDocument()
})
})
// Tab interactions should dispatch store actions.
describe('Tab actions', () => {
it('should activate the start tab when start tab is clicked', () => {
render(<FileTabs />)
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.startTab/i }))
expect(mocks.activateTab).toHaveBeenCalledTimes(1)
expect(mocks.activateTab).toHaveBeenCalledWith(START_TAB_ID)
})
it('should activate a file tab when a file tab is clicked', () => {
setMockState({
openTabIds: ['file-1'],
activeTabId: START_TAB_ID,
})
setMockNodeMap([createNode({ id: 'file-1', name: 'guide.md' })])
render(<FileTabs />)
fireEvent.click(screen.getByRole('button', { name: /guide\.md/i }))
expect(mocks.activateTab).toHaveBeenCalledTimes(1)
expect(mocks.activateTab).toHaveBeenCalledWith('file-1')
})
it('should pin a preview tab when it is double clicked', () => {
setMockState({
openTabIds: ['file-1'],
activeTabId: 'file-1',
previewTabId: 'file-1',
})
setMockNodeMap([createNode({ id: 'file-1', name: 'guide.md' })])
render(<FileTabs />)
fireEvent.doubleClick(screen.getByRole('button', { name: /guide\.md/i }))
expect(mocks.pinTab).toHaveBeenCalledTimes(1)
expect(mocks.pinTab).toHaveBeenCalledWith('file-1')
})
it('should close a clean file tab and clear draft and metadata', () => {
setMockState({
openTabIds: ['file-1'],
activeTabId: 'file-1',
})
setMockNodeMap([createNode({ id: 'file-1', name: 'guide.md' })])
render(<FileTabs />)
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.close/i }))
expect(mocks.closeTab).toHaveBeenCalledTimes(1)
expect(mocks.closeTab).toHaveBeenCalledWith('file-1')
expect(mocks.clearDraftContent).toHaveBeenCalledWith('file-1')
expect(mocks.clearFileMetadata).toHaveBeenCalledWith('file-1')
expect(mocks.clearArtifactSelection).not.toHaveBeenCalled()
})
it('should clear artifact selection before closing artifact tab', () => {
const artifactTabId = makeArtifactTabId('/assets/logo.png')
setMockState({
openTabIds: [artifactTabId],
activeTabId: artifactTabId,
})
render(<FileTabs />)
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.close/i }))
expect(mocks.clearArtifactSelection).toHaveBeenCalledTimes(1)
expect(mocks.closeTab).toHaveBeenCalledWith(artifactTabId)
expect(mocks.clearDraftContent).toHaveBeenCalledWith(artifactTabId)
expect(mocks.clearFileMetadata).toHaveBeenCalledWith(artifactTabId)
})
})
// Dirty tabs must show confirmation before closing.
describe('Unsaved changes confirmation', () => {
it('should show confirmation dialog instead of closing immediately for dirty tab', () => {
setMockState({
openTabIds: ['file-1'],
activeTabId: 'file-1',
dirtyContents: new Set(['file-1']),
})
setMockNodeMap([createNode({ id: 'file-1', name: 'guide.md' })])
render(<FileTabs />)
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.close/i }))
expect(mocks.closeTab).not.toHaveBeenCalled()
expect(screen.getByText('workflow.skillSidebar.unsavedChanges.title')).toBeInTheDocument()
})
it('should close the dirty tab when user confirms', () => {
setMockState({
openTabIds: ['file-1'],
activeTabId: 'file-1',
dirtyMetadataIds: new Set(['file-1']),
})
setMockNodeMap([createNode({ id: 'file-1', name: 'guide.md' })])
render(<FileTabs />)
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.close/i }))
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.unsavedChanges\.confirmClose/i }))
expect(mocks.closeTab).toHaveBeenCalledTimes(1)
expect(mocks.closeTab).toHaveBeenCalledWith('file-1')
expect(mocks.clearDraftContent).toHaveBeenCalledWith('file-1')
expect(mocks.clearFileMetadata).toHaveBeenCalledWith('file-1')
})
it('should keep the tab open when user cancels the close confirmation', () => {
setMockState({
openTabIds: ['file-1'],
activeTabId: 'file-1',
dirtyContents: new Set(['file-1']),
})
setMockNodeMap([createNode({ id: 'file-1', name: 'guide.md' })])
render(<FileTabs />)
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.close/i }))
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
expect(mocks.closeTab).not.toHaveBeenCalled()
expect(mocks.clearDraftContent).not.toHaveBeenCalled()
expect(mocks.clearFileMetadata).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,61 @@
import type { ComponentProps } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import StartTabItem from './start-tab-item'
type StartTabItemProps = ComponentProps<typeof StartTabItem>
const createProps = (overrides: Partial<StartTabItemProps> = {}) => {
const onClick = vi.fn()
const props: StartTabItemProps = {
isActive: false,
onClick,
...overrides,
}
return { props, onClick }
}
describe('StartTabItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering behavior for the start tab button and label.
describe('Rendering', () => {
it('should render the start tab button with translated label', () => {
const { props } = createProps()
render(<StartTabItem {...props} />)
expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.startTab/i })).toBeInTheDocument()
})
it('should style the start label as active when isActive is true', () => {
const { props } = createProps({ isActive: true })
render(<StartTabItem {...props} />)
expect(screen.getByText('workflow.skillSidebar.startTab')).toHaveClass('text-text-primary')
})
it('should style the start label as inactive when isActive is false', () => {
const { props } = createProps({ isActive: false })
render(<StartTabItem {...props} />)
expect(screen.getByText('workflow.skillSidebar.startTab')).toHaveClass('text-text-tertiary')
})
})
// Clicking the tab should delegate to the callback.
describe('Interactions', () => {
it('should call onClick when start tab is clicked', () => {
const { props, onClick } = createProps()
render(<StartTabItem {...props} />)
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.startTab/i }))
expect(onClick).toHaveBeenCalledTimes(1)
})
})
})