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: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null, view: null, visible: null, outgoingLinks: [], properties: {}, ...overrides, }) describe('useEntryActions', () => { const updateEntry = vi.fn() const handleUpdateFrontmatter = vi.fn().mockResolvedValue(undefined) const handleDeleteProperty = vi.fn().mockResolvedValue(undefined) const setToastMessage = vi.fn() const createTypeEntry = vi.fn().mockImplementation((typeName: string) => Promise.resolve(makeEntry({ isA: 'Type', title: typeName, path: `/vault/type/${typeName.toLowerCase()}.md` })), ) function setup(entries: VaultEntry[] = []) { return renderHook(() => useEntryActions({ entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, createTypeEntry, onFrontmatterPersisted, }) ) } const onFrontmatterPersisted = vi.fn() 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') expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1) }) }) 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') expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1) }) }) 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') expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1) }) }) 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') expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1) }) }) describe('handleCustomizeType', () => { it('updates icon and color on the type entry', async () => { const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md' }) const { result } = setup([typeEntry]) await act(async () => { await 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' }) expect(onFrontmatterPersisted).toHaveBeenCalled() }) it('auto-creates type entry when not found and applies customization', async () => { const { result } = setup([]) await act(async () => { await result.current.handleCustomizeType('Recipe', 'star', 'red') }) expect(createTypeEntry).toHaveBeenCalledWith('Recipe') expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { icon: 'star', color: 'red' }) expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'icon', 'star') expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'color', 'red') }) it('serializes frontmatter writes (icon before color)', async () => { const callOrder: string[] = [] handleUpdateFrontmatter.mockImplementation((_path: string, key: string) => { callOrder.push(key) return Promise.resolve() }) const typeEntry = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' }) const { result } = setup([typeEntry]) await act(async () => { await result.current.handleCustomizeType('Project', 'wrench', 'blue') }) expect(callOrder).toEqual(['icon', 'color']) }) }) describe('handleUpdateTypeTemplate', () => { it('updates template on the type entry', async () => { const typeEntry = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' }) const { result } = setup([typeEntry]) await act(async () => { await result.current.handleUpdateTypeTemplate('Project', '## Objective\n\n## Notes') }) expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/project.md', 'template', '## Objective\n\n## Notes') expect(updateEntry).toHaveBeenCalledWith('/vault/type/project.md', { template: '## Objective\n\n## Notes' }) expect(onFrontmatterPersisted).toHaveBeenCalled() }) it('sets template to null when empty string', async () => { const typeEntry = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' }) const { result } = setup([typeEntry]) await act(async () => { await result.current.handleUpdateTypeTemplate('Project', '') }) expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/project.md', 'template', '') expect(updateEntry).toHaveBeenCalledWith('/vault/type/project.md', { template: null }) }) it('auto-creates type entry when not found', async () => { const { result } = setup([]) await act(async () => { await result.current.handleUpdateTypeTemplate('NonExistent', '## Template') }) expect(createTypeEntry).toHaveBeenCalledWith('NonExistent') expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/nonexistent.md', 'template', '## Template') expect(updateEntry).toHaveBeenCalledWith('/vault/type/nonexistent.md', { template: '## Template' }) }) }) describe('handleReorderSections', () => { it('updates order on multiple type entries', async () => { 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]) await act(async () => { await 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 }) expect(onFrontmatterPersisted).toHaveBeenCalled() }) it('auto-creates type entries when not found', async () => { const typeA = makeEntry({ isA: 'Type', title: 'Note', path: '/vault/type/note.md' }) const { result } = setup([typeA]) await act(async () => { await result.current.handleReorderSections([ { typeName: 'Note', order: 0 }, { typeName: 'Missing', order: 1 }, ]) }) expect(createTypeEntry).toHaveBeenCalledWith('Missing') expect(handleUpdateFrontmatter).toHaveBeenCalledTimes(2) expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/note.md', 'order', 0) expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/missing.md', 'order', 1) }) }) describe('handleRenameSection', () => { it('writes sidebar label frontmatter and updates entry in memory', async () => { const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md', sidebarLabel: null }) const { result } = setup([typeEntry]) await act(async () => { await result.current.handleRenameSection('Recipe', 'Recipes') }) expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'sidebar label', 'Recipes') expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { sidebarLabel: 'Recipes' }) expect(onFrontmatterPersisted).toHaveBeenCalled() }) it('trims whitespace before saving', async () => { const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md', sidebarLabel: null }) const { result } = setup([typeEntry]) await act(async () => { await result.current.handleRenameSection('Recipe', ' Dishes ') }) expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'sidebar label', 'Dishes') expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { sidebarLabel: 'Dishes' }) }) it('deletes sidebar label when label is empty', async () => { const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md', sidebarLabel: 'Dishes' }) const { result } = setup([typeEntry]) await act(async () => { await result.current.handleRenameSection('Recipe', '') }) expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/type/recipe.md', 'sidebar label') expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { sidebarLabel: null }) expect(handleUpdateFrontmatter).not.toHaveBeenCalled() }) it('auto-creates type entry when not found', async () => { const { result } = setup([]) await act(async () => { await result.current.handleRenameSection('NonExistent', 'Label') }) expect(createTypeEntry).toHaveBeenCalledWith('NonExistent') expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/nonexistent.md', 'sidebar label', 'Label') expect(updateEntry).toHaveBeenCalledWith('/vault/type/nonexistent.md', { sidebarLabel: 'Label' }) }) }) describe('handleToggleTypeVisibility', () => { it('sets visible to false when currently visible (null/default)', async () => { const typeEntry = makeEntry({ isA: 'Type', title: 'Journal', path: '/vault/type/journal.md', visible: null }) const { result } = setup([typeEntry]) await act(async () => { await result.current.handleToggleTypeVisibility('Journal') }) expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/journal.md', 'visible', false) expect(updateEntry).toHaveBeenCalledWith('/vault/type/journal.md', { visible: false }) expect(onFrontmatterPersisted).toHaveBeenCalled() }) it('sets visible to true (deletes property) when currently hidden', async () => { const typeEntry = makeEntry({ isA: 'Type', title: 'Journal', path: '/vault/type/journal.md', visible: false }) const { result } = setup([typeEntry]) await act(async () => { await result.current.handleToggleTypeVisibility('Journal') }) expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/type/journal.md', 'visible') expect(updateEntry).toHaveBeenCalledWith('/vault/type/journal.md', { visible: null }) expect(onFrontmatterPersisted).toHaveBeenCalled() }) it('auto-creates type entry when not found', async () => { const { result } = setup([]) await act(async () => { await result.current.handleToggleTypeVisibility('Journal') }) expect(createTypeEntry).toHaveBeenCalledWith('Journal') expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/journal.md', 'visible', false) expect(updateEntry).toHaveBeenCalledWith('/vault/type/journal.md', { visible: false }) }) }) })