import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { act, render, screen, fireEvent, waitFor } from '@testing-library/react' import type { VaultEntry } from '../types' import { queueAiPrompt, requestOpenAiChat } from '../utils/aiPromptBridge' import { readSelectionRange } from './inlineWikilinkDom' import { CommandPalette } from './CommandPalette' import type { CommandAction } from '../hooks/useCommandRegistry' type NativeDropPayload = { type: string paths: string[] position: { x: number; y: number } } type NativeDropHandler = (event: { payload: NativeDropPayload }) => void const nativeDropState = vi.hoisted(() => ({ tauriMode: false, handlers: {} as Record, })) // jsdom doesn't implement scrollIntoView Element.prototype.scrollIntoView = vi.fn() vi.mock('../mock-tauri', () => ({ isTauri: () => nativeDropState.tauriMode, })) vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: () => ({ onDragDropEvent: vi.fn((handler: NativeDropHandler) => { nativeDropState.handlers['native-drag-drop'] = [ ...(nativeDropState.handlers['native-drag-drop'] ?? []), handler, ] return Promise.resolve(() => { const handlers = nativeDropState.handlers['native-drag-drop']?.filter((candidate) => candidate !== handler) ?? [] if (handlers.length > 0) nativeDropState.handlers['native-drag-drop'] = handlers else delete nativeDropState.handlers['native-drag-drop'] }) }), }), })) vi.mock('../utils/aiPromptBridge', () => ({ queueAiPrompt: vi.fn(), requestOpenAiChat: vi.fn(), })) const makeCommand = (overrides: Partial = {}): CommandAction => ({ id: 'test-cmd', label: 'Test Command', group: 'Navigation', keywords: [], enabled: true, shortcut: undefined, execute: vi.fn(), ...overrides, }) const commands: CommandAction[] = [ makeCommand({ id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: '⌘P', keywords: ['find'] }), makeCommand({ id: 'create-note', label: 'New Note', group: 'Note', shortcut: '⌘N' }), makeCommand({ id: 'commit-push', label: 'Commit & Push', group: 'Git', keywords: ['git', 'sync'] }), makeCommand({ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,' }), makeCommand({ id: 'disabled-cmd', label: 'Disabled Command', group: 'Note', enabled: false }), ] const makeEntry = (overrides: Partial = {}): VaultEntry => ({ path: '/vault/note/test.md', filename: 'test.md', title: 'Test Note', isA: 'Note', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null, archived: false, modifiedAt: 1700000000, createdAt: 1700000000, fileSize: 100, snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], ...overrides, }) const entries: VaultEntry[] = [ makeEntry({ path: '/vault/alpha.md', filename: 'alpha.md', title: 'Alpha', isA: 'Project' }), ] function setSelection(editor: HTMLElement, offset: number) { const selection = window.getSelection() if (!selection) return const targetNode = editor.firstChild ?? editor const safeOffset = targetNode.nodeType === Node.TEXT_NODE ? Math.min(offset, targetNode.textContent?.length ?? 0) : Math.min(offset, targetNode.childNodes.length) const range = document.createRange() range.setStart(targetNode, safeOffset) range.collapse(true) selection.removeAllRanges() selection.addRange(range) } function updateAiInput(text: string) { const editor = screen.getByTestId('command-palette-ai-input') editor.textContent = text setSelection(editor, text.length) fireEvent.input(editor) return screen.queryByTestId('command-palette-ai-input') ?? editor } function resetNativeDropState() { nativeDropState.tauriMode = false for (const eventName of Object.keys(nativeDropState.handlers)) { delete nativeDropState.handlers[eventName] } } function mockElementRect(element: HTMLElement) { Object.defineProperty(element, 'getBoundingClientRect', { configurable: true, value: () => ({ x: 0, y: 0, left: 0, top: 0, right: 400, bottom: 48, width: 400, height: 48, toJSON: () => ({}), }), }) } function emitNativePathDrop(paths: string[]) { const handlers = nativeDropState.handlers['native-drag-drop'] if (!handlers || handlers.length === 0) throw new Error('No native drop handler registered') for (const handler of handlers) { handler({ payload: { type: 'drop', paths, position: { x: 20, y: 20 }, }, }) } } async function waitForNativePathDropListener() { await waitFor(() => { expect(nativeDropState.handlers['native-drag-drop']?.length).toBeGreaterThan(0) }) } describe('CommandPalette', () => { const onClose = vi.fn() beforeEach(() => { vi.clearAllMocks() resetNativeDropState() }) afterEach(resetNativeDropState) it('renders nothing when closed', () => { const { container } = render( , ) expect(container.firstChild).toBeNull() }) it('shows search input when open', () => { render() expect(screen.getByPlaceholderText('Type a command...')).toBeInTheDocument() }) it('opts the command input out of spellcheck and text correction', () => { render() const input = screen.getByPlaceholderText('Type a command...') expect(input).toHaveAttribute('spellcheck', 'false') expect(input).toHaveAttribute('autocorrect', 'off') expect(input).toHaveAttribute('autocapitalize', 'off') expect(input).toHaveAttribute('autocomplete', 'off') }) it('shows all enabled commands grouped by category', () => { render() expect(screen.getByText('Search Notes')).toBeInTheDocument() expect(screen.getByText('New Note')).toBeInTheDocument() expect(screen.getByText('Commit & Push')).toBeInTheDocument() expect(screen.getByText('Open Settings')).toBeInTheDocument() // Disabled command should not appear expect(screen.queryByText('Disabled Command')).not.toBeInTheDocument() }) it('shows group labels', () => { render() expect(screen.getByText('Navigation')).toBeInTheDocument() expect(screen.getByText('Note')).toBeInTheDocument() expect(screen.getByText('Git')).toBeInTheDocument() expect(screen.getByText('Settings')).toBeInTheDocument() }) it('shows keyboard shortcuts', () => { render() expect(screen.getByText('⌘P')).toBeInTheDocument() expect(screen.getByText('⌘N')).toBeInTheDocument() expect(screen.getByText('⌘,')).toBeInTheDocument() }) it('filters commands by fuzzy search', () => { render() const input = screen.getByPlaceholderText('Type a command...') fireEvent.change(input, { target: { value: 'commit' } }) expect(screen.getByText('Commit & Push')).toBeInTheDocument() expect(screen.queryByText('Search Notes')).not.toBeInTheDocument() }) it('matches by keyword', () => { render() const input = screen.getByPlaceholderText('Type a command...') fireEvent.change(input, { target: { value: 'find' } }) expect(screen.getByText('Search Notes')).toBeInTheDocument() }) it('shows "No matching commands" when no results', () => { render() const input = screen.getByPlaceholderText('Type a command...') fireEvent.change(input, { target: { value: 'zzzzzzz' } }) expect(screen.getByText('No matching commands')).toBeInTheDocument() }) it('localizes command palette chrome', () => { render() const input = screen.getByPlaceholderText('输入命令...') fireEvent.change(input, { target: { value: 'zzzzzzz' } }) expect(screen.getByText('没有匹配的命令')).toBeInTheDocument() expect(screen.getByText('↑↓ 导航')).toBeInTheDocument() }) it('calls onClose when pressing Escape', () => { render() fireEvent.keyDown(window, { key: 'Escape' }) expect(onClose).toHaveBeenCalled() }) it('executes command and closes on Enter', () => { render() fireEvent.keyDown(window, { key: 'Enter' }) // First enabled command (Search Notes) should execute expect(commands[0].execute).toHaveBeenCalled() expect(onClose).toHaveBeenCalled() }) it('navigates with arrow keys and selects with Enter', () => { render() fireEvent.keyDown(window, { key: 'ArrowDown' }) fireEvent.keyDown(window, { key: 'Enter' }) // Second enabled command (New Note) should execute expect(commands[1].execute).toHaveBeenCalled() expect(onClose).toHaveBeenCalled() }) it('keeps a short query keyboard-selectable after ArrowDown and Enter', () => { const changeNoteType = makeCommand({ id: 'change-note-type', label: 'Change Note Type…', group: 'Note', }) render( , ) fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'ch' } }) fireEvent.keyDown(window, { key: 'ArrowDown' }) const selectedRow = screen.getByText('Change Note Type…').closest('[data-selected]') expect(selectedRow).toHaveAttribute('data-selected', 'true') fireEvent.keyDown(window, { key: 'Enter' }) expect(changeNoteType.execute).toHaveBeenCalledOnce() expect(onClose).toHaveBeenCalledOnce() }) it('does not go below the last item', () => { render() for (let i = 0; i < 20; i++) { fireEvent.keyDown(window, { key: 'ArrowDown' }) } fireEvent.keyDown(window, { key: 'Enter' }) // Should select last enabled command (Open Settings) expect(commands[3].execute).toHaveBeenCalled() }) it('does not go above first item', () => { render() fireEvent.keyDown(window, { key: 'ArrowUp' }) fireEvent.keyDown(window, { key: 'Enter' }) // Should still select first command expect(commands[0].execute).toHaveBeenCalled() }) it('calls onClose when clicking backdrop', () => { render() const backdrop = screen.getByPlaceholderText('Type a command...').closest('.fixed')! fireEvent.click(backdrop) expect(onClose).toHaveBeenCalled() }) it('executes command when clicking an item', () => { render() fireEvent.click(screen.getByText('Commit & Push')) expect(commands[2].execute).toHaveBeenCalled() expect(onClose).toHaveBeenCalled() }) it('shows footer hints', () => { render() expect(screen.getByText('↑↓ navigate')).toBeInTheDocument() expect(screen.getByText('↵ select')).toBeInTheDocument() expect(screen.getByText('esc close')).toBeInTheDocument() }) it('switches into AI mode when the query starts with a leading space', () => { render() fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: ' ' } }) expect(screen.getByTestId('command-palette-ai-input')).toBeInTheDocument() expect(screen.getAllByText('Ask Claude Code').length).toBeGreaterThan(0) expect(screen.queryByText('Search Notes')).not.toBeInTheDocument() }) it('keeps leading-space input in command search when AI mode is disabled', () => { render( , ) const input = screen.getByPlaceholderText('Type a command...') fireEvent.change(input, { target: { value: ' search' } }) expect(screen.queryByTestId('command-palette-ai-input')).not.toBeInTheDocument() expect(input).toHaveValue(' search') expect(queueAiPrompt).not.toHaveBeenCalled() expect(requestOpenAiChat).not.toHaveBeenCalled() }) it('inserts Tauri native folder drops into the command query input', async () => { nativeDropState.tauriMode = true render() const input = screen.getByPlaceholderText('Type a command...') as HTMLInputElement mockElementRect(input) input.focus() await waitForNativePathDropListener() act(() => { emitNativePathDrop(['/Users/test/Projects']) }) await waitFor(() => { expect(input).toHaveValue('/Users/test/Projects') }) }) it('focuses the AI editor immediately when the leading space triggers AI mode', () => { render() const input = screen.getByPlaceholderText('Type a command...') input.focus() fireEvent.change(input, { target: { value: ' ' } }) expect(screen.getByTestId('command-palette-ai-input')).toHaveFocus() }) it('places the AI editor caret after the trigger space on mode entry', () => { render() const input = screen.getByPlaceholderText('Type a command...') input.focus() fireEvent.change(input, { target: { value: ' ' } }) const editor = screen.getByTestId('command-palette-ai-input') as HTMLDivElement expect(editor).toHaveFocus() expect(readSelectionRange(editor)).toEqual({ start: 1, end: 1 }) }) it('returns to command mode when the leading space is deleted', () => { render( , ) fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: ' ' } }) updateAiInput('new') const input = screen.getByPlaceholderText('Type a command...') as HTMLInputElement expect(screen.queryByTestId('command-palette-ai-input')).toBeNull() expect(input.value).toBe('new') expect(screen.getByText('New Note')).toBeInTheDocument() }) it('queues a stripped AI prompt and closes on Enter in AI mode', () => { render( , ) fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: ' ' } }) const editor = updateAiInput(' hello world') fireEvent.keyDown(editor, { key: 'Enter' }) expect(queueAiPrompt).toHaveBeenCalledWith('hello world', []) expect(requestOpenAiChat).toHaveBeenCalledOnce() expect(onClose).toHaveBeenCalledOnce() }) it('closes without queueing when AI mode only contains the trigger space', () => { render() fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: ' ' } }) fireEvent.keyDown(screen.getByTestId('command-palette-ai-input'), { key: 'Enter' }) expect(queueAiPrompt).not.toHaveBeenCalled() expect(requestOpenAiChat).not.toHaveBeenCalled() expect(onClose).toHaveBeenCalledOnce() }) describe('relevance ranking', () => { const relevanceCommands: CommandAction[] = [ makeCommand({ id: 'create-note', label: 'New Note', group: 'Note' }), makeCommand({ id: 'toggle-raw', label: 'Toggle Raw Editor', group: 'View' }), makeCommand({ id: 'search-notes', label: 'Search Notes', group: 'Navigation' }), ] function getVisibleLabels() { return screen.getAllByText( (_content, el) => el?.tagName === 'SPAN' && el.classList.contains('text-foreground') && !!el.textContent, ).map(el => el.textContent) } it('shows only the relevant raw command for query "raw"', () => { render() fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'raw' } }) const labels = getVisibleLabels() expect(labels).toEqual(['Toggle Raw Editor']) }) it('ranks "New Note" first for query "new note"', () => { render() fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'new note' } }) const labels = getVisibleLabels() expect(labels[0]).toBe('New Note') }) it('preserves default section order with empty query', () => { render() const groupHeaders = screen.getAllByText( (_content, el) => el?.tagName === 'DIV' && el.classList.contains('text-[11px]') && el.classList.contains('font-medium') && !!el.textContent, ).map(el => el.textContent) // Default order: Navigation < Note < View expect(groupHeaders).toEqual(['Navigation', 'Note', 'View']) }) }) })