Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316// src/layout/file-tree/__tests__/FileTreeItem.test.tsximport { 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(<FileTreeItem {...defaultProps} />) expect(screen.getByText('test.md')).toBeInTheDocument() })
it('renders with correct ARIA role', () => { render(<FileTreeItem {...defaultProps} />) expect(screen.getByRole('treeitem')).toBeInTheDocument() })
it('applies active class when isActive is true', () => { render(<FileTreeItem {...defaultProps} isActive={true} />) expect(screen.getByRole('treeitem')).toHaveClass('file-tree-item--active') })
it('does not show chevron for files', () => { render(<FileTreeItem {...defaultProps} />) expect(screen.queryByLabelText(/expand/i)).not.toBeInTheDocument() expect(screen.queryByLabelText(/collapse/i)).not.toBeInTheDocument() })
it('applies indentation based on depth', () => { render(<FileTreeItem {...defaultProps} depth={2} />) 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(<FileTreeItem {...defaultProps} entry={folderEntry} />) expect(screen.getByText('documents')).toBeInTheDocument() })
it('shows expand chevron when collapsed', () => { render(<FileTreeItem {...defaultProps} entry={folderEntry} isExpanded={false} />) expect(screen.getByLabelText(/expand documents/i)).toBeInTheDocument() })
it('shows collapse chevron when expanded', () => { render(<FileTreeItem {...defaultProps} entry={folderEntry} isExpanded={true} />) expect(screen.getByLabelText(/collapse documents/i)).toBeInTheDocument() })
it('sets aria-expanded correctly', () => { const { rerender } = render( <FileTreeItem {...defaultProps} entry={folderEntry} isExpanded={false} /> ) expect(screen.getByRole('treeitem')).toHaveAttribute('aria-expanded', 'false')
rerender(<FileTreeItem {...defaultProps} entry={folderEntry} isExpanded={true} />) expect(screen.getByRole('treeitem')).toHaveAttribute('aria-expanded', 'true') }) })
describe('interactions', () => { it('calls onClick when clicked', async () => { const user = userEvent.setup() render(<FileTreeItem {...defaultProps} />) await user.click(screen.getByRole('treeitem')) expect(mockOnClick).toHaveBeenCalledWith(defaultProps.entry) })
it('calls onContextMenu on right-click', async () => { const user = userEvent.setup() render(<FileTreeItem {...defaultProps} />) 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(<FileTreeItem {...defaultProps} entry={folderEntry} isExpanded={false} />) 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(<FileTreeItem {...defaultProps} />) const item = screen.getByRole('treeitem') expect(item).toHaveAttribute('tabindex', '0') })
it('calls onClick on Enter key', async () => { const user = userEvent.setup() render(<FileTreeItem {...defaultProps} />) 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(<FileTreeItem {...defaultProps} entry={folderEntry} isExpanded={false} />) 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(<FileTreeItem {...defaultProps} entry={folderEntry} isExpanded={true} />) screen.getByRole('treeitem').focus() await user.keyboard('{ArrowLeft}') expect(mockOnToggleExpand).toHaveBeenCalledWith(folderEntry.url) }) })
describe('icon rendering', () => { it('shows FileText icon for markdown files', () => { render(<FileTreeItem {...defaultProps} entry={{ ...defaultProps.entry, name: 'note.md' }} />) // 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(<FileTreeItem {...defaultProps} entry={folderEntry} isExpanded={false} />) const item = screen.getByRole('treeitem') expect(item.querySelector('svg')).toBeInTheDocument() }) })
describe('accessibility', () => { it('has aria-selected for active state', () => { render(<FileTreeItem {...defaultProps} isActive={true} />) expect(screen.getByRole('treeitem')).toHaveAttribute('aria-selected', 'true') })
it('has aria-level for depth', () => { render(<FileTreeItem {...defaultProps} depth={2} />) expect(screen.getByRole('treeitem')).toHaveAttribute('aria-level', '3') // depth + 1 }) })
describe('displayName prop', () => { it('renders displayName when provided', () => { render(<FileTreeItem {...defaultProps} displayName="My Custom Title" />) expect(screen.getByText('My Custom Title')).toBeInTheDocument() })
it('renders entry.name when no displayName', () => { render(<FileTreeItem {...defaultProps} />) expect(screen.getByText(defaultProps.entry.name)).toBeInTheDocument() }) })
describe('action button', () => { it('renders action button with correct aria-label', () => { render(<FileTreeItem {...defaultProps} onActionClick={vi.fn()} />) 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(<FileTreeItem {...defaultProps} onActionClick={mockActionClick} />)
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(<FileTreeItem {...defaultProps} onActionClick={vi.fn()} />)
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(<FileTreeItem {...defaultProps} />) 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(<FileTreeItem {...defaultProps} entry={folderEntry} onActionClick={vi.fn()} />) 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(<FileTreeItem {...defaultProps} onActionClick={mockActionClick} />)
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(<FileTreeItem {...defaultProps} onActionClick={mockActionClick} />)
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(<FileTreeItem {...defaultProps} onActionClick={mockActionClick} />)
await user.pointer({ keys: '[MouseRight]', target: screen.getByRole('treeitem') })
expect(mockOnContextMenu).toHaveBeenCalled() expect(mockActionClick).not.toHaveBeenCalled() }) })})