106abfa718
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
201 lines
7.5 KiB
TypeScript
201 lines
7.5 KiB
TypeScript
import { NoteContextBuilder } from '../src/note-context-builder';
|
|
import { VaultIndexer } from '../src/vault-indexer';
|
|
import { TFile } from 'obsidian';
|
|
|
|
describe('NoteContextBuilder', () => {
|
|
let builder: NoteContextBuilder;
|
|
let mockVault: any;
|
|
let mockApp: any;
|
|
let mockVaultIndexer: jest.Mocked<VaultIndexer>;
|
|
|
|
beforeEach(() => {
|
|
mockVault = {
|
|
getAbstractFileByPath: jest.fn(),
|
|
getMarkdownFiles: jest.fn().mockReturnValue([]),
|
|
cachedRead: jest.fn().mockResolvedValue(''),
|
|
};
|
|
|
|
mockApp = {
|
|
workspace: {
|
|
getActiveFile: jest.fn().mockReturnValue(null),
|
|
getActiveViewOfType: jest.fn().mockReturnValue(null),
|
|
},
|
|
metadataCache: {
|
|
getCache: jest.fn().mockReturnValue(null),
|
|
getFileCache: jest.fn().mockReturnValue(null),
|
|
resolvedLinks: {},
|
|
},
|
|
};
|
|
|
|
mockVaultIndexer = {
|
|
searchVault: jest.fn().mockResolvedValue([]),
|
|
} as unknown as jest.Mocked<VaultIndexer>;
|
|
|
|
builder = new NoteContextBuilder(mockVault, mockApp, mockVaultIndexer);
|
|
});
|
|
|
|
describe('extractExplicitMentions', () => {
|
|
it('should extract simple wikilinks', () => {
|
|
const result = builder.extractExplicitMentions('What about [[My Note]]?');
|
|
expect(result).toEqual(['My Note']);
|
|
});
|
|
|
|
it('should extract multiple wikilinks', () => {
|
|
const result = builder.extractExplicitMentions('See [[Note A]] and [[Note B]]');
|
|
expect(result).toEqual(['Note A', 'Note B']);
|
|
});
|
|
|
|
it('should strip aliases', () => {
|
|
const result = builder.extractExplicitMentions('[[Real Name|Display Name]]');
|
|
expect(result).toEqual(['Real Name']);
|
|
});
|
|
|
|
it('should deduplicate mentions', () => {
|
|
const result = builder.extractExplicitMentions('[[Note]] [[Note]]');
|
|
expect(result).toEqual(['Note']);
|
|
});
|
|
|
|
it('should return empty array when no wikilinks', () => {
|
|
const result = builder.extractExplicitMentions('Just plain text');
|
|
expect(result).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('detectScopeIntent', () => {
|
|
it('should detect explicit scope', () => {
|
|
expect(builder.detectScopeIntent('use only this note')).toBe('explicit');
|
|
expect(builder.detectScopeIntent('Just this note please')).toBe('explicit');
|
|
});
|
|
|
|
it('should detect related scope', () => {
|
|
expect(builder.detectScopeIntent('include related notes')).toBe('related');
|
|
expect(builder.detectScopeIntent('show me linked notes')).toBe('related');
|
|
});
|
|
|
|
it('should default to default', () => {
|
|
expect(builder.detectScopeIntent('hello world')).toBe('default');
|
|
});
|
|
});
|
|
|
|
describe('buildContext', () => {
|
|
it('should include explicit mentions', async () => {
|
|
mockVault.getAbstractFileByPath.mockReturnValue({ path: 'Note.md', basename: 'Note' });
|
|
mockVault.getMarkdownFiles.mockReturnValue([{ path: 'Note.md', basename: 'Note' }]);
|
|
mockVault.cachedRead.mockResolvedValue('# Note\nContent');
|
|
|
|
const ctx = await builder.buildContext('What about [[Note]]?', 5);
|
|
expect(ctx.explicitMentions.length).toBe(1);
|
|
expect(ctx.explicitMentions[0].title).toBe('Note');
|
|
});
|
|
|
|
it('should include open note when available', async () => {
|
|
const activeFile = { path: 'Open.md', basename: 'Open' };
|
|
mockApp.workspace.getActiveFile.mockReturnValue(activeFile);
|
|
mockVault.getAbstractFileByPath.mockReturnValue(activeFile);
|
|
mockVault.cachedRead.mockResolvedValue('# Open\nBody');
|
|
|
|
const ctx = await builder.buildContext('hello', 5);
|
|
expect(ctx.openNote).toBeDefined();
|
|
expect(ctx.openNote?.title).toBe('Open');
|
|
});
|
|
|
|
it('should include selected text when available', async () => {
|
|
const activeFile = { path: 'Open.md', basename: 'Open' };
|
|
mockApp.workspace.getActiveFile.mockReturnValue(activeFile);
|
|
mockVault.getAbstractFileByPath.mockReturnValue(activeFile);
|
|
mockVault.cachedRead.mockResolvedValue('Body');
|
|
|
|
const mockEditor = { getSelection: jest.fn().mockReturnValue('Selected passage') };
|
|
const mockView = { editor: mockEditor };
|
|
mockApp.workspace.getActiveViewOfType.mockReturnValue(mockView);
|
|
|
|
const ctx = await builder.buildContext('hello', 5);
|
|
expect(ctx.selectedText).toBe('Selected passage');
|
|
});
|
|
|
|
it('should call vaultIndexer.searchVault for default scope', async () => {
|
|
await builder.buildContext('search term', 5);
|
|
expect(mockVaultIndexer.searchVault).toHaveBeenCalledWith('search term', 5);
|
|
});
|
|
|
|
it('should skip vault search for explicit scope', async () => {
|
|
mockVault.getAbstractFileByPath.mockReturnValue({ path: 'N.md', basename: 'N' });
|
|
mockVault.getMarkdownFiles.mockReturnValue([{ path: 'N.md', basename: 'N' }]);
|
|
mockVault.cachedRead.mockResolvedValue('');
|
|
|
|
await builder.buildContext('use only this note [[N]]', 5);
|
|
expect(mockVaultIndexer.searchVault).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should gather backlinks and outlinks in related mode', async () => {
|
|
const activeFile = { path: 'A.md', basename: 'A' };
|
|
const backFile = { path: 'B.md', basename: 'B' };
|
|
const outFile = { path: 'C.md', basename: 'C' };
|
|
|
|
mockApp.workspace.getActiveFile.mockReturnValue(activeFile);
|
|
mockApp.metadataCache.resolvedLinks = { 'B.md': { 'A.md': 1 } };
|
|
mockApp.metadataCache.getCache.mockReturnValue({ links: [{ link: 'C' }] });
|
|
|
|
mockVault.getAbstractFileByPath.mockImplementation((p: string) => {
|
|
if (p === 'A.md') return activeFile;
|
|
if (p === 'B.md') return backFile;
|
|
if (p === 'C.md') return outFile;
|
|
return null;
|
|
});
|
|
mockVault.getMarkdownFiles.mockReturnValue([backFile, outFile]);
|
|
mockVault.cachedRead.mockResolvedValue('');
|
|
|
|
const ctx = await builder.buildContext('include related notes', 5);
|
|
expect(ctx.backlinks.length).toBe(1);
|
|
expect(ctx.backlinks[0].path).toBe('B.md');
|
|
expect(ctx.outlinks.length).toBe(1);
|
|
expect(ctx.outlinks[0].path).toBe('C.md');
|
|
expect(ctx.relatedNotes.length).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('formatContext', () => {
|
|
it('should include selected text, open note, mentions, and search results', () => {
|
|
const ctx = {
|
|
explicitMentions: [
|
|
{ path: 'M.md', title: 'Mention', content: 'Mention body', score: 1 },
|
|
],
|
|
openNote: { path: 'O.md', title: 'Open', content: 'Open body', score: 1 },
|
|
selectedText: 'Selected text',
|
|
backlinks: [],
|
|
outlinks: [],
|
|
relatedNotes: [],
|
|
searchResults: [
|
|
{ path: 'S.md', title: 'Search', content: 'Search body', score: 1 },
|
|
],
|
|
};
|
|
|
|
const formatted = builder.formatContext(ctx as any, 2000);
|
|
expect(formatted).toContain('Selected text from current note:');
|
|
expect(formatted).toContain('Selected text');
|
|
expect(formatted).toContain('Current open note: Open (O.md)');
|
|
expect(formatted).toContain('Explicitly mentioned notes:');
|
|
expect(formatted).toContain('Mention (M.md)');
|
|
expect(formatted).toContain('Vault search results:');
|
|
expect(formatted).toContain('Search (S.md)');
|
|
});
|
|
|
|
it('should truncate long context', () => {
|
|
const ctx = {
|
|
explicitMentions: [],
|
|
openNote: undefined,
|
|
selectedText: undefined,
|
|
backlinks: [],
|
|
outlinks: [],
|
|
relatedNotes: [],
|
|
searchResults: [
|
|
{ path: 'S.md', title: 'Search', content: 'A'.repeat(500), score: 1 },
|
|
],
|
|
};
|
|
|
|
const formatted = builder.formatContext(ctx as any, 50);
|
|
expect(formatted).toContain('... [truncated]');
|
|
});
|
|
});
|
|
});
|