Files
obsidian_ollama/tests/auto-organizer.test.ts
fegger 3ab8542cf4 Fix semantic cache distance calculation, tool path validation, and add deferred settings persistence
- Convert Chroma cosine distance to similarity (1 - distance) for correct threshold comparison
- Simplify forbidden directory check to match path segments instead of string prefixes
- Add debounced settings save via onPersist callback in ChatView to prevent data loss
- Fix workflow engine to pass availableTools to LLM when includeToolCalls is enabled
- Add targetFolder support to AutoLinker config and update tests
- Harden vault file indexing to await background indexing before processing create/modify/rename events
2026-05-21 20:41:58 +02:00

404 lines
14 KiB
TypeScript

import {
AutoTagger,
AutoLinker,
normalizeTag,
buildTagVocabulary,
normalizeTagsAgainstVocabulary,
} 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}}',
dryRun: false,
targetFolder: '',
normalizeTags: true,
});
});
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);
});
it('should respect targetFolder', async () => {
tagger.updateConfig({ ...(tagger as any).config, targetFolder: 'Projects' });
const files = [
{ path: 'Projects/note1.md' },
{ path: 'Archive/note2.md' },
{ path: 'Projects/Sub/note3.md' },
] as any[];
mockGetMarkdownFiles.mockReturnValue(files);
mockApp.metadataCache.getFileCache.mockReturnValue(null);
const result = await tagger.getUntaggedNotes();
expect(result).toHaveLength(2);
expect(result.map((f: any) => f.path)).toEqual([
'Projects/note1.md',
'Projects/Sub/note3.md',
]);
});
});
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);
});
it('should return dry-run proposals when dryRun is enabled', async () => {
tagger.updateConfig({ ...tagger['config'], dryRun: true });
const files = [{ path: 'note.md', basename: 'Note' }] as any[];
mockGetMarkdownFiles.mockReturnValue(files);
mockApp.metadataCache.getFileCache.mockReturnValue(null);
mockRead.mockResolvedValue('A longer note about AI and machine learning with lots of interesting content that exceeds the minimum length requirement for tagging.');
// Mock the ollamaClient on the tagger instance
(tagger as any).ollamaClient = {
chat: jest.fn().mockResolvedValue({
content: 'ai, machine-learning',
role: 'assistant',
}),
};
const result = await tagger.run();
expect(result.dryRun).toBeDefined();
expect(result.dryRun!.length).toBe(1);
expect(result.dryRun![0].proposedTags).toContain('ai');
expect(result.dryRun![0].proposedTags).toContain('machine-learning');
});
});
describe('normalizeTag', () => {
it('should lowercase and hyphenate tags', () => {
expect(normalizeTag('Machine Learning')).toBe('machine-learning');
expect(normalizeTag('AI')).toBe('ai');
expect(normalizeTag('obsidian-plugin')).toBe('obsidian-plugin');
});
it('should strip special characters', () => {
expect(normalizeTag('C++')).toBe('c');
expect(normalizeTag('Node.js')).toBe('nodejs');
});
it('should trim dashes', () => {
expect(normalizeTag('-leading')).toBe('leading');
expect(normalizeTag('trailing-')).toBe('trailing');
});
});
describe('buildTagVocabulary', () => {
it('should collect existing tags from vault frontmatter', () => {
const mockFiles = [{ path: 'a.md' }, { path: 'b.md' }] as any[];
const vault = createMockVault();
vault.getMarkdownFiles.mockReturnValue(mockFiles);
;(vault as any).app = {
metadataCache: {
getFileCache: jest.fn().mockImplementation((f: any) => {
if (f.path === 'a.md') return { frontmatter: { tags: ['machine-learning', 'ai'] } };
if (f.path === 'b.md') return { frontmatter: { tags: 'obsidian-plugin' } };
return null;
}),
},
};
const vocab = buildTagVocabulary(vault as any, (vault as any).app);
expect(vocab.get('machine-learning')).toBe('machine-learning');
expect(vocab.get('ai')).toBe('ai');
expect(vocab.get('obsidian-plugin')).toBe('obsidian-plugin');
});
});
describe('normalizeTagsAgainstVocabulary', () => {
it('should prefer canonical forms from vocabulary', () => {
const vocab = new Map([
['machine-learning', 'machine-learning'],
['obsidian', 'Obsidian'],
]);
const result = normalizeTagsAgainstVocabulary(
['Machine Learning', 'obsidian', 'new-tag'],
vocab
);
expect(result).toContain('machine-learning');
expect(result).toContain('Obsidian');
expect(result).toContain('new-tag');
});
it('should deduplicate normalized tags', () => {
const vocab = new Map();
const result = normalizeTagsAgainstVocabulary(['ai', 'AI', 'Ai'], vocab);
expect(result).toEqual(['ai']);
});
});
});
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);
});
it('should respect targetFolder', async () => {
linker.setTargetFolder('Projects');
const files = [{ path: 'Projects/note1.md' }, { path: 'Archive/note2.md' }] as any[];
mockVault.getMarkdownFiles.mockReturnValue(files);
mockVault.read.mockResolvedValue('Content');
mockIndexer.searchVault!.mockResolvedValue([]);
const result = await linker.run();
expect(mockVault.read).toHaveBeenCalledTimes(1);
expect(mockVault.read).toHaveBeenCalledWith(files[0]);
});
it('should update targetFolder through updateConfig', async () => {
linker.setTargetFolder('Projects');
linker.updateConfig({
enabled: true,
maxLinksPerNote: 3,
similarityThreshold: 0.5,
targetFolder: 'Archive',
});
const files = [{ path: 'Projects/note1.md' }, { path: 'Archive/note2.md' }] as any[];
mockVault.getMarkdownFiles.mockReturnValue(files);
mockVault.read.mockResolvedValue('Content');
mockIndexer.searchVault!.mockResolvedValue([]);
await linker.run();
expect(mockVault.read).toHaveBeenCalledTimes(1);
expect(mockVault.read).toHaveBeenCalledWith(files[1]);
});
it('should return dry-run proposals when dryRun is true', async () => {
const files = [{ path: 'note.md' }] as any[];
mockVault.getMarkdownFiles.mockReturnValue(files);
mockVault.read.mockResolvedValue('Content');
mockIndexer.searchVault!.mockResolvedValue([
{ path: 'other.md', title: 'Other', score: 0.9, content: '' },
]);
const result = await linker.run(true);
expect(result.dryRun).toBeDefined();
expect(result.dryRun!.length).toBe(1);
expect(result.dryRun![0].relatedNotes[0].path).toBe('other.md');
expect(mockVault.modify).not.toHaveBeenCalled();
});
});
});