89 tests covering: - useNoteActions: slugify, buildNewEntry, entryMatchesTarget, buildNoteContent, resolveNewNote/Type, createNote, createType, navigateWikilink, frontmatter ops - useEntryActions: trash/restore, archive/unarchive, customizeType, reorderSections - useTabManagement: tab selection, close, switch, reorder, replace, localStorage - useVaultLoader: vault loading, modified files, addEntry, updateContent, updateEntry, git history, diff, commitAndPush - useKeyboardNavigation: Cmd+Shift+Arrow tab switching, Cmd+Alt+Arrow note nav - mockFrontmatterHelpers: updateMockFrontmatter, deleteMockFrontmatterProperty Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
230 lines
6.4 KiB
TypeScript
230 lines
6.4 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import { renderHook, act } from '@testing-library/react'
|
|
import type { VaultEntry, SidebarSelection } from '../types'
|
|
import { useKeyboardNavigation } from './useKeyboardNavigation'
|
|
|
|
vi.mock('../mock-tauri', () => ({
|
|
isTauri: () => false,
|
|
}))
|
|
|
|
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,
|
|
})
|
|
|
|
interface Tab {
|
|
entry: VaultEntry
|
|
content: string
|
|
}
|
|
|
|
describe('useKeyboardNavigation', () => {
|
|
const onSwitchTab = vi.fn()
|
|
const onReplaceActiveTab = vi.fn()
|
|
const onSelectNote = vi.fn()
|
|
|
|
const entries = [
|
|
makeEntry({ path: '/vault/a.md', title: 'A', modifiedAt: 1700000003 }),
|
|
makeEntry({ path: '/vault/b.md', title: 'B', modifiedAt: 1700000002 }),
|
|
makeEntry({ path: '/vault/c.md', title: 'C', modifiedAt: 1700000001 }),
|
|
]
|
|
|
|
const tabs: Tab[] = [
|
|
{ entry: entries[0], content: '# A' },
|
|
{ entry: entries[1], content: '# B' },
|
|
{ entry: entries[2], content: '# C' },
|
|
]
|
|
|
|
const selection: SidebarSelection = { kind: 'filter', filter: 'all' }
|
|
const allContent: Record<string, string> = {}
|
|
|
|
let addedListeners: { type: string; handler: EventListenerOrEventListenerObject }[] = []
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
addedListeners = []
|
|
// Track added listeners for cleanup verification
|
|
const origAdd = window.addEventListener
|
|
const origRemove = window.removeEventListener
|
|
vi.spyOn(window, 'addEventListener').mockImplementation((type: string, handler: EventListenerOrEventListenerObject, opts?: any) => {
|
|
addedListeners.push({ type, handler })
|
|
origAdd.call(window, type, handler, opts)
|
|
})
|
|
vi.spyOn(window, 'removeEventListener').mockImplementation((type: string, handler: EventListenerOrEventListenerObject, opts?: any) => {
|
|
origRemove.call(window, type, handler, opts)
|
|
})
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('registers keydown listener on mount', () => {
|
|
renderHook(() =>
|
|
useKeyboardNavigation({
|
|
tabs, activeTabPath: '/vault/a.md', entries, selection, allContent,
|
|
onSwitchTab, onReplaceActiveTab, onSelectNote,
|
|
})
|
|
)
|
|
|
|
expect(addedListeners.some(l => l.type === 'keydown')).toBe(true)
|
|
})
|
|
|
|
it('switches to next tab on Cmd+Shift+ArrowRight (browser mode)', () => {
|
|
renderHook(() =>
|
|
useKeyboardNavigation({
|
|
tabs, activeTabPath: '/vault/a.md', entries, selection, allContent,
|
|
onSwitchTab, onReplaceActiveTab, onSelectNote,
|
|
})
|
|
)
|
|
|
|
act(() => {
|
|
window.dispatchEvent(new KeyboardEvent('keydown', {
|
|
key: 'ArrowRight', metaKey: true, shiftKey: true, bubbles: true,
|
|
}))
|
|
})
|
|
|
|
expect(onSwitchTab).toHaveBeenCalledWith('/vault/b.md')
|
|
})
|
|
|
|
it('switches to previous tab on Cmd+Shift+ArrowLeft (browser mode)', () => {
|
|
renderHook(() =>
|
|
useKeyboardNavigation({
|
|
tabs, activeTabPath: '/vault/b.md', entries, selection, allContent,
|
|
onSwitchTab, onReplaceActiveTab, onSelectNote,
|
|
})
|
|
)
|
|
|
|
act(() => {
|
|
window.dispatchEvent(new KeyboardEvent('keydown', {
|
|
key: 'ArrowLeft', metaKey: true, shiftKey: true, bubbles: true,
|
|
}))
|
|
})
|
|
|
|
expect(onSwitchTab).toHaveBeenCalledWith('/vault/a.md')
|
|
})
|
|
|
|
it('wraps around when navigating past last tab', () => {
|
|
renderHook(() =>
|
|
useKeyboardNavigation({
|
|
tabs, activeTabPath: '/vault/c.md', entries, selection, allContent,
|
|
onSwitchTab, onReplaceActiveTab, onSelectNote,
|
|
})
|
|
)
|
|
|
|
act(() => {
|
|
window.dispatchEvent(new KeyboardEvent('keydown', {
|
|
key: 'ArrowRight', metaKey: true, shiftKey: true, bubbles: true,
|
|
}))
|
|
})
|
|
|
|
expect(onSwitchTab).toHaveBeenCalledWith('/vault/a.md')
|
|
})
|
|
|
|
it('navigates to next note on Cmd+Alt+ArrowDown', () => {
|
|
renderHook(() =>
|
|
useKeyboardNavigation({
|
|
tabs, activeTabPath: '/vault/a.md', entries, selection, allContent,
|
|
onSwitchTab, onReplaceActiveTab, onSelectNote,
|
|
})
|
|
)
|
|
|
|
act(() => {
|
|
window.dispatchEvent(new KeyboardEvent('keydown', {
|
|
key: 'ArrowDown', metaKey: true, altKey: true, bubbles: true,
|
|
}))
|
|
})
|
|
|
|
expect(onReplaceActiveTab).toHaveBeenCalled()
|
|
})
|
|
|
|
it('navigates to previous note on Cmd+Alt+ArrowUp', () => {
|
|
renderHook(() =>
|
|
useKeyboardNavigation({
|
|
tabs, activeTabPath: '/vault/b.md', entries, selection, allContent,
|
|
onSwitchTab, onReplaceActiveTab, onSelectNote,
|
|
})
|
|
)
|
|
|
|
act(() => {
|
|
window.dispatchEvent(new KeyboardEvent('keydown', {
|
|
key: 'ArrowUp', metaKey: true, altKey: true, bubbles: true,
|
|
}))
|
|
})
|
|
|
|
expect(onReplaceActiveTab).toHaveBeenCalled()
|
|
})
|
|
|
|
it('selects first note when no active tab', () => {
|
|
renderHook(() =>
|
|
useKeyboardNavigation({
|
|
tabs: [], activeTabPath: null, entries, selection, allContent,
|
|
onSwitchTab, onReplaceActiveTab, onSelectNote,
|
|
})
|
|
)
|
|
|
|
act(() => {
|
|
window.dispatchEvent(new KeyboardEvent('keydown', {
|
|
key: 'ArrowDown', metaKey: true, altKey: true, bubbles: true,
|
|
}))
|
|
})
|
|
|
|
expect(onSelectNote).toHaveBeenCalled()
|
|
})
|
|
|
|
it('does nothing without modifier keys', () => {
|
|
renderHook(() =>
|
|
useKeyboardNavigation({
|
|
tabs, activeTabPath: '/vault/a.md', entries, selection, allContent,
|
|
onSwitchTab, onReplaceActiveTab, onSelectNote,
|
|
})
|
|
)
|
|
|
|
act(() => {
|
|
window.dispatchEvent(new KeyboardEvent('keydown', {
|
|
key: 'ArrowRight', bubbles: true,
|
|
}))
|
|
})
|
|
|
|
expect(onSwitchTab).not.toHaveBeenCalled()
|
|
expect(onReplaceActiveTab).not.toHaveBeenCalled()
|
|
expect(onSelectNote).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does nothing with empty tabs for tab navigation', () => {
|
|
renderHook(() =>
|
|
useKeyboardNavigation({
|
|
tabs: [], activeTabPath: null, entries, selection, allContent,
|
|
onSwitchTab, onReplaceActiveTab, onSelectNote,
|
|
})
|
|
)
|
|
|
|
act(() => {
|
|
window.dispatchEvent(new KeyboardEvent('keydown', {
|
|
key: 'ArrowRight', metaKey: true, shiftKey: true, bubbles: true,
|
|
}))
|
|
})
|
|
|
|
expect(onSwitchTab).not.toHaveBeenCalled()
|
|
})
|
|
})
|