Files
obsidian_ollama/tests/auto-organizer.test.ts
T
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

247 lines
8.2 KiB
TypeScript

import { AutoTagger, AutoLinker } from '../src/auto-organizer';
import { OllamaClient } from '../src/ollama-client';
// Mock dependencies
jest.mock('../src/ollama-client');
jest.mock('../src/utils', () => ({
Logger: {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}));
// Mock Obsidian
const mockModify = jest.fn();
const mockCachedRead = jest.fn();
const mockRead = jest.fn();
const mockGetMarkdownFiles = jest.fn();
const createMockVault = () => ({
getMarkdownFiles: mockGetMarkdownFiles,
cachedRead: mockCachedRead,
read: mockRead,
modify: mockModify,
});
const createMockApp = () => ({
metadataCache: {
getFileCache: jest.fn().mockReturnValue(null),
},
});
describe('AutoTagger', () => {
let tagger: AutoTagger;
let mockVault: ReturnType<typeof createMockVault>;
let mockApp: ReturnType<typeof createMockApp>;
beforeEach(() => {
jest.clearAllMocks();
mockVault = createMockVault();
mockApp = createMockApp();
tagger = new AutoTagger(mockVault as any, mockApp as any, 'http://localhost:11434', 'llama3', {
enabled: true,
maxTagsPerNote: 5,
minNoteLength: 50,
maxNoteLength: 8000,
tagPromptTemplate: 'Tags for {{title}}: {{content}}',
});
});
describe('getUntaggedNotes', () => {
it('should return files without frontmatter', async () => {
const files = [{ path: 'note1.md' }, { path: 'note2.md' }] as any[];
mockGetMarkdownFiles.mockReturnValue(files);
mockApp.metadataCache.getFileCache
.mockReturnValueOnce(null)
.mockReturnValueOnce({ frontmatter: { tags: 'existing' } });
const result = await tagger.getUntaggedNotes();
expect(result).toHaveLength(1);
expect(result[0].path).toBe('note1.md');
});
it('should return files with empty tags', async () => {
const files = [{ path: 'note1.md' }] as any[];
mockGetMarkdownFiles.mockReturnValue(files);
mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter: { tags: '' } });
const result = await tagger.getUntaggedNotes();
expect(result).toHaveLength(1);
});
it('should return files with empty array tags', async () => {
const files = [{ path: 'note1.md' }] as any[];
mockGetMarkdownFiles.mockReturnValue(files);
mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter: { tags: [] } });
const result = await tagger.getUntaggedNotes();
expect(result).toHaveLength(1);
});
it('should skip files with existing tags', async () => {
const files = [{ path: 'note1.md' }] as any[];
mockGetMarkdownFiles.mockReturnValue(files);
mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter: { tags: ['ai', 'ml'] } });
const result = await tagger.getUntaggedNotes();
expect(result).toHaveLength(0);
});
});
describe('parseTagResponse', () => {
it('should parse comma-separated tags', () => {
const tags = (tagger as any).parseTagResponse('ai, machine-learning, obsidian');
expect(tags).toEqual(['ai', 'machine-learning', 'obsidian']);
});
it('should clean up hashtags and quotes', () => {
const tags = (tagger as any).parseTagResponse('#ai, "machine learning", #obsidian');
expect(tags).toEqual(['ai', 'machine learning', 'obsidian']);
});
it('should limit to max tags', () => {
const tags = (tagger as any).parseTagResponse('a, b, c, d, e, f, g');
expect(tags).toHaveLength(5);
});
it('should filter empty tags', () => {
const tags = (tagger as any).parseTagResponse('ai,, , ml');
expect(tags).toEqual(['ai', 'ml']);
});
});
describe('applyTags', () => {
it('should add frontmatter to note without it', async () => {
const file = { path: 'note.md' } as any;
mockRead.mockResolvedValue('Just content');
mockApp.metadataCache.getFileCache.mockReturnValue(null);
await tagger.applyTags(file, ['ai', 'ml']);
expect(mockModify).toHaveBeenCalledWith(file, '---\ntags: ai, ml\n---\n\nJust content');
});
it('should update existing frontmatter with tags', async () => {
const file = { path: 'note.md' } as any;
mockRead.mockResolvedValue('---\ndate: 2024-01-01\n---\nContent');
mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter: {} });
await tagger.applyTags(file, ['ai']);
expect(mockModify).toHaveBeenCalledWith(file, expect.stringContaining('tags: ai'));
});
it('should replace existing tags line', async () => {
const file = { path: 'note.md' } as any;
mockRead.mockResolvedValue('---\ntags: old\n---\nContent');
mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter: { tags: 'old' } });
await tagger.applyTags(file, ['new']);
expect(mockModify).toHaveBeenCalledWith(file, expect.stringContaining('tags: new'));
expect(mockModify).not.toHaveBeenCalledWith(file, expect.stringContaining('tags: old'));
});
});
describe('run', () => {
it('should return early when disabled', async () => {
tagger.updateConfig({ ...tagger['config'], enabled: false });
const result = await tagger.run();
expect(result.tagged).toBe(0);
});
it('should skip notes that are too short', async () => {
const files = [{ path: 'note.md' }] as any[];
mockGetMarkdownFiles.mockReturnValue(files);
mockApp.metadataCache.getFileCache.mockReturnValue(null);
mockRead.mockResolvedValue('Short');
const result = await tagger.run();
expect(result.skipped).toBeGreaterThanOrEqual(0);
});
});
});
describe('AutoLinker', () => {
let linker: AutoLinker;
let mockVault: ReturnType<typeof createMockVault>;
let mockIndexer: { searchVault: jest.Mock };
beforeEach(() => {
jest.clearAllMocks();
mockVault = createMockVault();
mockIndexer = {
searchVault: jest.fn(),
};
linker = new AutoLinker(mockVault as any, mockIndexer as any, {
enabled: true,
maxLinksPerNote: 3,
similarityThreshold: 0.5,
});
});
describe('findRelatedNotes', () => {
it('should return related notes excluding self', async () => {
const file = { path: 'note.md', basename: 'Note' } as any;
mockVault.read.mockResolvedValue('Content about AI');
mockIndexer.searchVault!.mockResolvedValue([
{ path: 'other.md', title: 'Other', score: 0.9, content: '' },
{ path: 'note.md', title: 'Note', score: 0.95, content: '' },
]);
const result = await linker.findRelatedNotes(file);
expect(result).toHaveLength(1);
expect(result[0].path).toBe('other.md');
});
it('should filter by similarity threshold', async () => {
const file = { path: 'note.md', basename: 'Note' } as any;
mockVault.read.mockResolvedValue('Content');
mockIndexer.searchVault!.mockResolvedValue([
{ path: 'high.md', title: 'High', score: 0.8, content: '' },
{ path: 'low.md', title: 'Low', score: 0.3, content: '' },
]);
const result = await linker.findRelatedNotes(file);
expect(result).toHaveLength(1);
expect(result[0].path).toBe('high.md');
});
});
describe('addRelatedLinks', () => {
it('should add Related Notes section', async () => {
const file = { path: 'note.md' } as any;
mockVault.read.mockResolvedValue('# Note\n\nContent');
await linker.addRelatedLinks(file, [{ path: 'other.md', title: 'Other' }]);
expect(mockVault.modify).toHaveBeenCalledWith(
file,
expect.stringContaining('## Related Notes')
);
expect(mockVault.modify).toHaveBeenCalledWith(
file,
expect.stringContaining('[[Other|other]]')
);
});
it('should skip if Related Notes already exists', async () => {
const file = { path: 'note.md' } as any;
mockVault.read.mockResolvedValue('# Note\n\n## Related Notes\nAlready linked');
await linker.addRelatedLinks(file, [{ path: 'other.md', title: 'Other' }]);
expect(mockVault.modify).not.toHaveBeenCalled();
});
});
describe('run', () => {
it('should return early when disabled', async () => {
linker.updateConfig({ enabled: false, maxLinksPerNote: 3, similarityThreshold: 0.5 });
const result = await linker.run();
expect(result.linked).toBe(0);
});
});
});