test: add command palette and fuzzy match tests
Tests for CommandPalette component (rendering, filtering, keyboard navigation, grouped results, shortcuts display), useCommandRegistry hook (contextual actions, archive toggle, git availability), fuzzyMatch utility, and Cmd+K shortcut. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
168
src/components/CommandPalette.test.tsx
Normal file
168
src/components/CommandPalette.test.tsx
Normal file
@@ -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> = {}): 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(
|
||||
<CommandPalette open={false} commands={commands} onClose={onClose} />,
|
||||
)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('shows search input when open', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
expect(screen.getByPlaceholderText('Type a command...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows all enabled commands grouped by category', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
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(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
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(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
expect(screen.getByText('⌘P')).toBeInTheDocument()
|
||||
expect(screen.getByText('⌘N')).toBeInTheDocument()
|
||||
expect(screen.getByText('⌘,')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters commands by fuzzy search', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
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(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
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(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
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(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('executes command and closes on Enter', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
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(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
|
||||
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(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
|
||||
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(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
|
||||
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(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
|
||||
const backdrop = screen.getByPlaceholderText('Type a command...').closest('.fixed')!
|
||||
fireEvent.click(backdrop)
|
||||
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('executes command when clicking an item', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
fireEvent.click(screen.getByText('Commit & Push'))
|
||||
|
||||
expect(commands[2].execute).toHaveBeenCalled()
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows footer hints', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
expect(screen.getByText('↑↓ navigate')).toBeInTheDocument()
|
||||
expect(screen.getByText('↵ select')).toBeInTheDocument()
|
||||
expect(screen.getByText('esc close')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
150
src/hooks/useCommandRegistry.test.ts
Normal file
150
src/hooks/useCommandRegistry.test.ts
Normal file
@@ -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> = {}): 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<string, unknown> = {}) {
|
||||
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'))
|
||||
})
|
||||
})
|
||||
46
src/utils/fuzzyMatch.test.ts
Normal file
46
src/utils/fuzzyMatch.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user