mirror of
https://github.com/langgenius/dify.git
synced 2026-02-09 23:20:12 -05:00
test(skill): add comprehensive unit tests for file-tree domain
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { App, AppSSO } from '@/types/app'
|
||||
import type { AppAssetTreeResponse, AppAssetTreeView } from '@/types/app-asset'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import {
|
||||
useExistingSkillNames,
|
||||
useSkillAssetNodeMap,
|
||||
useSkillAssetTreeData,
|
||||
} from './use-skill-asset-tree'
|
||||
|
||||
const { mockUseGetAppAssetTree } = vi.hoisted(() => ({
|
||||
mockUseGetAppAssetTree: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-app-asset', () => ({
|
||||
useGetAppAssetTree: (...args: unknown[]) => mockUseGetAppAssetTree(...args),
|
||||
}))
|
||||
|
||||
const createTreeNode = (
|
||||
overrides: Partial<AppAssetTreeView> & Pick<AppAssetTreeView, 'id' | 'node_type' | 'name'>,
|
||||
): AppAssetTreeView => ({
|
||||
id: overrides.id,
|
||||
node_type: overrides.node_type,
|
||||
name: overrides.name,
|
||||
path: overrides.path ?? `/${overrides.name}`,
|
||||
extension: overrides.extension ?? '',
|
||||
size: overrides.size ?? 0,
|
||||
children: overrides.children ?? [],
|
||||
})
|
||||
|
||||
describe('useSkillAssetTree', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useAppStore.setState({
|
||||
appDetail: { id: 'app-1' } as App & Partial<AppSSO>,
|
||||
})
|
||||
mockUseGetAppAssetTree.mockReturnValue({
|
||||
data: null,
|
||||
isPending: false,
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: should pass app id from app store to the data query hook.
|
||||
describe('useSkillAssetTreeData', () => {
|
||||
it('should request tree data with current app id', () => {
|
||||
const expectedResult = { data: { children: [] }, isPending: false }
|
||||
mockUseGetAppAssetTree.mockReturnValue(expectedResult)
|
||||
|
||||
const { result } = renderHook(() => useSkillAssetTreeData())
|
||||
|
||||
expect(mockUseGetAppAssetTree).toHaveBeenCalledWith('app-1')
|
||||
expect(result.current).toBe(expectedResult)
|
||||
})
|
||||
|
||||
it('should request tree data with empty app id when app detail is missing', () => {
|
||||
useAppStore.setState({ appDetail: undefined })
|
||||
|
||||
renderHook(() => useSkillAssetTreeData())
|
||||
|
||||
expect(mockUseGetAppAssetTree).toHaveBeenCalledWith('')
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: should expose a select transform that builds node lookup maps.
|
||||
describe('useSkillAssetNodeMap', () => {
|
||||
it('should build a map including nested nodes', () => {
|
||||
renderHook(() => useSkillAssetNodeMap())
|
||||
|
||||
const options = mockUseGetAppAssetTree.mock.calls[0][1] as {
|
||||
select: (data: AppAssetTreeResponse) => Map<string, AppAssetTreeView>
|
||||
}
|
||||
|
||||
const map = options.select({
|
||||
children: [
|
||||
createTreeNode({
|
||||
id: 'folder-1',
|
||||
node_type: 'folder',
|
||||
name: 'skill-a',
|
||||
children: [
|
||||
createTreeNode({
|
||||
id: 'file-1',
|
||||
node_type: 'file',
|
||||
name: 'README.md',
|
||||
extension: 'md',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
expect(map.get('folder-1')?.name).toBe('skill-a')
|
||||
expect(map.get('file-1')?.name).toBe('README.md')
|
||||
expect(map.size).toBe(2)
|
||||
})
|
||||
|
||||
it('should return an empty map when tree response has no children', () => {
|
||||
renderHook(() => useSkillAssetNodeMap())
|
||||
|
||||
const options = mockUseGetAppAssetTree.mock.calls[0][1] as {
|
||||
select: (data: AppAssetTreeResponse) => Map<string, AppAssetTreeView>
|
||||
}
|
||||
|
||||
const map = options.select({} as AppAssetTreeResponse)
|
||||
|
||||
expect(map.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: should expose root-level existing skill folder names.
|
||||
describe('useExistingSkillNames', () => {
|
||||
it('should collect only root folder names', () => {
|
||||
renderHook(() => useExistingSkillNames())
|
||||
|
||||
const options = mockUseGetAppAssetTree.mock.calls[0][1] as {
|
||||
select: (data: AppAssetTreeResponse) => Set<string>
|
||||
}
|
||||
|
||||
const names = options.select({
|
||||
children: [
|
||||
createTreeNode({
|
||||
id: 'folder-1',
|
||||
node_type: 'folder',
|
||||
name: 'skill-a',
|
||||
children: [
|
||||
createTreeNode({
|
||||
id: 'folder-2',
|
||||
node_type: 'folder',
|
||||
name: 'nested-folder',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createTreeNode({
|
||||
id: 'file-1',
|
||||
node_type: 'file',
|
||||
name: 'README.md',
|
||||
extension: 'md',
|
||||
}),
|
||||
createTreeNode({
|
||||
id: 'folder-3',
|
||||
node_type: 'folder',
|
||||
name: 'skill-b',
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
expect(names.has('skill-a')).toBe(true)
|
||||
expect(names.has('skill-b')).toBe(true)
|
||||
expect(names.has('nested-folder')).toBe(false)
|
||||
expect(names.size).toBe(2)
|
||||
})
|
||||
|
||||
it('should return an empty set when tree response has no children', () => {
|
||||
renderHook(() => useExistingSkillNames())
|
||||
|
||||
const options = mockUseGetAppAssetTree.mock.calls[0][1] as {
|
||||
select: (data: AppAssetTreeResponse) => Set<string>
|
||||
}
|
||||
|
||||
const names = options.select({} as AppAssetTreeResponse)
|
||||
|
||||
expect(names.size).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { App, AppSSO } from '@/types/app'
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
} from '@tanstack/react-query'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import {
|
||||
useSkillTreeCollaboration,
|
||||
useSkillTreeUpdateEmitter,
|
||||
} from './use-skill-tree-collaboration'
|
||||
|
||||
const {
|
||||
mockEmitTreeUpdate,
|
||||
mockOnTreeUpdate,
|
||||
mockUnsubscribe,
|
||||
} = vi.hoisted(() => ({
|
||||
mockEmitTreeUpdate: vi.fn(),
|
||||
mockOnTreeUpdate: vi.fn(),
|
||||
mockUnsubscribe: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/collaboration/skills/skill-collaboration-manager', () => ({
|
||||
skillCollaborationManager: {
|
||||
emitTreeUpdate: mockEmitTreeUpdate,
|
||||
onTreeUpdate: mockOnTreeUpdate,
|
||||
},
|
||||
}))
|
||||
|
||||
const createWrapper = (queryClient: QueryClient) => {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('useSkillTreeCollaboration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useAppStore.setState({
|
||||
appDetail: { id: 'app-1' } as App & Partial<AppSSO>,
|
||||
})
|
||||
|
||||
const currentFeatures = useGlobalPublicStore.getState().systemFeatures
|
||||
useGlobalPublicStore.setState({
|
||||
systemFeatures: {
|
||||
...currentFeatures,
|
||||
enable_collaboration_mode: true,
|
||||
},
|
||||
})
|
||||
|
||||
mockOnTreeUpdate.mockReturnValue(mockUnsubscribe)
|
||||
})
|
||||
|
||||
// Scenario: update emitter sends events only when collaboration is enabled and app id exists.
|
||||
describe('useSkillTreeUpdateEmitter', () => {
|
||||
it('should emit tree update with app id and payload', () => {
|
||||
const { result } = renderHook(() => useSkillTreeUpdateEmitter())
|
||||
|
||||
act(() => {
|
||||
result.current({ source: 'test' })
|
||||
})
|
||||
|
||||
expect(mockEmitTreeUpdate).toHaveBeenCalledWith('app-1', { source: 'test' })
|
||||
})
|
||||
|
||||
it('should not emit tree update when collaboration is disabled', () => {
|
||||
const currentFeatures = useGlobalPublicStore.getState().systemFeatures
|
||||
useGlobalPublicStore.setState({
|
||||
systemFeatures: {
|
||||
...currentFeatures,
|
||||
enable_collaboration_mode: false,
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useSkillTreeUpdateEmitter())
|
||||
act(() => {
|
||||
result.current({ source: 'disabled' })
|
||||
})
|
||||
|
||||
expect(mockEmitTreeUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not emit tree update when app id is missing', () => {
|
||||
useAppStore.setState({ appDetail: undefined })
|
||||
|
||||
const { result } = renderHook(() => useSkillTreeUpdateEmitter())
|
||||
act(() => {
|
||||
result.current({ source: 'no-app' })
|
||||
})
|
||||
|
||||
expect(mockEmitTreeUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: collaboration hook subscribes to updates and invalidates tree query cache.
|
||||
describe('useSkillTreeCollaboration', () => {
|
||||
it('should subscribe to tree updates and invalidate app tree query when updates arrive', async () => {
|
||||
let treeUpdateCallback: ((payload: Record<string, unknown>) => void) | null = null
|
||||
mockOnTreeUpdate.mockImplementation((_appId: string, callback: (payload: Record<string, unknown>) => void) => {
|
||||
treeUpdateCallback = callback
|
||||
return mockUnsubscribe
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
|
||||
renderHook(() => useSkillTreeCollaboration(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
})
|
||||
|
||||
expect(mockOnTreeUpdate).toHaveBeenCalledWith('app-1', expect.any(Function))
|
||||
|
||||
act(() => {
|
||||
treeUpdateCallback?.({ reason: 'remote' })
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(invalidateQueriesSpy).toHaveBeenCalledWith({
|
||||
queryKey: consoleQuery.appAsset.tree.queryKey({ input: { params: { appId: 'app-1' } } }),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should clean up tree update subscription on unmount', () => {
|
||||
const queryClient = new QueryClient()
|
||||
const { unmount } = renderHook(() => useSkillTreeCollaboration(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
})
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockUnsubscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should skip subscription when collaboration is disabled', () => {
|
||||
const currentFeatures = useGlobalPublicStore.getState().systemFeatures
|
||||
useGlobalPublicStore.setState({
|
||||
systemFeatures: {
|
||||
...currentFeatures,
|
||||
enable_collaboration_mode: false,
|
||||
},
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
renderHook(() => useSkillTreeCollaboration(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
})
|
||||
|
||||
expect(mockOnTreeUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should skip subscription when app id is missing', () => {
|
||||
useAppStore.setState({ appDetail: undefined })
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
renderHook(() => useSkillTreeCollaboration(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
})
|
||||
|
||||
expect(mockOnTreeUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,278 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { App, AppSSO } from '@/types/app'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
import { createWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { ROOT_ID } from '../../../constants'
|
||||
import { useFileDrop } from './use-file-drop'
|
||||
|
||||
const {
|
||||
mockUploadMutateAsync,
|
||||
mockPrepareSkillUploadFile,
|
||||
mockEmitTreeUpdate,
|
||||
mockToastNotify,
|
||||
} = vi.hoisted(() => ({
|
||||
mockUploadMutateAsync: vi.fn(),
|
||||
mockPrepareSkillUploadFile: vi.fn(),
|
||||
mockEmitTreeUpdate: vi.fn(),
|
||||
mockToastNotify: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-app-asset', () => ({
|
||||
useUploadFileWithPresignedUrl: () => ({
|
||||
mutateAsync: mockUploadMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../utils/skill-upload-utils', () => ({
|
||||
prepareSkillUploadFile: mockPrepareSkillUploadFile,
|
||||
}))
|
||||
|
||||
vi.mock('../data/use-skill-tree-collaboration', () => ({
|
||||
useSkillTreeUpdateEmitter: () => mockEmitTreeUpdate,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: mockToastNotify,
|
||||
},
|
||||
}))
|
||||
|
||||
type MockDataTransferItem = {
|
||||
kind: string
|
||||
getAsFile: () => File | null
|
||||
webkitGetAsEntry: () => { isDirectory: boolean } | null
|
||||
}
|
||||
|
||||
type MockDragEvent = {
|
||||
preventDefault: ReturnType<typeof vi.fn>
|
||||
stopPropagation: ReturnType<typeof vi.fn>
|
||||
dataTransfer: {
|
||||
types: string[]
|
||||
items: DataTransferItem[]
|
||||
dropEffect: 'none' | 'copy' | 'move' | 'link'
|
||||
}
|
||||
}
|
||||
|
||||
const createWrapper = (store: ReturnType<typeof createWorkflowStore>) => {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<WorkflowContext.Provider value={store}>
|
||||
{children}
|
||||
</WorkflowContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const createDataTransferItem = (params: {
|
||||
file?: File | null
|
||||
kind?: string
|
||||
isDirectory?: boolean
|
||||
} = {}): DataTransferItem => {
|
||||
const {
|
||||
file = null,
|
||||
kind = 'file',
|
||||
isDirectory,
|
||||
} = params
|
||||
|
||||
const item: MockDataTransferItem = {
|
||||
kind,
|
||||
getAsFile: () => file,
|
||||
webkitGetAsEntry: () => {
|
||||
if (typeof isDirectory === 'boolean')
|
||||
return { isDirectory }
|
||||
return null
|
||||
},
|
||||
}
|
||||
|
||||
return item as unknown as DataTransferItem
|
||||
}
|
||||
|
||||
const createDragEvent = (params: {
|
||||
types?: string[]
|
||||
items?: DataTransferItem[]
|
||||
} = {}): MockDragEvent => {
|
||||
return {
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
dataTransfer: {
|
||||
types: params.types ?? ['Files'],
|
||||
items: params.items ?? [],
|
||||
dropEffect: 'none',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('useFileDrop', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useAppStore.setState({
|
||||
appDetail: { id: 'app-1' } as App & Partial<AppSSO>,
|
||||
})
|
||||
mockPrepareSkillUploadFile.mockImplementation(async (file: File) => file)
|
||||
mockUploadMutateAsync.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
// Scenario: drag-over updates upload drag state for valid external file drags.
|
||||
describe('Drag Over', () => {
|
||||
it('should set upload drag state when file drag enters root target', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const { result } = renderHook(() => useFileDrop(), { wrapper: createWrapper(store) })
|
||||
const event = createDragEvent()
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragOver(event as unknown as React.DragEvent, {
|
||||
folderId: null,
|
||||
isFolder: false,
|
||||
})
|
||||
})
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(event.stopPropagation).toHaveBeenCalledTimes(1)
|
||||
expect(event.dataTransfer.dropEffect).toBe('copy')
|
||||
expect(store.getState().currentDragType).toBe('upload')
|
||||
expect(store.getState().dragOverFolderId).toBe(ROOT_ID)
|
||||
})
|
||||
|
||||
it('should ignore drag-over when dragged payload does not contain files', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const { result } = renderHook(() => useFileDrop(), { wrapper: createWrapper(store) })
|
||||
const event = createDragEvent({ types: ['text/plain'] })
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragOver(event as unknown as React.DragEvent, {
|
||||
folderId: 'folder-1',
|
||||
isFolder: true,
|
||||
})
|
||||
})
|
||||
|
||||
expect(store.getState().currentDragType).toBeNull()
|
||||
expect(store.getState().dragOverFolderId).toBeNull()
|
||||
expect(event.dataTransfer.dropEffect).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: directory drops are rejected and do not trigger upload mutations.
|
||||
describe('Folder Drop Rejection', () => {
|
||||
it('should reject dropped folders and show an error toast', async () => {
|
||||
const store = createWorkflowStore({})
|
||||
store.getState().setCurrentDragType('upload')
|
||||
store.getState().setDragOverFolderId('folder-1')
|
||||
const { result } = renderHook(() => useFileDrop(), { wrapper: createWrapper(store) })
|
||||
const event = createDragEvent({
|
||||
items: [createDataTransferItem({ isDirectory: true })],
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDrop(event as unknown as React.DragEvent, 'folder-1')
|
||||
})
|
||||
|
||||
expect(mockPrepareSkillUploadFile).not.toHaveBeenCalled()
|
||||
expect(mockUploadMutateAsync).not.toHaveBeenCalled()
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'workflow.skillSidebar.menu.folderDropNotSupported',
|
||||
})
|
||||
expect(store.getState().currentDragType).toBeNull()
|
||||
expect(store.getState().dragOverFolderId).toBeNull()
|
||||
})
|
||||
|
||||
it('should upload valid files while rejecting directories in a mixed drop payload', async () => {
|
||||
const store = createWorkflowStore({})
|
||||
const { result } = renderHook(() => useFileDrop(), { wrapper: createWrapper(store) })
|
||||
const file = new File(['gamma'], 'gamma.md', { type: 'text/markdown' })
|
||||
const event = createDragEvent({
|
||||
items: [
|
||||
createDataTransferItem({ isDirectory: true }),
|
||||
createDataTransferItem({ file }),
|
||||
],
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDrop(event as unknown as React.DragEvent, 'folder-mixed')
|
||||
})
|
||||
|
||||
expect(mockPrepareSkillUploadFile).toHaveBeenCalledTimes(1)
|
||||
expect(mockPrepareSkillUploadFile).toHaveBeenCalledWith(file)
|
||||
expect(mockUploadMutateAsync).toHaveBeenCalledTimes(1)
|
||||
expect(mockUploadMutateAsync).toHaveBeenCalledWith({
|
||||
appId: 'app-1',
|
||||
file,
|
||||
parentId: 'folder-mixed',
|
||||
})
|
||||
expect(mockEmitTreeUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(mockToastNotify).toHaveBeenNthCalledWith(1, {
|
||||
type: 'error',
|
||||
message: 'workflow.skillSidebar.menu.folderDropNotSupported',
|
||||
})
|
||||
expect(mockToastNotify).toHaveBeenNthCalledWith(2, {
|
||||
type: 'success',
|
||||
message: 'workflow.skillSidebar.menu.filesUploaded:{"count":1}',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: successful drops upload prepared files and emit collaboration updates.
|
||||
describe('Upload Success', () => {
|
||||
it('should upload dropped files and show success toast when upload succeeds', async () => {
|
||||
const store = createWorkflowStore({})
|
||||
const { result } = renderHook(() => useFileDrop(), { wrapper: createWrapper(store) })
|
||||
const firstFile = new File(['alpha'], 'alpha.md', { type: 'text/markdown' })
|
||||
const secondFile = new File(['beta'], 'beta.txt', { type: 'text/plain' })
|
||||
const event = createDragEvent({
|
||||
items: [
|
||||
createDataTransferItem({ file: firstFile }),
|
||||
createDataTransferItem({ file: secondFile }),
|
||||
],
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDrop(event as unknown as React.DragEvent, 'folder-9')
|
||||
})
|
||||
|
||||
expect(mockPrepareSkillUploadFile).toHaveBeenCalledTimes(2)
|
||||
expect(mockPrepareSkillUploadFile).toHaveBeenNthCalledWith(1, firstFile)
|
||||
expect(mockPrepareSkillUploadFile).toHaveBeenNthCalledWith(2, secondFile)
|
||||
expect(mockUploadMutateAsync).toHaveBeenCalledTimes(2)
|
||||
expect(mockUploadMutateAsync).toHaveBeenNthCalledWith(1, {
|
||||
appId: 'app-1',
|
||||
file: firstFile,
|
||||
parentId: 'folder-9',
|
||||
})
|
||||
expect(mockUploadMutateAsync).toHaveBeenNthCalledWith(2, {
|
||||
appId: 'app-1',
|
||||
file: secondFile,
|
||||
parentId: 'folder-9',
|
||||
})
|
||||
expect(mockEmitTreeUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'workflow.skillSidebar.menu.filesUploaded:{"count":2}',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: failed uploads surface an error toast and skip collaboration updates.
|
||||
describe('Upload Error', () => {
|
||||
it('should show error toast when upload fails', async () => {
|
||||
const store = createWorkflowStore({})
|
||||
const { result } = renderHook(() => useFileDrop(), { wrapper: createWrapper(store) })
|
||||
const file = new File(['content'], 'failed.md', { type: 'text/markdown' })
|
||||
const event = createDragEvent({
|
||||
items: [createDataTransferItem({ file })],
|
||||
})
|
||||
mockUploadMutateAsync.mockRejectedValueOnce(new Error('upload failed'))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDrop(event as unknown as React.DragEvent, 'folder-err')
|
||||
})
|
||||
|
||||
expect(mockUploadMutateAsync).toHaveBeenCalledTimes(1)
|
||||
expect(mockEmitTreeUpdate).not.toHaveBeenCalled()
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'workflow.skillSidebar.menu.uploadError',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,242 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { NodeApi } from 'react-arborist'
|
||||
import type { TreeNodeData } from '../../../type'
|
||||
import type { AppAssetTreeView } from '@/types/app-asset'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
import { createWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { INTERNAL_NODE_DRAG_TYPE } from '../../../constants'
|
||||
import { useFolderFileDrop } from './use-folder-file-drop'
|
||||
|
||||
const {
|
||||
mockHandleDragOver,
|
||||
mockHandleDrop,
|
||||
} = vi.hoisted(() => ({
|
||||
mockHandleDragOver: vi.fn(),
|
||||
mockHandleDrop: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./use-unified-drag', () => ({
|
||||
useUnifiedDrag: () => ({
|
||||
handleDragOver: mockHandleDragOver,
|
||||
handleDrop: mockHandleDrop,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createWrapper = (store: ReturnType<typeof createWorkflowStore>) => {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<WorkflowContext.Provider value={store}>
|
||||
{children}
|
||||
</WorkflowContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const createNode = (params: {
|
||||
id?: string
|
||||
nodeType: 'file' | 'folder'
|
||||
isOpen?: boolean
|
||||
}): NodeApi<TreeNodeData> => {
|
||||
const node = {
|
||||
data: {
|
||||
id: params.id ?? 'node-1',
|
||||
node_type: params.nodeType,
|
||||
name: params.nodeType === 'folder' ? 'folder-a' : 'README.md',
|
||||
path: '/node-1',
|
||||
extension: params.nodeType === 'folder' ? '' : 'md',
|
||||
size: 1,
|
||||
children: [],
|
||||
},
|
||||
isOpen: params.isOpen ?? false,
|
||||
open: vi.fn(),
|
||||
}
|
||||
|
||||
return node as unknown as NodeApi<TreeNodeData>
|
||||
}
|
||||
|
||||
const createDragEvent = (types: string[]): React.DragEvent => {
|
||||
return {
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
dataTransfer: {
|
||||
types,
|
||||
items: [],
|
||||
dropEffect: 'none',
|
||||
} as unknown as DataTransfer,
|
||||
} as unknown as React.DragEvent
|
||||
}
|
||||
|
||||
const EMPTY_TREE_CHILDREN: AppAssetTreeView[] = []
|
||||
|
||||
describe('useFolderFileDrop', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// Scenario: derive drag-over state from workflow store and folder identity.
|
||||
describe('isDragOver', () => {
|
||||
it('should be true when node is folder and dragOverFolderId matches node id', () => {
|
||||
const store = createWorkflowStore({})
|
||||
store.getState().setDragOverFolderId('folder-1')
|
||||
const node = createNode({ id: 'folder-1', nodeType: 'folder' })
|
||||
|
||||
const { result } = renderHook(() => useFolderFileDrop({
|
||||
node,
|
||||
treeChildren: EMPTY_TREE_CHILDREN,
|
||||
}), {
|
||||
wrapper: createWrapper(store),
|
||||
})
|
||||
|
||||
expect(result.current.isDragOver).toBe(true)
|
||||
})
|
||||
|
||||
it('should be false when node is not a folder even if dragOverFolderId matches', () => {
|
||||
const store = createWorkflowStore({})
|
||||
store.getState().setDragOverFolderId('file-1')
|
||||
const node = createNode({ id: 'file-1', nodeType: 'file' })
|
||||
|
||||
const { result } = renderHook(() => useFolderFileDrop({
|
||||
node,
|
||||
treeChildren: EMPTY_TREE_CHILDREN,
|
||||
}), {
|
||||
wrapper: createWrapper(store),
|
||||
})
|
||||
|
||||
expect(result.current.isDragOver).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: drag handlers delegate only for supported drag events on folder nodes.
|
||||
describe('drag handlers', () => {
|
||||
it('should delegate drag over and drop for supported file drag events', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const node = createNode({ id: 'folder-2', nodeType: 'folder' })
|
||||
|
||||
const { result } = renderHook(() => useFolderFileDrop({
|
||||
node,
|
||||
treeChildren: EMPTY_TREE_CHILDREN,
|
||||
}), {
|
||||
wrapper: createWrapper(store),
|
||||
})
|
||||
|
||||
const dragOverEvent = createDragEvent(['Files'])
|
||||
const dropEvent = createDragEvent(['Files'])
|
||||
|
||||
act(() => {
|
||||
result.current.dragHandlers.onDragOver(dragOverEvent)
|
||||
result.current.dragHandlers.onDrop(dropEvent)
|
||||
})
|
||||
|
||||
expect(mockHandleDragOver).toHaveBeenCalledWith(dragOverEvent, {
|
||||
folderId: 'folder-2',
|
||||
isFolder: true,
|
||||
})
|
||||
expect(mockHandleDrop).toHaveBeenCalledWith(dropEvent, 'folder-2')
|
||||
})
|
||||
|
||||
it('should ignore unsupported drag events', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const node = createNode({ id: 'folder-3', nodeType: 'folder' })
|
||||
|
||||
const { result } = renderHook(() => useFolderFileDrop({
|
||||
node,
|
||||
treeChildren: EMPTY_TREE_CHILDREN,
|
||||
}), {
|
||||
wrapper: createWrapper(store),
|
||||
})
|
||||
|
||||
const unsupportedEvent = createDragEvent(['text/plain'])
|
||||
act(() => {
|
||||
result.current.dragHandlers.onDragEnter(unsupportedEvent)
|
||||
result.current.dragHandlers.onDragOver(unsupportedEvent)
|
||||
result.current.dragHandlers.onDragLeave(unsupportedEvent)
|
||||
})
|
||||
|
||||
expect(mockHandleDragOver).not.toHaveBeenCalled()
|
||||
expect(mockHandleDrop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support internal node drag type in drag over handler', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const node = createNode({ id: 'folder-4', nodeType: 'folder' })
|
||||
|
||||
const { result } = renderHook(() => useFolderFileDrop({
|
||||
node,
|
||||
treeChildren: EMPTY_TREE_CHILDREN,
|
||||
}), {
|
||||
wrapper: createWrapper(store),
|
||||
})
|
||||
|
||||
const internalDragEvent = createDragEvent([INTERNAL_NODE_DRAG_TYPE])
|
||||
act(() => {
|
||||
result.current.dragHandlers.onDragOver(internalDragEvent)
|
||||
})
|
||||
|
||||
expect(mockHandleDragOver).toHaveBeenCalledWith(internalDragEvent, {
|
||||
folderId: 'folder-4',
|
||||
isFolder: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: auto-expand lifecycle should blink first, expand later, and cleanup when drag state changes.
|
||||
describe('auto expand and blink', () => {
|
||||
it('should blink after delay and auto-expand folder after longer delay', () => {
|
||||
const store = createWorkflowStore({})
|
||||
store.getState().setDragOverFolderId('folder-5')
|
||||
const node = createNode({ id: 'folder-5', nodeType: 'folder', isOpen: false })
|
||||
|
||||
const { result } = renderHook(() => useFolderFileDrop({
|
||||
node,
|
||||
treeChildren: EMPTY_TREE_CHILDREN,
|
||||
}), {
|
||||
wrapper: createWrapper(store),
|
||||
})
|
||||
|
||||
expect(result.current.isBlinking).toBe(false)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
expect(result.current.isBlinking).toBe(true)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
expect(result.current.isBlinking).toBe(false)
|
||||
expect(node.open).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should cancel auto-expand when drag over state is cleared before expand delay', () => {
|
||||
const store = createWorkflowStore({})
|
||||
store.getState().setDragOverFolderId('folder-6')
|
||||
const node = createNode({ id: 'folder-6', nodeType: 'folder', isOpen: false })
|
||||
|
||||
const { result } = renderHook(() => useFolderFileDrop({
|
||||
node,
|
||||
treeChildren: EMPTY_TREE_CHILDREN,
|
||||
}), {
|
||||
wrapper: createWrapper(store),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
expect(result.current.isBlinking).toBe(true)
|
||||
|
||||
act(() => {
|
||||
store.getState().setDragOverFolderId(null)
|
||||
})
|
||||
expect(result.current.isBlinking).toBe(false)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000)
|
||||
})
|
||||
expect(node.open).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { App, AppSSO } from '@/types/app'
|
||||
import type { AppAssetTreeView } from '@/types/app-asset'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
import { createWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { INTERNAL_NODE_DRAG_TYPE, ROOT_ID } from '../../../constants'
|
||||
import { useRootFileDrop } from './use-root-file-drop'
|
||||
|
||||
const { mockUploadMutateAsync, uploadHookState } = vi.hoisted(() => ({
|
||||
mockUploadMutateAsync: vi.fn(),
|
||||
uploadHookState: { isPending: false },
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-app-asset', () => ({
|
||||
useUploadFileWithPresignedUrl: () => ({
|
||||
mutateAsync: mockUploadMutateAsync,
|
||||
isPending: uploadHookState.isPending,
|
||||
}),
|
||||
}))
|
||||
|
||||
type DragEventOptions = {
|
||||
types: string[]
|
||||
items?: DataTransferItem[]
|
||||
}
|
||||
|
||||
const createDragEvent = ({ types, items = [] }: DragEventOptions): React.DragEvent => {
|
||||
return {
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
dataTransfer: {
|
||||
types,
|
||||
items,
|
||||
dropEffect: 'none',
|
||||
} as unknown as DataTransfer,
|
||||
} as unknown as React.DragEvent
|
||||
}
|
||||
|
||||
const createWrapper = (store: ReturnType<typeof createWorkflowStore>) => {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<WorkflowContext.Provider value={store}>
|
||||
{children}
|
||||
</WorkflowContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const EMPTY_TREE_CHILDREN: AppAssetTreeView[] = []
|
||||
|
||||
describe('useRootFileDrop', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
uploadHookState.isPending = false
|
||||
useAppStore.setState({
|
||||
appDetail: { id: 'app-1' } as App & Partial<AppSSO>,
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleRootDragOver', () => {
|
||||
it('should set root upload drag state when files are dragged over root', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const { result } = renderHook(() => useRootFileDrop({ treeChildren: EMPTY_TREE_CHILDREN }), {
|
||||
wrapper: createWrapper(store),
|
||||
})
|
||||
const dragEvent = createDragEvent({ types: ['Files'] })
|
||||
|
||||
act(() => {
|
||||
result.current.handleRootDragOver(dragEvent)
|
||||
})
|
||||
|
||||
expect(store.getState().currentDragType).toBe('upload')
|
||||
expect(store.getState().dragOverFolderId).toBe(ROOT_ID)
|
||||
})
|
||||
|
||||
it('should skip dragOver handling when drag source is not files', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const { result } = renderHook(() => useRootFileDrop({ treeChildren: EMPTY_TREE_CHILDREN }), {
|
||||
wrapper: createWrapper(store),
|
||||
})
|
||||
const dragEvent = createDragEvent({ types: [INTERNAL_NODE_DRAG_TYPE] })
|
||||
|
||||
act(() => {
|
||||
result.current.handleRootDragOver(dragEvent)
|
||||
})
|
||||
|
||||
expect(store.getState().currentDragType).toBeNull()
|
||||
expect(store.getState().dragOverFolderId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('drag counter behavior', () => {
|
||||
it('should keep drag state until nested drag leaves reach zero', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const { result } = renderHook(() => useRootFileDrop({ treeChildren: EMPTY_TREE_CHILDREN }), {
|
||||
wrapper: createWrapper(store),
|
||||
})
|
||||
const fileDragEvent = createDragEvent({ types: ['Files'] })
|
||||
|
||||
act(() => {
|
||||
result.current.handleRootDragOver(fileDragEvent)
|
||||
})
|
||||
expect(store.getState().currentDragType).toBe('upload')
|
||||
expect(store.getState().dragOverFolderId).toBe(ROOT_ID)
|
||||
|
||||
act(() => {
|
||||
result.current.handleRootDragEnter(fileDragEvent)
|
||||
result.current.handleRootDragEnter(fileDragEvent)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleRootDragLeave(fileDragEvent)
|
||||
})
|
||||
expect(store.getState().currentDragType).toBe('upload')
|
||||
expect(store.getState().dragOverFolderId).toBe(ROOT_ID)
|
||||
|
||||
act(() => {
|
||||
result.current.handleRootDragLeave(fileDragEvent)
|
||||
})
|
||||
expect(store.getState().currentDragType).toBeNull()
|
||||
expect(store.getState().dragOverFolderId).toBeNull()
|
||||
})
|
||||
|
||||
it('should not increment counter when dragEnter is not a supported drag event', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const { result } = renderHook(() => useRootFileDrop({ treeChildren: EMPTY_TREE_CHILDREN }), {
|
||||
wrapper: createWrapper(store),
|
||||
})
|
||||
const fileDragEvent = createDragEvent({ types: ['Files'] })
|
||||
const unsupportedDragEvent = createDragEvent({ types: ['text/plain'] })
|
||||
|
||||
act(() => {
|
||||
result.current.handleRootDragOver(fileDragEvent)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleRootDragEnter(unsupportedDragEvent)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleRootDragLeave(fileDragEvent)
|
||||
})
|
||||
|
||||
expect(store.getState().currentDragType).toBeNull()
|
||||
expect(store.getState().dragOverFolderId).toBeNull()
|
||||
})
|
||||
|
||||
it('should not decrement counter when dragLeave is not a supported drag event', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const { result } = renderHook(() => useRootFileDrop({ treeChildren: EMPTY_TREE_CHILDREN }), {
|
||||
wrapper: createWrapper(store),
|
||||
})
|
||||
const fileDragEvent = createDragEvent({ types: ['Files'] })
|
||||
const unsupportedDragEvent = createDragEvent({ types: ['text/plain'] })
|
||||
|
||||
act(() => {
|
||||
result.current.handleRootDragOver(fileDragEvent)
|
||||
result.current.handleRootDragEnter(fileDragEvent)
|
||||
result.current.handleRootDragEnter(fileDragEvent)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleRootDragLeave(unsupportedDragEvent)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleRootDragLeave(fileDragEvent)
|
||||
})
|
||||
expect(store.getState().currentDragType).toBe('upload')
|
||||
expect(store.getState().dragOverFolderId).toBe(ROOT_ID)
|
||||
|
||||
act(() => {
|
||||
result.current.handleRootDragLeave(fileDragEvent)
|
||||
})
|
||||
expect(store.getState().currentDragType).toBeNull()
|
||||
expect(store.getState().dragOverFolderId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('counter reset', () => {
|
||||
it('should clear counter when resetRootDragCounter is called', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const { result } = renderHook(() => useRootFileDrop({ treeChildren: EMPTY_TREE_CHILDREN }), {
|
||||
wrapper: createWrapper(store),
|
||||
})
|
||||
const fileDragEvent = createDragEvent({ types: ['Files'] })
|
||||
|
||||
act(() => {
|
||||
result.current.handleRootDragOver(fileDragEvent)
|
||||
result.current.handleRootDragEnter(fileDragEvent)
|
||||
result.current.handleRootDragEnter(fileDragEvent)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.resetRootDragCounter()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleRootDragLeave(fileDragEvent)
|
||||
})
|
||||
expect(store.getState().currentDragType).toBeNull()
|
||||
expect(store.getState().dragOverFolderId).toBeNull()
|
||||
})
|
||||
|
||||
it('should reset counter after drop and clear drag state', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const { result } = renderHook(() => useRootFileDrop({ treeChildren: EMPTY_TREE_CHILDREN }), {
|
||||
wrapper: createWrapper(store),
|
||||
})
|
||||
const beforeDropEvent = createDragEvent({ types: ['Files'], items: [] })
|
||||
const afterDropEvent = createDragEvent({ types: ['Files'], items: [] })
|
||||
|
||||
act(() => {
|
||||
result.current.handleRootDragOver(beforeDropEvent)
|
||||
result.current.handleRootDragEnter(beforeDropEvent)
|
||||
result.current.handleRootDragEnter(beforeDropEvent)
|
||||
result.current.handleRootDrop(beforeDropEvent)
|
||||
})
|
||||
|
||||
expect(store.getState().currentDragType).toBeNull()
|
||||
expect(store.getState().dragOverFolderId).toBeNull()
|
||||
expect(beforeDropEvent.preventDefault).toHaveBeenCalled()
|
||||
expect(beforeDropEvent.stopPropagation).toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
result.current.handleRootDragOver(afterDropEvent)
|
||||
})
|
||||
expect(store.getState().currentDragType).toBe('upload')
|
||||
expect(store.getState().dragOverFolderId).toBe(ROOT_ID)
|
||||
|
||||
act(() => {
|
||||
result.current.handleRootDragLeave(afterDropEvent)
|
||||
})
|
||||
expect(store.getState().currentDragType).toBeNull()
|
||||
expect(store.getState().dragOverFolderId).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { App, AppSSO } from '@/types/app'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
import { createWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { INTERNAL_NODE_DRAG_TYPE } from '../../../constants'
|
||||
import { useUnifiedDrag } from './use-unified-drag'
|
||||
|
||||
const { mockUploadMutateAsync, uploadHookState } = vi.hoisted(() => ({
|
||||
mockUploadMutateAsync: vi.fn(),
|
||||
uploadHookState: { isPending: false },
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-app-asset', () => ({
|
||||
useUploadFileWithPresignedUrl: () => ({
|
||||
mutateAsync: mockUploadMutateAsync,
|
||||
isPending: uploadHookState.isPending,
|
||||
}),
|
||||
}))
|
||||
|
||||
type DragEventOptions = {
|
||||
types: string[]
|
||||
items?: DataTransferItem[]
|
||||
}
|
||||
|
||||
const createDragEvent = ({ types, items = [] }: DragEventOptions): React.DragEvent => {
|
||||
return {
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
dataTransfer: {
|
||||
types,
|
||||
items,
|
||||
dropEffect: 'none',
|
||||
} as unknown as DataTransfer,
|
||||
} as unknown as React.DragEvent
|
||||
}
|
||||
|
||||
const createWrapper = (store: ReturnType<typeof createWorkflowStore>) => {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<WorkflowContext.Provider value={store}>
|
||||
{children}
|
||||
</WorkflowContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('useUnifiedDrag', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
uploadHookState.isPending = false
|
||||
useAppStore.setState({
|
||||
appDetail: { id: 'app-1' } as App & Partial<AppSSO>,
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleDragOver', () => {
|
||||
it('should update drag state when drag source contains files', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const { result } = renderHook(() => useUnifiedDrag(), {
|
||||
wrapper: createWrapper(store),
|
||||
})
|
||||
const dragEvent = createDragEvent({ types: ['Files'] })
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragOver(dragEvent, { folderId: 'folder-1', isFolder: true })
|
||||
})
|
||||
|
||||
expect(store.getState().currentDragType).toBe('upload')
|
||||
expect(store.getState().dragOverFolderId).toBe('folder-1')
|
||||
expect(dragEvent.dataTransfer.dropEffect).toBe('copy')
|
||||
})
|
||||
|
||||
it('should ignore dragOver when drag source does not contain files', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const { result } = renderHook(() => useUnifiedDrag(), {
|
||||
wrapper: createWrapper(store),
|
||||
})
|
||||
const dragEvent = createDragEvent({ types: [INTERNAL_NODE_DRAG_TYPE] })
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragOver(dragEvent, { folderId: 'folder-1', isFolder: true })
|
||||
})
|
||||
|
||||
expect(store.getState().currentDragType).toBeNull()
|
||||
expect(store.getState().dragOverFolderId).toBeNull()
|
||||
expect(dragEvent.dataTransfer.dropEffect).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleDragLeave', () => {
|
||||
it('should clear drag state when drag source contains files', () => {
|
||||
const store = createWorkflowStore({})
|
||||
const { result } = renderHook(() => useUnifiedDrag(), {
|
||||
wrapper: createWrapper(store),
|
||||
})
|
||||
const dragEvent = createDragEvent({ types: ['Files'] })
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragOver(dragEvent, { folderId: 'folder-1', isFolder: true })
|
||||
})
|
||||
expect(store.getState().currentDragType).toBe('upload')
|
||||
expect(store.getState().dragOverFolderId).toBe('folder-1')
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragLeave(dragEvent)
|
||||
})
|
||||
|
||||
expect(store.getState().currentDragType).toBeNull()
|
||||
expect(store.getState().dragOverFolderId).toBeNull()
|
||||
})
|
||||
|
||||
it('should ignore dragLeave when drag source does not contain files', () => {
|
||||
const store = createWorkflowStore({})
|
||||
store.getState().setCurrentDragType('upload')
|
||||
store.getState().setDragOverFolderId('folder-1')
|
||||
|
||||
const { result } = renderHook(() => useUnifiedDrag(), {
|
||||
wrapper: createWrapper(store),
|
||||
})
|
||||
const dragEvent = createDragEvent({ types: [INTERNAL_NODE_DRAG_TYPE] })
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragLeave(dragEvent)
|
||||
})
|
||||
|
||||
expect(store.getState().currentDragType).toBe('upload')
|
||||
expect(store.getState().dragOverFolderId).toBe('folder-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleDrop', () => {
|
||||
it('should delegate drop handling when drag source contains files', async () => {
|
||||
const store = createWorkflowStore({})
|
||||
store.getState().setCurrentDragType('upload')
|
||||
store.getState().setDragOverFolderId('folder-1')
|
||||
|
||||
const { result } = renderHook(() => useUnifiedDrag(), {
|
||||
wrapper: createWrapper(store),
|
||||
})
|
||||
const dragEvent = createDragEvent({ types: ['Files'], items: [] })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDrop(dragEvent, null)
|
||||
})
|
||||
|
||||
expect(store.getState().currentDragType).toBeNull()
|
||||
expect(store.getState().dragOverFolderId).toBeNull()
|
||||
expect(dragEvent.preventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(dragEvent.stopPropagation).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should return undefined and skip drop handling when drag source does not contain files', async () => {
|
||||
const store = createWorkflowStore({})
|
||||
store.getState().setCurrentDragType('upload')
|
||||
store.getState().setDragOverFolderId('folder-1')
|
||||
|
||||
const { result } = renderHook(() => useUnifiedDrag(), {
|
||||
wrapper: createWrapper(store),
|
||||
})
|
||||
const dragEvent = createDragEvent({ types: [INTERNAL_NODE_DRAG_TYPE], items: [] })
|
||||
|
||||
let dropResult: Promise<void> | undefined
|
||||
await act(async () => {
|
||||
dropResult = result.current.handleDrop(dragEvent, null)
|
||||
})
|
||||
|
||||
expect(dropResult).toBeUndefined()
|
||||
expect(store.getState().currentDragType).toBe('upload')
|
||||
expect(store.getState().dragOverFolderId).toBe('folder-1')
|
||||
expect(dragEvent.preventDefault).not.toHaveBeenCalled()
|
||||
expect(dragEvent.stopPropagation).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isUploading', () => {
|
||||
it('should expose uploading state from file drop hook', () => {
|
||||
uploadHookState.isPending = true
|
||||
const store = createWorkflowStore({})
|
||||
|
||||
const { result } = renderHook(() => useUnifiedDrag(), {
|
||||
wrapper: createWrapper(store),
|
||||
})
|
||||
|
||||
expect(result.current.isUploading).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useDelayedClick } from './use-delayed-click'
|
||||
|
||||
describe('useDelayedClick', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('Single Click', () => {
|
||||
it('should call onSingleClick after the delay when clicked once', () => {
|
||||
const onSingleClick = vi.fn()
|
||||
const onDoubleClick = vi.fn()
|
||||
const { result } = renderHook(() => useDelayedClick({
|
||||
delay: 200,
|
||||
onSingleClick,
|
||||
onDoubleClick,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleClick()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(199)
|
||||
})
|
||||
expect(onSingleClick).not.toHaveBeenCalled()
|
||||
expect(onDoubleClick).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1)
|
||||
})
|
||||
expect(onSingleClick).toHaveBeenCalledTimes(1)
|
||||
expect(onDoubleClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should schedule only one single click when clicked twice before delay ends', () => {
|
||||
const onSingleClick = vi.fn()
|
||||
const onDoubleClick = vi.fn()
|
||||
const { result } = renderHook(() => useDelayedClick({
|
||||
delay: 200,
|
||||
onSingleClick,
|
||||
onDoubleClick,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleClick()
|
||||
})
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleClick()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(199)
|
||||
})
|
||||
expect(onSingleClick).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1)
|
||||
})
|
||||
expect(onSingleClick).toHaveBeenCalledTimes(1)
|
||||
expect(onDoubleClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Double Click', () => {
|
||||
it('should cancel pending single click and call onDoubleClick when double-clicked', () => {
|
||||
const onSingleClick = vi.fn()
|
||||
const onDoubleClick = vi.fn()
|
||||
const { result } = renderHook(() => useDelayedClick({
|
||||
delay: 200,
|
||||
onSingleClick,
|
||||
onDoubleClick,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleClick()
|
||||
})
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(50)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleDoubleClick()
|
||||
})
|
||||
|
||||
expect(onDoubleClick).toHaveBeenCalledTimes(1)
|
||||
expect(onSingleClick).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
expect(onSingleClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onDoubleClick when no single-click timeout is pending', () => {
|
||||
const onSingleClick = vi.fn()
|
||||
const onDoubleClick = vi.fn()
|
||||
const { result } = renderHook(() => useDelayedClick({
|
||||
onSingleClick,
|
||||
onDoubleClick,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleDoubleClick()
|
||||
})
|
||||
|
||||
expect(onDoubleClick).toHaveBeenCalledTimes(1)
|
||||
expect(onSingleClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cleanup', () => {
|
||||
it('should clear pending timeout on unmount', () => {
|
||||
const onSingleClick = vi.fn()
|
||||
const onDoubleClick = vi.fn()
|
||||
const { result, unmount } = renderHook(() => useDelayedClick({
|
||||
delay: 200,
|
||||
onSingleClick,
|
||||
onDoubleClick,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleClick()
|
||||
})
|
||||
|
||||
unmount()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
expect(onSingleClick).not.toHaveBeenCalled()
|
||||
expect(onDoubleClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
import type { RefObject } from 'react'
|
||||
import type { TreeApi } from 'react-arborist'
|
||||
import type { TreeNodeData } from '../../../type'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils/common'
|
||||
import { useSkillShortcuts } from './use-skill-shortcuts'
|
||||
|
||||
const {
|
||||
mockUseKeyPress,
|
||||
mockCutNodes,
|
||||
mockHasClipboard,
|
||||
registeredShortcutHandlers,
|
||||
} = vi.hoisted(() => ({
|
||||
mockUseKeyPress: vi.fn(),
|
||||
mockCutNodes: vi.fn(),
|
||||
mockHasClipboard: vi.fn(() => false),
|
||||
registeredShortcutHandlers: {} as Record<string, (event: KeyboardEvent) => void>,
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useKeyPress: (hotkey: string, callback: (event: KeyboardEvent) => void) => {
|
||||
mockUseKeyPress(hotkey, callback)
|
||||
registeredShortcutHandlers[hotkey] = callback
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
cutNodes: mockCutNodes,
|
||||
hasClipboard: mockHasClipboard,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const createTreeRef = (selectedIds: string[]): RefObject<TreeApi<TreeNodeData> | null> => {
|
||||
return {
|
||||
current: {
|
||||
selectedNodes: selectedIds.map(id => ({ id })),
|
||||
} as unknown as TreeApi<TreeNodeData>,
|
||||
}
|
||||
}
|
||||
|
||||
const createShortcutEvent = (target: HTMLElement): KeyboardEvent => {
|
||||
return {
|
||||
target,
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as KeyboardEvent
|
||||
}
|
||||
|
||||
describe('useSkillShortcuts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.keys(registeredShortcutHandlers).forEach((shortcut) => {
|
||||
delete registeredShortcutHandlers[shortcut]
|
||||
})
|
||||
mockHasClipboard.mockReturnValue(false)
|
||||
})
|
||||
|
||||
// Scenario: register platform-aware cut and paste shortcuts on mount.
|
||||
describe('shortcut registration', () => {
|
||||
it('should register cut and paste key combinations', () => {
|
||||
const treeRef = createTreeRef([])
|
||||
renderHook(() => useSkillShortcuts({ treeRef }))
|
||||
|
||||
const ctrlKey = getKeyboardKeyCodeBySystem('ctrl')
|
||||
expect(mockUseKeyPress).toHaveBeenCalledTimes(2)
|
||||
expect(registeredShortcutHandlers[`${ctrlKey}.x`]).toBeTypeOf('function')
|
||||
expect(registeredShortcutHandlers[`${ctrlKey}.v`]).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: cut shortcut depends on target context, selection state, and enabled state.
|
||||
describe('cut shortcut', () => {
|
||||
it('should cut selected nodes when keyboard event originates in tree container', () => {
|
||||
const treeRef = createTreeRef(['file-1', 'file-2'])
|
||||
renderHook(() => useSkillShortcuts({ treeRef }))
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.setAttribute('data-skill-tree-container', '')
|
||||
const target = document.createElement('button')
|
||||
container.appendChild(target)
|
||||
const event = createShortcutEvent(target)
|
||||
|
||||
const cutShortcut = `${getKeyboardKeyCodeBySystem('ctrl')}.x`
|
||||
act(() => {
|
||||
registeredShortcutHandlers[cutShortcut](event)
|
||||
})
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(mockCutNodes).toHaveBeenCalledWith(['file-1', 'file-2'])
|
||||
})
|
||||
|
||||
it('should cut selected nodes even when event target is outside tree container', () => {
|
||||
const treeRef = createTreeRef(['file-3'])
|
||||
renderHook(() => useSkillShortcuts({ treeRef }))
|
||||
|
||||
const outsideTarget = document.createElement('button')
|
||||
const event = createShortcutEvent(outsideTarget)
|
||||
|
||||
const cutShortcut = `${getKeyboardKeyCodeBySystem('ctrl')}.x`
|
||||
act(() => {
|
||||
registeredShortcutHandlers[cutShortcut](event)
|
||||
})
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(mockCutNodes).toHaveBeenCalledWith(['file-3'])
|
||||
})
|
||||
|
||||
it('should ignore cut shortcut when target is an input area', () => {
|
||||
const treeRef = createTreeRef(['file-1'])
|
||||
renderHook(() => useSkillShortcuts({ treeRef }))
|
||||
|
||||
const input = document.createElement('input')
|
||||
const event = createShortcutEvent(input)
|
||||
|
||||
const cutShortcut = `${getKeyboardKeyCodeBySystem('ctrl')}.x`
|
||||
act(() => {
|
||||
registeredShortcutHandlers[cutShortcut](event)
|
||||
})
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled()
|
||||
expect(mockCutNodes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should ignore cut shortcut when shortcuts are disabled', () => {
|
||||
const treeRef = createTreeRef(['file-1'])
|
||||
const { rerender } = renderHook(
|
||||
({ enabled }) => useSkillShortcuts({ treeRef, enabled }),
|
||||
{ initialProps: { enabled: true } },
|
||||
)
|
||||
|
||||
rerender({ enabled: false })
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.setAttribute('data-skill-tree-container', '')
|
||||
const target = document.createElement('button')
|
||||
container.appendChild(target)
|
||||
const event = createShortcutEvent(target)
|
||||
|
||||
const cutShortcut = `${getKeyboardKeyCodeBySystem('ctrl')}.x`
|
||||
act(() => {
|
||||
registeredShortcutHandlers[cutShortcut](event)
|
||||
})
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled()
|
||||
expect(mockCutNodes).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: paste shortcut dispatches global paste event only when clipboard has content.
|
||||
describe('paste shortcut', () => {
|
||||
it('should dispatch paste event when clipboard has content and shortcut should be handled', () => {
|
||||
mockHasClipboard.mockReturnValue(true)
|
||||
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent')
|
||||
const treeRef = createTreeRef(['file-1'])
|
||||
renderHook(() => useSkillShortcuts({ treeRef }))
|
||||
|
||||
const target = document.createElement('button')
|
||||
const event = createShortcutEvent(target)
|
||||
|
||||
const pasteShortcut = `${getKeyboardKeyCodeBySystem('ctrl')}.v`
|
||||
act(() => {
|
||||
registeredShortcutHandlers[pasteShortcut](event)
|
||||
})
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(dispatchEventSpy).toHaveBeenCalledTimes(1)
|
||||
expect(dispatchEventSpy.mock.calls[0][0].type).toBe('skill:paste')
|
||||
})
|
||||
|
||||
it('should ignore paste shortcut when clipboard is empty', () => {
|
||||
mockHasClipboard.mockReturnValue(false)
|
||||
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent')
|
||||
const treeRef = createTreeRef(['file-1'])
|
||||
renderHook(() => useSkillShortcuts({ treeRef }))
|
||||
|
||||
const target = document.createElement('button')
|
||||
const event = createShortcutEvent(target)
|
||||
|
||||
const pasteShortcut = `${getKeyboardKeyCodeBySystem('ctrl')}.v`
|
||||
act(() => {
|
||||
registeredShortcutHandlers[pasteShortcut](event)
|
||||
})
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled()
|
||||
expect(dispatchEventSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { NodeApi } from 'react-arborist'
|
||||
import type { TreeNodeData } from '../../../type'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useTreeNodeHandlers } from './use-tree-node-handlers'
|
||||
|
||||
const {
|
||||
mockClearArtifactSelection,
|
||||
mockOpenTab,
|
||||
mockSetContextMenu,
|
||||
} = vi.hoisted(() => ({
|
||||
mockClearArtifactSelection: vi.fn(),
|
||||
mockOpenTab: vi.fn(),
|
||||
mockSetContextMenu: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('es-toolkit/function', () => ({
|
||||
throttle: (fn: () => void) => fn,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
clearArtifactSelection: mockClearArtifactSelection,
|
||||
openTab: mockOpenTab,
|
||||
setContextMenu: mockSetContextMenu,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const createNode = (params: {
|
||||
id?: string
|
||||
nodeType: 'file' | 'folder'
|
||||
}) => {
|
||||
const id = params.id ?? 'node-1'
|
||||
return {
|
||||
data: {
|
||||
id,
|
||||
node_type: params.nodeType,
|
||||
name: params.nodeType === 'folder' ? 'folder-a' : 'README.md',
|
||||
path: `/${id}`,
|
||||
extension: params.nodeType === 'folder' ? '' : 'md',
|
||||
size: 1,
|
||||
children: [],
|
||||
},
|
||||
toggle: vi.fn(),
|
||||
select: vi.fn(),
|
||||
selectMulti: vi.fn(),
|
||||
selectContiguous: vi.fn(),
|
||||
isOpen: false,
|
||||
} as unknown as NodeApi<TreeNodeData>
|
||||
}
|
||||
|
||||
const createMouseEvent = (params: {
|
||||
shiftKey?: boolean
|
||||
ctrlKey?: boolean
|
||||
metaKey?: boolean
|
||||
clientX?: number
|
||||
clientY?: number
|
||||
} = {}) => {
|
||||
return {
|
||||
stopPropagation: vi.fn(),
|
||||
preventDefault: vi.fn(),
|
||||
shiftKey: params.shiftKey ?? false,
|
||||
ctrlKey: params.ctrlKey ?? false,
|
||||
metaKey: params.metaKey ?? false,
|
||||
clientX: params.clientX ?? 0,
|
||||
clientY: params.clientY ?? 0,
|
||||
} as unknown as React.MouseEvent
|
||||
}
|
||||
|
||||
const createKeyboardEvent = (key: string) => {
|
||||
return {
|
||||
key,
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as React.KeyboardEvent
|
||||
}
|
||||
|
||||
describe('useTreeNodeHandlers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// Scenario: click behavior differs for folders/files and modifier keys.
|
||||
describe('handleClick', () => {
|
||||
it('should select contiguous node and toggle folder on shift-click', () => {
|
||||
const node = createNode({ nodeType: 'folder' })
|
||||
const { result } = renderHook(() => useTreeNodeHandlers({ node }))
|
||||
const event = createMouseEvent({ shiftKey: true })
|
||||
|
||||
act(() => {
|
||||
result.current.handleClick(event)
|
||||
})
|
||||
|
||||
expect(event.stopPropagation).toHaveBeenCalledTimes(1)
|
||||
expect(node.selectContiguous).toHaveBeenCalledTimes(1)
|
||||
expect(node.toggle).toHaveBeenCalledTimes(1)
|
||||
expect(node.select).not.toHaveBeenCalled()
|
||||
expect(node.selectMulti).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should open file preview tab on plain click after delayed click timeout', () => {
|
||||
const node = createNode({ id: 'file-1', nodeType: 'file' })
|
||||
const { result } = renderHook(() => useTreeNodeHandlers({ node }))
|
||||
const event = createMouseEvent()
|
||||
|
||||
act(() => {
|
||||
result.current.handleClick(event)
|
||||
})
|
||||
|
||||
expect(node.select).toHaveBeenCalledTimes(1)
|
||||
expect(mockOpenTab).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
expect(mockClearArtifactSelection).toHaveBeenCalledTimes(1)
|
||||
expect(mockOpenTab).toHaveBeenCalledWith('file-1', { pinned: false })
|
||||
})
|
||||
|
||||
it('should not trigger file preview tab on ctrl-click', () => {
|
||||
const node = createNode({ id: 'file-2', nodeType: 'file' })
|
||||
const { result } = renderHook(() => useTreeNodeHandlers({ node }))
|
||||
const event = createMouseEvent({ ctrlKey: true })
|
||||
|
||||
act(() => {
|
||||
result.current.handleClick(event)
|
||||
vi.advanceTimersByTime(250)
|
||||
})
|
||||
|
||||
expect(node.selectMulti).toHaveBeenCalledTimes(1)
|
||||
expect(mockOpenTab).not.toHaveBeenCalled()
|
||||
expect(mockClearArtifactSelection).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: double-click and toggle handlers route to folder toggle or pinned file open.
|
||||
describe('double click and toggle', () => {
|
||||
it('should toggle folder on double click', () => {
|
||||
const node = createNode({ nodeType: 'folder' })
|
||||
const { result } = renderHook(() => useTreeNodeHandlers({ node }))
|
||||
const event = createMouseEvent()
|
||||
|
||||
act(() => {
|
||||
result.current.handleDoubleClick(event)
|
||||
})
|
||||
|
||||
expect(event.stopPropagation).toHaveBeenCalledTimes(1)
|
||||
expect(node.toggle).toHaveBeenCalledTimes(1)
|
||||
expect(mockOpenTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should open file as pinned tab on double click', () => {
|
||||
const node = createNode({ id: 'file-3', nodeType: 'file' })
|
||||
const { result } = renderHook(() => useTreeNodeHandlers({ node }))
|
||||
const event = createMouseEvent()
|
||||
|
||||
act(() => {
|
||||
result.current.handleDoubleClick(event)
|
||||
})
|
||||
|
||||
expect(event.stopPropagation).toHaveBeenCalledTimes(1)
|
||||
expect(mockClearArtifactSelection).toHaveBeenCalledTimes(1)
|
||||
expect(mockOpenTab).toHaveBeenCalledWith('file-3', { pinned: true })
|
||||
})
|
||||
|
||||
it('should toggle node when toggle handler is invoked', () => {
|
||||
const node = createNode({ nodeType: 'folder' })
|
||||
const { result } = renderHook(() => useTreeNodeHandlers({ node }))
|
||||
const event = createMouseEvent()
|
||||
|
||||
act(() => {
|
||||
result.current.handleToggle(event)
|
||||
})
|
||||
|
||||
expect(event.stopPropagation).toHaveBeenCalledTimes(1)
|
||||
expect(node.toggle).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: context menu and keyboard handlers update menu state and open/toggle actions.
|
||||
describe('context menu and keyboard', () => {
|
||||
it('should select node and set context menu payload on right click', () => {
|
||||
const node = createNode({ id: 'folder-1', nodeType: 'folder' })
|
||||
const { result } = renderHook(() => useTreeNodeHandlers({ node }))
|
||||
const event = createMouseEvent({ clientX: 120, clientY: 45 })
|
||||
|
||||
act(() => {
|
||||
result.current.handleContextMenu(event)
|
||||
})
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(event.stopPropagation).toHaveBeenCalledTimes(1)
|
||||
expect(node.select).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetContextMenu).toHaveBeenCalledWith({
|
||||
top: 45,
|
||||
left: 120,
|
||||
type: 'node',
|
||||
nodeId: 'folder-1',
|
||||
isFolder: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should toggle folder on Enter key', () => {
|
||||
const node = createNode({ nodeType: 'folder' })
|
||||
const { result } = renderHook(() => useTreeNodeHandlers({ node }))
|
||||
const event = createKeyboardEvent('Enter')
|
||||
|
||||
act(() => {
|
||||
result.current.handleKeyDown(event)
|
||||
})
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(node.toggle).toHaveBeenCalledTimes(1)
|
||||
expect(mockOpenTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should open file as pinned tab on Space key', () => {
|
||||
const node = createNode({ id: 'file-4', nodeType: 'file' })
|
||||
const { result } = renderHook(() => useTreeNodeHandlers({ node }))
|
||||
const event = createKeyboardEvent(' ')
|
||||
|
||||
act(() => {
|
||||
result.current.handleKeyDown(event)
|
||||
})
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(mockClearArtifactSelection).toHaveBeenCalledTimes(1)
|
||||
expect(mockOpenTab).toHaveBeenCalledWith('file-4', { pinned: true })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,427 @@
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { SkillEditorSliceShape, UploadStatus } from '@/app/components/workflow/store/workflow/skill-editor/types'
|
||||
import type { BatchUploadNodeInput } from '@/types/app-asset'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useCreateOperations } from './use-create-operations'
|
||||
|
||||
type UploadMutationPayload = {
|
||||
appId: string
|
||||
file: File
|
||||
parentId?: string | null
|
||||
}
|
||||
|
||||
type BatchUploadMutationPayload = {
|
||||
appId: string
|
||||
tree: BatchUploadNodeInput[]
|
||||
files: Map<string, File>
|
||||
parentId?: string | null
|
||||
onProgress?: (uploaded: number, total: number) => void
|
||||
}
|
||||
|
||||
type UploadProgress = {
|
||||
uploaded: number
|
||||
total: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createFolderPending: false,
|
||||
uploadPending: false,
|
||||
batchPending: false,
|
||||
uploadMutateAsync: vi.fn<(payload: UploadMutationPayload) => Promise<void>>(),
|
||||
batchMutateAsync: vi.fn<(payload: BatchUploadMutationPayload) => Promise<unknown>>(),
|
||||
prepareSkillUploadFile: vi.fn<(file: File) => Promise<File>>(),
|
||||
emitTreeUpdate: vi.fn<() => void>(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-app-asset', () => ({
|
||||
useCreateAppAssetFolder: () => ({
|
||||
isPending: mocks.createFolderPending,
|
||||
}),
|
||||
useUploadFileWithPresignedUrl: () => ({
|
||||
mutateAsync: mocks.uploadMutateAsync,
|
||||
isPending: mocks.uploadPending,
|
||||
}),
|
||||
useBatchUpload: () => ({
|
||||
mutateAsync: mocks.batchMutateAsync,
|
||||
isPending: mocks.batchPending,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../utils/skill-upload-utils', () => ({
|
||||
prepareSkillUploadFile: mocks.prepareSkillUploadFile,
|
||||
}))
|
||||
|
||||
vi.mock('../data/use-skill-tree-collaboration', () => ({
|
||||
useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
|
||||
}))
|
||||
|
||||
const createStoreApi = () => {
|
||||
const startCreateNode = vi.fn<(nodeType: 'file' | 'folder', parentId: string | null) => void>()
|
||||
const setUploadStatus = vi.fn<(status: UploadStatus) => void>()
|
||||
const setUploadProgress = vi.fn<(progress: UploadProgress) => void>()
|
||||
|
||||
const state = {
|
||||
startCreateNode,
|
||||
setUploadStatus,
|
||||
setUploadProgress,
|
||||
} as Pick<SkillEditorSliceShape, 'startCreateNode' | 'setUploadStatus' | 'setUploadProgress'>
|
||||
|
||||
const storeApi = {
|
||||
getState: () => state,
|
||||
} as unknown as StoreApi<SkillEditorSliceShape>
|
||||
|
||||
return {
|
||||
storeApi,
|
||||
startCreateNode,
|
||||
setUploadStatus,
|
||||
setUploadProgress,
|
||||
}
|
||||
}
|
||||
|
||||
const createInputChangeEvent = (files: File[] | null) => {
|
||||
return {
|
||||
target: {
|
||||
files,
|
||||
value: 'selected',
|
||||
},
|
||||
} as unknown as React.ChangeEvent<HTMLInputElement>
|
||||
}
|
||||
|
||||
const withRelativePath = (file: File, relativePath: string): File => {
|
||||
Object.defineProperty(file, 'webkitRelativePath', {
|
||||
value: relativePath,
|
||||
configurable: true,
|
||||
})
|
||||
return file
|
||||
}
|
||||
|
||||
describe('useCreateOperations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.createFolderPending = false
|
||||
mocks.uploadPending = false
|
||||
mocks.batchPending = false
|
||||
mocks.prepareSkillUploadFile.mockImplementation(async file => file)
|
||||
mocks.uploadMutateAsync.mockResolvedValue(undefined)
|
||||
mocks.batchMutateAsync.mockResolvedValue([])
|
||||
})
|
||||
|
||||
// Scenario: loading state should combine all create-related pending flags.
|
||||
describe('State', () => {
|
||||
it('should expose isCreating false when no mutation is pending', () => {
|
||||
const { storeApi } = createStoreApi()
|
||||
|
||||
const { result } = renderHook(() => useCreateOperations({
|
||||
parentId: 'folder-1',
|
||||
appId: 'app-1',
|
||||
storeApi,
|
||||
onClose: vi.fn(),
|
||||
}))
|
||||
|
||||
expect(result.current.isCreating).toBe(false)
|
||||
expect(result.current.fileInputRef.current).toBeNull()
|
||||
expect(result.current.folderInputRef.current).toBeNull()
|
||||
})
|
||||
|
||||
it('should expose isCreating true when any mutation is pending', () => {
|
||||
const { storeApi } = createStoreApi()
|
||||
mocks.createFolderPending = true
|
||||
|
||||
const { result } = renderHook(() => useCreateOperations({
|
||||
parentId: 'folder-1',
|
||||
appId: 'app-1',
|
||||
storeApi,
|
||||
onClose: vi.fn(),
|
||||
}))
|
||||
|
||||
expect(result.current.isCreating).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: new node handlers should initialize create mode and close menu.
|
||||
describe('New node handlers', () => {
|
||||
it('should start inline file creation when handleNewFile is called', () => {
|
||||
const { storeApi, startCreateNode } = createStoreApi()
|
||||
const onClose = vi.fn()
|
||||
const { result } = renderHook(() => useCreateOperations({
|
||||
parentId: 'parent-1',
|
||||
appId: 'app-1',
|
||||
storeApi,
|
||||
onClose,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleNewFile()
|
||||
})
|
||||
|
||||
expect(startCreateNode).toHaveBeenCalledWith('file', 'parent-1')
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should start inline folder creation when handleNewFolder is called', () => {
|
||||
const { storeApi, startCreateNode } = createStoreApi()
|
||||
const onClose = vi.fn()
|
||||
const { result } = renderHook(() => useCreateOperations({
|
||||
parentId: null,
|
||||
appId: 'app-1',
|
||||
storeApi,
|
||||
onClose,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleNewFolder()
|
||||
})
|
||||
|
||||
expect(startCreateNode).toHaveBeenCalledWith('folder', null)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: file upload handler should process empty, success, partial-failure, and preparation-failure branches.
|
||||
describe('handleFileChange', () => {
|
||||
it('should close menu and no-op when no files are selected', async () => {
|
||||
const { storeApi, setUploadStatus, setUploadProgress } = createStoreApi()
|
||||
const onClose = vi.fn()
|
||||
const event = createInputChangeEvent([])
|
||||
const { result } = renderHook(() => useCreateOperations({
|
||||
parentId: 'parent-empty',
|
||||
appId: 'app-empty',
|
||||
storeApi,
|
||||
onClose,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleFileChange(event)
|
||||
})
|
||||
|
||||
expect(setUploadStatus).not.toHaveBeenCalled()
|
||||
expect(setUploadProgress).not.toHaveBeenCalled()
|
||||
expect(mocks.uploadMutateAsync).not.toHaveBeenCalled()
|
||||
expect(mocks.emitTreeUpdate).not.toHaveBeenCalled()
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(event.target.value).toBe('selected')
|
||||
})
|
||||
|
||||
it('should upload all files and set success status when all uploads succeed', async () => {
|
||||
const { storeApi, setUploadStatus, setUploadProgress } = createStoreApi()
|
||||
const onClose = vi.fn()
|
||||
const first = new File(['first'], 'first.md', { type: 'text/markdown' })
|
||||
const second = new File(['second'], 'second.txt', { type: 'text/plain' })
|
||||
const event = createInputChangeEvent([first, second])
|
||||
const { result } = renderHook(() => useCreateOperations({
|
||||
parentId: 'folder-success',
|
||||
appId: 'app-success',
|
||||
storeApi,
|
||||
onClose,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleFileChange(event)
|
||||
})
|
||||
|
||||
expect(mocks.prepareSkillUploadFile).toHaveBeenNthCalledWith(1, first)
|
||||
expect(mocks.prepareSkillUploadFile).toHaveBeenNthCalledWith(2, second)
|
||||
expect(mocks.uploadMutateAsync).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.uploadMutateAsync).toHaveBeenNthCalledWith(1, {
|
||||
appId: 'app-success',
|
||||
file: first,
|
||||
parentId: 'folder-success',
|
||||
})
|
||||
expect(mocks.uploadMutateAsync).toHaveBeenNthCalledWith(2, {
|
||||
appId: 'app-success',
|
||||
file: second,
|
||||
parentId: 'folder-success',
|
||||
})
|
||||
|
||||
expect(setUploadStatus).toHaveBeenNthCalledWith(1, 'uploading')
|
||||
expect(setUploadStatus).toHaveBeenNthCalledWith(2, 'success')
|
||||
expect(setUploadProgress).toHaveBeenCalledWith({ uploaded: 0, total: 2, failed: 0 })
|
||||
expect(setUploadProgress).toHaveBeenCalledWith({ uploaded: 1, total: 2, failed: 0 })
|
||||
expect(setUploadProgress).toHaveBeenCalledWith({ uploaded: 2, total: 2, failed: 0 })
|
||||
|
||||
expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(event.target.value).toBe('')
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should set partial_error when some file uploads fail but still emit updates for uploaded files', async () => {
|
||||
const { storeApi, setUploadStatus, setUploadProgress } = createStoreApi()
|
||||
const onClose = vi.fn()
|
||||
const okFile = new File(['ok'], 'ok.md', { type: 'text/markdown' })
|
||||
const failedFile = new File(['nope'], 'nope.md', { type: 'text/markdown' })
|
||||
const event = createInputChangeEvent([okFile, failedFile])
|
||||
mocks.uploadMutateAsync
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('upload failed'))
|
||||
|
||||
const { result } = renderHook(() => useCreateOperations({
|
||||
parentId: 'folder-partial',
|
||||
appId: 'app-partial',
|
||||
storeApi,
|
||||
onClose,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleFileChange(event)
|
||||
})
|
||||
|
||||
expect(setUploadStatus).toHaveBeenNthCalledWith(1, 'uploading')
|
||||
expect(setUploadStatus).toHaveBeenNthCalledWith(2, 'partial_error')
|
||||
expect(setUploadProgress).toHaveBeenCalledWith({ uploaded: 1, total: 2, failed: 1 })
|
||||
expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(event.target.value).toBe('')
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should set partial_error and skip API upload when file preparation fails', async () => {
|
||||
const { storeApi, setUploadStatus } = createStoreApi()
|
||||
const onClose = vi.fn()
|
||||
const file = new File(['broken'], 'broken.md', { type: 'text/markdown' })
|
||||
const event = createInputChangeEvent([file])
|
||||
mocks.prepareSkillUploadFile.mockRejectedValueOnce(new Error('prepare failed'))
|
||||
|
||||
const { result } = renderHook(() => useCreateOperations({
|
||||
parentId: null,
|
||||
appId: 'app-prepare-error',
|
||||
storeApi,
|
||||
onClose,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleFileChange(event)
|
||||
})
|
||||
|
||||
expect(mocks.uploadMutateAsync).not.toHaveBeenCalled()
|
||||
expect(setUploadStatus).toHaveBeenNthCalledWith(1, 'uploading')
|
||||
expect(setUploadStatus).toHaveBeenNthCalledWith(2, 'partial_error')
|
||||
expect(mocks.emitTreeUpdate).not.toHaveBeenCalled()
|
||||
expect(event.target.value).toBe('')
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: folder upload handler should build nested tree payload and handle success/failure branches.
|
||||
describe('handleFolderChange', () => {
|
||||
it('should close menu and no-op when no folder files are selected', async () => {
|
||||
const { storeApi, setUploadStatus, setUploadProgress } = createStoreApi()
|
||||
const onClose = vi.fn()
|
||||
const event = createInputChangeEvent([])
|
||||
const { result } = renderHook(() => useCreateOperations({
|
||||
parentId: 'parent-empty-folder',
|
||||
appId: 'app-empty-folder',
|
||||
storeApi,
|
||||
onClose,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleFolderChange(event)
|
||||
})
|
||||
|
||||
expect(setUploadStatus).not.toHaveBeenCalled()
|
||||
expect(setUploadProgress).not.toHaveBeenCalled()
|
||||
expect(mocks.batchMutateAsync).not.toHaveBeenCalled()
|
||||
expect(mocks.emitTreeUpdate).not.toHaveBeenCalled()
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(event.target.value).toBe('selected')
|
||||
})
|
||||
|
||||
it('should batch upload folder files, update progress callback, and emit success update', async () => {
|
||||
const { storeApi, setUploadStatus, setUploadProgress } = createStoreApi()
|
||||
const onClose = vi.fn()
|
||||
const fileA = withRelativePath(new File(['a'], 'a.md', { type: 'text/markdown' }), 'docs/a.md')
|
||||
const fileB = withRelativePath(new File(['b'], 'b.txt', { type: 'text/plain' }), 'docs/nested/b.txt')
|
||||
const rootFile = new File(['root'], 'root.md', { type: 'text/markdown' })
|
||||
const event = createInputChangeEvent([fileA, fileB, rootFile])
|
||||
|
||||
mocks.batchMutateAsync.mockImplementationOnce(async ({ onProgress }) => {
|
||||
onProgress?.(1, 3)
|
||||
onProgress?.(3, 3)
|
||||
return []
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCreateOperations({
|
||||
parentId: 'folder-parent',
|
||||
appId: 'app-folder',
|
||||
storeApi,
|
||||
onClose,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleFolderChange(event)
|
||||
})
|
||||
|
||||
expect(mocks.batchMutateAsync).toHaveBeenCalledTimes(1)
|
||||
const batchPayload = mocks.batchMutateAsync.mock.calls[0][0]
|
||||
|
||||
expect(batchPayload.appId).toBe('app-folder')
|
||||
expect(batchPayload.parentId).toBe('folder-parent')
|
||||
expect(batchPayload.tree).toEqual([
|
||||
{
|
||||
name: 'docs',
|
||||
node_type: 'folder',
|
||||
children: [
|
||||
{
|
||||
name: 'a.md',
|
||||
node_type: 'file',
|
||||
size: fileA.size,
|
||||
},
|
||||
{
|
||||
name: 'nested',
|
||||
node_type: 'folder',
|
||||
children: [
|
||||
{
|
||||
name: 'b.txt',
|
||||
node_type: 'file',
|
||||
size: fileB.size,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'root.md',
|
||||
node_type: 'file',
|
||||
size: rootFile.size,
|
||||
},
|
||||
])
|
||||
expect([...batchPayload.files.keys()]).toEqual(['docs/a.md', 'docs/nested/b.txt', 'root.md'])
|
||||
expect(batchPayload.files.get('docs/a.md')).toBe(fileA)
|
||||
expect(batchPayload.files.get('docs/nested/b.txt')).toBe(fileB)
|
||||
expect(batchPayload.files.get('root.md')).toBe(rootFile)
|
||||
|
||||
expect(setUploadStatus).toHaveBeenNthCalledWith(1, 'uploading')
|
||||
expect(setUploadStatus).toHaveBeenNthCalledWith(2, 'success')
|
||||
expect(setUploadProgress).toHaveBeenCalledWith({ uploaded: 1, total: 3, failed: 0 })
|
||||
expect(setUploadProgress).toHaveBeenCalledWith({ uploaded: 3, total: 3, failed: 0 })
|
||||
expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(event.target.value).toBe('')
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should set partial_error when batch upload fails', async () => {
|
||||
const { storeApi, setUploadStatus } = createStoreApi()
|
||||
const onClose = vi.fn()
|
||||
const file = withRelativePath(new File(['f'], 'f.md', { type: 'text/markdown' }), 'folder/f.md')
|
||||
const event = createInputChangeEvent([file])
|
||||
mocks.batchMutateAsync.mockRejectedValueOnce(new Error('batch failed'))
|
||||
|
||||
const { result } = renderHook(() => useCreateOperations({
|
||||
parentId: null,
|
||||
appId: 'app-folder-error',
|
||||
storeApi,
|
||||
onClose,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleFolderChange(event)
|
||||
})
|
||||
|
||||
expect(setUploadStatus).toHaveBeenNthCalledWith(1, 'uploading')
|
||||
expect(setUploadStatus).toHaveBeenNthCalledWith(2, 'partial_error')
|
||||
expect(mocks.emitTreeUpdate).not.toHaveBeenCalled()
|
||||
expect(event.target.value).toBe('')
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { useDownloadOperation } from './use-download-operation'
|
||||
|
||||
type DownloadRequest = {
|
||||
params: {
|
||||
appId: string
|
||||
nodeId: string
|
||||
}
|
||||
}
|
||||
|
||||
type DownloadResponse = {
|
||||
download_url: string
|
||||
}
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (reason?: unknown) => void
|
||||
}
|
||||
|
||||
const createDeferred = <T,>(): Deferred<T> => {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
const {
|
||||
mockGetFileDownloadUrl,
|
||||
mockDownloadUrl,
|
||||
mockToastNotify,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetFileDownloadUrl: vi.fn<(request: DownloadRequest) => Promise<DownloadResponse>>(),
|
||||
mockDownloadUrl: vi.fn<(payload: { url: string, fileName?: string }) => void>(),
|
||||
mockToastNotify: vi.fn<(payload: { type: string, message: string }) => void>(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleClient: {
|
||||
appAsset: {
|
||||
getFileDownloadUrl: mockGetFileDownloadUrl,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadUrl: mockDownloadUrl,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: mockToastNotify,
|
||||
},
|
||||
}))
|
||||
|
||||
describe('useDownloadOperation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetFileDownloadUrl.mockResolvedValue({ download_url: 'https://example.com/file.txt' })
|
||||
})
|
||||
|
||||
// Scenario: hook should no-op when required identifiers are missing.
|
||||
describe('Guards', () => {
|
||||
it('should not call download API when appId or nodeId is missing', async () => {
|
||||
const onClose = vi.fn()
|
||||
const { result } = renderHook(() => useDownloadOperation({
|
||||
appId: '',
|
||||
nodeId: '',
|
||||
onClose,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDownload()
|
||||
})
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(mockGetFileDownloadUrl).not.toHaveBeenCalled()
|
||||
expect(mockDownloadUrl).not.toHaveBeenCalled()
|
||||
expect(result.current.isDownloading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: successful downloads should fetch URL and trigger browser download.
|
||||
describe('Success', () => {
|
||||
it('should download file when API call succeeds', async () => {
|
||||
const onClose = vi.fn()
|
||||
const { result } = renderHook(() => useDownloadOperation({
|
||||
appId: 'app-1',
|
||||
nodeId: 'node-1',
|
||||
fileName: 'notes.md',
|
||||
onClose,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDownload()
|
||||
})
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(mockGetFileDownloadUrl).toHaveBeenCalledWith({
|
||||
params: {
|
||||
appId: 'app-1',
|
||||
nodeId: 'node-1',
|
||||
},
|
||||
})
|
||||
expect(mockDownloadUrl).toHaveBeenCalledWith({
|
||||
url: 'https://example.com/file.txt',
|
||||
fileName: 'notes.md',
|
||||
})
|
||||
expect(mockToastNotify).not.toHaveBeenCalled()
|
||||
expect(result.current.isDownloading).toBe(false)
|
||||
})
|
||||
|
||||
it('should set isDownloading true while download request is pending', async () => {
|
||||
const deferred = createDeferred<DownloadResponse>()
|
||||
mockGetFileDownloadUrl.mockReturnValueOnce(deferred.promise)
|
||||
const onClose = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => useDownloadOperation({
|
||||
appId: 'app-2',
|
||||
nodeId: 'node-2',
|
||||
onClose,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
void result.current.handleDownload()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isDownloading).toBe(true)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
deferred.resolve({ download_url: 'https://example.com/slow.txt' })
|
||||
await deferred.promise
|
||||
})
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(mockDownloadUrl).toHaveBeenCalledWith({
|
||||
url: 'https://example.com/slow.txt',
|
||||
fileName: undefined,
|
||||
})
|
||||
expect(result.current.isDownloading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: failed downloads should notify users and reset loading state.
|
||||
describe('Error handling', () => {
|
||||
it('should show error toast when download API fails', async () => {
|
||||
mockGetFileDownloadUrl.mockRejectedValueOnce(new Error('network failure'))
|
||||
const onClose = vi.fn()
|
||||
const { result } = renderHook(() => useDownloadOperation({
|
||||
appId: 'app-3',
|
||||
nodeId: 'node-3',
|
||||
onClose,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDownload()
|
||||
})
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(mockDownloadUrl).not.toHaveBeenCalled()
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'workflow.skillSidebar.menu.downloadError',
|
||||
})
|
||||
expect(result.current.isDownloading).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,335 @@
|
||||
import type { RefObject } from 'react'
|
||||
import type { NodeApi, TreeApi } from 'react-arborist'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TreeNodeData } from '../../../type'
|
||||
import type { SkillEditorSliceShape } from '@/app/components/workflow/store/workflow/skill-editor/types'
|
||||
import type { AppAssetTreeResponse } from '@/types/app-asset'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useFileOperations } from './use-file-operations'
|
||||
|
||||
type AppStoreState = {
|
||||
appDetail?: {
|
||||
id: string
|
||||
} | null
|
||||
}
|
||||
|
||||
type CreateOpsResult = {
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>
|
||||
folderInputRef: React.RefObject<HTMLInputElement | null>
|
||||
handleNewFile: () => void
|
||||
handleNewFolder: () => void
|
||||
handleFileChange: (e: React.ChangeEvent<HTMLInputElement>) => Promise<void>
|
||||
handleFolderChange: (e: React.ChangeEvent<HTMLInputElement>) => Promise<void>
|
||||
isCreating: boolean
|
||||
}
|
||||
|
||||
type ModifyOpsResult = {
|
||||
showDeleteConfirm: boolean
|
||||
handleRename: () => void
|
||||
handleDeleteClick: () => void
|
||||
handleDeleteConfirm: () => Promise<void>
|
||||
handleDeleteCancel: () => void
|
||||
isDeleting: boolean
|
||||
}
|
||||
|
||||
type DownloadOpsResult = {
|
||||
handleDownload: () => Promise<void>
|
||||
isDownloading: boolean
|
||||
}
|
||||
|
||||
const createDefaultCreateOps = (): CreateOpsResult => ({
|
||||
fileInputRef: { current: null } as React.RefObject<HTMLInputElement | null>,
|
||||
folderInputRef: { current: null } as React.RefObject<HTMLInputElement | null>,
|
||||
handleNewFile: vi.fn(),
|
||||
handleNewFolder: vi.fn(),
|
||||
handleFileChange: vi.fn(async () => undefined),
|
||||
handleFolderChange: vi.fn(async () => undefined),
|
||||
isCreating: false,
|
||||
})
|
||||
|
||||
const createDefaultModifyOps = (): ModifyOpsResult => ({
|
||||
showDeleteConfirm: false,
|
||||
handleRename: vi.fn(),
|
||||
handleDeleteClick: vi.fn(),
|
||||
handleDeleteConfirm: vi.fn(async () => undefined),
|
||||
handleDeleteCancel: vi.fn(),
|
||||
isDeleting: false,
|
||||
})
|
||||
|
||||
const createDefaultDownloadOps = (): DownloadOpsResult => ({
|
||||
handleDownload: vi.fn(async () => undefined),
|
||||
isDownloading: false,
|
||||
})
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const workflowStore = {} as StoreApi<SkillEditorSliceShape>
|
||||
const fileInputRef = { current: null } as React.RefObject<HTMLInputElement | null>
|
||||
const folderInputRef = { current: null } as React.RefObject<HTMLInputElement | null>
|
||||
return {
|
||||
appStoreState: {
|
||||
appDetail: { id: 'app-1' },
|
||||
} as AppStoreState,
|
||||
workflowStore,
|
||||
treeData: {
|
||||
children: [],
|
||||
} as AppAssetTreeResponse,
|
||||
toApiParentId: vi.fn<(folderId: string | null | undefined) => string | null>(),
|
||||
createOpsHook: vi.fn<(options: {
|
||||
parentId: string | null
|
||||
appId: string
|
||||
storeApi: StoreApi<SkillEditorSliceShape>
|
||||
onClose: () => void
|
||||
}) => CreateOpsResult>(),
|
||||
modifyOpsHook: vi.fn<(options: {
|
||||
nodeId: string
|
||||
node?: NodeApi<TreeNodeData>
|
||||
treeRef?: RefObject<TreeApi<TreeNodeData> | null>
|
||||
appId: string
|
||||
storeApi: StoreApi<SkillEditorSliceShape>
|
||||
treeData?: AppAssetTreeResponse
|
||||
onClose: () => void
|
||||
}) => ModifyOpsResult>(),
|
||||
downloadOpsHook: vi.fn<(options: {
|
||||
appId: string
|
||||
nodeId: string
|
||||
fileName?: string
|
||||
onClose: () => void
|
||||
}) => DownloadOpsResult>(),
|
||||
createOpsResult: {
|
||||
fileInputRef,
|
||||
folderInputRef,
|
||||
handleNewFile: vi.fn<() => void>(),
|
||||
handleNewFolder: vi.fn<() => void>(),
|
||||
handleFileChange: vi.fn<(e: React.ChangeEvent<HTMLInputElement>) => Promise<void>>(async () => undefined),
|
||||
handleFolderChange: vi.fn<(e: React.ChangeEvent<HTMLInputElement>) => Promise<void>>(async () => undefined),
|
||||
isCreating: false,
|
||||
} as CreateOpsResult,
|
||||
modifyOpsResult: {
|
||||
showDeleteConfirm: false,
|
||||
handleRename: vi.fn<() => void>(),
|
||||
handleDeleteClick: vi.fn<() => void>(),
|
||||
handleDeleteConfirm: vi.fn<() => Promise<void>>(async () => undefined),
|
||||
handleDeleteCancel: vi.fn<() => void>(),
|
||||
isDeleting: false,
|
||||
} as ModifyOpsResult,
|
||||
downloadOpsResult: {
|
||||
handleDownload: vi.fn<() => Promise<void>>(async () => undefined),
|
||||
isDownloading: false,
|
||||
} as DownloadOpsResult,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: AppStoreState) => unknown) => selector(mocks.appStoreState),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => mocks.workflowStore,
|
||||
}))
|
||||
|
||||
vi.mock('../data/use-skill-asset-tree', () => ({
|
||||
useSkillAssetTreeData: () => ({
|
||||
data: mocks.treeData,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../utils/tree-utils', () => ({
|
||||
toApiParentId: mocks.toApiParentId,
|
||||
}))
|
||||
|
||||
vi.mock('./use-create-operations', () => ({
|
||||
useCreateOperations: (options: {
|
||||
parentId: string | null
|
||||
appId: string
|
||||
storeApi: StoreApi<SkillEditorSliceShape>
|
||||
onClose: () => void
|
||||
}) => mocks.createOpsHook(options),
|
||||
}))
|
||||
|
||||
vi.mock('./use-modify-operations', () => ({
|
||||
useModifyOperations: (options: {
|
||||
nodeId: string
|
||||
node?: NodeApi<TreeNodeData>
|
||||
treeRef?: RefObject<TreeApi<TreeNodeData> | null>
|
||||
appId: string
|
||||
storeApi: StoreApi<SkillEditorSliceShape>
|
||||
treeData?: AppAssetTreeResponse
|
||||
onClose: () => void
|
||||
}) => mocks.modifyOpsHook(options),
|
||||
}))
|
||||
|
||||
vi.mock('./use-download-operation', () => ({
|
||||
useDownloadOperation: (options: {
|
||||
appId: string
|
||||
nodeId: string
|
||||
fileName?: string
|
||||
onClose: () => void
|
||||
}) => mocks.downloadOpsHook(options),
|
||||
}))
|
||||
|
||||
const createNodeApi = (id: string, name: string): NodeApi<TreeNodeData> => {
|
||||
return {
|
||||
data: {
|
||||
id,
|
||||
node_type: 'file',
|
||||
name,
|
||||
path: `/${id}`,
|
||||
extension: 'md',
|
||||
size: 1,
|
||||
children: [],
|
||||
},
|
||||
} as unknown as NodeApi<TreeNodeData>
|
||||
}
|
||||
|
||||
describe('useFileOperations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.appStoreState.appDetail = { id: 'app-1' }
|
||||
mocks.treeData = { children: [] }
|
||||
mocks.toApiParentId.mockReturnValue('parent-api-id')
|
||||
mocks.createOpsResult = createDefaultCreateOps()
|
||||
mocks.modifyOpsResult = createDefaultModifyOps()
|
||||
mocks.downloadOpsResult = createDefaultDownloadOps()
|
||||
mocks.createOpsHook.mockImplementation(() => mocks.createOpsResult)
|
||||
mocks.modifyOpsHook.mockImplementation(() => mocks.modifyOpsResult)
|
||||
mocks.downloadOpsHook.mockImplementation(() => mocks.downloadOpsResult)
|
||||
})
|
||||
|
||||
// Scenario: node id and wiring should prioritize selected node over explicit id.
|
||||
describe('Hook wiring', () => {
|
||||
it('should use node data id and pass expected options to child operation hooks', () => {
|
||||
const node = createNodeApi('node-from-node', 'from-node.md')
|
||||
const treeRef = { current: null } as RefObject<TreeApi<TreeNodeData> | null>
|
||||
const onClose = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => useFileOperations({
|
||||
nodeId: 'explicit-node',
|
||||
node,
|
||||
treeRef,
|
||||
onClose,
|
||||
}))
|
||||
|
||||
expect(mocks.toApiParentId).toHaveBeenCalledWith('node-from-node')
|
||||
expect(mocks.createOpsHook).toHaveBeenCalledWith({
|
||||
parentId: 'parent-api-id',
|
||||
appId: 'app-1',
|
||||
storeApi: mocks.workflowStore,
|
||||
onClose,
|
||||
})
|
||||
expect(mocks.modifyOpsHook).toHaveBeenCalledWith({
|
||||
nodeId: 'node-from-node',
|
||||
node,
|
||||
treeRef,
|
||||
appId: 'app-1',
|
||||
storeApi: mocks.workflowStore,
|
||||
treeData: mocks.treeData,
|
||||
onClose,
|
||||
})
|
||||
expect(mocks.downloadOpsHook).toHaveBeenCalledWith({
|
||||
appId: 'app-1',
|
||||
nodeId: 'node-from-node',
|
||||
fileName: 'from-node.md',
|
||||
onClose,
|
||||
})
|
||||
|
||||
expect(result.current.handleNewFile).toBe(mocks.createOpsResult.handleNewFile)
|
||||
expect(result.current.handleRename).toBe(mocks.modifyOpsResult.handleRename)
|
||||
expect(result.current.handleDownload).toBe(mocks.downloadOpsResult.handleDownload)
|
||||
})
|
||||
|
||||
it('should fallback to explicit nodeId when node is not provided', () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
renderHook(() => useFileOperations({
|
||||
nodeId: 'explicit-only',
|
||||
onClose,
|
||||
}))
|
||||
|
||||
expect(mocks.toApiParentId).toHaveBeenCalledWith('explicit-only')
|
||||
expect(mocks.downloadOpsHook).toHaveBeenCalledWith({
|
||||
appId: 'app-1',
|
||||
nodeId: 'explicit-only',
|
||||
fileName: undefined,
|
||||
onClose,
|
||||
})
|
||||
})
|
||||
|
||||
it('should fallback to empty nodeId when both node and explicit nodeId are missing', () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
renderHook(() => useFileOperations({
|
||||
onClose,
|
||||
}))
|
||||
|
||||
expect(mocks.toApiParentId).toHaveBeenCalledWith('')
|
||||
expect(mocks.modifyOpsHook).toHaveBeenCalledWith({
|
||||
nodeId: '',
|
||||
node: undefined,
|
||||
treeRef: undefined,
|
||||
appId: 'app-1',
|
||||
storeApi: mocks.workflowStore,
|
||||
treeData: mocks.treeData,
|
||||
onClose,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: returned values should pass through child hook outputs and aggregate loading state.
|
||||
describe('Return shape', () => {
|
||||
it('should expose all operation handlers and refs from composed hooks', () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => useFileOperations({ onClose }))
|
||||
|
||||
expect(result.current.fileInputRef).toBe(mocks.createOpsResult.fileInputRef)
|
||||
expect(result.current.folderInputRef).toBe(mocks.createOpsResult.folderInputRef)
|
||||
expect(result.current.handleNewFile).toBe(mocks.createOpsResult.handleNewFile)
|
||||
expect(result.current.handleNewFolder).toBe(mocks.createOpsResult.handleNewFolder)
|
||||
expect(result.current.handleFileChange).toBe(mocks.createOpsResult.handleFileChange)
|
||||
expect(result.current.handleFolderChange).toBe(mocks.createOpsResult.handleFolderChange)
|
||||
expect(result.current.showDeleteConfirm).toBe(mocks.modifyOpsResult.showDeleteConfirm)
|
||||
expect(result.current.handleRename).toBe(mocks.modifyOpsResult.handleRename)
|
||||
expect(result.current.handleDeleteClick).toBe(mocks.modifyOpsResult.handleDeleteClick)
|
||||
expect(result.current.handleDeleteConfirm).toBe(mocks.modifyOpsResult.handleDeleteConfirm)
|
||||
expect(result.current.handleDeleteCancel).toBe(mocks.modifyOpsResult.handleDeleteCancel)
|
||||
expect(result.current.handleDownload).toBe(mocks.downloadOpsResult.handleDownload)
|
||||
expect(result.current.isDeleting).toBe(mocks.modifyOpsResult.isDeleting)
|
||||
expect(result.current.isDownloading).toBe(mocks.downloadOpsResult.isDownloading)
|
||||
})
|
||||
|
||||
it('should compute isLoading as false when all child hooks are idle', () => {
|
||||
const { result } = renderHook(() => useFileOperations({ onClose: vi.fn() }))
|
||||
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'create operation is pending',
|
||||
isCreating: true,
|
||||
isDeleting: false,
|
||||
isDownloading: false,
|
||||
},
|
||||
{
|
||||
name: 'delete operation is pending',
|
||||
isCreating: false,
|
||||
isDeleting: true,
|
||||
isDownloading: false,
|
||||
},
|
||||
{
|
||||
name: 'download operation is pending',
|
||||
isCreating: false,
|
||||
isDeleting: false,
|
||||
isDownloading: true,
|
||||
},
|
||||
])('should compute isLoading as true when $name', ({ isCreating, isDeleting, isDownloading }) => {
|
||||
mocks.createOpsResult.isCreating = isCreating
|
||||
mocks.modifyOpsResult.isDeleting = isDeleting
|
||||
mocks.downloadOpsResult.isDownloading = isDownloading
|
||||
|
||||
const { result } = renderHook(() => useFileOperations({ onClose: vi.fn() }))
|
||||
|
||||
expect(result.current.isLoading).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,338 @@
|
||||
import type { RefObject } from 'react'
|
||||
import type { NodeApi, TreeApi } from 'react-arborist'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TreeNodeData } from '../../../type'
|
||||
import type { SkillEditorSliceShape } from '@/app/components/workflow/store/workflow/skill-editor/types'
|
||||
import type { AppAssetTreeResponse } from '@/types/app-asset'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useModifyOperations } from './use-modify-operations'
|
||||
|
||||
type DeleteMutationPayload = {
|
||||
appId: string
|
||||
nodeId: string
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
deletePending: false,
|
||||
deleteMutateAsync: vi.fn<(payload: DeleteMutationPayload) => Promise<void>>(),
|
||||
emitTreeUpdate: vi.fn<() => void>(),
|
||||
toastNotify: vi.fn<(payload: { type: string, message: string }) => void>(),
|
||||
getAllDescendantFileIds: vi.fn<(nodeId: string, nodes: TreeNodeData[]) => string[]>(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-app-asset', () => ({
|
||||
useDeleteAppAssetNode: () => ({
|
||||
mutateAsync: mocks.deleteMutateAsync,
|
||||
isPending: mocks.deletePending,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../data/use-skill-tree-collaboration', () => ({
|
||||
useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
|
||||
}))
|
||||
|
||||
vi.mock('../../../utils/tree-utils', () => ({
|
||||
getAllDescendantFileIds: mocks.getAllDescendantFileIds,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: mocks.toastNotify,
|
||||
},
|
||||
}))
|
||||
|
||||
const createTreeNodeData = (id: string, nodeType: 'file' | 'folder', children: TreeNodeData[] = []): TreeNodeData => ({
|
||||
id,
|
||||
node_type: nodeType,
|
||||
name: nodeType === 'folder' ? `folder-${id}` : `${id}.md`,
|
||||
path: `/${id}`,
|
||||
extension: nodeType === 'folder' ? '' : 'md',
|
||||
size: 1,
|
||||
children,
|
||||
})
|
||||
|
||||
const createNodeApi = (nodeType: 'file' | 'folder', id = 'node-1') => {
|
||||
const edit = vi.fn()
|
||||
const node = {
|
||||
data: createTreeNodeData(id, nodeType),
|
||||
edit,
|
||||
} as unknown as NodeApi<TreeNodeData>
|
||||
return { node, edit }
|
||||
}
|
||||
|
||||
const createTreeRef = (targetNode: NodeApi<TreeNodeData> | null) => {
|
||||
const get = vi.fn<(nodeId: string) => NodeApi<TreeNodeData> | null>().mockReturnValue(targetNode)
|
||||
const treeRef = {
|
||||
current: {
|
||||
get,
|
||||
},
|
||||
} as unknown as RefObject<TreeApi<TreeNodeData> | null>
|
||||
return { treeRef, get }
|
||||
}
|
||||
|
||||
const createStoreApi = () => {
|
||||
const closeTab = vi.fn<(fileId: string) => void>()
|
||||
const clearDraftContent = vi.fn<(fileId: string) => void>()
|
||||
const state = {
|
||||
closeTab,
|
||||
clearDraftContent,
|
||||
} as Pick<SkillEditorSliceShape, 'closeTab' | 'clearDraftContent'>
|
||||
|
||||
const storeApi = {
|
||||
getState: () => state,
|
||||
} as unknown as StoreApi<SkillEditorSliceShape>
|
||||
|
||||
return {
|
||||
storeApi,
|
||||
closeTab,
|
||||
clearDraftContent,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useModifyOperations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.deletePending = false
|
||||
mocks.deleteMutateAsync.mockResolvedValue(undefined)
|
||||
mocks.getAllDescendantFileIds.mockReturnValue([])
|
||||
})
|
||||
|
||||
// Scenario: loading state should match mutation pending status.
|
||||
describe('State', () => {
|
||||
it('should expose mutation pending state as isDeleting', () => {
|
||||
mocks.deletePending = true
|
||||
const { storeApi } = createStoreApi()
|
||||
|
||||
const { result } = renderHook(() => useModifyOperations({
|
||||
nodeId: 'node-1',
|
||||
appId: 'app-1',
|
||||
storeApi,
|
||||
onClose: vi.fn(),
|
||||
}))
|
||||
|
||||
expect(result.current.isDeleting).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: rename action should prefer treeRef editing and fallback to node editing.
|
||||
describe('Rename', () => {
|
||||
it('should edit node from treeRef when treeRef is available', () => {
|
||||
const { storeApi } = createStoreApi()
|
||||
const onClose = vi.fn()
|
||||
const { node: treeNode, edit: treeNodeEdit } = createNodeApi('file', 'tree-node')
|
||||
const { treeRef, get } = createTreeRef(treeNode)
|
||||
const { node: fallbackNode, edit: fallbackEdit } = createNodeApi('file', 'fallback-node')
|
||||
|
||||
const { result } = renderHook(() => useModifyOperations({
|
||||
nodeId: 'tree-node',
|
||||
node: fallbackNode,
|
||||
treeRef,
|
||||
appId: 'app-1',
|
||||
storeApi,
|
||||
onClose,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleRename()
|
||||
})
|
||||
|
||||
expect(get).toHaveBeenCalledWith('tree-node')
|
||||
expect(treeNodeEdit).toHaveBeenCalledTimes(1)
|
||||
expect(fallbackEdit).not.toHaveBeenCalled()
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should fallback to provided node edit when treeRef is absent', () => {
|
||||
const { storeApi } = createStoreApi()
|
||||
const onClose = vi.fn()
|
||||
const { node, edit } = createNodeApi('folder', 'folder-2')
|
||||
|
||||
const { result } = renderHook(() => useModifyOperations({
|
||||
nodeId: 'folder-2',
|
||||
node,
|
||||
appId: 'app-1',
|
||||
storeApi,
|
||||
onClose,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleRename()
|
||||
})
|
||||
|
||||
expect(edit).toHaveBeenCalledTimes(1)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: delete confirm dialog toggles with click/cancel handlers.
|
||||
describe('Delete dialog state', () => {
|
||||
it('should open and close delete confirmation dialog', () => {
|
||||
const { storeApi } = createStoreApi()
|
||||
|
||||
const { result } = renderHook(() => useModifyOperations({
|
||||
nodeId: 'node-1',
|
||||
appId: 'app-1',
|
||||
storeApi,
|
||||
onClose: vi.fn(),
|
||||
}))
|
||||
|
||||
expect(result.current.showDeleteConfirm).toBe(false)
|
||||
|
||||
act(() => {
|
||||
result.current.handleDeleteClick()
|
||||
})
|
||||
|
||||
expect(result.current.showDeleteConfirm).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.handleDeleteCancel()
|
||||
})
|
||||
|
||||
expect(result.current.showDeleteConfirm).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: successful deletes should close tabs/drafts and emit collaboration updates.
|
||||
describe('Delete success', () => {
|
||||
it('should delete file node, clear descendants and current file tabs, and show file success toast', async () => {
|
||||
const { storeApi, closeTab, clearDraftContent } = createStoreApi()
|
||||
const onClose = vi.fn()
|
||||
const { node } = createNodeApi('file', 'file-7')
|
||||
const treeData: AppAssetTreeResponse = {
|
||||
children: [createTreeNodeData('root-folder', 'folder')],
|
||||
}
|
||||
mocks.getAllDescendantFileIds.mockReturnValue(['desc-1', 'desc-2'])
|
||||
|
||||
const { result } = renderHook(() => useModifyOperations({
|
||||
nodeId: 'file-7',
|
||||
node,
|
||||
appId: 'app-77',
|
||||
storeApi,
|
||||
treeData,
|
||||
onClose,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleDeleteClick()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDeleteConfirm()
|
||||
})
|
||||
|
||||
expect(mocks.getAllDescendantFileIds).toHaveBeenCalledWith('file-7', treeData.children)
|
||||
expect(mocks.deleteMutateAsync).toHaveBeenCalledWith({
|
||||
appId: 'app-77',
|
||||
nodeId: 'file-7',
|
||||
})
|
||||
expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(closeTab).toHaveBeenNthCalledWith(1, 'desc-1')
|
||||
expect(closeTab).toHaveBeenNthCalledWith(2, 'desc-2')
|
||||
expect(closeTab).toHaveBeenNthCalledWith(3, 'file-7')
|
||||
expect(clearDraftContent).toHaveBeenNthCalledWith(1, 'desc-1')
|
||||
expect(clearDraftContent).toHaveBeenNthCalledWith(2, 'desc-2')
|
||||
expect(clearDraftContent).toHaveBeenNthCalledWith(3, 'file-7')
|
||||
|
||||
expect(mocks.toastNotify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'workflow.skillSidebar.menu.fileDeleted',
|
||||
})
|
||||
expect(result.current.showDeleteConfirm).toBe(false)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should delete folder node and skip closing the folder tab itself', async () => {
|
||||
const { storeApi, closeTab, clearDraftContent } = createStoreApi()
|
||||
const { node } = createNodeApi('folder', 'folder-9')
|
||||
const treeData: AppAssetTreeResponse = {
|
||||
children: [createTreeNodeData('root-folder', 'folder')],
|
||||
}
|
||||
mocks.getAllDescendantFileIds.mockReturnValue(['file-in-folder'])
|
||||
|
||||
const { result } = renderHook(() => useModifyOperations({
|
||||
nodeId: 'folder-9',
|
||||
node,
|
||||
appId: 'app-9',
|
||||
storeApi,
|
||||
treeData,
|
||||
onClose: vi.fn(),
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDeleteConfirm()
|
||||
})
|
||||
|
||||
expect(closeTab).toHaveBeenCalledTimes(1)
|
||||
expect(closeTab).toHaveBeenCalledWith('file-in-folder')
|
||||
expect(clearDraftContent).toHaveBeenCalledTimes(1)
|
||||
expect(clearDraftContent).toHaveBeenCalledWith('file-in-folder')
|
||||
expect(closeTab).not.toHaveBeenCalledWith('folder-9')
|
||||
expect(clearDraftContent).not.toHaveBeenCalledWith('folder-9')
|
||||
expect(mocks.toastNotify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'workflow.skillSidebar.menu.deleted',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: failed deletes should surface proper error toasts and always close dialog.
|
||||
describe('Delete errors', () => {
|
||||
it('should show folder delete error toast on failure', async () => {
|
||||
mocks.deleteMutateAsync.mockRejectedValueOnce(new Error('delete failed'))
|
||||
const { storeApi, closeTab, clearDraftContent } = createStoreApi()
|
||||
const onClose = vi.fn()
|
||||
const { node } = createNodeApi('folder', 'folder-err')
|
||||
const treeData: AppAssetTreeResponse = {
|
||||
children: [createTreeNodeData('top', 'folder')],
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useModifyOperations({
|
||||
nodeId: 'folder-err',
|
||||
node,
|
||||
appId: 'app-err',
|
||||
storeApi,
|
||||
treeData,
|
||||
onClose,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDeleteConfirm()
|
||||
})
|
||||
|
||||
expect(mocks.emitTreeUpdate).not.toHaveBeenCalled()
|
||||
expect(closeTab).not.toHaveBeenCalled()
|
||||
expect(clearDraftContent).not.toHaveBeenCalled()
|
||||
expect(mocks.toastNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'workflow.skillSidebar.menu.deleteError',
|
||||
})
|
||||
expect(result.current.showDeleteConfirm).toBe(false)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should show file delete error toast and skip descendant lookup when treeData is missing', async () => {
|
||||
mocks.deleteMutateAsync.mockRejectedValueOnce(new Error('delete failed'))
|
||||
const { storeApi } = createStoreApi()
|
||||
const { node } = createNodeApi('file', 'file-err')
|
||||
|
||||
const { result } = renderHook(() => useModifyOperations({
|
||||
nodeId: 'file-err',
|
||||
node,
|
||||
appId: 'app-err',
|
||||
storeApi,
|
||||
onClose: vi.fn(),
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDeleteConfirm()
|
||||
})
|
||||
|
||||
expect(mocks.getAllDescendantFileIds).not.toHaveBeenCalled()
|
||||
expect(mocks.toastNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'workflow.skillSidebar.menu.fileDeleteError',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useNodeMove } from './use-node-move'
|
||||
|
||||
type AppStoreState = {
|
||||
appDetail?: {
|
||||
id: string
|
||||
} | null
|
||||
}
|
||||
|
||||
type MoveMutationPayload = {
|
||||
appId: string
|
||||
nodeId: string
|
||||
payload: {
|
||||
parent_id: string | null
|
||||
}
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
appStoreState: {
|
||||
appDetail: { id: 'app-1' },
|
||||
} as AppStoreState,
|
||||
movePending: false,
|
||||
moveMutateAsync: vi.fn<(payload: MoveMutationPayload) => Promise<void>>(),
|
||||
emitTreeUpdate: vi.fn<() => void>(),
|
||||
toastNotify: vi.fn<(payload: { type: string, message: string }) => void>(),
|
||||
toApiParentId: vi.fn<(folderId: string | null | undefined) => string | null>(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: AppStoreState) => unknown) => selector(mocks.appStoreState),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-app-asset', () => ({
|
||||
useMoveAppAssetNode: () => ({
|
||||
mutateAsync: mocks.moveMutateAsync,
|
||||
isPending: mocks.movePending,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../data/use-skill-tree-collaboration', () => ({
|
||||
useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
|
||||
}))
|
||||
|
||||
vi.mock('../../../utils/tree-utils', () => ({
|
||||
toApiParentId: mocks.toApiParentId,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: mocks.toastNotify,
|
||||
},
|
||||
}))
|
||||
|
||||
describe('useNodeMove', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.appStoreState.appDetail = { id: 'app-1' }
|
||||
mocks.movePending = false
|
||||
mocks.moveMutateAsync.mockResolvedValue(undefined)
|
||||
mocks.toApiParentId.mockImplementation(folderId => folderId ?? null)
|
||||
})
|
||||
|
||||
// Scenario: loading state should mirror mutation pending state.
|
||||
describe('State', () => {
|
||||
it('should expose mutation pending state as isMoving', () => {
|
||||
mocks.movePending = true
|
||||
|
||||
const { result } = renderHook(() => useNodeMove())
|
||||
|
||||
expect(result.current.isMoving).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: successful move should call API, emit update, and show success toast.
|
||||
describe('Success', () => {
|
||||
it('should move node and emit collaboration update when API succeeds', async () => {
|
||||
mocks.toApiParentId.mockReturnValueOnce('parent-api-id')
|
||||
const { result } = renderHook(() => useNodeMove())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.executeMoveNode('node-11', 'folder-22')
|
||||
})
|
||||
|
||||
expect(mocks.toApiParentId).toHaveBeenCalledWith('folder-22')
|
||||
expect(mocks.moveMutateAsync).toHaveBeenCalledWith({
|
||||
appId: 'app-1',
|
||||
nodeId: 'node-11',
|
||||
payload: {
|
||||
parent_id: 'parent-api-id',
|
||||
},
|
||||
})
|
||||
expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.toastNotify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'workflow.skillSidebar.menu.moved',
|
||||
})
|
||||
})
|
||||
|
||||
it('should use empty appId when app detail is unavailable', async () => {
|
||||
mocks.appStoreState.appDetail = undefined
|
||||
mocks.toApiParentId.mockReturnValueOnce(null)
|
||||
const { result } = renderHook(() => useNodeMove())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.executeMoveNode('node-99', null)
|
||||
})
|
||||
|
||||
expect(mocks.moveMutateAsync).toHaveBeenCalledWith({
|
||||
appId: '',
|
||||
nodeId: 'node-99',
|
||||
payload: {
|
||||
parent_id: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: failed move should surface an error toast and skip update emission.
|
||||
describe('Error handling', () => {
|
||||
it('should show error toast when move fails', async () => {
|
||||
mocks.moveMutateAsync.mockRejectedValueOnce(new Error('move failed'))
|
||||
const { result } = renderHook(() => useNodeMove())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.executeMoveNode('node-7', 'folder-7')
|
||||
})
|
||||
|
||||
expect(mocks.emitTreeUpdate).not.toHaveBeenCalled()
|
||||
expect(mocks.toastNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'workflow.skillSidebar.menu.moveError',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useNodeReorder } from './use-node-reorder'
|
||||
|
||||
type AppStoreState = {
|
||||
appDetail?: {
|
||||
id: string
|
||||
} | null
|
||||
}
|
||||
|
||||
type ReorderMutationPayload = {
|
||||
appId: string
|
||||
nodeId: string
|
||||
payload: {
|
||||
after_node_id: string | null
|
||||
}
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
appStoreState: {
|
||||
appDetail: { id: 'app-10' },
|
||||
} as AppStoreState,
|
||||
reorderPending: false,
|
||||
reorderMutateAsync: vi.fn<(payload: ReorderMutationPayload) => Promise<void>>(),
|
||||
emitTreeUpdate: vi.fn<() => void>(),
|
||||
toastNotify: vi.fn<(payload: { type: string, message: string }) => void>(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: AppStoreState) => unknown) => selector(mocks.appStoreState),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-app-asset', () => ({
|
||||
useReorderAppAssetNode: () => ({
|
||||
mutateAsync: mocks.reorderMutateAsync,
|
||||
isPending: mocks.reorderPending,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../data/use-skill-tree-collaboration', () => ({
|
||||
useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: mocks.toastNotify,
|
||||
},
|
||||
}))
|
||||
|
||||
describe('useNodeReorder', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.appStoreState.appDetail = { id: 'app-10' }
|
||||
mocks.reorderPending = false
|
||||
mocks.reorderMutateAsync.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
// Scenario: loading state should mirror reorder mutation status.
|
||||
describe('State', () => {
|
||||
it('should expose mutation pending state as isReordering', () => {
|
||||
mocks.reorderPending = true
|
||||
|
||||
const { result } = renderHook(() => useNodeReorder())
|
||||
|
||||
expect(result.current.isReordering).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: successful reorder should call API, emit update, and notify success.
|
||||
describe('Success', () => {
|
||||
it('should reorder node with provided afterNodeId', async () => {
|
||||
const { result } = renderHook(() => useNodeReorder())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.executeReorderNode('node-1', 'node-0')
|
||||
})
|
||||
|
||||
expect(mocks.reorderMutateAsync).toHaveBeenCalledWith({
|
||||
appId: 'app-10',
|
||||
nodeId: 'node-1',
|
||||
payload: {
|
||||
after_node_id: 'node-0',
|
||||
},
|
||||
})
|
||||
expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.toastNotify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'workflow.skillSidebar.menu.moved',
|
||||
})
|
||||
})
|
||||
|
||||
it('should use empty appId when app detail is missing', async () => {
|
||||
mocks.appStoreState.appDetail = null
|
||||
const { result } = renderHook(() => useNodeReorder())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.executeReorderNode('node-2', null)
|
||||
})
|
||||
|
||||
expect(mocks.reorderMutateAsync).toHaveBeenCalledWith({
|
||||
appId: '',
|
||||
nodeId: 'node-2',
|
||||
payload: {
|
||||
after_node_id: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: failed reorder should not emit update and should show error toast.
|
||||
describe('Error handling', () => {
|
||||
it('should show error toast when reorder fails', async () => {
|
||||
mocks.reorderMutateAsync.mockRejectedValueOnce(new Error('reorder failed'))
|
||||
const { result } = renderHook(() => useNodeReorder())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.executeReorderNode('node-3', 'node-1')
|
||||
})
|
||||
|
||||
expect(mocks.emitTreeUpdate).not.toHaveBeenCalled()
|
||||
expect(mocks.toastNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'workflow.skillSidebar.menu.moveError',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,381 @@
|
||||
import type { RefObject } from 'react'
|
||||
import type { TreeApi } from 'react-arborist'
|
||||
import type { TreeNodeData } from '../../../type'
|
||||
import type { AppAssetTreeResponse } from '@/types/app-asset'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { usePasteOperation } from './use-paste-operation'
|
||||
|
||||
type MoveMutationPayload = {
|
||||
appId: string
|
||||
nodeId: string
|
||||
payload: {
|
||||
parent_id: string | null
|
||||
}
|
||||
}
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (reason?: unknown) => void
|
||||
}
|
||||
|
||||
const createDeferred = <T,>(): Deferred<T> => {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
type WorkflowStoreState = {
|
||||
clipboard: {
|
||||
operation: 'cut'
|
||||
nodeIds: Set<string>
|
||||
} | null
|
||||
selectedTreeNodeId: string | null
|
||||
clearClipboard: () => void
|
||||
}
|
||||
|
||||
type AppStoreState = {
|
||||
appDetail?: {
|
||||
id: string
|
||||
} | null
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
workflowState: {
|
||||
clipboard: null,
|
||||
selectedTreeNodeId: null,
|
||||
clearClipboard: vi.fn<() => void>(),
|
||||
} as WorkflowStoreState,
|
||||
appStoreState: {
|
||||
appDetail: { id: 'app-1' },
|
||||
} as AppStoreState,
|
||||
movePending: false,
|
||||
moveMutateAsync: vi.fn<(payload: MoveMutationPayload) => Promise<void>>(),
|
||||
emitTreeUpdate: vi.fn<() => void>(),
|
||||
toastNotify: vi.fn<(payload: { type: string, message: string }) => void>(),
|
||||
getTargetFolderIdFromSelection: vi.fn<(selectedId: string | null, nodes: TreeNodeData[]) => string>(),
|
||||
toApiParentId: vi.fn<(folderId: string | null | undefined) => string | null>(),
|
||||
findNodeById: vi.fn<(nodes: TreeNodeData[], nodeId: string) => TreeNodeData | null>(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => mocks.workflowState,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: AppStoreState) => unknown) => selector(mocks.appStoreState),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-app-asset', () => ({
|
||||
useMoveAppAssetNode: () => ({
|
||||
mutateAsync: mocks.moveMutateAsync,
|
||||
isPending: mocks.movePending,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../data/use-skill-tree-collaboration', () => ({
|
||||
useSkillTreeUpdateEmitter: () => mocks.emitTreeUpdate,
|
||||
}))
|
||||
|
||||
vi.mock('../../../utils/tree-utils', () => ({
|
||||
getTargetFolderIdFromSelection: mocks.getTargetFolderIdFromSelection,
|
||||
toApiParentId: mocks.toApiParentId,
|
||||
findNodeById: mocks.findNodeById,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: mocks.toastNotify,
|
||||
},
|
||||
}))
|
||||
|
||||
const createTreeNode = (id: string, nodeType: 'file' | 'folder'): TreeNodeData => ({
|
||||
id,
|
||||
node_type: nodeType,
|
||||
name: nodeType === 'folder' ? `folder-${id}` : `${id}.md`,
|
||||
path: `/${id}`,
|
||||
extension: nodeType === 'folder' ? '' : 'md',
|
||||
size: 1,
|
||||
children: [],
|
||||
})
|
||||
|
||||
const createTreeRef = (selectedId?: string): RefObject<TreeApi<TreeNodeData> | null> => {
|
||||
const selectedNodes = selectedId ? [{ id: selectedId }] : []
|
||||
return {
|
||||
current: {
|
||||
selectedNodes,
|
||||
},
|
||||
} as unknown as RefObject<TreeApi<TreeNodeData> | null>
|
||||
}
|
||||
|
||||
describe('usePasteOperation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.workflowState.clipboard = null
|
||||
mocks.workflowState.selectedTreeNodeId = null
|
||||
mocks.appStoreState.appDetail = { id: 'app-1' }
|
||||
mocks.movePending = false
|
||||
mocks.moveMutateAsync.mockResolvedValue(undefined)
|
||||
mocks.getTargetFolderIdFromSelection.mockReturnValue('target-folder')
|
||||
mocks.toApiParentId.mockReturnValue('target-parent')
|
||||
mocks.findNodeById.mockReturnValue(null)
|
||||
})
|
||||
|
||||
// Scenario: isPasting output should reflect mutation pending state.
|
||||
describe('State', () => {
|
||||
it('should expose mutation pending state as isPasting', () => {
|
||||
mocks.movePending = true
|
||||
const treeRef = createTreeRef('selected')
|
||||
|
||||
const { result } = renderHook(() => usePasteOperation({ treeRef }))
|
||||
|
||||
expect(result.current.isPasting).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: guard clauses should skip paste work when clipboard is unavailable.
|
||||
describe('Guards', () => {
|
||||
it('should no-op when clipboard is empty', async () => {
|
||||
const treeRef = createTreeRef('selected')
|
||||
const { result } = renderHook(() => usePasteOperation({ treeRef }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handlePaste()
|
||||
})
|
||||
|
||||
expect(mocks.getTargetFolderIdFromSelection).not.toHaveBeenCalled()
|
||||
expect(mocks.moveMutateAsync).not.toHaveBeenCalled()
|
||||
expect(mocks.toastNotify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should no-op when clipboard has no node ids', async () => {
|
||||
mocks.workflowState.clipboard = {
|
||||
operation: 'cut',
|
||||
nodeIds: new Set(),
|
||||
}
|
||||
const treeRef = createTreeRef('selected')
|
||||
const { result } = renderHook(() => usePasteOperation({ treeRef }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handlePaste()
|
||||
})
|
||||
|
||||
expect(mocks.getTargetFolderIdFromSelection).not.toHaveBeenCalled()
|
||||
expect(mocks.moveMutateAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reject moving folder into itself and show error toast', async () => {
|
||||
mocks.workflowState.clipboard = {
|
||||
operation: 'cut',
|
||||
nodeIds: new Set(['folder-1']),
|
||||
}
|
||||
mocks.getTargetFolderIdFromSelection.mockReturnValueOnce('folder-1')
|
||||
mocks.findNodeById.mockReturnValueOnce(createTreeNode('folder-1', 'folder'))
|
||||
const treeRef = createTreeRef('folder-1')
|
||||
const treeData: AppAssetTreeResponse = {
|
||||
children: [createTreeNode('folder-1', 'folder')],
|
||||
}
|
||||
const { result } = renderHook(() => usePasteOperation({ treeRef, treeData }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handlePaste()
|
||||
})
|
||||
|
||||
expect(mocks.moveMutateAsync).not.toHaveBeenCalled()
|
||||
expect(mocks.toastNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'workflow.skillSidebar.menu.cannotMoveToSelf',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: successful cut-paste should move all nodes and clear clipboard.
|
||||
describe('Success', () => {
|
||||
it('should move cut nodes, clear clipboard, and emit update', async () => {
|
||||
mocks.workflowState.clipboard = {
|
||||
operation: 'cut',
|
||||
nodeIds: new Set(['node-1', 'node-2']),
|
||||
}
|
||||
const treeRef = createTreeRef('selected-node')
|
||||
const treeData: AppAssetTreeResponse = {
|
||||
children: [createTreeNode('node-1', 'file'), createTreeNode('node-2', 'file')],
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => usePasteOperation({ treeRef, treeData }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handlePaste()
|
||||
})
|
||||
|
||||
expect(mocks.getTargetFolderIdFromSelection).toHaveBeenCalledWith('selected-node', treeData.children)
|
||||
expect(mocks.toApiParentId).toHaveBeenCalledWith('target-folder')
|
||||
expect(mocks.moveMutateAsync).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.moveMutateAsync).toHaveBeenNthCalledWith(1, {
|
||||
appId: 'app-1',
|
||||
nodeId: 'node-1',
|
||||
payload: {
|
||||
parent_id: 'target-parent',
|
||||
},
|
||||
})
|
||||
expect(mocks.moveMutateAsync).toHaveBeenNthCalledWith(2, {
|
||||
appId: 'app-1',
|
||||
nodeId: 'node-2',
|
||||
payload: {
|
||||
parent_id: 'target-parent',
|
||||
},
|
||||
})
|
||||
expect(mocks.workflowState.clearClipboard).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.emitTreeUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.toastNotify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'workflow.skillSidebar.menu.moved',
|
||||
})
|
||||
})
|
||||
|
||||
it('should fallback to selectedTreeNodeId when tree has no selected node', async () => {
|
||||
mocks.workflowState.clipboard = {
|
||||
operation: 'cut',
|
||||
nodeIds: new Set(['node-store']),
|
||||
}
|
||||
mocks.workflowState.selectedTreeNodeId = 'store-selected'
|
||||
const treeRef = createTreeRef()
|
||||
const treeData: AppAssetTreeResponse = {
|
||||
children: [createTreeNode('node-store', 'file')],
|
||||
}
|
||||
const { result } = renderHook(() => usePasteOperation({ treeRef, treeData }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handlePaste()
|
||||
})
|
||||
|
||||
expect(mocks.getTargetFolderIdFromSelection).toHaveBeenCalledWith('store-selected', treeData.children)
|
||||
expect(mocks.moveMutateAsync).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: failed paste should keep clipboard and show error toast.
|
||||
describe('Error handling', () => {
|
||||
it('should show move error toast when API call fails', async () => {
|
||||
mocks.workflowState.clipboard = {
|
||||
operation: 'cut',
|
||||
nodeIds: new Set(['node-error']),
|
||||
}
|
||||
mocks.moveMutateAsync.mockRejectedValueOnce(new Error('move failed'))
|
||||
const treeRef = createTreeRef('target')
|
||||
const treeData: AppAssetTreeResponse = {
|
||||
children: [createTreeNode('node-error', 'file')],
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => usePasteOperation({ treeRef, treeData }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handlePaste()
|
||||
})
|
||||
|
||||
expect(mocks.workflowState.clearClipboard).not.toHaveBeenCalled()
|
||||
expect(mocks.emitTreeUpdate).not.toHaveBeenCalled()
|
||||
expect(mocks.toastNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'workflow.skillSidebar.menu.moveError',
|
||||
})
|
||||
})
|
||||
|
||||
it('should prevent re-entrant paste while a paste is in progress', async () => {
|
||||
mocks.workflowState.clipboard = {
|
||||
operation: 'cut',
|
||||
nodeIds: new Set(['node-slow']),
|
||||
}
|
||||
const deferred = createDeferred<void>()
|
||||
mocks.moveMutateAsync.mockReturnValueOnce(deferred.promise)
|
||||
const treeRef = createTreeRef('target')
|
||||
const treeData: AppAssetTreeResponse = {
|
||||
children: [createTreeNode('node-slow', 'file')],
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => usePasteOperation({ treeRef, treeData }))
|
||||
|
||||
act(() => {
|
||||
void result.current.handlePaste()
|
||||
void result.current.handlePaste()
|
||||
})
|
||||
|
||||
expect(mocks.moveMutateAsync).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
deferred.resolve(undefined)
|
||||
await deferred.promise
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: enabled flag should control window event listener lifecycle.
|
||||
describe('Window event integration', () => {
|
||||
it('should register and cleanup paste listener when enabled', () => {
|
||||
const addListenerSpy = vi.spyOn(window, 'addEventListener')
|
||||
const removeListenerSpy = vi.spyOn(window, 'removeEventListener')
|
||||
const treeRef = createTreeRef('selected')
|
||||
|
||||
const { unmount } = renderHook(() => usePasteOperation({ treeRef, enabled: true }))
|
||||
|
||||
const addCall = addListenerSpy.mock.calls.find(call => String(call[0]) === 'skill:paste')
|
||||
expect(addCall).toBeDefined()
|
||||
|
||||
unmount()
|
||||
|
||||
const removeCall = removeListenerSpy.mock.calls.find(call => String(call[0]) === 'skill:paste')
|
||||
expect(removeCall).toBeDefined()
|
||||
expect(removeCall?.[1]).toBe(addCall?.[1])
|
||||
|
||||
addListenerSpy.mockRestore()
|
||||
removeListenerSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should trigger paste handler when skill:paste event is dispatched and enabled', async () => {
|
||||
mocks.workflowState.clipboard = {
|
||||
operation: 'cut',
|
||||
nodeIds: new Set(['node-event']),
|
||||
}
|
||||
const treeRef = createTreeRef('selected')
|
||||
const treeData: AppAssetTreeResponse = {
|
||||
children: [createTreeNode('node-event', 'file')],
|
||||
}
|
||||
|
||||
renderHook(() => usePasteOperation({ treeRef, treeData, enabled: true }))
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('skill:paste'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.moveMutateAsync).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore skill:paste event when disabled', async () => {
|
||||
mocks.workflowState.clipboard = {
|
||||
operation: 'cut',
|
||||
nodeIds: new Set(['node-disabled']),
|
||||
}
|
||||
const treeRef = createTreeRef('selected')
|
||||
const treeData: AppAssetTreeResponse = {
|
||||
children: [createTreeNode('node-disabled', 'file')],
|
||||
}
|
||||
|
||||
renderHook(() => usePasteOperation({ treeRef, treeData, enabled: false }))
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('skill:paste'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.moveMutateAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user