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:
lucaronin
2026-02-22 12:02:19 +01:00
parent c67ba46062
commit a36bcd6d43
6 changed files with 1443 additions and 0 deletions

View 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('')
})
})
})

View 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)
})
})
})

View 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()
})
})

View 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')
})
})

View 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()
})
})
})

View 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)
})
})
})