Files
tolaria/src/hooks/useAppKeyboard.test.ts

402 lines
14 KiB
TypeScript
Raw Normal View History

import { describe, it, expect, vi, afterEach } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useAppKeyboard } from './useAppKeyboard'
import { resetAppCommandDispatchStateForTests } from './appCommandDispatcher'
function fireKey(
key: string,
mods: { altKey?: boolean; metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; code?: string } = {},
) {
fireKeyOnTarget(window, key, mods)
}
function fireKeyOnTarget(
target: EventTarget,
key: string,
mods: { altKey?: boolean; metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; code?: string } = {},
) {
const event = new KeyboardEvent('keydown', {
key,
code: mods.code,
altKey: mods.altKey ?? false,
metaKey: mods.metaKey ?? false,
ctrlKey: mods.ctrlKey ?? false,
feat: full-text search with qmd backend and SearchPanel UI (Cmd+Shift+F) (#64) * feat: add search backend (qmd CLI) and design file - Add search.rs: qmd integration for keyword (BM25), semantic (vsearch), and hybrid search modes via CLI JSON output - Register search_vault Tauri command in lib.rs - Design file with 3 frames: empty, results, no-results states - Fix pre-existing test failures: install @tauri-apps/plugin-opener, add mock in test setup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add SearchPanel UI with Cmd+Shift+F shortcut - SearchPanel component: full-text search overlay with keyword/semantic mode toggle, debounced search (200ms), arrow key navigation, result count + elapsed time display, note type badges from vault entries - Cmd+Shift+F shortcut registered in useAppKeyboard - Mock search_vault handler in mock-tauri for browser dev mode - SearchResult/SearchResponse types in types.ts - Tests: 15 new tests for SearchPanel + 2 for keyboard shortcut - scrollIntoView mock in test setup for jsdom compatibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: correct SearchPanel imports for Tauri/browser dual-mode Use invoke from @tauri-apps/api/core and mockInvoke from mock-tauri with a searchCall wrapper, fixing the TypeScript build error. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: make test_detect_collection_fallback robust when qmd not installed * fix: reset localStorage between App tests to prevent view mode state leak --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 17:09:50 +01:00
shiftKey: mods.shiftKey ?? false,
bubbles: true,
cancelable: true,
})
target.dispatchEvent(event)
}
function makeActions() {
return {
onQuickOpen: vi.fn(),
onCommandPalette: vi.fn(),
feat: full-text search with qmd backend and SearchPanel UI (Cmd+Shift+F) (#64) * feat: add search backend (qmd CLI) and design file - Add search.rs: qmd integration for keyword (BM25), semantic (vsearch), and hybrid search modes via CLI JSON output - Register search_vault Tauri command in lib.rs - Design file with 3 frames: empty, results, no-results states - Fix pre-existing test failures: install @tauri-apps/plugin-opener, add mock in test setup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add SearchPanel UI with Cmd+Shift+F shortcut - SearchPanel component: full-text search overlay with keyword/semantic mode toggle, debounced search (200ms), arrow key navigation, result count + elapsed time display, note type badges from vault entries - Cmd+Shift+F shortcut registered in useAppKeyboard - Mock search_vault handler in mock-tauri for browser dev mode - SearchResult/SearchResponse types in types.ts - Tests: 15 new tests for SearchPanel + 2 for keyboard shortcut - scrollIntoView mock in test setup for jsdom compatibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: correct SearchPanel imports for Tauri/browser dual-mode Use invoke from @tauri-apps/api/core and mockInvoke from mock-tauri with a searchCall wrapper, fixing the TypeScript build error. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: make test_detect_collection_fallback robust when qmd not installed * fix: reset localStorage between App tests to prevent view mode state leak --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 17:09:50 +01:00
onSearch: vi.fn(),
onCreateNote: vi.fn(),
onSave: vi.fn(),
onOpenSettings: vi.fn(),
onDeleteNote: vi.fn(),
onArchiveNote: vi.fn(),
2026-04-10 12:08:17 +02:00
onToggleOrganized: vi.fn(),
onSetViewMode: vi.fn(),
onZoomIn: vi.fn(),
onZoomOut: vi.fn(),
onZoomReset: vi.fn(),
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject<string | null>,
multiSelectionCommandRef: { current: null },
}
}
describe('useAppKeyboard', () => {
afterEach(() => {
delete (window as typeof window & { __TAURI__?: unknown }).__TAURI__
resetAppCommandDispatchStateForTests()
vi.restoreAllMocks()
})
it('Cmd+1 sets view mode to editor-only', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('1', { metaKey: true })
expect(actions.onSetViewMode).toHaveBeenCalledWith('editor-only')
})
it('Cmd+2 sets view mode to editor-list', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('2', { metaKey: true })
expect(actions.onSetViewMode).toHaveBeenCalledWith('editor-list')
})
it('Cmd+3 sets view mode to all', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('3', { metaKey: true })
expect(actions.onSetViewMode).toHaveBeenCalledWith('all')
})
it('does not fire view mode when Cmd+Alt pressed', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('1', { metaKey: true, altKey: true })
expect(actions.onSetViewMode).not.toHaveBeenCalled()
})
it('Cmd+P triggers quick open', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('p', { metaKey: true })
expect(actions.onQuickOpen).toHaveBeenCalled()
})
2026-04-19 13:47:02 +02:00
it('Cmd+O triggers quick open', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('o', { metaKey: true, code: 'KeyO' })
expect(actions.onQuickOpen).toHaveBeenCalled()
})
it('Cmd+N triggers create note', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('n', { metaKey: true })
expect(actions.onCreateNote).toHaveBeenCalled()
})
it('Cmd+N still works in Tauri mode', () => {
const actions = makeActions()
;(window as typeof window & { __TAURI__?: object }).__TAURI__ = {}
renderHook(() => useAppKeyboard(actions))
fireKey('n', { metaKey: true })
expect(actions.onCreateNote).toHaveBeenCalled()
})
it('Cmd+\\ still works in Tauri mode', () => {
const actions = makeActions()
actions.onToggleRawEditor = vi.fn()
;(window as typeof window & { __TAURI__?: object }).__TAURI__ = {}
renderHook(() => useAppKeyboard(actions))
fireKey('\\', { metaKey: true })
expect(actions.onToggleRawEditor).toHaveBeenCalled()
})
it('Cmd+Shift+I still works in Tauri mode', () => {
const actions = makeActions()
const onToggleInspector = vi.fn()
;(window as typeof window & { __TAURI__?: object }).__TAURI__ = {}
renderHook(() => useAppKeyboard({ ...actions, onToggleInspector }))
fireKey('i', { metaKey: true, shiftKey: true })
expect(onToggleInspector).toHaveBeenCalled()
})
it('Cmd+Shift+L still works in Tauri mode', () => {
const actions = makeActions()
const onToggleAIChat = vi.fn()
;(window as typeof window & { __TAURI__?: object }).__TAURI__ = {}
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
fireKey('l', { metaKey: true, shiftKey: true })
expect(onToggleAIChat).toHaveBeenCalled()
})
it('Cmd+D triggers toggle favorite on active note', () => {
const actions = makeActions()
actions.onToggleFavorite = vi.fn()
renderHook(() => useAppKeyboard(actions))
fireKey('d', { metaKey: true })
expect(actions.onToggleFavorite).toHaveBeenCalledWith('/vault/test.md')
})
it('Cmd+D still works in Tauri mode', () => {
const actions = makeActions()
actions.onToggleFavorite = vi.fn()
;(window as typeof window & { __TAURI__?: object }).__TAURI__ = {}
renderHook(() => useAppKeyboard(actions))
fireKey('d', { metaKey: true })
expect(actions.onToggleFavorite).toHaveBeenCalledWith('/vault/test.md')
})
2026-04-10 12:08:17 +02:00
it('Cmd+E triggers toggle organized on active note, not archive', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('e', { metaKey: true })
expect(actions.onToggleOrganized).toHaveBeenCalledWith('/vault/test.md')
expect(actions.onArchiveNote).not.toHaveBeenCalled()
})
it('Cmd+E uses the current multi-selection instead of the active note', () => {
const actions = makeActions()
const organizeSelected = vi.fn()
actions.multiSelectionCommandRef.current = {
selectedPaths: ['/vault/a.md', '/vault/b.md'],
organizeSelected,
}
renderHook(() => useAppKeyboard(actions))
fireKey('e', { metaKey: true })
expect(organizeSelected).toHaveBeenCalledTimes(1)
expect(actions.onToggleOrganized).not.toHaveBeenCalled()
})
it('Cmd+E still works when editor focus stops propagation', () => {
const actions = makeActions()
const onToggleOrganized = vi.fn()
renderHook(() => useAppKeyboard({ ...actions, onToggleOrganized }))
withFocusedContentEditable((editable) => {
editable.addEventListener('keydown', (event) => event.stopPropagation())
fireKeyOnTarget(editable, 'e', { metaKey: true })
expect(onToggleOrganized).toHaveBeenCalledWith('/vault/test.md')
expect(actions.onArchiveNote).not.toHaveBeenCalled()
})
})
it('Cmd+J no longer triggers any app command', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('j', { metaKey: true })
expect(actions.onCreateNote).not.toHaveBeenCalled()
expect(actions.onQuickOpen).not.toHaveBeenCalled()
})
it('Alt+4 does not trigger any view mode', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
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()
})
feat: full-text search with qmd backend and SearchPanel UI (Cmd+Shift+F) (#64) * feat: add search backend (qmd CLI) and design file - Add search.rs: qmd integration for keyword (BM25), semantic (vsearch), and hybrid search modes via CLI JSON output - Register search_vault Tauri command in lib.rs - Design file with 3 frames: empty, results, no-results states - Fix pre-existing test failures: install @tauri-apps/plugin-opener, add mock in test setup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add SearchPanel UI with Cmd+Shift+F shortcut - SearchPanel component: full-text search overlay with keyword/semantic mode toggle, debounced search (200ms), arrow key navigation, result count + elapsed time display, note type badges from vault entries - Cmd+Shift+F shortcut registered in useAppKeyboard - Mock search_vault handler in mock-tauri for browser dev mode - SearchResult/SearchResponse types in types.ts - Tests: 15 new tests for SearchPanel + 2 for keyboard shortcut - scrollIntoView mock in test setup for jsdom compatibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: correct SearchPanel imports for Tauri/browser dual-mode Use invoke from @tauri-apps/api/core and mockInvoke from mock-tauri with a searchCall wrapper, fixing the TypeScript build error. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: make test_detect_collection_fallback robust when qmd not installed * fix: reset localStorage between App tests to prevent view mode state leak --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 17:09:50 +01:00
it('Cmd+Shift+F triggers search', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('f', { metaKey: true, shiftKey: true })
expect(actions.onSearch).toHaveBeenCalled()
})
it('Cmd+Shift+F does not trigger other shortcuts', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('f', { metaKey: true, shiftKey: true })
expect(actions.onQuickOpen).not.toHaveBeenCalled()
expect(actions.onCreateNote).not.toHaveBeenCalled()
})
function withFocusedInput(fn: () => void) {
const input = document.createElement('input')
document.body.appendChild(input)
input.focus()
try { fn() } finally { document.body.removeChild(input) }
}
function withFocusedContentEditable(fn: (editable: HTMLDivElement) => void) {
const editable = document.createElement('div')
editable.setAttribute('contenteditable', 'true')
document.body.appendChild(editable)
editable.focus()
try { fn(editable) } finally { document.body.removeChild(editable) }
}
it('Cmd+Backspace does not delete note when text input is focused', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
withFocusedInput(() => {
fireKey('Backspace', { metaKey: true })
expect(actions.onDeleteNote).not.toHaveBeenCalled()
})
})
it('Cmd+Backspace does not delete note when contenteditable is focused', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
withFocusedContentEditable((editable) => {
fireKeyOnTarget(editable, 'Backspace', { metaKey: true })
expect(actions.onDeleteNote).not.toHaveBeenCalled()
})
})
it('Cmd+Backspace deletes note when no text input is focused', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('Backspace', { metaKey: true })
expect(actions.onDeleteNote).toHaveBeenCalledWith('/vault/test.md')
})
it('Cmd+Backspace deletes the current multi-selection instead of the active note', () => {
const actions = makeActions()
const deleteSelected = vi.fn()
actions.multiSelectionCommandRef.current = {
selectedPaths: ['/vault/a.md', '/vault/b.md'],
deleteSelected,
}
renderHook(() => useAppKeyboard(actions))
fireKey('Backspace', { metaKey: true })
expect(deleteSelected).toHaveBeenCalledTimes(1)
expect(actions.onDeleteNote).not.toHaveBeenCalled()
})
it('Cmd+K still works when text input is focused', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
withFocusedInput(() => {
fireKey('k', { metaKey: true })
expect(actions.onCommandPalette).toHaveBeenCalled()
})
})
it('Cmd+= triggers zoom in', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('=', { metaKey: true })
expect(actions.onZoomIn).toHaveBeenCalled()
})
it('Cmd++ triggers zoom in', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('+', { metaKey: true })
expect(actions.onZoomIn).toHaveBeenCalled()
})
it('Cmd+- triggers zoom out', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('-', { metaKey: true })
expect(actions.onZoomOut).toHaveBeenCalled()
})
it('Cmd+0 triggers zoom reset', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('0', { metaKey: true })
expect(actions.onZoomReset).toHaveBeenCalled()
})
it('Cmd+Shift+L triggers toggle AI chat', () => {
const actions = makeActions()
const onToggleAIChat = vi.fn()
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
fireKey('l', { metaKey: true, shiftKey: true })
expect(onToggleAIChat).toHaveBeenCalled()
})
it('Cmd+Shift+L works when text input is focused', () => {
const actions = makeActions()
const onToggleAIChat = vi.fn()
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
withFocusedInput(() => {
fireKey('l', { metaKey: true, shiftKey: true })
expect(onToggleAIChat).toHaveBeenCalled()
})
})
it('Cmd+Shift+L works when editor stops propagation', () => {
const actions = makeActions()
const onToggleAIChat = vi.fn()
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
withFocusedContentEditable((editable) => {
editable.addEventListener('keydown', (event) => event.stopPropagation())
fireKeyOnTarget(editable, 'l', { metaKey: true, shiftKey: true })
expect(onToggleAIChat).toHaveBeenCalled()
})
})
it('Cmd+Shift+L matches by physical key code when the localized key differs', () => {
const actions = makeActions()
const onToggleAIChat = vi.fn()
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
fireKey('¬', { code: 'KeyL', metaKey: true, shiftKey: true })
expect(onToggleAIChat).toHaveBeenCalled()
})
it('Ctrl+Shift+L does not trigger toggle AI chat', () => {
const actions = makeActions()
const onToggleAIChat = vi.fn()
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
fireKey('l', { ctrlKey: true, shiftKey: true })
expect(onToggleAIChat).not.toHaveBeenCalled()
})
it('Cmd+I does not trigger AI chat (reserved for italic)', () => {
const actions = makeActions()
const onToggleAIChat = vi.fn()
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
fireKey('i', { metaKey: true })
expect(onToggleAIChat).not.toHaveBeenCalled()
})
it('Cmd+Shift+O triggers open in new window', () => {
const actions = makeActions()
const onOpenInNewWindow = vi.fn()
renderHook(() => useAppKeyboard({ ...actions, onOpenInNewWindow }))
fireKey('o', { metaKey: true, shiftKey: true })
expect(onOpenInNewWindow).toHaveBeenCalled()
})
it('Cmd+Shift+I triggers toggle inspector', () => {
const actions = makeActions()
const onToggleInspector = vi.fn()
renderHook(() => useAppKeyboard({ ...actions, onToggleInspector }))
fireKey('i', { metaKey: true, shiftKey: true })
expect(onToggleInspector).toHaveBeenCalled()
})
it('Cmd+Shift+I does not trigger AI chat toggle', () => {
const actions = makeActions()
const onToggleAIChat = vi.fn()
const onToggleInspector = vi.fn()
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat, onToggleInspector }))
fireKey('i', { metaKey: true, shiftKey: true })
expect(onToggleInspector).toHaveBeenCalled()
expect(onToggleAIChat).not.toHaveBeenCalled()
})
})