test: add comprehensive link-utils tests
- Add 46 comprehensive tests for LinkUtils covering all methods - Test parseWikilinks() with various formats, aliases, headings, paths - Test resolveLink() with MetadataCache integration and edge cases - Test findSuggestions() with scoring algorithms and fuzzy matching - Test getBacklinks() with linked/unlinked mentions and snippet extraction - Test validateWikilinks() with resolved/unresolved link validation - Achieve 100% statement, function, and line coverage on link-utils.ts
This commit is contained in:
846
tests/link-utils.test.ts
Normal file
846
tests/link-utils.test.ts
Normal file
@@ -0,0 +1,846 @@
|
||||
import { LinkUtils } from '../src/utils/link-utils';
|
||||
import { createMockVaultAdapter, createMockMetadataCacheAdapter, createMockTFile } from './__mocks__/adapters';
|
||||
import { TFile } from 'obsidian';
|
||||
|
||||
describe('LinkUtils', () => {
|
||||
describe('parseWikilinks()', () => {
|
||||
test('parses simple wikilinks', () => {
|
||||
const content = 'This is a [[simple link]] in text.';
|
||||
const links = LinkUtils.parseWikilinks(content);
|
||||
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]).toEqual({
|
||||
raw: '[[simple link]]',
|
||||
target: 'simple link',
|
||||
alias: undefined,
|
||||
line: 1,
|
||||
column: 10
|
||||
});
|
||||
});
|
||||
|
||||
test('parses wikilinks with aliases', () => {
|
||||
const content = 'Check [[target|display alias]] here.';
|
||||
const links = LinkUtils.parseWikilinks(content);
|
||||
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]).toEqual({
|
||||
raw: '[[target|display alias]]',
|
||||
target: 'target',
|
||||
alias: 'display alias',
|
||||
line: 1,
|
||||
column: 6
|
||||
});
|
||||
});
|
||||
|
||||
test('parses wikilinks with headings', () => {
|
||||
const content = 'See [[Note#Heading]] and [[Note#Heading|Custom]].';
|
||||
const links = LinkUtils.parseWikilinks(content);
|
||||
|
||||
expect(links).toHaveLength(2);
|
||||
expect(links[0]).toEqual({
|
||||
raw: '[[Note#Heading]]',
|
||||
target: 'Note#Heading',
|
||||
alias: undefined,
|
||||
line: 1,
|
||||
column: 4
|
||||
});
|
||||
expect(links[1]).toEqual({
|
||||
raw: '[[Note#Heading|Custom]]',
|
||||
target: 'Note#Heading',
|
||||
alias: 'Custom',
|
||||
line: 1,
|
||||
column: 25
|
||||
});
|
||||
});
|
||||
|
||||
test('parses nested folder paths', () => {
|
||||
const content = 'Link to [[folder/subfolder/note]].';
|
||||
const links = LinkUtils.parseWikilinks(content);
|
||||
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]).toEqual({
|
||||
raw: '[[folder/subfolder/note]]',
|
||||
target: 'folder/subfolder/note',
|
||||
alias: undefined,
|
||||
line: 1,
|
||||
column: 8
|
||||
});
|
||||
});
|
||||
|
||||
test('parses multiple wikilinks on same line', () => {
|
||||
const content = '[[first]] and [[second|alias]] and [[third]].';
|
||||
const links = LinkUtils.parseWikilinks(content);
|
||||
|
||||
expect(links).toHaveLength(3);
|
||||
expect(links[0].target).toBe('first');
|
||||
expect(links[1].target).toBe('second');
|
||||
expect(links[1].alias).toBe('alias');
|
||||
expect(links[2].target).toBe('third');
|
||||
});
|
||||
|
||||
test('parses wikilinks across multiple lines', () => {
|
||||
const content = `Line 1 has [[link1]]
|
||||
Line 2 has [[link2|alias]]
|
||||
Line 3 has [[link3]]`;
|
||||
const links = LinkUtils.parseWikilinks(content);
|
||||
|
||||
expect(links).toHaveLength(3);
|
||||
expect(links[0].line).toBe(1);
|
||||
expect(links[1].line).toBe(2);
|
||||
expect(links[2].line).toBe(3);
|
||||
});
|
||||
|
||||
test('trims whitespace from target and alias', () => {
|
||||
const content = '[[ spaced target | spaced alias ]]';
|
||||
const links = LinkUtils.parseWikilinks(content);
|
||||
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0].target).toBe('spaced target');
|
||||
expect(links[0].alias).toBe('spaced alias');
|
||||
});
|
||||
|
||||
test('returns empty array for content with no wikilinks', () => {
|
||||
const content = 'No links here, just plain text.';
|
||||
const links = LinkUtils.parseWikilinks(content);
|
||||
|
||||
expect(links).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('returns empty array for empty content', () => {
|
||||
const links = LinkUtils.parseWikilinks('');
|
||||
expect(links).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('tracks correct column positions', () => {
|
||||
const content = 'Start [[first]] middle [[second]] end';
|
||||
const links = LinkUtils.parseWikilinks(content);
|
||||
|
||||
expect(links).toHaveLength(2);
|
||||
expect(links[0].column).toBe(6);
|
||||
expect(links[1].column).toBe(23);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveLink()', () => {
|
||||
test('resolves link using MetadataCache', () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const metadata = createMockMetadataCacheAdapter();
|
||||
|
||||
const sourceFile = createMockTFile('source.md');
|
||||
const targetFile = createMockTFile('target.md');
|
||||
|
||||
(vault.getAbstractFileByPath as jest.Mock).mockReturnValue(sourceFile);
|
||||
(metadata.getFirstLinkpathDest as jest.Mock).mockReturnValue(targetFile);
|
||||
|
||||
const result = LinkUtils.resolveLink(vault, metadata, 'source.md', 'target');
|
||||
|
||||
expect(result).toBe(targetFile);
|
||||
expect(vault.getAbstractFileByPath).toHaveBeenCalledWith('source.md');
|
||||
expect(metadata.getFirstLinkpathDest).toHaveBeenCalledWith('target', 'source.md');
|
||||
});
|
||||
|
||||
test('returns null when source file not found', () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const metadata = createMockMetadataCacheAdapter();
|
||||
|
||||
(vault.getAbstractFileByPath as jest.Mock).mockReturnValue(null);
|
||||
|
||||
const result = LinkUtils.resolveLink(vault, metadata, 'nonexistent.md', 'target');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when source is not a TFile', () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const metadata = createMockMetadataCacheAdapter();
|
||||
|
||||
const folder = { path: 'folder', basename: 'folder' }; // Not a TFile
|
||||
(vault.getAbstractFileByPath as jest.Mock).mockReturnValue(folder);
|
||||
|
||||
const result = LinkUtils.resolveLink(vault, metadata, 'folder', 'target');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when link cannot be resolved', () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const metadata = createMockMetadataCacheAdapter();
|
||||
|
||||
const sourceFile = createMockTFile('source.md');
|
||||
(vault.getAbstractFileByPath as jest.Mock).mockReturnValue(sourceFile);
|
||||
(metadata.getFirstLinkpathDest as jest.Mock).mockReturnValue(null);
|
||||
|
||||
const result = LinkUtils.resolveLink(vault, metadata, 'source.md', 'nonexistent');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('resolves links with headings', () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const metadata = createMockMetadataCacheAdapter();
|
||||
|
||||
const sourceFile = createMockTFile('source.md');
|
||||
const targetFile = createMockTFile('target.md');
|
||||
|
||||
(vault.getAbstractFileByPath as jest.Mock).mockReturnValue(sourceFile);
|
||||
(metadata.getFirstLinkpathDest as jest.Mock).mockReturnValue(targetFile);
|
||||
|
||||
const result = LinkUtils.resolveLink(vault, metadata, 'source.md', 'target#heading');
|
||||
|
||||
expect(result).toBe(targetFile);
|
||||
expect(metadata.getFirstLinkpathDest).toHaveBeenCalledWith('target#heading', 'source.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSuggestions()', () => {
|
||||
test('exact basename match gets highest score', () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const files = [
|
||||
createMockTFile('exact.md'),
|
||||
createMockTFile('exact-match.md'),
|
||||
createMockTFile('folder/exact.md')
|
||||
];
|
||||
(vault.getMarkdownFiles as jest.Mock).mockReturnValue(files);
|
||||
|
||||
const suggestions = LinkUtils.findSuggestions(vault, 'exact');
|
||||
|
||||
expect(suggestions).toHaveLength(3);
|
||||
// Both exact matches should come first (either order is fine as they have same score)
|
||||
expect(suggestions[0]).toMatch(/exact\.md$/);
|
||||
expect(suggestions[1]).toMatch(/exact\.md$/);
|
||||
});
|
||||
|
||||
test('basename contains match scores higher than path contains', () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const files = [
|
||||
createMockTFile('path/with/test/file.md'), // path contains
|
||||
createMockTFile('test-file.md'), // basename contains
|
||||
createMockTFile('testing.md') // basename contains
|
||||
];
|
||||
(vault.getMarkdownFiles as jest.Mock).mockReturnValue(files);
|
||||
|
||||
const suggestions = LinkUtils.findSuggestions(vault, 'test');
|
||||
|
||||
expect(suggestions).toHaveLength(3);
|
||||
// Basename matches should come before path matches
|
||||
// The first two can be in any order as they both score similarly (basename contains)
|
||||
expect(suggestions.slice(0, 2)).toContain('test-file.md');
|
||||
expect(suggestions.slice(0, 2)).toContain('testing.md');
|
||||
expect(suggestions[2]).toBe('path/with/test/file.md');
|
||||
});
|
||||
|
||||
test('removes heading and block references before matching', () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const files = [
|
||||
createMockTFile('note.md'),
|
||||
createMockTFile('note-extra.md')
|
||||
];
|
||||
(vault.getMarkdownFiles as jest.Mock).mockReturnValue(files);
|
||||
|
||||
const suggestions = LinkUtils.findSuggestions(vault, 'note#heading');
|
||||
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
expect(suggestions).toContain('note.md');
|
||||
});
|
||||
|
||||
test('removes block references before matching', () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const files = [
|
||||
createMockTFile('note.md')
|
||||
];
|
||||
(vault.getMarkdownFiles as jest.Mock).mockReturnValue(files);
|
||||
|
||||
const suggestions = LinkUtils.findSuggestions(vault, 'note^block');
|
||||
|
||||
expect(suggestions).toContain('note.md');
|
||||
});
|
||||
|
||||
test('respects maxSuggestions limit', () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const files = Array.from({ length: 10 }, (_, i) =>
|
||||
createMockTFile(`file${i}.md`)
|
||||
);
|
||||
(vault.getMarkdownFiles as jest.Mock).mockReturnValue(files);
|
||||
|
||||
const suggestions = LinkUtils.findSuggestions(vault, 'file', 3);
|
||||
|
||||
expect(suggestions).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('defaults to 5 suggestions', () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const files = Array.from({ length: 10 }, (_, i) =>
|
||||
createMockTFile(`test${i}.md`)
|
||||
);
|
||||
(vault.getMarkdownFiles as jest.Mock).mockReturnValue(files);
|
||||
|
||||
const suggestions = LinkUtils.findSuggestions(vault, 'test');
|
||||
|
||||
expect(suggestions).toHaveLength(5);
|
||||
});
|
||||
|
||||
test('returns empty array when no files match', () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const files = [
|
||||
createMockTFile('unrelated.md'),
|
||||
createMockTFile('different.md')
|
||||
];
|
||||
(vault.getMarkdownFiles as jest.Mock).mockReturnValue(files);
|
||||
|
||||
const suggestions = LinkUtils.findSuggestions(vault, 'zzzzz', 5);
|
||||
|
||||
// May return low-scoring matches based on character similarity
|
||||
// or empty if no characters match
|
||||
expect(Array.isArray(suggestions)).toBe(true);
|
||||
});
|
||||
|
||||
test('case insensitive matching', () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const files = [
|
||||
createMockTFile('MyNote.md'),
|
||||
createMockTFile('ANOTHER.md')
|
||||
];
|
||||
(vault.getMarkdownFiles as jest.Mock).mockReturnValue(files);
|
||||
|
||||
const suggestions = LinkUtils.findSuggestions(vault, 'mynote');
|
||||
|
||||
expect(suggestions).toContain('MyNote.md');
|
||||
});
|
||||
|
||||
test('scores based on character similarity when no contains match', () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const files = [
|
||||
createMockTFile('abcdef.md'), // More matching chars
|
||||
createMockTFile('xyz.md') // Fewer matching chars
|
||||
];
|
||||
(vault.getMarkdownFiles as jest.Mock).mockReturnValue(files);
|
||||
|
||||
const suggestions = LinkUtils.findSuggestions(vault, 'abc');
|
||||
|
||||
expect(suggestions[0]).toBe('abcdef.md');
|
||||
});
|
||||
|
||||
test('only returns files with score > 0', () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const files = [
|
||||
createMockTFile('match.md'),
|
||||
createMockTFile('zzz.md') // No matching characters with 'abc'
|
||||
];
|
||||
(vault.getMarkdownFiles as jest.Mock).mockReturnValue(files);
|
||||
|
||||
const suggestions = LinkUtils.findSuggestions(vault, 'match');
|
||||
|
||||
// Should only return files that scored > 0
|
||||
expect(suggestions.every(s => s.includes('match'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBacklinks()', () => {
|
||||
test('returns linked backlinks from resolvedLinks', async () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const metadata = createMockMetadataCacheAdapter();
|
||||
|
||||
const targetFile = createMockTFile('target.md');
|
||||
const sourceFile = createMockTFile('source.md');
|
||||
|
||||
(vault.getAbstractFileByPath as jest.Mock).mockImplementation((path: string) => {
|
||||
if (path === 'target.md') return targetFile;
|
||||
if (path === 'source.md') return sourceFile;
|
||||
return null;
|
||||
});
|
||||
|
||||
metadata.resolvedLinks = {
|
||||
'source.md': { 'target.md': 1 }
|
||||
};
|
||||
|
||||
const sourceContent = 'This links to [[target]].';
|
||||
(vault.read as jest.Mock).mockResolvedValue(sourceContent);
|
||||
(metadata.getFirstLinkpathDest as jest.Mock).mockReturnValue(targetFile);
|
||||
|
||||
const backlinks = await LinkUtils.getBacklinks(vault, metadata, 'target.md');
|
||||
|
||||
expect(backlinks).toHaveLength(1);
|
||||
expect(backlinks[0]).toMatchObject({
|
||||
sourcePath: 'source.md',
|
||||
type: 'linked',
|
||||
});
|
||||
expect(backlinks[0].occurrences).toHaveLength(1);
|
||||
expect(backlinks[0].occurrences[0].line).toBe(1);
|
||||
expect(backlinks[0].occurrences[0].snippet).toBe('This links to [[target]].');
|
||||
});
|
||||
|
||||
test('returns empty array when target file not found', async () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const metadata = createMockMetadataCacheAdapter();
|
||||
|
||||
(vault.getAbstractFileByPath as jest.Mock).mockReturnValue(null);
|
||||
|
||||
const backlinks = await LinkUtils.getBacklinks(vault, metadata, 'nonexistent.md');
|
||||
|
||||
expect(backlinks).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('returns empty array when target is not a TFile', async () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const metadata = createMockMetadataCacheAdapter();
|
||||
|
||||
const folder = { path: 'folder', basename: 'folder' };
|
||||
(vault.getAbstractFileByPath as jest.Mock).mockReturnValue(folder);
|
||||
|
||||
const backlinks = await LinkUtils.getBacklinks(vault, metadata, 'folder');
|
||||
|
||||
expect(backlinks).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('skips sources that are not TFiles', async () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const metadata = createMockMetadataCacheAdapter();
|
||||
|
||||
const targetFile = createMockTFile('target.md');
|
||||
|
||||
(vault.getAbstractFileByPath as jest.Mock).mockImplementation((path: string) => {
|
||||
if (path === 'target.md') return targetFile;
|
||||
if (path === 'folder') return { path: 'folder' }; // Not a TFile
|
||||
return null;
|
||||
});
|
||||
|
||||
metadata.resolvedLinks = {
|
||||
'folder': { 'target.md': 1 }
|
||||
};
|
||||
|
||||
const backlinks = await LinkUtils.getBacklinks(vault, metadata, 'target.md');
|
||||
|
||||
expect(backlinks).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('skips sources that do not link to target', async () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const metadata = createMockMetadataCacheAdapter();
|
||||
|
||||
const targetFile = createMockTFile('target.md');
|
||||
const sourceFile = createMockTFile('source.md');
|
||||
const otherFile = createMockTFile('other.md');
|
||||
|
||||
(vault.getAbstractFileByPath as jest.Mock).mockImplementation((path: string) => {
|
||||
if (path === 'target.md') return targetFile;
|
||||
if (path === 'source.md') return sourceFile;
|
||||
if (path === 'other.md') return otherFile;
|
||||
return null;
|
||||
});
|
||||
|
||||
// source.md has links, but not to target.md - it links to other.md
|
||||
metadata.resolvedLinks = {
|
||||
'source.md': { 'other.md': 1 }
|
||||
};
|
||||
|
||||
const backlinks = await LinkUtils.getBacklinks(vault, metadata, 'target.md');
|
||||
|
||||
expect(backlinks).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('finds multiple backlink occurrences in same file', async () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const metadata = createMockMetadataCacheAdapter();
|
||||
|
||||
const targetFile = createMockTFile('target.md');
|
||||
const sourceFile = createMockTFile('source.md');
|
||||
|
||||
(vault.getAbstractFileByPath as jest.Mock).mockImplementation((path: string) => {
|
||||
if (path === 'target.md') return targetFile;
|
||||
if (path === 'source.md') return sourceFile;
|
||||
return null;
|
||||
});
|
||||
|
||||
metadata.resolvedLinks = {
|
||||
'source.md': { 'target.md': 2 }
|
||||
};
|
||||
|
||||
const sourceContent = `First link to [[target]].
|
||||
Second link to [[target]].`;
|
||||
(vault.read as jest.Mock).mockResolvedValue(sourceContent);
|
||||
(metadata.getFirstLinkpathDest as jest.Mock).mockReturnValue(targetFile);
|
||||
|
||||
const backlinks = await LinkUtils.getBacklinks(vault, metadata, 'target.md');
|
||||
|
||||
expect(backlinks).toHaveLength(1);
|
||||
expect(backlinks[0].occurrences).toHaveLength(2);
|
||||
expect(backlinks[0].occurrences[0].line).toBe(1);
|
||||
expect(backlinks[0].occurrences[1].line).toBe(2);
|
||||
});
|
||||
|
||||
test('only includes links that resolve to target', async () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const metadata = createMockMetadataCacheAdapter();
|
||||
|
||||
const targetFile = createMockTFile('target.md');
|
||||
const sourceFile = createMockTFile('source.md');
|
||||
const otherFile = createMockTFile('other.md');
|
||||
|
||||
(vault.getAbstractFileByPath as jest.Mock).mockImplementation((path: string) => {
|
||||
if (path === 'target.md') return targetFile;
|
||||
if (path === 'source.md') return sourceFile;
|
||||
return null;
|
||||
});
|
||||
|
||||
metadata.resolvedLinks = {
|
||||
'source.md': { 'target.md': 1 }
|
||||
};
|
||||
|
||||
const sourceContent = '[[target]] and [[other]].';
|
||||
(vault.read as jest.Mock).mockResolvedValue(sourceContent);
|
||||
(metadata.getFirstLinkpathDest as jest.Mock).mockImplementation((link: string) => {
|
||||
if (link === 'target') return targetFile;
|
||||
if (link === 'other') return otherFile;
|
||||
return null;
|
||||
});
|
||||
|
||||
const backlinks = await LinkUtils.getBacklinks(vault, metadata, 'target.md');
|
||||
|
||||
expect(backlinks).toHaveLength(1);
|
||||
expect(backlinks[0].occurrences).toHaveLength(1);
|
||||
expect(backlinks[0].occurrences[0].snippet).toBe('[[target]] and [[other]].');
|
||||
});
|
||||
|
||||
test('includes unlinked mentions when includeUnlinked=true', async () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const metadata = createMockMetadataCacheAdapter();
|
||||
|
||||
const targetFile = createMockTFile('target.md');
|
||||
const mentionFile = createMockTFile('mentions.md');
|
||||
|
||||
(vault.getAbstractFileByPath as jest.Mock).mockImplementation((path: string) => {
|
||||
if (path === 'target.md') return targetFile;
|
||||
if (path === 'mentions.md') return mentionFile;
|
||||
return null;
|
||||
});
|
||||
|
||||
metadata.resolvedLinks = {};
|
||||
(vault.getMarkdownFiles as jest.Mock).mockReturnValue([targetFile, mentionFile]);
|
||||
|
||||
const mentionContent = 'This mentions target in plain text.';
|
||||
(vault.read as jest.Mock).mockResolvedValue(mentionContent);
|
||||
|
||||
const backlinks = await LinkUtils.getBacklinks(vault, metadata, 'target.md', true);
|
||||
|
||||
expect(backlinks).toHaveLength(1);
|
||||
expect(backlinks[0]).toMatchObject({
|
||||
sourcePath: 'mentions.md',
|
||||
type: 'unlinked',
|
||||
});
|
||||
expect(backlinks[0].occurrences).toHaveLength(1);
|
||||
expect(backlinks[0].occurrences[0].snippet).toBe('This mentions target in plain text.');
|
||||
});
|
||||
|
||||
test('skips files with linked backlinks when searching unlinked', async () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const metadata = createMockMetadataCacheAdapter();
|
||||
|
||||
const targetFile = createMockTFile('target.md');
|
||||
const linkedFile = createMockTFile('linked.md');
|
||||
const unlinkedFile = createMockTFile('unlinked.md');
|
||||
|
||||
(vault.getAbstractFileByPath as jest.Mock).mockImplementation((path: string) => {
|
||||
if (path === 'target.md') return targetFile;
|
||||
if (path === 'linked.md') return linkedFile;
|
||||
if (path === 'unlinked.md') return unlinkedFile;
|
||||
return null;
|
||||
});
|
||||
|
||||
metadata.resolvedLinks = {
|
||||
'linked.md': { 'target.md': 1 }
|
||||
};
|
||||
|
||||
(vault.getMarkdownFiles as jest.Mock).mockReturnValue([targetFile, linkedFile, unlinkedFile]);
|
||||
|
||||
(vault.read as jest.Mock).mockImplementation(async (file: TFile) => {
|
||||
if (file.path === 'linked.md') return '[[target]] is linked.';
|
||||
if (file.path === 'unlinked.md') return 'target is mentioned.';
|
||||
return '';
|
||||
});
|
||||
|
||||
(metadata.getFirstLinkpathDest as jest.Mock).mockReturnValue(targetFile);
|
||||
|
||||
const backlinks = await LinkUtils.getBacklinks(vault, metadata, 'target.md', true);
|
||||
|
||||
expect(backlinks).toHaveLength(2);
|
||||
const linked = backlinks.find(b => b.type === 'linked');
|
||||
const unlinked = backlinks.find(b => b.type === 'unlinked');
|
||||
|
||||
expect(linked?.sourcePath).toBe('linked.md');
|
||||
expect(unlinked?.sourcePath).toBe('unlinked.md');
|
||||
});
|
||||
|
||||
test('skips target file itself when searching unlinked', async () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const metadata = createMockMetadataCacheAdapter();
|
||||
|
||||
const targetFile = createMockTFile('target.md');
|
||||
|
||||
(vault.getAbstractFileByPath as jest.Mock).mockReturnValue(targetFile);
|
||||
metadata.resolvedLinks = {};
|
||||
(vault.getMarkdownFiles as jest.Mock).mockReturnValue([targetFile]);
|
||||
|
||||
const backlinks = await LinkUtils.getBacklinks(vault, metadata, 'target.md', true);
|
||||
|
||||
expect(backlinks).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('uses word boundary matching for unlinked mentions', async () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const metadata = createMockMetadataCacheAdapter();
|
||||
|
||||
const targetFile = createMockTFile('test.md');
|
||||
const mentionFile = createMockTFile('mentions.md');
|
||||
|
||||
(vault.getAbstractFileByPath as jest.Mock).mockImplementation((path: string) => {
|
||||
if (path === 'test.md') return targetFile;
|
||||
if (path === 'mentions.md') return mentionFile;
|
||||
return null;
|
||||
});
|
||||
|
||||
metadata.resolvedLinks = {};
|
||||
(vault.getMarkdownFiles as jest.Mock).mockReturnValue([targetFile, mentionFile]);
|
||||
|
||||
// "testing" should not match "test" with word boundary
|
||||
const mentionContent = 'This has test but not testing.';
|
||||
(vault.read as jest.Mock).mockResolvedValue(mentionContent);
|
||||
|
||||
const backlinks = await LinkUtils.getBacklinks(vault, metadata, 'test.md', true);
|
||||
|
||||
expect(backlinks).toHaveLength(1);
|
||||
expect(backlinks[0].occurrences).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('handles special regex characters in target basename', async () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const metadata = createMockMetadataCacheAdapter();
|
||||
|
||||
const targetFile = createMockTFile('test.file.md');
|
||||
const mentionFile = createMockTFile('mentions.md');
|
||||
|
||||
(vault.getAbstractFileByPath as jest.Mock).mockImplementation((path: string) => {
|
||||
if (path === 'test.file.md') return targetFile;
|
||||
if (path === 'mentions.md') return mentionFile;
|
||||
return null;
|
||||
});
|
||||
|
||||
metadata.resolvedLinks = {};
|
||||
(vault.getMarkdownFiles as jest.Mock).mockReturnValue([targetFile, mentionFile]);
|
||||
|
||||
const mentionContent = 'Mentions test.file here.';
|
||||
(vault.read as jest.Mock).mockResolvedValue(mentionContent);
|
||||
|
||||
const backlinks = await LinkUtils.getBacklinks(vault, metadata, 'test.file.md', true);
|
||||
|
||||
expect(backlinks).toHaveLength(1);
|
||||
expect(backlinks[0].occurrences).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('extracts snippets with correct line numbers', async () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const metadata = createMockMetadataCacheAdapter();
|
||||
|
||||
const targetFile = createMockTFile('target.md');
|
||||
const sourceFile = createMockTFile('source.md');
|
||||
|
||||
(vault.getAbstractFileByPath as jest.Mock).mockImplementation((path: string) => {
|
||||
if (path === 'target.md') return targetFile;
|
||||
if (path === 'source.md') return sourceFile;
|
||||
return null;
|
||||
});
|
||||
|
||||
metadata.resolvedLinks = {
|
||||
'source.md': { 'target.md': 1 }
|
||||
};
|
||||
|
||||
const sourceContent = `Line 1
|
||||
Line 2 has [[target]]
|
||||
Line 3`;
|
||||
(vault.read as jest.Mock).mockResolvedValue(sourceContent);
|
||||
(metadata.getFirstLinkpathDest as jest.Mock).mockReturnValue(targetFile);
|
||||
|
||||
const backlinks = await LinkUtils.getBacklinks(vault, metadata, 'target.md');
|
||||
|
||||
expect(backlinks[0].occurrences[0].line).toBe(2);
|
||||
expect(backlinks[0].occurrences[0].snippet).toBe('Line 2 has [[target]]');
|
||||
});
|
||||
|
||||
test('truncates long snippets', async () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const metadata = createMockMetadataCacheAdapter();
|
||||
|
||||
const targetFile = createMockTFile('target.md');
|
||||
const sourceFile = createMockTFile('source.md');
|
||||
|
||||
(vault.getAbstractFileByPath as jest.Mock).mockImplementation((path: string) => {
|
||||
if (path === 'target.md') return targetFile;
|
||||
if (path === 'source.md') return sourceFile;
|
||||
return null;
|
||||
});
|
||||
|
||||
metadata.resolvedLinks = {
|
||||
'source.md': { 'target.md': 1 }
|
||||
};
|
||||
|
||||
// Create a line longer than 100 characters
|
||||
const longLine = 'a'.repeat(150) + '[[target]]' + 'b'.repeat(150);
|
||||
(vault.read as jest.Mock).mockResolvedValue(longLine);
|
||||
(metadata.getFirstLinkpathDest as jest.Mock).mockReturnValue(targetFile);
|
||||
|
||||
const backlinks = await LinkUtils.getBacklinks(vault, metadata, 'target.md');
|
||||
|
||||
expect(backlinks[0].occurrences[0].snippet).toContain('...');
|
||||
expect(backlinks[0].occurrences[0].snippet.length).toBeLessThanOrEqual(103); // 100 + '...'
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateWikilinks()', () => {
|
||||
test('validates resolved and unresolved links', async () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const metadata = createMockMetadataCacheAdapter();
|
||||
|
||||
const sourceFile = createMockTFile('source.md');
|
||||
const targetFile = createMockTFile('target.md');
|
||||
|
||||
(vault.getAbstractFileByPath as jest.Mock).mockReturnValue(sourceFile);
|
||||
|
||||
const content = `[[target]] is valid
|
||||
[[missing]] is not valid`;
|
||||
(vault.read as jest.Mock).mockResolvedValue(content);
|
||||
|
||||
(metadata.getFirstLinkpathDest as jest.Mock).mockImplementation((link: string) => {
|
||||
if (link === 'target') return targetFile;
|
||||
return null;
|
||||
});
|
||||
|
||||
const suggestion1 = createMockTFile('maybe.md');
|
||||
(vault.getMarkdownFiles as jest.Mock).mockReturnValue([suggestion1]);
|
||||
|
||||
const result = await LinkUtils.validateWikilinks(vault, metadata, 'source.md');
|
||||
|
||||
expect(result.resolvedLinks).toHaveLength(1);
|
||||
expect(result.resolvedLinks[0]).toEqual({
|
||||
text: '[[target]]',
|
||||
target: 'target.md',
|
||||
alias: undefined
|
||||
});
|
||||
|
||||
expect(result.unresolvedLinks).toHaveLength(1);
|
||||
expect(result.unresolvedLinks[0]).toMatchObject({
|
||||
text: '[[missing]]',
|
||||
line: 2,
|
||||
});
|
||||
expect(Array.isArray(result.unresolvedLinks[0].suggestions)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns empty arrays when file not found', async () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const metadata = createMockMetadataCacheAdapter();
|
||||
|
||||
(vault.getAbstractFileByPath as jest.Mock).mockReturnValue(null);
|
||||
|
||||
const result = await LinkUtils.validateWikilinks(vault, metadata, 'nonexistent.md');
|
||||
|
||||
expect(result.resolvedLinks).toHaveLength(0);
|
||||
expect(result.unresolvedLinks).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('returns empty arrays when path is not a TFile', async () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const metadata = createMockMetadataCacheAdapter();
|
||||
|
||||
const folder = { path: 'folder', basename: 'folder' };
|
||||
(vault.getAbstractFileByPath as jest.Mock).mockReturnValue(folder);
|
||||
|
||||
const result = await LinkUtils.validateWikilinks(vault, metadata, 'folder');
|
||||
|
||||
expect(result.resolvedLinks).toHaveLength(0);
|
||||
expect(result.unresolvedLinks).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('preserves aliases in resolved links', async () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const metadata = createMockMetadataCacheAdapter();
|
||||
|
||||
const sourceFile = createMockTFile('source.md');
|
||||
const targetFile = createMockTFile('target.md');
|
||||
|
||||
(vault.getAbstractFileByPath as jest.Mock).mockReturnValue(sourceFile);
|
||||
|
||||
const content = '[[target|Custom Alias]]';
|
||||
(vault.read as jest.Mock).mockResolvedValue(content);
|
||||
(metadata.getFirstLinkpathDest as jest.Mock).mockReturnValue(targetFile);
|
||||
|
||||
const result = await LinkUtils.validateWikilinks(vault, metadata, 'source.md');
|
||||
|
||||
expect(result.resolvedLinks[0]).toEqual({
|
||||
text: '[[target|Custom Alias]]',
|
||||
target: 'target.md',
|
||||
alias: 'Custom Alias'
|
||||
});
|
||||
});
|
||||
|
||||
test('provides suggestions for unresolved links', async () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const metadata = createMockMetadataCacheAdapter();
|
||||
|
||||
const sourceFile = createMockTFile('source.md');
|
||||
const suggestionFile = createMockTFile('similar.md');
|
||||
|
||||
(vault.getAbstractFileByPath as jest.Mock).mockReturnValue(sourceFile);
|
||||
|
||||
const content = '[[simila]]'; // Typo
|
||||
(vault.read as jest.Mock).mockResolvedValue(content);
|
||||
(metadata.getFirstLinkpathDest as jest.Mock).mockReturnValue(null);
|
||||
(vault.getMarkdownFiles as jest.Mock).mockReturnValue([suggestionFile]);
|
||||
|
||||
const result = await LinkUtils.validateWikilinks(vault, metadata, 'source.md');
|
||||
|
||||
expect(result.unresolvedLinks[0].suggestions).toContain('similar.md');
|
||||
});
|
||||
|
||||
test('handles files with no wikilinks', async () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const metadata = createMockMetadataCacheAdapter();
|
||||
|
||||
const sourceFile = createMockTFile('source.md');
|
||||
|
||||
(vault.getAbstractFileByPath as jest.Mock).mockReturnValue(sourceFile);
|
||||
|
||||
const content = 'No links here.';
|
||||
(vault.read as jest.Mock).mockResolvedValue(content);
|
||||
|
||||
const result = await LinkUtils.validateWikilinks(vault, metadata, 'source.md');
|
||||
|
||||
expect(result.resolvedLinks).toHaveLength(0);
|
||||
expect(result.unresolvedLinks).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('validates multiple links correctly', async () => {
|
||||
const vault = createMockVaultAdapter();
|
||||
const metadata = createMockMetadataCacheAdapter();
|
||||
|
||||
const sourceFile = createMockTFile('source.md');
|
||||
const file1 = createMockTFile('file1.md');
|
||||
const file2 = createMockTFile('file2.md');
|
||||
|
||||
(vault.getAbstractFileByPath as jest.Mock).mockReturnValue(sourceFile);
|
||||
|
||||
const content = '[[file1]] [[file2]] [[missing1]] [[missing2]]';
|
||||
(vault.read as jest.Mock).mockResolvedValue(content);
|
||||
|
||||
(metadata.getFirstLinkpathDest as jest.Mock).mockImplementation((link: string) => {
|
||||
if (link === 'file1') return file1;
|
||||
if (link === 'file2') return file2;
|
||||
return null;
|
||||
});
|
||||
|
||||
(vault.getMarkdownFiles as jest.Mock).mockReturnValue([file1, file2]);
|
||||
|
||||
const result = await LinkUtils.validateWikilinks(vault, metadata, 'source.md');
|
||||
|
||||
expect(result.resolvedLinks).toHaveLength(2);
|
||||
expect(result.unresolvedLinks).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user