Files
tolaria/src/hooks/useVaultLoader.test.ts
lucaronin 3e05a9425a test: add Playwright smoke test for exact-match search ranking
Verifies that searching "Writing" in Quick Open shows the exact title
match first, followed by prefix matches. Also adds Refactoring test
entries to mock data and demo vault.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 23:53:00 +01:00

506 lines
19 KiB
TypeScript

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, resolveNoteStatus } 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: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [],
},
]
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 as Record<string, string>)?.commitHash}`)
if (cmd === 'git_commit') return Promise.resolve('committed')
if (cmd === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed to remote' })
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: (cmd: string, args?: Record<string, unknown>) => mockInvokeFn(cmd, args),
}))
/** Render the vault loader hook and wait for initial data to load. */
async function renderVaultLoader() {
const hook = renderHook(() => useVaultLoader('/vault'))
await waitFor(() => { expect(hook.result.current.entries).toHaveLength(1) })
return hook
}
describe('useVaultLoader', () => {
beforeEach(() => {
mockInvokeFn.mockImplementation(defaultMockInvoke)
})
it('loads entries on mount', async () => {
const { result } = await renderVaultLoader()
expect(result.current.entries[0].title).toBe('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', async () => {
const { result } = await renderVaultLoader()
const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/new.md', filename: 'new.md', title: 'New Note' }
act(() => { result.current.addEntry(newEntry) })
expect(result.current.entries).toHaveLength(2)
expect(result.current.entries[0].title).toBe('New Note')
})
it('ignores duplicate entry with same path', async () => {
const { result } = await renderVaultLoader()
const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/new.md', filename: 'new.md', title: 'New Note' }
act(() => {
result.current.addEntry(newEntry)
result.current.addEntry(newEntry)
})
expect(result.current.entries).toHaveLength(2)
})
})
describe('removeEntry', () => {
it('removes entry by path', async () => {
const { result } = await renderVaultLoader()
act(() => { result.current.removeEntry('/vault/note/hello.md') })
expect(result.current.entries).toHaveLength(0)
})
it('is a no-op for non-existent paths', async () => {
const { result } = await renderVaultLoader()
act(() => { result.current.removeEntry('/vault/note/nonexistent.md') })
expect(result.current.entries).toHaveLength(1)
})
})
describe('updateEntry', () => {
it('patches an existing entry by path', async () => {
const { result } = await renderVaultLoader()
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('getNoteStatus', () => {
it('returns modified for git-modified files', async () => {
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitFor(() => {
expect(result.current.modifiedFiles).toHaveLength(1)
})
expect(result.current.getNoteStatus('/vault/note/hello.md')).toBe('modified')
expect(result.current.getNoteStatus('/vault/note/other.md')).toBe('clean')
})
it('returns new for freshly added entries', async () => {
const { result } = await renderVaultLoader()
const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/brand-new.md', filename: 'brand-new.md', title: 'Brand New' }
act(() => { result.current.addEntry(newEntry) })
expect(result.current.getNoteStatus('/vault/note/brand-new.md')).toBe('new')
})
it('returns new for git-untracked files (saved but not committed)', async () => {
mockInvokeFn.mockImplementation(((cmd: string) => {
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([
{ path: '/vault/note/brand-new.md', relativePath: 'note/brand-new.md', status: 'untracked' },
])
return Promise.resolve(null)
}) as typeof defaultMockInvoke)
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitFor(() => {
expect(result.current.modifiedFiles).toHaveLength(1)
})
expect(result.current.getNoteStatus('/vault/note/brand-new.md')).toBe('new')
})
it('returns new for git-added files (staged but not committed)', async () => {
mockInvokeFn.mockImplementation(((cmd: string) => {
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([
{ path: '/vault/note/staged.md', relativePath: 'note/staged.md', status: 'added' },
])
return Promise.resolve(null)
}) as typeof defaultMockInvoke)
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitFor(() => {
expect(result.current.modifiedFiles).toHaveLength(1)
})
expect(result.current.getNoteStatus('/vault/note/staged.md')).toBe('new')
})
it('new status takes priority over git modified', async () => {
// If a path is both new and in modifiedFiles, it should show as new
mockInvokeFn.mockImplementation(((cmd: string) => {
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([
{ path: '/vault/note/new.md', relativePath: 'note/new.md', status: 'modified' },
])
return Promise.resolve(null)
}) as typeof defaultMockInvoke)
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitFor(() => {
expect(result.current.modifiedFiles).toHaveLength(1)
})
const newEntry: VaultEntry = {
...mockEntries[0],
path: '/vault/note/new.md',
filename: 'new.md',
title: 'New',
}
act(() => {
result.current.addEntry(newEntry)
})
expect(result.current.getNoteStatus('/vault/note/new.md')).toBe('new')
})
it('returns unsaved for paths in unsavedPaths', async () => {
const { result } = await renderVaultLoader()
const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/draft.md', filename: 'draft.md', title: 'Draft' }
act(() => {
result.current.addEntry(newEntry)
result.current.trackUnsaved('/vault/note/draft.md')
})
expect(result.current.getNoteStatus('/vault/note/draft.md')).toBe('unsaved')
})
it('unsaved has higher priority than new', async () => {
const { result } = await renderVaultLoader()
const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/draft.md', filename: 'draft.md', title: 'Draft' }
act(() => {
result.current.addEntry(newEntry)
result.current.trackUnsaved('/vault/note/draft.md')
})
// addEntry also calls trackNew, so path is in both newPaths and unsavedPaths
expect(result.current.getNoteStatus('/vault/note/draft.md')).toBe('unsaved')
})
it('clearUnsaved transitions from unsaved to new', async () => {
const { result } = await renderVaultLoader()
const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/draft.md', filename: 'draft.md', title: 'Draft' }
act(() => {
result.current.addEntry(newEntry)
result.current.trackUnsaved('/vault/note/draft.md')
})
expect(result.current.getNoteStatus('/vault/note/draft.md')).toBe('unsaved')
act(() => { result.current.clearUnsaved('/vault/note/draft.md') })
expect(result.current.getNoteStatus('/vault/note/draft.md')).toBe('new')
})
it('treats untracked files as new (green dot, not orange)', async () => {
mockInvokeFn.mockImplementation(((cmd: string) => {
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([
{ path: '/vault/note/hello.md', relativePath: 'note/hello.md', status: 'untracked' },
])
return Promise.resolve(null)
}) as typeof defaultMockInvoke)
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitFor(() => {
expect(result.current.modifiedFiles).toHaveLength(1)
})
expect(result.current.getNoteStatus('/vault/note/hello.md')).toBe('new')
})
})
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)
}) as typeof defaultMockInvoke)
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')
})
it('returns actionable message when push is rejected', async () => {
mockInvokeFn.mockImplementation(((cmd: string) => {
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([])
if (cmd === 'git_commit') return Promise.resolve('committed')
if (cmd === 'git_push') return Promise.resolve({ status: 'rejected', message: 'Push rejected: remote has new commits. Pull first, then push.' })
return Promise.resolve(null)
}) as typeof defaultMockInvoke)
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).toContain('Pull first')
expect(response).not.toBe('Committed and pushed')
})
it('returns network error message on network failure', async () => {
mockInvokeFn.mockImplementation(((cmd: string) => {
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([])
if (cmd === 'git_commit') return Promise.resolve('committed')
if (cmd === 'git_push') return Promise.resolve({ status: 'network_error', message: 'Push failed: network error. Check your connection and try again.' })
return Promise.resolve(null)
}) as typeof defaultMockInvoke)
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).toContain('network error')
})
})
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)
})
})
})
describe('resolveNoteStatus', () => {
const mf = (path: string, status: string): ModifiedFile => ({ path, relativePath: path.replace('/vault/', ''), status })
it('returns new when path is in newPaths (not yet on disk)', () => {
expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [])).toBe('new')
})
it('returns new for untracked files in git', () => {
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'untracked')])).toBe('new')
})
it('returns new for added files in git', () => {
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'added')])).toBe('new')
})
it('returns modified for git-modified files', () => {
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')])).toBe('modified')
})
it('returns clean for files not in git status', () => {
expect(resolveNoteStatus('/vault/x.md', new Set(), [])).toBe('clean')
})
it('returns clean for deleted files', () => {
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'deleted')])).toBe('clean')
})
it('newPaths takes priority over git modified', () => {
expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [mf('/vault/x.md', 'modified')])).toBe('new')
})
it('pendingSave takes priority over new status', () => {
const pendingSave = new Set(['/vault/x.md'])
expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [], pendingSave)).toBe('pendingSave')
})
it('pendingSave takes priority over modified status', () => {
const pendingSave = new Set(['/vault/x.md'])
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')], pendingSave)).toBe('pendingSave')
})
it('pendingSave takes priority over clean status', () => {
const pendingSave = new Set(['/vault/x.md'])
expect(resolveNoteStatus('/vault/x.md', new Set(), [], pendingSave)).toBe('pendingSave')
})
it('without pendingSavePaths parameter, behavior is unchanged', () => {
// Omitting the optional parameter should produce the same results as before
expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [])).toBe('new')
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')])).toBe('modified')
expect(resolveNoteStatus('/vault/x.md', new Set(), [])).toBe('clean')
})
it('empty pendingSavePaths set does not affect other statuses', () => {
const emptyPending = new Set<string>()
expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [], emptyPending)).toBe('new')
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')], emptyPending)).toBe('modified')
expect(resolveNoteStatus('/vault/x.md', new Set(), [], emptyPending)).toBe('clean')
})
it('unsaved takes priority over all other statuses', () => {
const unsaved = new Set(['/vault/x.md'])
expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [], undefined, unsaved)).toBe('unsaved')
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')], undefined, unsaved)).toBe('unsaved')
expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [], new Set(['/vault/x.md']), unsaved)).toBe('unsaved')
})
it('without unsavedPaths parameter, behavior is unchanged', () => {
expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [])).toBe('new')
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')])).toBe('modified')
})
})