// src/layout/file-tree/__tests__/FileTreeItem.test.tsx import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, cleanup } from '@testing-library/react' import userEvent from '@testing-library/user-event' import type { AutomergeUrl } from '@automerge/automerge-repo' import { FileTreeItem } from '../FileTreeItem' import type { DirectoryEntry } from '../../../types/pushwork' describe('FileTreeItem', () => { const mockOnClick = vi.fn() const mockOnContextMenu = vi.fn() const mockOnToggleExpand = vi.fn() const defaultProps = { entry: { name: 'test.md', type: 'file' as const, url: 'automerge:123' as AutomergeUrl }, depth: 0, isExpanded: false, isActive: false, onClick: mockOnClick, onContextMenu: mockOnContextMenu, onToggleExpand: mockOnToggleExpand, } beforeEach(() => { vi.clearAllMocks() }) afterEach(() => { cleanup() }) describe('file rendering', () => { it('renders file name', () => { render() expect(screen.getByText('test.md')).toBeInTheDocument() }) it('renders with correct ARIA role', () => { render() expect(screen.getByRole('treeitem')).toBeInTheDocument() }) it('applies active class when isActive is true', () => { render() expect(screen.getByRole('treeitem')).toHaveClass('file-tree-item--active') }) it('does not show chevron for files', () => { render() expect(screen.queryByLabelText(/expand/i)).not.toBeInTheDocument() expect(screen.queryByLabelText(/collapse/i)).not.toBeInTheDocument() }) it('applies indentation based on depth', () => { render() const item = screen.getByRole('treeitem') // FILE_TREE_BASE_PAD_PX (8) + depth * FILE_TREE_INDENT_PX (16) = 8 + 32 = 40 expect(item).toHaveStyle({ paddingLeft: '40px' }) }) }) describe('folder rendering', () => { const folderEntry: DirectoryEntry = { name: 'documents', type: 'folder', url: 'automerge:folder' as AutomergeUrl, } it('renders folder name', () => { render() expect(screen.getByText('documents')).toBeInTheDocument() }) it('shows expand chevron when collapsed', () => { render() expect(screen.getByLabelText(/expand documents/i)).toBeInTheDocument() }) it('shows collapse chevron when expanded', () => { render() expect(screen.getByLabelText(/collapse documents/i)).toBeInTheDocument() }) it('sets aria-expanded correctly', () => { const { rerender } = render( ) expect(screen.getByRole('treeitem')).toHaveAttribute('aria-expanded', 'false') rerender() expect(screen.getByRole('treeitem')).toHaveAttribute('aria-expanded', 'true') }) }) describe('interactions', () => { it('calls onClick when clicked', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('treeitem')) expect(mockOnClick).toHaveBeenCalledWith(defaultProps.entry) }) it('calls onContextMenu on right-click', async () => { const user = userEvent.setup() render() await user.pointer({ keys: '[MouseRight]', target: screen.getByRole('treeitem') }) expect(mockOnContextMenu).toHaveBeenCalled() }) it('calls onToggleExpand when clicking chevron on folder', async () => { const user = userEvent.setup() const folderEntry: DirectoryEntry = { name: 'docs', type: 'folder', url: 'automerge:folder' as AutomergeUrl, } render() await user.click(screen.getByLabelText(/expand docs/i)) expect(mockOnToggleExpand).toHaveBeenCalledWith(folderEntry.url) // onClick should NOT be called when clicking chevron expect(mockOnClick).not.toHaveBeenCalled() }) }) describe('keyboard interaction', () => { it('is focusable', () => { render() const item = screen.getByRole('treeitem') expect(item).toHaveAttribute('tabindex', '0') }) it('calls onClick on Enter key', async () => { const user = userEvent.setup() render() screen.getByRole('treeitem').focus() await user.keyboard('{Enter}') expect(mockOnClick).toHaveBeenCalledWith(defaultProps.entry) }) it('calls onToggleExpand on ArrowRight for collapsed folder', async () => { const user = userEvent.setup() const folderEntry: DirectoryEntry = { name: 'docs', type: 'folder', url: 'automerge:folder' as AutomergeUrl, } render() screen.getByRole('treeitem').focus() await user.keyboard('{ArrowRight}') expect(mockOnToggleExpand).toHaveBeenCalledWith(folderEntry.url) }) it('calls onToggleExpand on ArrowLeft for expanded folder', async () => { const user = userEvent.setup() const folderEntry: DirectoryEntry = { name: 'docs', type: 'folder', url: 'automerge:folder' as AutomergeUrl, } render() screen.getByRole('treeitem').focus() await user.keyboard('{ArrowLeft}') expect(mockOnToggleExpand).toHaveBeenCalledWith(folderEntry.url) }) }) describe('icon rendering', () => { it('shows FileText icon for markdown files', () => { render() // Icon is rendered - we check it exists via accessible name or test id const item = screen.getByRole('treeitem') expect(item.querySelector('svg')).toBeInTheDocument() }) it('shows Folder icon for collapsed folder', () => { const folderEntry: DirectoryEntry = { name: 'docs', type: 'folder', url: 'automerge:folder' as AutomergeUrl, } render() const item = screen.getByRole('treeitem') expect(item.querySelector('svg')).toBeInTheDocument() }) }) describe('accessibility', () => { it('has aria-selected for active state', () => { render() expect(screen.getByRole('treeitem')).toHaveAttribute('aria-selected', 'true') }) it('has aria-level for depth', () => { render() expect(screen.getByRole('treeitem')).toHaveAttribute('aria-level', '3') // depth + 1 }) }) describe('displayName prop', () => { it('renders displayName when provided', () => { render() expect(screen.getByText('My Custom Title')).toBeInTheDocument() }) it('renders entry.name when no displayName', () => { render() expect(screen.getByText(defaultProps.entry.name)).toBeInTheDocument() }) }) describe('action button', () => { it('renders action button with correct aria-label', () => { render() expect(screen.getByRole('button', { name: /actions for test\.md/i })).toBeInTheDocument() }) it('calls onActionClick with entry and position when clicked', async () => { const user = userEvent.setup() const mockActionClick = vi.fn() render() await user.click(screen.getByRole('button', { name: /actions for test\.md/i })) expect(mockActionClick).toHaveBeenCalledTimes(1) expect(mockActionClick).toHaveBeenCalledWith( defaultProps.entry, expect.objectContaining({ x: expect.any(Number), y: expect.any(Number) }) ) }) it('does not trigger item onClick when action button is clicked', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: /actions for test\.md/i })) expect(mockOnClick).not.toHaveBeenCalled() }) it('does not render action button when onActionClick is not provided', () => { render() expect(screen.queryByRole('button', { name: /actions for/i })).not.toBeInTheDocument() }) it('renders action button for folders too', () => { const folderEntry: DirectoryEntry = { name: 'docs', type: 'folder', url: 'automerge:folder' as AutomergeUrl, } render() expect(screen.getByRole('button', { name: /actions for docs/i })).toBeInTheDocument() }) it('receives position derived from the button bounding rectangle', async () => { const user = userEvent.setup() const mockActionClick = vi.fn() render() const actionBtn = screen.getByRole('button', { name: /actions for test\.md/i }) actionBtn.getBoundingClientRect = vi.fn(() => ({ x: 180, y: 30, width: 20, height: 20, top: 30, right: 200, bottom: 50, left: 180, toJSON: vi.fn(), })) await user.click(actionBtn) expect(mockActionClick).toHaveBeenCalledWith( defaultProps.entry, { x: 200, y: 50 } ) }) it('does not interfere with existing onClick when clicking the treeitem', async () => { const user = userEvent.setup() const mockActionClick = vi.fn() render() await user.click(screen.getByRole('treeitem')) expect(mockOnClick).toHaveBeenCalledWith(defaultProps.entry) expect(mockActionClick).not.toHaveBeenCalled() }) it('does not interfere with existing onContextMenu on right-click', async () => { const user = userEvent.setup() const mockActionClick = vi.fn() render() await user.pointer({ keys: '[MouseRight]', target: screen.getByRole('treeitem') }) expect(mockOnContextMenu).toHaveBeenCalled() expect(mockActionClick).not.toHaveBeenCalled() }) }) })