import { ActionPreviewBuilder, isWriteTool } from '../src/action-preview-builder'; import { TFile } from 'obsidian'; import { ToolCall } from '../src/types'; // Mock Obsidian module jest.mock('obsidian', () => { class TFile {} return { Vault: jest.fn(), App: jest.fn(), Notice: jest.fn(), TFile, }; }); describe('isWriteTool', () => { it('should return true for write tools', () => { expect(isWriteTool('create_note')).toBe(true); expect(isWriteTool('create_file')).toBe(true); expect(isWriteTool('append_to_note')).toBe(true); expect(isWriteTool('replace_note_section')).toBe(true); expect(isWriteTool('update_frontmatter')).toBe(true); expect(isWriteTool('rename_note')).toBe(true); expect(isWriteTool('move_note')).toBe(true); expect(isWriteTool('delete_note')).toBe(true); expect(isWriteTool('insert_link')).toBe(true); }); it('should return false for read/search tools', () => { expect(isWriteTool('read_vault_file')).toBe(false); expect(isWriteTool('search_vault_files')).toBe(false); }); it('should return false for unknown tools', () => { expect(isWriteTool('unknown_tool')).toBe(false); expect(isWriteTool('')).toBe(false); }); }); describe('ActionPreviewBuilder', () => { let builder: ActionPreviewBuilder; let mockVault: { getAbstractFileByPath: jest.Mock; cachedRead: jest.Mock; }; let mockApp: { metadataCache: { getFileCache: jest.Mock; }; }; beforeEach(() => { mockVault = { getAbstractFileByPath: jest.fn(), cachedRead: jest.fn().mockResolvedValue(''), }; mockApp = { metadataCache: { getFileCache: jest.fn().mockReturnValue(null), }, }; builder = new ActionPreviewBuilder(mockVault as unknown as any, mockApp as unknown as any); }); describe('buildPreview', () => { it('should build preview for create_note', async () => { const call: ToolCall = { id: 'call_1', type: 'function', function: { name: 'create_note', arguments: JSON.stringify({ path: 'New.md', content: '# Hello' }), }, }; const preview = await builder.buildPreview(call); expect(preview.operation).toBe('create'); expect(preview.path).toBe('New.md'); expect(preview.preview?.before).toBeUndefined(); expect(preview.preview?.after).toBe('# Hello'); }); it('should build preview for append_to_note', async () => { mockVault.getAbstractFileByPath.mockReturnValue(new TFile()); mockVault.cachedRead.mockResolvedValue('Existing content'); const call: ToolCall = { id: 'call_2', type: 'function', function: { name: 'append_to_note', arguments: JSON.stringify({ path: 'Note.md', content: 'Appended' }), }, }; const preview = await builder.buildPreview(call); expect(preview.operation).toBe('append'); expect(preview.preview?.before).toBe('Existing content'); expect(preview.preview?.after).toBe('Existing content\nAppended'); }); it('should build preview for replace_note_section using metadataCache', async () => { mockVault.getAbstractFileByPath.mockReturnValue(new TFile()); mockVault.cachedRead.mockResolvedValue('# Title\n\n## Section A\nOld\n\n## Section B\nOther'); mockApp.metadataCache.getFileCache.mockReturnValue({ headings: [ { heading: 'Title', level: 1, position: { start: { offset: 0 } } }, { heading: 'Section A', level: 2, position: { start: { offset: 9 } } }, { heading: 'Section B', level: 2, position: { start: { offset: 24 } } }, ], }); const call: ToolCall = { id: 'call_3', type: 'function', function: { name: 'replace_note_section', arguments: JSON.stringify({ path: 'Note.md', heading: 'Section A', content: 'New' }), }, }; const preview = await builder.buildPreview(call); expect(preview.operation).toBe('replace_section'); expect(preview.preview?.after).toContain('New'); expect(preview.preview?.after).not.toContain('Old'); }); it('should build preview for replace_note_section with regex fallback', async () => { mockVault.getAbstractFileByPath.mockReturnValue(new TFile()); mockVault.cachedRead.mockResolvedValue('# Title\n\n## Section A\nOld\n\n## Section B\nOther'); mockApp.metadataCache.getFileCache.mockReturnValue(null); const call: ToolCall = { id: 'call_3b', type: 'function', function: { name: 'replace_note_section', arguments: JSON.stringify({ path: 'Note.md', heading: 'Section A', content: 'New' }), }, }; const preview = await builder.buildPreview(call); expect(preview.operation).toBe('replace_section'); expect(preview.preview?.after).toContain('New'); expect(preview.preview?.after).not.toContain('Old'); }); it('should build preview for update_frontmatter using metadataCache', async () => { mockVault.getAbstractFileByPath.mockReturnValue(new TFile()); mockVault.cachedRead.mockResolvedValue('---\ntitle: Old\n---\nBody'); mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter: { title: 'Old' }, }); const call: ToolCall = { id: 'call_4', type: 'function', function: { name: 'update_frontmatter', arguments: JSON.stringify({ path: 'Note.md', fields: { title: 'New' } }), }, }; const preview = await builder.buildPreview(call); expect(preview.operation).toBe('update_frontmatter'); expect(preview.preview?.after).toContain('title: New'); }); it('should build preview for update_frontmatter with regex fallback', async () => { mockVault.getAbstractFileByPath.mockReturnValue(new TFile()); mockVault.cachedRead.mockResolvedValue('---\ntitle: Old\n---\nBody'); mockApp.metadataCache.getFileCache.mockReturnValue(null); const call: ToolCall = { id: 'call_4b', type: 'function', function: { name: 'update_frontmatter', arguments: JSON.stringify({ path: 'Note.md', fields: { title: 'New' } }), }, }; const preview = await builder.buildPreview(call); expect(preview.operation).toBe('update_frontmatter'); expect(preview.preview?.after).toContain('title: New'); }); it('should build preview for rename_note', async () => { const call: ToolCall = { id: 'call_5', type: 'function', function: { name: 'rename_note', arguments: JSON.stringify({ oldPath: 'Old.md', newPath: 'New.md' }), }, }; const preview = await builder.buildPreview(call); expect(preview.operation).toBe('rename'); expect(preview.preview?.before).toBe('Old.md'); expect(preview.preview?.after).toBe('New.md'); }); it('should build preview for move_note', async () => { const call: ToolCall = { id: 'call_6', type: 'function', function: { name: 'move_note', arguments: JSON.stringify({ path: 'Projects/Note.md', folder: 'Archive' }), }, }; const preview = await builder.buildPreview(call); expect(preview.operation).toBe('move'); expect(preview.preview?.before).toBe('Projects/Note.md'); expect(preview.preview?.after).toBe('Archive/Note.md'); }); it('should build preview for delete_note', async () => { mockVault.getAbstractFileByPath.mockReturnValue(new TFile()); mockVault.cachedRead.mockResolvedValue('File content'); const call: ToolCall = { id: 'call_7', type: 'function', function: { name: 'delete_note', arguments: JSON.stringify({ path: 'Note.md' }), }, }; const preview = await builder.buildPreview(call); expect(preview.operation).toBe('delete'); expect(preview.preview?.before).toBe('File content'); expect(preview.preview?.after).toBeUndefined(); }); it('should build preview for insert_link without anchor text', async () => { mockVault.getAbstractFileByPath.mockReturnValue(new TFile()); mockVault.cachedRead.mockResolvedValue('Source content'); const call: ToolCall = { id: 'call_8', type: 'function', function: { name: 'insert_link', arguments: JSON.stringify({ sourcePath: 'A.md', targetPath: 'B.md' }), }, }; const preview = await builder.buildPreview(call); expect(preview.operation).toBe('insert_link'); expect(preview.preview?.after).toContain('[[B.md]]'); }); it('should build preview for insert_link with anchor text', async () => { mockVault.getAbstractFileByPath.mockReturnValue(new TFile()); mockVault.cachedRead.mockResolvedValue('Source content'); const call: ToolCall = { id: 'call_9', type: 'function', function: { name: 'insert_link', arguments: JSON.stringify({ sourcePath: 'A.md', targetPath: 'B.md', anchorText: 'Link' }), }, }; const preview = await builder.buildPreview(call); expect(preview.preview?.after).toContain('[[B.md|Link]]'); }); it('should handle object arguments directly', async () => { const call: ToolCall = { id: 'call_10', type: 'function', function: { name: 'create_note', arguments: { path: 'Direct.md', content: 'Body' } as unknown as string, }, }; const preview = await builder.buildPreview(call); expect(preview.path).toBe('Direct.md'); expect(preview.preview?.after).toBe('Body'); }); it('should return unknown operation for unrecognized tools', async () => { const call: ToolCall = { id: 'call_11', type: 'function', function: { name: 'weird_tool', arguments: '{}', }, }; const preview = await builder.buildPreview(call); expect(preview.operation).toBe('read'); expect(preview.description).toContain('weird_tool'); }); it('should handle missing file gracefully for append_to_note', async () => { mockVault.getAbstractFileByPath.mockReturnValue(null); const call: ToolCall = { id: 'call_12', type: 'function', function: { name: 'append_to_note', arguments: JSON.stringify({ path: 'Missing.md', content: 'test' }), }, }; const preview = await builder.buildPreview(call); expect(preview.preview?.before).toBeUndefined(); expect(preview.preview?.after).toBe('test'); }); it('should handle invalid JSON arguments gracefully', async () => { const call: ToolCall = { id: 'call_13', type: 'function', function: { name: 'create_note', arguments: 'not json', }, }; const preview = await builder.buildPreview(call); expect(preview.path).toBe(''); expect(preview.preview?.after).toBe(''); }); }); });