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; 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), resolvedLinks: {}, }, }; mockVaultIndexer = { searchVault: jest.fn().mockResolvedValue([]), } as unknown as jest.Mocked; 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]'); }); }); });