Files
obsidian_ollama/tests/auto-organizer.test.ts
T
fegger a8d8936b11 feat: add auto-tag and auto-link note organization features
- Add AutoTagger: scans untagged notes, generates AI tags via Ollama,
  and applies them to frontmatter
- Add AutoLinker: finds semantically related notes via vault search
  and appends a 'Related Notes' section with wiki-links
- Add settings UI for both features with configurable thresholds,
  prompt templates, and limits
- Add commands: 'Auto-Tag Untagged Notes' and 'Auto-Link Related Notes'
- Add auto-organizer.test.ts with 17 tests covering tagging,
  linking, frontmatter manipulation, and filtering
2026-05-20 00:12:30 +02:00

227 lines
7.4 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,
});
describe('AutoTagger', () => {
let tagger: AutoTagger;
let mockVault: ReturnType<typeof createMockVault>;
beforeEach(() => {
jest.clearAllMocks();
mockVault = createMockVault();
tagger = new AutoTagger(mockVault 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);
mockCachedRead
.mockResolvedValueOnce('No frontmatter here')
.mockResolvedValueOnce('---\ntags: existing\n---\nContent');
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);
mockCachedRead.mockResolvedValue('---\ntags: \n---\nContent');
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);
mockCachedRead.mockResolvedValue('---\ntags: ai, ml\n---\nContent');
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');
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');
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');
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);
mockCachedRead.mockResolvedValue('---\n---\nContent');
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);
});
});
});