// 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()
})
})
})