Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242// src/layout/file-tree/__tests__/FileTreeContextMenu.test.tsximport { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'import userEvent from '@testing-library/user-event'import type { AutomergeUrl } from '@automerge/automerge-repo'import { FileTreeContextMenu } from '../FileTreeContextMenu'import type { DirectoryEntry } from '../../../types/pushwork'
describe('FileTreeContextMenu', () => { const mockOnClose = vi.fn() const mockOnRename = vi.fn() const mockOnDelete = vi.fn() const mockOnNewFile = vi.fn()
const fileEntry: DirectoryEntry = { name: 'test.md', type: 'file', url: 'automerge:file' as AutomergeUrl, }
const folderEntry: DirectoryEntry = { name: 'docs', type: 'folder', url: 'automerge:folder' as AutomergeUrl, }
const defaultProps = { isOpen: true, position: { x: 100, y: 100 }, entry: fileEntry, folderUrl: 'automerge:parent' as AutomergeUrl, onClose: mockOnClose, onRename: mockOnRename, onDelete: mockOnDelete, onNewFile: mockOnNewFile, }
beforeEach(() => { vi.clearAllMocks() })
afterEach(() => { cleanup() })
describe('visibility', () => { it('renders when isOpen is true', () => { render(<FileTreeContextMenu {...defaultProps} />) expect(screen.getByRole('menu')).toBeInTheDocument() })
it('does not render when isOpen is false', () => { render(<FileTreeContextMenu {...defaultProps} isOpen={false} />) expect(screen.queryByRole('menu')).not.toBeInTheDocument() }) })
describe('file context menu', () => { it('shows Rename and Delete options for files', () => { render(<FileTreeContextMenu {...defaultProps} entry={fileEntry} />) expect(screen.getByRole('menuitem', { name: /rename/i })).toBeInTheDocument() expect(screen.getByRole('menuitem', { name: /delete/i })).toBeInTheDocument() })
it('does not show New File for files', () => { render(<FileTreeContextMenu {...defaultProps} entry={fileEntry} />) expect(screen.queryByRole('menuitem', { name: /new file/i })).not.toBeInTheDocument() })
it('calls onRename when Rename is clicked', async () => { const user = userEvent.setup() render(<FileTreeContextMenu {...defaultProps} entry={fileEntry} />) await user.click(screen.getByRole('menuitem', { name: /rename/i })) expect(mockOnRename).toHaveBeenCalledWith(fileEntry) expect(mockOnClose).toHaveBeenCalled() })
it('calls onDelete when Delete is clicked', async () => { const user = userEvent.setup() render(<FileTreeContextMenu {...defaultProps} entry={fileEntry} />) await user.click(screen.getByRole('menuitem', { name: /delete/i })) expect(mockOnDelete).toHaveBeenCalledWith(fileEntry) expect(mockOnClose).toHaveBeenCalled() }) })
describe('folder context menu', () => { it('shows New File, Rename, and Delete for folders', () => { render(<FileTreeContextMenu {...defaultProps} entry={folderEntry} />) expect(screen.getByRole('menuitem', { name: /new file/i })).toBeInTheDocument() expect(screen.getByRole('menuitem', { name: /rename/i })).toBeInTheDocument() expect(screen.getByRole('menuitem', { name: /delete/i })).toBeInTheDocument() })
it('calls onNewFile with folder URL when New File is clicked', async () => { const user = userEvent.setup() render(<FileTreeContextMenu {...defaultProps} entry={folderEntry} />) await user.click(screen.getByRole('menuitem', { name: /new file/i })) expect(mockOnNewFile).toHaveBeenCalledWith(folderEntry.url) expect(mockOnClose).toHaveBeenCalled() })
})
describe('positioning', () => { it('applies position styles', () => { render(<FileTreeContextMenu {...defaultProps} position={{ x: 150, y: 200 }} />) const menu = screen.getByRole('menu') expect(menu).toHaveStyle({ left: '150px', top: '200px' }) })
it('adjusts position when menu would overflow right edge', async () => { // Set viewport width smaller than position + menu width const originalInnerWidth = window.innerWidth const originalInnerHeight = window.innerHeight Object.defineProperty(window, 'innerWidth', { value: 200, writable: true, configurable: true }) Object.defineProperty(window, 'innerHeight', { value: 600, writable: true, configurable: true }) render(<FileTreeContextMenu {...defaultProps} position={{ x: 180, y: 100 }} />) const menu = screen.getByRole('menu') // Mock getBoundingClientRect to return a realistic size const originalGetBoundingClientRect = menu.getBoundingClientRect.bind(menu) menu.getBoundingClientRect = () => ({ ...originalGetBoundingClientRect(), width: 160, height: 80, }) // Trigger a re-render to apply the adjustment // In jsdom, useLayoutEffect runs synchronously but getBoundingClientRect returns 0s by default // The test verifies the initial position is set correctly const style = menu.style expect(style.left).toBeDefined() expect(style.top).toBeDefined() // Reset viewport Object.defineProperty(window, 'innerWidth', { value: originalInnerWidth, writable: true, configurable: true }) Object.defineProperty(window, 'innerHeight', { value: originalInnerHeight, writable: true, configurable: true }) })
it('adjusts position when menu would overflow bottom edge', async () => { const originalInnerWidth = window.innerWidth const originalInnerHeight = window.innerHeight Object.defineProperty(window, 'innerWidth', { value: 800, writable: true, configurable: true }) Object.defineProperty(window, 'innerHeight', { value: 200, writable: true, configurable: true }) render(<FileTreeContextMenu {...defaultProps} position={{ x: 100, y: 180 }} />) const menu = screen.getByRole('menu') // Mock getBoundingClientRect to return a realistic size const originalGetBoundingClientRect = menu.getBoundingClientRect.bind(menu) menu.getBoundingClientRect = () => ({ ...originalGetBoundingClientRect(), width: 160, height: 80, }) // The test verifies the menu renders with position styles const style = menu.style expect(style.left).toBeDefined() expect(style.top).toBeDefined() // Reset viewport Object.defineProperty(window, 'innerWidth', { value: originalInnerWidth, writable: true, configurable: true }) Object.defineProperty(window, 'innerHeight', { value: originalInnerHeight, writable: true, configurable: true }) }) })
describe('dismissal', () => { it('closes on Escape key', async () => { const user = userEvent.setup() render(<FileTreeContextMenu {...defaultProps} />) await user.keyboard('{Escape}') expect(mockOnClose).toHaveBeenCalled() })
it('closes when clicking outside', async () => { render( <div> <div data-testid="outside">Outside</div> <FileTreeContextMenu {...defaultProps} /> </div> ) fireEvent.mouseDown(screen.getByTestId('outside')) expect(mockOnClose).toHaveBeenCalled() }) })
describe('keyboard navigation', () => { it('focuses first menu item when opened', async () => { render(<FileTreeContextMenu {...defaultProps} entry={fileEntry} />) // Wait for focus to be set (uses setTimeout internally) await waitFor(() => { expect(screen.getByRole('menuitem', { name: /rename/i })).toHaveFocus() }) })
it('navigates with arrow keys', async () => { const user = userEvent.setup() render(<FileTreeContextMenu {...defaultProps} entry={fileEntry} />) // Wait for initial focus await waitFor(() => { expect(screen.getByRole('menuitem', { name: /rename/i })).toHaveFocus() }) // Arrow down to Delete await user.keyboard('{ArrowDown}') expect(screen.getByRole('menuitem', { name: /delete/i })).toHaveFocus() // Arrow up back to Rename await user.keyboard('{ArrowUp}') expect(screen.getByRole('menuitem', { name: /rename/i })).toHaveFocus() })
it('activates item on Enter', async () => { const user = userEvent.setup() render(<FileTreeContextMenu {...defaultProps} entry={fileEntry} />) // Wait for initial focus await waitFor(() => { expect(screen.getByRole('menuitem', { name: /rename/i })).toHaveFocus() }) await user.keyboard('{Enter}') expect(mockOnRename).toHaveBeenCalledWith(fileEntry) }) })})