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 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 { 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(), 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('react') type MockTreeComponentProps = { children?: ReactNode } & Record const Tree = React.forwardRef((props: MockTreeComponentProps, ref: Ref) => { 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
}) return { Tree } }) vi.mock('@tanstack/react-query', async () => { const actual = await vi.importActual('@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 }) => (
{searchTerm}
), })) vi.mock('./drag-action-tooltip', () => ({ default: ({ action }: { action: string }) => (
{action}
), })) vi.mock('./upload-status-tooltip', () => ({ default: ({ fallback }: { fallback?: ReactNode }) => (
{fallback}
), })) vi.mock('./tree-context-menu', () => ({ default: () =>
, })) 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() }) }) })