diff --git a/src/hooks/mockFrontmatterHelpers.test.ts b/src/hooks/mockFrontmatterHelpers.test.ts new file mode 100644 index 00000000..b9348aac --- /dev/null +++ b/src/hooks/mockFrontmatterHelpers.test.ts @@ -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 + } +} + +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('') + }) + }) +}) diff --git a/src/hooks/useEntryActions.test.ts b/src/hooks/useEntryActions.test.ts new file mode 100644 index 00000000..734e233a --- /dev/null +++ b/src/hooks/useEntryActions.test.ts @@ -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 => ({ + 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) + }) + }) +}) diff --git a/src/hooks/useKeyboardNavigation.test.ts b/src/hooks/useKeyboardNavigation.test.ts new file mode 100644 index 00000000..7b3e1805 --- /dev/null +++ b/src/hooks/useKeyboardNavigation.test.ts @@ -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 => ({ + 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 = {} + + 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() + }) +}) diff --git a/src/hooks/useNoteActions.test.ts b/src/hooks/useNoteActions.test.ts new file mode 100644 index 00000000..18bf92c7 --- /dev/null +++ b/src/hooks/useNoteActions.test.ts @@ -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 => ({ + 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') + }) +}) diff --git a/src/hooks/useTabManagement.test.ts b/src/hooks/useTabManagement.test.ts new file mode 100644 index 00000000..607776e2 --- /dev/null +++ b/src/hooks/useTabManagement.test.ts @@ -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 => ({ + 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 = {} + 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() + }) + }) +}) diff --git a/src/hooks/useVaultLoader.test.ts b/src/hooks/useVaultLoader.test.ts new file mode 100644 index 00000000..99f39bfa --- /dev/null +++ b/src/hooks/useVaultLoader.test.ts @@ -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 = { + '/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) { + 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) + }) + }) +})