Files
obsidian_ollama/tests/action-preview-builder.test.ts
fegger 106abfa718 Integrate Obsidian metadataCache across extraction and tooling
Replaces regex-based parsing of frontmatter and headings with
Obsidian's metadataCache where available, falling back to regex
when the cache is unavailable. Propagates App dependency through
constructors to enable cache access.

Key changes:
- ContentExtractor accepts optional cache for frontmatter/headings
- ToolExecutor uses cache for section replacement and frontmatter
- NoteContextBuilder resolves titles/tags from cache
- VaultVectorStore passes cache through indexing pipeline
- AutoTagger checks cache for existing tags instead of content
- Mock updated with metadataCache stubs for tests
2026-05-20 20:36:47 +02:00

322 lines
11 KiB
TypeScript

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