diff --git a/tests/link-utils.test.ts b/tests/link-utils.test.ts new file mode 100644 index 0000000..3e715cf --- /dev/null +++ b/tests/link-utils.test.ts @@ -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); + }); + }); +});