test: add unit tests for hooks — useNoteActions, useEntryActions, useTabManagement, useVaultLoader, useKeyboardNavigation, mockFrontmatterHelpers
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>
This commit is contained in:
171
src/hooks/mockFrontmatterHelpers.test.ts
Normal file
171
src/hooks/mockFrontmatterHelpers.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { updateMockFrontmatter, deleteMockFrontmatterProperty } from './mockFrontmatterHelpers'
|
||||
|
||||
// Setup window.__mockContent for tests
|
||||
declare global {
|
||||
interface Window {
|
||||
__mockContent?: Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
describe('mockFrontmatterHelpers', () => {
|
||||
beforeEach(() => {
|
||||
window.__mockContent = {}
|
||||
})
|
||||
|
||||
describe('updateMockFrontmatter', () => {
|
||||
it('updates an existing string property', () => {
|
||||
window.__mockContent = {
|
||||
'/test.md': '---\ntitle: Hello\nstatus: Active\n---\n\n# Hello\n',
|
||||
}
|
||||
|
||||
const result = updateMockFrontmatter('/test.md', 'status', 'Done')
|
||||
expect(result).toContain('status: Done')
|
||||
expect(result).toContain('title: Hello')
|
||||
expect(result).toContain('# Hello')
|
||||
})
|
||||
|
||||
it('adds a new property when key does not exist', () => {
|
||||
window.__mockContent = {
|
||||
'/test.md': '---\ntitle: Hello\n---\n\n# Hello\n',
|
||||
}
|
||||
|
||||
const result = updateMockFrontmatter('/test.md', 'owner', 'Luca')
|
||||
expect(result).toContain('owner: Luca')
|
||||
expect(result).toContain('title: Hello')
|
||||
})
|
||||
|
||||
it('handles boolean value (true)', () => {
|
||||
window.__mockContent = {
|
||||
'/test.md': '---\ntitle: Hello\n---\n\n# Hello\n',
|
||||
}
|
||||
|
||||
const result = updateMockFrontmatter('/test.md', 'archived', true)
|
||||
expect(result).toContain('archived: true')
|
||||
})
|
||||
|
||||
it('handles boolean value (false)', () => {
|
||||
window.__mockContent = {
|
||||
'/test.md': '---\ntitle: Hello\narchived: true\n---\n\n# Hello\n',
|
||||
}
|
||||
|
||||
const result = updateMockFrontmatter('/test.md', 'archived', false)
|
||||
expect(result).toContain('archived: false')
|
||||
})
|
||||
|
||||
it('handles array values', () => {
|
||||
window.__mockContent = {
|
||||
'/test.md': '---\ntitle: Hello\n---\n\n# Hello\n',
|
||||
}
|
||||
|
||||
const result = updateMockFrontmatter('/test.md', 'aliases', ['ML', 'AI'])
|
||||
expect(result).toContain('aliases:')
|
||||
expect(result).toContain(' - "ML"')
|
||||
expect(result).toContain(' - "AI"')
|
||||
})
|
||||
|
||||
it('replaces existing array property', () => {
|
||||
window.__mockContent = {
|
||||
'/test.md': '---\ntitle: Hello\naliases:\n - "old"\n---\n\n# Hello\n',
|
||||
}
|
||||
|
||||
const result = updateMockFrontmatter('/test.md', 'aliases', ['new1', 'new2'])
|
||||
expect(result).toContain(' - "new1"')
|
||||
expect(result).toContain(' - "new2"')
|
||||
expect(result).not.toContain('"old"')
|
||||
})
|
||||
|
||||
it('handles numeric value', () => {
|
||||
window.__mockContent = {
|
||||
'/test.md': '---\ntitle: Hello\n---\n\n# Hello\n',
|
||||
}
|
||||
|
||||
const result = updateMockFrontmatter('/test.md', 'order', 3)
|
||||
expect(result).toContain('order: 3')
|
||||
})
|
||||
|
||||
it('creates frontmatter when none exists', () => {
|
||||
window.__mockContent = {
|
||||
'/test.md': '# Just content',
|
||||
}
|
||||
|
||||
const result = updateMockFrontmatter('/test.md', 'title', 'Hello')
|
||||
expect(result).toMatch(/^---\n/)
|
||||
expect(result).toContain('title: Hello')
|
||||
expect(result).toContain('# Just content')
|
||||
})
|
||||
|
||||
it('handles empty content gracefully', () => {
|
||||
window.__mockContent = {}
|
||||
|
||||
const result = updateMockFrontmatter('/test.md', 'title', 'New')
|
||||
expect(result).toContain('title: New')
|
||||
})
|
||||
|
||||
it('handles keys with spaces', () => {
|
||||
window.__mockContent = {
|
||||
'/test.md': '---\ntitle: Hello\n---\n\n# Hello\n',
|
||||
}
|
||||
|
||||
const result = updateMockFrontmatter('/test.md', 'Belongs to', '[[Project A]]')
|
||||
expect(result).toContain('"Belongs to": [[Project A]]')
|
||||
})
|
||||
|
||||
it('handles null value', () => {
|
||||
window.__mockContent = {
|
||||
'/test.md': '---\ntitle: Hello\n---\n',
|
||||
}
|
||||
|
||||
const result = updateMockFrontmatter('/test.md', 'status', null)
|
||||
expect(result).toContain('status: null')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteMockFrontmatterProperty', () => {
|
||||
it('removes an existing property', () => {
|
||||
window.__mockContent = {
|
||||
'/test.md': '---\ntitle: Hello\nstatus: Active\n---\n\n# Hello\n',
|
||||
}
|
||||
|
||||
const result = deleteMockFrontmatterProperty('/test.md', 'status')
|
||||
expect(result).not.toContain('status:')
|
||||
expect(result).toContain('title: Hello')
|
||||
})
|
||||
|
||||
it('removes an array property with all its items', () => {
|
||||
window.__mockContent = {
|
||||
'/test.md': '---\ntitle: Hello\naliases:\n - "A"\n - "B"\nstatus: Active\n---\n\n# Hello\n',
|
||||
}
|
||||
|
||||
const result = deleteMockFrontmatterProperty('/test.md', 'aliases')
|
||||
expect(result).not.toContain('aliases:')
|
||||
expect(result).not.toContain(' - "A"')
|
||||
expect(result).toContain('status: Active')
|
||||
})
|
||||
|
||||
it('returns content unchanged when no frontmatter', () => {
|
||||
window.__mockContent = {
|
||||
'/test.md': '# Just content',
|
||||
}
|
||||
|
||||
const result = deleteMockFrontmatterProperty('/test.md', 'status')
|
||||
expect(result).toBe('# Just content')
|
||||
})
|
||||
|
||||
it('returns content unchanged when key not found', () => {
|
||||
window.__mockContent = {
|
||||
'/test.md': '---\ntitle: Hello\n---\n\n# Hello\n',
|
||||
}
|
||||
|
||||
const result = deleteMockFrontmatterProperty('/test.md', 'nonexistent')
|
||||
expect(result).toContain('title: Hello')
|
||||
})
|
||||
|
||||
it('handles empty content', () => {
|
||||
window.__mockContent = {}
|
||||
|
||||
const result = deleteMockFrontmatterProperty('/test.md', 'status')
|
||||
expect(result).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
179
src/hooks/useEntryActions.test.ts
Normal file
179
src/hooks/useEntryActions.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { useEntryActions } from './useEntryActions'
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
describe('useEntryActions', () => {
|
||||
const updateEntry = vi.fn()
|
||||
const handleUpdateFrontmatter = vi.fn().mockResolvedValue(undefined)
|
||||
const handleDeleteProperty = vi.fn().mockResolvedValue(undefined)
|
||||
const setToastMessage = vi.fn()
|
||||
|
||||
function setup(entries: VaultEntry[] = []) {
|
||||
return renderHook(() =>
|
||||
useEntryActions({
|
||||
entries,
|
||||
updateEntry,
|
||||
handleUpdateFrontmatter,
|
||||
handleDeleteProperty,
|
||||
setToastMessage,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('handleTrashNote', () => {
|
||||
it('sets trashed frontmatter and updates entry state', async () => {
|
||||
const { result } = setup()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleTrashNote('/vault/note/test.md')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', 'trashed', true)
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', 'trashed_at', expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/))
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', {
|
||||
trashed: true,
|
||||
trashedAt: expect.any(Number),
|
||||
})
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Note moved to trash')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleRestoreNote', () => {
|
||||
it('clears trashed frontmatter and updates entry state', async () => {
|
||||
const { result } = setup()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRestoreNote('/vault/note/test.md')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', 'trashed', false)
|
||||
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/note/test.md', 'trashed_at')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', {
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
})
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Note restored from trash')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleArchiveNote', () => {
|
||||
it('sets archived frontmatter and updates entry state', async () => {
|
||||
const { result } = setup()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleArchiveNote('/vault/note/test.md')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', 'archived', true)
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', { archived: true })
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Note archived')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleUnarchiveNote', () => {
|
||||
it('clears archived frontmatter and updates entry state', async () => {
|
||||
const { result } = setup()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUnarchiveNote('/vault/note/test.md')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', 'archived', false)
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', { archived: false })
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Note unarchived')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleCustomizeType', () => {
|
||||
it('updates icon and color on the type entry', () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md' })
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
act(() => {
|
||||
result.current.handleCustomizeType('Recipe', 'cooking-pot', 'green')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'icon', 'cooking-pot')
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'color', 'green')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { icon: 'cooking-pot', color: 'green' })
|
||||
})
|
||||
|
||||
it('does nothing when type entry not found', () => {
|
||||
const { result } = setup([])
|
||||
|
||||
act(() => {
|
||||
result.current.handleCustomizeType('NonExistent', 'star', 'red')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
|
||||
expect(updateEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleReorderSections', () => {
|
||||
it('updates order on multiple type entries', () => {
|
||||
const typeA = makeEntry({ isA: 'Type', title: 'Note', path: '/vault/type/note.md' })
|
||||
const typeB = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' })
|
||||
const { result } = setup([typeA, typeB])
|
||||
|
||||
act(() => {
|
||||
result.current.handleReorderSections([
|
||||
{ typeName: 'Note', order: 0 },
|
||||
{ typeName: 'Project', order: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/note.md', 'order', 0)
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/project.md', 'order', 1)
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/note.md', { order: 0 })
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/project.md', { order: 1 })
|
||||
})
|
||||
|
||||
it('skips types that are not found', () => {
|
||||
const typeA = makeEntry({ isA: 'Type', title: 'Note', path: '/vault/type/note.md' })
|
||||
const { result } = setup([typeA])
|
||||
|
||||
act(() => {
|
||||
result.current.handleReorderSections([
|
||||
{ typeName: 'Note', order: 0 },
|
||||
{ typeName: 'Missing', order: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
// Only Note's order was set; Missing was skipped
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledTimes(1)
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/note.md', 'order', 0)
|
||||
expect(updateEntry).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
229
src/hooks/useKeyboardNavigation.test.ts
Normal file
229
src/hooks/useKeyboardNavigation.test.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
316
src/hooks/useNoteActions.test.ts
Normal file
316
src/hooks/useNoteActions.test.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
slugify,
|
||||
buildNewEntry,
|
||||
generateUntitledName,
|
||||
entryMatchesTarget,
|
||||
buildNoteContent,
|
||||
resolveNewNote,
|
||||
resolveNewType,
|
||||
useNoteActions,
|
||||
} from './useNoteActions'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
addMockEntry: vi.fn(),
|
||||
updateMockContent: vi.fn(),
|
||||
mockInvoke: vi.fn().mockResolvedValue(''),
|
||||
}))
|
||||
vi.mock('./mockFrontmatterHelpers', () => ({
|
||||
updateMockFrontmatter: vi.fn().mockReturnValue('---\nupdated: true\n---\n'),
|
||||
deleteMockFrontmatterProperty: vi.fn().mockReturnValue('---\n---\n'),
|
||||
}))
|
||||
|
||||
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
path: '/Users/luca/Laputa/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,
|
||||
})
|
||||
|
||||
describe('slugify', () => {
|
||||
it('converts text to lowercase kebab-case', () => {
|
||||
expect(slugify('Hello World')).toBe('hello-world')
|
||||
})
|
||||
|
||||
it('removes special characters', () => {
|
||||
expect(slugify('My Project! @#$%')).toBe('my-project')
|
||||
})
|
||||
|
||||
it('strips leading and trailing hyphens', () => {
|
||||
expect(slugify('--hello--')).toBe('hello')
|
||||
})
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(slugify('')).toBe('')
|
||||
})
|
||||
|
||||
it('collapses multiple separators into one hyphen', () => {
|
||||
expect(slugify('hello world---foo')).toBe('hello-world-foo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildNewEntry', () => {
|
||||
it('creates a VaultEntry with correct fields', () => {
|
||||
const entry = buildNewEntry({
|
||||
path: '/vault/note/my-note.md',
|
||||
slug: 'my-note',
|
||||
title: 'My Note',
|
||||
type: 'Note',
|
||||
status: 'Active',
|
||||
})
|
||||
|
||||
expect(entry.path).toBe('/vault/note/my-note.md')
|
||||
expect(entry.filename).toBe('my-note.md')
|
||||
expect(entry.title).toBe('My Note')
|
||||
expect(entry.isA).toBe('Note')
|
||||
expect(entry.status).toBe('Active')
|
||||
expect(entry.archived).toBe(false)
|
||||
expect(entry.trashed).toBe(false)
|
||||
expect(entry.modifiedAt).toBeGreaterThan(0)
|
||||
expect(entry.createdAt).toBe(entry.modifiedAt)
|
||||
})
|
||||
|
||||
it('sets null status when provided', () => {
|
||||
const entry = buildNewEntry({
|
||||
path: '/vault/topic/ai.md',
|
||||
slug: 'ai',
|
||||
title: 'AI',
|
||||
type: 'Topic',
|
||||
status: null,
|
||||
})
|
||||
expect(entry.status).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateUntitledName', () => {
|
||||
it('returns base name when no conflicts', () => {
|
||||
expect(generateUntitledName([], 'Note')).toBe('Untitled note')
|
||||
})
|
||||
|
||||
it('appends counter when base name exists', () => {
|
||||
const entries = [makeEntry({ title: 'Untitled note' })]
|
||||
expect(generateUntitledName(entries, 'Note')).toBe('Untitled note 2')
|
||||
})
|
||||
|
||||
it('increments counter past existing numbered entries', () => {
|
||||
const entries = [
|
||||
makeEntry({ title: 'Untitled note' }),
|
||||
makeEntry({ title: 'Untitled note 2' }),
|
||||
makeEntry({ title: 'Untitled note 3' }),
|
||||
]
|
||||
expect(generateUntitledName(entries, 'Note')).toBe('Untitled note 4')
|
||||
})
|
||||
|
||||
it('uses type name in lowercase', () => {
|
||||
expect(generateUntitledName([], 'Project')).toBe('Untitled project')
|
||||
})
|
||||
})
|
||||
|
||||
describe('entryMatchesTarget', () => {
|
||||
it('matches by exact title (case-insensitive)', () => {
|
||||
const entry = makeEntry({ title: 'My Project' })
|
||||
expect(entryMatchesTarget(entry, 'my project', 'my project')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches by alias', () => {
|
||||
const entry = makeEntry({ aliases: ['MP', 'TheProject'] })
|
||||
expect(entryMatchesTarget(entry, 'mp', 'mp')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches by path stem (relative to Laputa)', () => {
|
||||
const entry = makeEntry({ path: '/Users/luca/Laputa/project/my-project.md' })
|
||||
expect(entryMatchesTarget(entry, 'project/my-project', 'project/my-project')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches by filename stem', () => {
|
||||
const entry = makeEntry({ filename: 'my-project.md' })
|
||||
expect(entryMatchesTarget(entry, 'my-project', 'my-project')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches when target as words matches title', () => {
|
||||
const entry = makeEntry({ title: 'my project' })
|
||||
expect(entryMatchesTarget(entry, 'project/my-project', 'my project')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when nothing matches', () => {
|
||||
const entry = makeEntry({ title: 'Something Else', aliases: [], filename: 'else.md' })
|
||||
expect(entryMatchesTarget(entry, 'nonexistent', 'nonexistent')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildNoteContent', () => {
|
||||
it('generates frontmatter with status for regular types', () => {
|
||||
const content = buildNoteContent('My Note', 'Note', 'Active')
|
||||
expect(content).toBe('---\ntitle: My Note\nis_a: Note\nstatus: Active\n---\n\n# My Note\n\n')
|
||||
})
|
||||
|
||||
it('omits status when null', () => {
|
||||
const content = buildNoteContent('AI', 'Topic', null)
|
||||
expect(content).toBe('---\ntitle: AI\nis_a: Topic\n---\n\n# AI\n\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNewNote', () => {
|
||||
it('uses TYPE_FOLDER_MAP for known types', () => {
|
||||
const { entry, content } = resolveNewNote('My Project', 'Project')
|
||||
expect(entry.path).toBe('/Users/luca/Laputa/project/my-project.md')
|
||||
expect(entry.isA).toBe('Project')
|
||||
expect(entry.status).toBe('Active')
|
||||
expect(content).toContain('is_a: Project')
|
||||
expect(content).toContain('status: Active')
|
||||
})
|
||||
|
||||
it('falls back to slugified type for custom types', () => {
|
||||
const { entry } = resolveNewNote('First Recipe', 'Recipe')
|
||||
expect(entry.path).toBe('/Users/luca/Laputa/recipe/first-recipe.md')
|
||||
})
|
||||
|
||||
it('omits status for Topic type', () => {
|
||||
const { entry, content } = resolveNewNote('Machine Learning', 'Topic')
|
||||
expect(entry.status).toBeNull()
|
||||
expect(content).not.toContain('status:')
|
||||
})
|
||||
|
||||
it('omits status for Person type', () => {
|
||||
const { entry } = resolveNewNote('John Doe', 'Person')
|
||||
expect(entry.status).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNewType', () => {
|
||||
it('creates a type entry in the type folder', () => {
|
||||
const { entry, content } = resolveNewType('Recipe')
|
||||
expect(entry.path).toBe('/Users/luca/Laputa/type/recipe.md')
|
||||
expect(entry.isA).toBe('Type')
|
||||
expect(entry.status).toBeNull()
|
||||
expect(content).toContain('Is A: Type')
|
||||
expect(content).toContain('# Recipe')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useNoteActions hook', () => {
|
||||
const addEntry = vi.fn()
|
||||
const updateContent = vi.fn()
|
||||
const setToastMessage = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('handleCreateNote calls addEntry and creates correct entry', () => {
|
||||
const entries: VaultEntry[] = []
|
||||
const { result } = renderHook(() =>
|
||||
useNoteActions(addEntry, updateContent, entries, setToastMessage)
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleCreateNote('Test Note', 'Note')
|
||||
})
|
||||
|
||||
expect(addEntry).toHaveBeenCalledTimes(1)
|
||||
const [createdEntry, createdContent] = addEntry.mock.calls[0]
|
||||
expect(createdEntry.title).toBe('Test Note')
|
||||
expect(createdEntry.isA).toBe('Note')
|
||||
expect(createdEntry.path).toContain('note/test-note.md')
|
||||
expect(createdContent).toContain('title: Test Note')
|
||||
})
|
||||
|
||||
it('handleCreateType creates type entry', () => {
|
||||
const entries: VaultEntry[] = []
|
||||
const { result } = renderHook(() =>
|
||||
useNoteActions(addEntry, updateContent, entries, setToastMessage)
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleCreateType('Recipe')
|
||||
})
|
||||
|
||||
expect(addEntry).toHaveBeenCalledTimes(1)
|
||||
const [createdEntry] = addEntry.mock.calls[0]
|
||||
expect(createdEntry.isA).toBe('Type')
|
||||
expect(createdEntry.title).toBe('Recipe')
|
||||
})
|
||||
|
||||
it('handleNavigateWikilink finds entry by title', async () => {
|
||||
const target = makeEntry({ title: 'Target Note', path: '/vault/note/target.md' })
|
||||
const entries = [target]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useNoteActions(addEntry, updateContent, entries, setToastMessage)
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleNavigateWikilink('Target Note')
|
||||
})
|
||||
|
||||
// Should set active tab path (via handleSelectNote which loads content)
|
||||
expect(result.current.activeTabPath).toBe('/vault/note/target.md')
|
||||
})
|
||||
|
||||
it('handleNavigateWikilink warns when target not found', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const entries: VaultEntry[] = []
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useNoteActions(addEntry, updateContent, entries, setToastMessage)
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleNavigateWikilink('Nonexistent')
|
||||
})
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith('Navigation target not found: Nonexistent')
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handleUpdateFrontmatter calls mock and shows toast on success', async () => {
|
||||
const entries: VaultEntry[] = []
|
||||
const { result } = renderHook(() =>
|
||||
useNoteActions(addEntry, updateContent, entries, setToastMessage)
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdateFrontmatter('/vault/note.md', 'status', 'Done')
|
||||
})
|
||||
|
||||
expect(updateContent).toHaveBeenCalled()
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Property updated')
|
||||
})
|
||||
|
||||
it('handleDeleteProperty calls mock and shows toast on success', async () => {
|
||||
const entries: VaultEntry[] = []
|
||||
const { result } = renderHook(() =>
|
||||
useNoteActions(addEntry, updateContent, entries, setToastMessage)
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDeleteProperty('/vault/note.md', 'status')
|
||||
})
|
||||
|
||||
expect(updateContent).toHaveBeenCalled()
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Property deleted')
|
||||
})
|
||||
})
|
||||
294
src/hooks/useTabManagement.test.ts
Normal file
294
src/hooks/useTabManagement.test.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { useTabManagement } from './useTabManagement'
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: vi.fn().mockResolvedValue('# Mock content'),
|
||||
}))
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, val: string) => { store[key] = val }),
|
||||
removeItem: vi.fn((key: string) => { delete store[key] }),
|
||||
clear: vi.fn(() => { store = {} }),
|
||||
}
|
||||
})()
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock })
|
||||
|
||||
describe('useTabManagement', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorageMock.clear()
|
||||
})
|
||||
|
||||
it('starts with no tabs and no active tab', () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
expect(result.current.tabs).toEqual([])
|
||||
expect(result.current.activeTabPath).toBeNull()
|
||||
})
|
||||
|
||||
describe('handleSelectNote', () => {
|
||||
it('opens a new tab and sets it active', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
const entry = makeEntry({ path: '/vault/note/a.md' })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(entry)
|
||||
})
|
||||
|
||||
expect(result.current.tabs).toHaveLength(1)
|
||||
expect(result.current.tabs[0].entry.path).toBe('/vault/note/a.md')
|
||||
expect(result.current.activeTabPath).toBe('/vault/note/a.md')
|
||||
})
|
||||
|
||||
it('switches to existing tab without duplicating', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
const entry = makeEntry({ path: '/vault/note/a.md' })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(entry)
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/note/b.md', title: 'B' }))
|
||||
})
|
||||
// Select first entry again
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(entry)
|
||||
})
|
||||
|
||||
expect(result.current.tabs).toHaveLength(2)
|
||||
expect(result.current.activeTabPath).toBe('/vault/note/a.md')
|
||||
})
|
||||
|
||||
it('handles load content failure gracefully', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('fail'))
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
const entry = makeEntry()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(entry)
|
||||
})
|
||||
|
||||
// Tab still opens with empty content on failure
|
||||
expect(result.current.tabs).toHaveLength(1)
|
||||
expect(result.current.tabs[0].content).toBe('')
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleCloseTab', () => {
|
||||
it('removes the tab', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
const entry = makeEntry({ path: '/vault/note/a.md' })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(entry)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleCloseTab('/vault/note/a.md')
|
||||
})
|
||||
|
||||
expect(result.current.tabs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('selects next tab when active tab is closed', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/c.md', title: 'C' }))
|
||||
})
|
||||
|
||||
// Close middle tab B, should switch to C (same index)
|
||||
act(() => {
|
||||
result.current.handleSwitchTab('/vault/b.md')
|
||||
})
|
||||
act(() => {
|
||||
result.current.handleCloseTab('/vault/b.md')
|
||||
})
|
||||
|
||||
expect(result.current.tabs).toHaveLength(2)
|
||||
expect(result.current.activeTabPath).toBe('/vault/c.md')
|
||||
})
|
||||
|
||||
it('sets null active when last tab is closed', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md' }))
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleCloseTab('/vault/a.md')
|
||||
})
|
||||
|
||||
expect(result.current.activeTabPath).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleSwitchTab', () => {
|
||||
it('changes the active tab path', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleSwitchTab('/vault/a.md')
|
||||
})
|
||||
|
||||
expect(result.current.activeTabPath).toBe('/vault/a.md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleReorderTabs', () => {
|
||||
it('moves a tab from one position to another', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/c.md', title: 'C' }))
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleReorderTabs(2, 0)
|
||||
})
|
||||
|
||||
expect(result.current.tabs.map(t => t.entry.title)).toEqual(['C', 'A', 'B'])
|
||||
})
|
||||
|
||||
it('persists tab order to localStorage', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleReorderTabs(1, 0)
|
||||
})
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'laputa-tab-order',
|
||||
expect.any(String),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleReplaceActiveTab', () => {
|
||||
it('replaces the active tab with a new entry', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
|
||||
})
|
||||
|
||||
const replacement = makeEntry({ path: '/vault/b.md', title: 'B' })
|
||||
await act(async () => {
|
||||
await result.current.handleReplaceActiveTab(replacement)
|
||||
})
|
||||
|
||||
expect(result.current.tabs).toHaveLength(1)
|
||||
expect(result.current.tabs[0].entry.path).toBe('/vault/b.md')
|
||||
expect(result.current.activeTabPath).toBe('/vault/b.md')
|
||||
})
|
||||
|
||||
it('does nothing when replacing with same entry', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
const entry = makeEntry({ path: '/vault/a.md' })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(entry)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleReplaceActiveTab(entry)
|
||||
})
|
||||
|
||||
expect(result.current.tabs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('falls back to handleSelectNote when no active tab', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
const entry = makeEntry({ path: '/vault/a.md' })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleReplaceActiveTab(entry)
|
||||
})
|
||||
|
||||
expect(result.current.tabs).toHaveLength(1)
|
||||
expect(result.current.activeTabPath).toBe('/vault/a.md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeAllTabs', () => {
|
||||
it('clears all tabs and active path', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md' }))
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.closeAllTabs()
|
||||
})
|
||||
|
||||
expect(result.current.tabs).toHaveLength(0)
|
||||
expect(result.current.activeTabPath).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
254
src/hooks/useVaultLoader.test.ts
Normal file
254
src/hooks/useVaultLoader.test.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import type { VaultEntry, ModifiedFile, GitCommit } from '../types'
|
||||
import { useVaultLoader } from './useVaultLoader'
|
||||
|
||||
const mockEntries: VaultEntry[] = [
|
||||
{
|
||||
path: '/vault/note/hello.md', filename: 'hello.md', title: 'Hello',
|
||||
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,
|
||||
},
|
||||
]
|
||||
|
||||
const mockContent: Record<string, string> = {
|
||||
'/vault/note/hello.md': '---\ntitle: Hello\n---\n\n# Hello\n',
|
||||
}
|
||||
|
||||
const mockModifiedFiles: ModifiedFile[] = [
|
||||
{ path: '/vault/note/hello.md', relativePath: 'note/hello.md', status: 'modified' },
|
||||
]
|
||||
|
||||
const mockGitHistory: GitCommit[] = [
|
||||
{ hash: 'abc1234567', shortHash: 'abc1234', message: 'initial commit', author: 'luca', date: 1700000000 },
|
||||
]
|
||||
|
||||
function defaultMockInvoke(cmd: string, args?: Record<string, unknown>) {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve(mockModifiedFiles)
|
||||
if (cmd === 'get_file_history') return Promise.resolve(mockGitHistory)
|
||||
if (cmd === 'get_file_diff') return Promise.resolve('--- a/note.md\n+++ b/note.md')
|
||||
if (cmd === 'get_file_diff_at_commit') return Promise.resolve(`diff for ${args?.commitHash}`)
|
||||
if (cmd === 'git_commit') return Promise.resolve('committed')
|
||||
if (cmd === 'git_push') return Promise.resolve('pushed')
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
|
||||
const mockInvokeFn = vi.fn(defaultMockInvoke)
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: (...args: any[]) => mockInvokeFn(...args),
|
||||
}))
|
||||
|
||||
describe('useVaultLoader', () => {
|
||||
beforeEach(() => {
|
||||
mockInvokeFn.mockImplementation(defaultMockInvoke)
|
||||
})
|
||||
|
||||
it('loads entries and content on mount', async () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1)
|
||||
})
|
||||
|
||||
expect(result.current.entries[0].title).toBe('Hello')
|
||||
expect(result.current.allContent['/vault/note/hello.md']).toContain('# Hello')
|
||||
})
|
||||
|
||||
it('loads modified files on mount', async () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.modifiedFiles).toHaveLength(1)
|
||||
})
|
||||
|
||||
expect(result.current.modifiedFiles[0].status).toBe('modified')
|
||||
})
|
||||
|
||||
describe('addEntry', () => {
|
||||
it('prepends new entry and adds content', async () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1)
|
||||
})
|
||||
|
||||
const newEntry: VaultEntry = {
|
||||
...mockEntries[0],
|
||||
path: '/vault/note/new.md',
|
||||
filename: 'new.md',
|
||||
title: 'New Note',
|
||||
}
|
||||
|
||||
act(() => {
|
||||
result.current.addEntry(newEntry, '# New Note')
|
||||
})
|
||||
|
||||
expect(result.current.entries).toHaveLength(2)
|
||||
expect(result.current.entries[0].title).toBe('New Note')
|
||||
expect(result.current.allContent['/vault/note/new.md']).toBe('# New Note')
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateContent', () => {
|
||||
it('updates content for an existing path', async () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.allContent['/vault/note/hello.md']).toBeDefined()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.updateContent('/vault/note/hello.md', '# Updated')
|
||||
})
|
||||
|
||||
expect(result.current.allContent['/vault/note/hello.md']).toBe('# Updated')
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateEntry', () => {
|
||||
it('patches an existing entry by path', async () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.updateEntry('/vault/note/hello.md', { archived: true, status: 'Done' })
|
||||
})
|
||||
|
||||
expect(result.current.entries[0].archived).toBe(true)
|
||||
expect(result.current.entries[0].status).toBe('Done')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isFileModified', () => {
|
||||
it('returns true for modified files', async () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.modifiedFiles).toHaveLength(1)
|
||||
})
|
||||
|
||||
expect(result.current.isFileModified('/vault/note/hello.md')).toBe(true)
|
||||
expect(result.current.isFileModified('/vault/note/other.md')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadGitHistory', () => {
|
||||
it('returns git commits for a file', async () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1)
|
||||
})
|
||||
|
||||
let history: GitCommit[] = []
|
||||
await act(async () => {
|
||||
history = await result.current.loadGitHistory('/vault/note/hello.md')
|
||||
})
|
||||
|
||||
expect(history).toHaveLength(1)
|
||||
expect(history[0].shortHash).toBe('abc1234')
|
||||
})
|
||||
|
||||
it('returns empty array on error', async () => {
|
||||
mockInvokeFn.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'get_file_history') return Promise.reject(new Error('fail'))
|
||||
if (cmd === 'list_vault') return Promise.resolve([])
|
||||
if (cmd === 'get_all_content') return Promise.resolve({})
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([])
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
let history: GitCommit[] = []
|
||||
await act(async () => {
|
||||
history = await result.current.loadGitHistory('/vault/note/hello.md')
|
||||
})
|
||||
|
||||
expect(history).toEqual([])
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadDiff', () => {
|
||||
it('returns diff string for a file', async () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1)
|
||||
})
|
||||
|
||||
let diff = ''
|
||||
await act(async () => {
|
||||
diff = await result.current.loadDiff('/vault/note/hello.md')
|
||||
})
|
||||
|
||||
expect(diff).toContain('--- a/note.md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadDiffAtCommit', () => {
|
||||
it('returns diff for a specific commit', async () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1)
|
||||
})
|
||||
|
||||
let diff = ''
|
||||
await act(async () => {
|
||||
diff = await result.current.loadDiffAtCommit('/vault/note/hello.md', 'abc1234')
|
||||
})
|
||||
|
||||
expect(diff).toBe('diff for abc1234')
|
||||
})
|
||||
})
|
||||
|
||||
describe('commitAndPush', () => {
|
||||
it('commits and pushes in mock mode', async () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1)
|
||||
})
|
||||
|
||||
let response = ''
|
||||
await act(async () => {
|
||||
response = await result.current.commitAndPush('test commit')
|
||||
})
|
||||
|
||||
expect(response).toBe('Committed and pushed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadModifiedFiles', () => {
|
||||
it('refreshes modified files list', async () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.modifiedFiles).toHaveLength(1)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadModifiedFiles()
|
||||
})
|
||||
|
||||
expect(result.current.modifiedFiles).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user