3abacb5d6e
Introduces NoteContextBuilder to extract explicit wikilink mentions, detect scope intent, and gather contextual note data including backlinks, outlinks, and related notes. Integrates into ChatView and adds comprehensive unit tests. Also includes minor type fixes: removes unnecessary `as` cast in action-preview-builder, fixes non-null assertion in tool-executor, and cleans up unused import in auto-organizer. Simplifies auto-tag command callback by removing redundant async/await.
200 lines
7.4 KiB
TypeScript
200 lines
7.4 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),
|
|
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]');
|
|
});
|
|
});
|
|
});
|