// src/layout/file-tree/__tests__/FileTreeContextMenu.test.tsx import { 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() expect(screen.getByRole('menu')).toBeInTheDocument() }) it('does not render when isOpen is false', () => { render() expect(screen.queryByRole('menu')).not.toBeInTheDocument() }) }) describe('file context menu', () => { it('shows Rename and Delete options for files', () => { render() 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() expect(screen.queryByRole('menuitem', { name: /new file/i })).not.toBeInTheDocument() }) it('calls onRename when Rename is clicked', async () => { const user = userEvent.setup() render() 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() 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() 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() 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() 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() 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() 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() await user.keyboard('{Escape}') expect(mockOnClose).toHaveBeenCalled() }) it('closes when clicking outside', async () => { render(
Outside
) fireEvent.mouseDown(screen.getByTestId('outside')) expect(mockOnClose).toHaveBeenCalled() }) }) describe('keyboard navigation', () => { it('focuses first menu item when opened', async () => { render() // 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() // 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() // Wait for initial focus await waitFor(() => { expect(screen.getByRole('menuitem', { name: /rename/i })).toHaveFocus() }) await user.keyboard('{Enter}') expect(mockOnRename).toHaveBeenCalledWith(fileEntry) }) }) })