diff --git a/src/components/CommandPalette.test.tsx b/src/components/CommandPalette.test.tsx new file mode 100644 index 00000000..6469cc0a --- /dev/null +++ b/src/components/CommandPalette.test.tsx @@ -0,0 +1,168 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { CommandPalette } from './CommandPalette' +import type { CommandAction } from '../hooks/useCommandRegistry' + +// jsdom doesn't implement scrollIntoView +Element.prototype.scrollIntoView = 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: 'Create 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 }), +] + +describe('CommandPalette', () => { + const onClose = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + 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('shows all enabled commands grouped by category', () => { + render() + expect(screen.getByText('Search Notes')).toBeInTheDocument() + expect(screen.getByText('Create 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('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 (Create New Note) should execute + expect(commands[1].execute).toHaveBeenCalled() + expect(onClose).toHaveBeenCalled() + }) + + 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() + }) +}) diff --git a/src/hooks/useAppKeyboard.test.ts b/src/hooks/useAppKeyboard.test.ts index 51198c5a..3e3b0538 100644 --- a/src/hooks/useAppKeyboard.test.ts +++ b/src/hooks/useAppKeyboard.test.ts @@ -17,6 +17,7 @@ function fireKey(key: string, mods: { altKey?: boolean; metaKey?: boolean; ctrlK function makeActions() { return { onQuickOpen: vi.fn(), + onCommandPalette: vi.fn(), onCreateNote: vi.fn(), onSave: vi.fn(), onOpenSettings: vi.fn(), @@ -86,4 +87,11 @@ describe('useAppKeyboard', () => { fireKey('4', { altKey: true }) expect(actions.onSetViewMode).not.toHaveBeenCalled() }) + + it('Cmd+K triggers command palette', () => { + const actions = makeActions() + renderHook(() => useAppKeyboard(actions)) + fireKey('k', { metaKey: true }) + expect(actions.onCommandPalette).toHaveBeenCalled() + }) }) diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts new file mode 100644 index 00000000..a0d859b4 --- /dev/null +++ b/src/hooks/useCommandRegistry.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, vi } from 'vitest' +import { renderHook } from '@testing-library/react' +import { useCommandRegistry, groupSortKey } from './useCommandRegistry' +import type { VaultEntry } from '../types' + +const makeEntry = (overrides: Partial = {}): VaultEntry => ({ + path: '/vault/note/test.md', + filename: 'test.md', + title: 'Test Note', + isA: 'Note', + aliases: [], + belongsTo: [], + relatedTo: [], + status: 'Active', + owner: null, + cadence: null, + archived: false, + trashed: false, + trashedAt: null, + modifiedAt: 1700000000, + createdAt: 1700000000, + fileSize: 100, + snippet: '', + relationships: {}, + icon: null, + color: null, + order: null, + ...overrides, +}) + +function makeConfig(overrides: Record = {}) { + return { + activeTabPath: null as string | null, + entries: [] as VaultEntry[], + modifiedCount: 0, + onQuickOpen: vi.fn(), + onCreateNote: vi.fn(), + onSave: vi.fn(), + onOpenSettings: vi.fn(), + onTrashNote: vi.fn(), + onArchiveNote: vi.fn(), + onUnarchiveNote: vi.fn(), + onCommitPush: vi.fn(), + onSetViewMode: vi.fn(), + onToggleInspector: vi.fn(), + onSelect: vi.fn(), + onCloseTab: vi.fn(), + ...overrides, + } +} + +describe('useCommandRegistry', () => { + it('returns all command groups', () => { + const { result } = renderHook(() => useCommandRegistry(makeConfig())) + const groups = new Set(result.current.map(c => c.group)) + expect(groups).toContain('Navigation') + expect(groups).toContain('Note') + expect(groups).toContain('Git') + expect(groups).toContain('View') + expect(groups).toContain('Settings') + }) + + it('has search-notes command with shortcut', () => { + const { result } = renderHook(() => useCommandRegistry(makeConfig())) + const cmd = result.current.find(c => c.id === 'search-notes') + expect(cmd).toBeDefined() + expect(cmd!.shortcut).toBe('⌘P') + expect(cmd!.enabled).toBe(true) + }) + + it('disables contextual actions when no note is open', () => { + const { result } = renderHook(() => useCommandRegistry(makeConfig({ activeTabPath: null }))) + const trashCmd = result.current.find(c => c.id === 'trash-note') + expect(trashCmd!.enabled).toBe(false) + + const saveCmd = result.current.find(c => c.id === 'save-note') + expect(saveCmd!.enabled).toBe(false) + + const closeCmd = result.current.find(c => c.id === 'close-tab') + expect(closeCmd!.enabled).toBe(false) + }) + + it('enables contextual actions when a note is open', () => { + const entries = [makeEntry({ path: '/vault/note/test.md' })] + const { result } = renderHook(() => + useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries })), + ) + expect(result.current.find(c => c.id === 'trash-note')!.enabled).toBe(true) + expect(result.current.find(c => c.id === 'save-note')!.enabled).toBe(true) + expect(result.current.find(c => c.id === 'close-tab')!.enabled).toBe(true) + }) + + it('shows "Unarchive Note" when active note is archived', () => { + const entries = [makeEntry({ path: '/vault/note/test.md', archived: true })] + const { result } = renderHook(() => + useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries })), + ) + const archiveCmd = result.current.find(c => c.id === 'archive-note') + expect(archiveCmd!.label).toBe('Unarchive Note') + }) + + it('shows "Archive Note" when active note is not archived', () => { + const entries = [makeEntry({ path: '/vault/note/test.md', archived: false })] + const { result } = renderHook(() => + useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries })), + ) + const archiveCmd = result.current.find(c => c.id === 'archive-note') + expect(archiveCmd!.label).toBe('Archive Note') + }) + + it('disables commit when no modified files', () => { + const { result } = renderHook(() => useCommandRegistry(makeConfig({ modifiedCount: 0 }))) + expect(result.current.find(c => c.id === 'commit-push')!.enabled).toBe(false) + }) + + it('enables commit when modified files exist', () => { + const { result } = renderHook(() => useCommandRegistry(makeConfig({ modifiedCount: 3 }))) + expect(result.current.find(c => c.id === 'commit-push')!.enabled).toBe(true) + }) + + it('calls onQuickOpen when search-notes executes', () => { + const onQuickOpen = vi.fn() + const { result } = renderHook(() => useCommandRegistry(makeConfig({ onQuickOpen }))) + result.current.find(c => c.id === 'search-notes')!.execute() + expect(onQuickOpen).toHaveBeenCalled() + }) + + it('calls onSelect with filter when navigation commands execute', () => { + const onSelect = vi.fn() + const { result } = renderHook(() => useCommandRegistry(makeConfig({ onSelect }))) + result.current.find(c => c.id === 'go-all')!.execute() + expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'all' }) + }) + + it('calls onSetViewMode when view commands execute', () => { + const onSetViewMode = vi.fn() + const { result } = renderHook(() => useCommandRegistry(makeConfig({ onSetViewMode }))) + result.current.find(c => c.id === 'view-editor')!.execute() + expect(onSetViewMode).toHaveBeenCalledWith('editor-only') + }) +}) + +describe('groupSortKey', () => { + it('returns ordered keys for all groups', () => { + expect(groupSortKey('Navigation')).toBeLessThan(groupSortKey('Note')) + expect(groupSortKey('Note')).toBeLessThan(groupSortKey('Git')) + expect(groupSortKey('Git')).toBeLessThan(groupSortKey('View')) + expect(groupSortKey('View')).toBeLessThan(groupSortKey('Settings')) + }) +}) diff --git a/src/utils/fuzzyMatch.test.ts b/src/utils/fuzzyMatch.test.ts new file mode 100644 index 00000000..59dc41d9 --- /dev/null +++ b/src/utils/fuzzyMatch.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { fuzzyMatch } from './fuzzyMatch' + +describe('fuzzyMatch', () => { + it('matches exact string', () => { + const result = fuzzyMatch('hello', 'hello') + expect(result.match).toBe(true) + expect(result.score).toBeGreaterThan(0) + }) + + it('matches case-insensitively', () => { + expect(fuzzyMatch('hello', 'Hello World').match).toBe(true) + }) + + it('matches subsequence chars in order', () => { + expect(fuzzyMatch('cnt', 'Create New Type').match).toBe(true) + }) + + it('rejects when chars are not all present', () => { + expect(fuzzyMatch('xyz', 'hello').match).toBe(false) + }) + + it('rejects when chars are out of order', () => { + expect(fuzzyMatch('ba', 'abc').match).toBe(false) + }) + + it('returns higher score for consecutive matches', () => { + const consecutive = fuzzyMatch('com', 'Commit & Push') + const scattered = fuzzyMatch('cmt', 'Commit & Push') + expect(consecutive.score).toBeGreaterThan(scattered.score) + }) + + it('gives bonus for word-start matches', () => { + const wordStart = fuzzyMatch('cp', 'Commit Push') + const midWord = fuzzyMatch('om', 'Commit Push') + expect(wordStart.score).toBeGreaterThan(midWord.score) + }) + + it('matches empty query against any string', () => { + expect(fuzzyMatch('', 'anything').match).toBe(true) + }) + + it('handles empty target', () => { + expect(fuzzyMatch('a', '').match).toBe(false) + }) +})