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; let mockApp: ReturnType; 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; 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(); }); }); });