mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2026-02-06 09:55:47 +00:00
500 lines
18 KiB
TypeScript
500 lines
18 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
|
|
import {
|
|
preprocessMarkdown,
|
|
preprocessVideoLinks,
|
|
preprocessHashtags,
|
|
preprocessItemMentions,
|
|
removeMarkdownSyntax,
|
|
truncateMarkdown,
|
|
} from './preprocessMarkdown'
|
|
|
|
// ============================================================================
|
|
// convertNakedUrls (tested via preprocessMarkdown)
|
|
// ============================================================================
|
|
describe('convertNakedUrls (via preprocessMarkdown)', () => {
|
|
describe('Happy Path', () => {
|
|
it('converts a naked URL to markdown link', () => {
|
|
const result = preprocessMarkdown('Check https://example.com out')
|
|
expect(result).toContain('[example.com](https://example.com)')
|
|
})
|
|
|
|
it('removes www from display text', () => {
|
|
const result = preprocessMarkdown('Visit https://www.example.com')
|
|
expect(result).toContain('[example.com](https://www.example.com)')
|
|
})
|
|
|
|
it('converts multiple naked URLs', () => {
|
|
const result = preprocessMarkdown('See https://a.com and https://b.com')
|
|
expect(result).toContain('[a.com](https://a.com)')
|
|
expect(result).toContain('[b.com](https://b.com)')
|
|
})
|
|
|
|
it('preserves query parameters in URL', () => {
|
|
const result = preprocessMarkdown('Link https://example.com?a=1&b=2 here')
|
|
expect(result).toContain('](https://example.com?a=1&b=2)')
|
|
})
|
|
|
|
it('converts http URLs', () => {
|
|
const result = preprocessMarkdown('Old http://example.com link')
|
|
expect(result).toContain('[example.com](http://example.com)')
|
|
})
|
|
})
|
|
|
|
describe('Skip - Already Linked', () => {
|
|
it('does not convert URL already in markdown link', () => {
|
|
const input = '[my link](https://example.com)'
|
|
const result = preprocessMarkdown(input)
|
|
// Should not double-wrap
|
|
expect(result).toBe(input)
|
|
})
|
|
|
|
it('does not convert URL in autolink syntax', () => {
|
|
const input = '<https://example.com>'
|
|
const result = preprocessMarkdown(input)
|
|
// Autolinks are preserved (may be converted to video embeds if matching)
|
|
expect(result).not.toContain('](https://example.com)')
|
|
})
|
|
})
|
|
|
|
describe('Edge Cases', () => {
|
|
it('handles URL at end of sentence with period', () => {
|
|
const result = preprocessMarkdown('Visit https://example.com.')
|
|
// Note: Current implementation includes trailing period in URL
|
|
// This documents actual behavior - may be improved later
|
|
expect(result).toContain('[example.com.](https://example.com.)')
|
|
})
|
|
|
|
it('handles URL in parentheses', () => {
|
|
const result = preprocessMarkdown('(https://example.com)')
|
|
expect(result).toContain('[example.com](https://example.com)')
|
|
})
|
|
|
|
it('handles URL at line start', () => {
|
|
const result = preprocessMarkdown('https://example.com is great')
|
|
expect(result).toContain('[example.com](https://example.com)')
|
|
})
|
|
|
|
it('handles URL with path', () => {
|
|
const result = preprocessMarkdown('See https://example.com/path/to/page')
|
|
expect(result).toContain('[example.com/path/to/page](https://example.com/path/to/page)')
|
|
})
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// preprocessVideoLinks
|
|
// ============================================================================
|
|
describe('preprocessVideoLinks', () => {
|
|
describe('YouTube - Happy Path', () => {
|
|
it('converts YouTube autolink (standard)', () => {
|
|
const result = preprocessVideoLinks('<https://www.youtube.com/watch?v=abc123def45>')
|
|
expect(result).toBe('<video-embed provider="youtube" video-id="abc123def45"></video-embed>')
|
|
})
|
|
|
|
it('converts YouTube autolink (short URL)', () => {
|
|
const result = preprocessVideoLinks('<https://youtu.be/abc123def45>')
|
|
expect(result).toBe('<video-embed provider="youtube" video-id="abc123def45"></video-embed>')
|
|
})
|
|
|
|
it('converts YouTube markdown link (standard)', () => {
|
|
const result = preprocessVideoLinks('[Video](https://youtube.com/watch?v=abc123)')
|
|
expect(result).toBe('<video-embed provider="youtube" video-id="abc123"></video-embed>')
|
|
})
|
|
|
|
it('converts YouTube markdown link (short URL)', () => {
|
|
const result = preprocessVideoLinks('[Watch](https://youtu.be/xyz789)')
|
|
expect(result).toBe('<video-embed provider="youtube" video-id="xyz789"></video-embed>')
|
|
})
|
|
|
|
it('handles YouTube without www', () => {
|
|
const result = preprocessVideoLinks('<https://youtube.com/watch?v=test123>')
|
|
expect(result).toBe('<video-embed provider="youtube" video-id="test123"></video-embed>')
|
|
})
|
|
})
|
|
|
|
describe('YouTube - Edge Cases', () => {
|
|
it('extracts only video-id, ignores extra params', () => {
|
|
const result = preprocessVideoLinks('<https://youtube.com/watch?v=abc123&t=120&list=xyz>')
|
|
expect(result).toBe('<video-embed provider="youtube" video-id="abc123"></video-embed>')
|
|
})
|
|
|
|
it('handles http (non-https) YouTube links', () => {
|
|
const result = preprocessVideoLinks('<http://youtube.com/watch?v=test>')
|
|
expect(result).toBe('<video-embed provider="youtube" video-id="test"></video-embed>')
|
|
})
|
|
|
|
it('handles short URL with query params', () => {
|
|
const result = preprocessVideoLinks('<https://youtu.be/abc?t=30>')
|
|
expect(result).toBe('<video-embed provider="youtube" video-id="abc"></video-embed>')
|
|
})
|
|
})
|
|
|
|
describe('Rumble - Happy Path', () => {
|
|
it('converts Rumble autolink', () => {
|
|
const result = preprocessVideoLinks('<https://rumble.com/embed/v1abc>')
|
|
expect(result).toBe('<video-embed provider="rumble" video-id="v1abc"></video-embed>')
|
|
})
|
|
|
|
it('converts Rumble markdown link', () => {
|
|
const result = preprocessVideoLinks('[Rumble Video](https://rumble.com/embed/xyz123)')
|
|
expect(result).toBe('<video-embed provider="rumble" video-id="xyz123"></video-embed>')
|
|
})
|
|
})
|
|
|
|
describe('Non-Video Links', () => {
|
|
it('does not convert non-video autolinks', () => {
|
|
const input = '<https://example.com>'
|
|
expect(preprocessVideoLinks(input)).toBe(input)
|
|
})
|
|
|
|
it('does not convert non-video markdown links', () => {
|
|
const input = '[Example](https://example.com)'
|
|
expect(preprocessVideoLinks(input)).toBe(input)
|
|
})
|
|
})
|
|
|
|
describe('Mixed Content', () => {
|
|
it('converts video in mixed content', () => {
|
|
const result = preprocessVideoLinks('Check this: <https://youtu.be/abc> and more text')
|
|
expect(result).toContain('<video-embed provider="youtube" video-id="abc"></video-embed>')
|
|
expect(result).toContain('Check this:')
|
|
expect(result).toContain('and more text')
|
|
})
|
|
|
|
it('converts multiple videos', () => {
|
|
const result = preprocessVideoLinks('<https://youtu.be/a> and <https://rumble.com/embed/b>')
|
|
expect(result).toContain('video-id="a"')
|
|
expect(result).toContain('video-id="b"')
|
|
})
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// preprocessHashtags
|
|
// ============================================================================
|
|
describe('preprocessHashtags', () => {
|
|
describe('Happy Path', () => {
|
|
it('converts simple hashtag', () => {
|
|
const result = preprocessHashtags('Hello #world')
|
|
expect(result).toBe('Hello <span data-hashtag data-label="world">#world</span>')
|
|
})
|
|
|
|
it('converts multiple hashtags', () => {
|
|
const result = preprocessHashtags('#one #two #three')
|
|
expect(result).toContain('data-label="one"')
|
|
expect(result).toContain('data-label="two"')
|
|
expect(result).toContain('data-label="three"')
|
|
})
|
|
|
|
it('converts hashtag with numbers', () => {
|
|
const result = preprocessHashtags('#test123')
|
|
expect(result).toContain('data-label="test123"')
|
|
})
|
|
|
|
it('converts hashtag with underscore', () => {
|
|
const result = preprocessHashtags('#my_tag')
|
|
expect(result).toContain('data-label="my_tag"')
|
|
})
|
|
|
|
it('converts hashtag with hyphen', () => {
|
|
const result = preprocessHashtags('#my-tag')
|
|
expect(result).toContain('data-label="my-tag"')
|
|
})
|
|
|
|
it('converts hashtag with German umlauts', () => {
|
|
const result = preprocessHashtags('#München')
|
|
expect(result).toContain('data-label="München"')
|
|
})
|
|
|
|
it('converts hashtag with French accents', () => {
|
|
const result = preprocessHashtags('#café')
|
|
expect(result).toContain('data-label="café"')
|
|
})
|
|
})
|
|
|
|
describe('Skip - Inside Links', () => {
|
|
it('does not convert hashtag in link text', () => {
|
|
const input = '[#tag](#anchor)'
|
|
const result = preprocessHashtags(input)
|
|
expect(result).not.toContain('data-hashtag')
|
|
})
|
|
|
|
it('does not convert hashtag in link URL', () => {
|
|
const input = '[section](#section-heading)'
|
|
const result = preprocessHashtags(input)
|
|
expect(result).not.toContain('data-hashtag')
|
|
})
|
|
})
|
|
|
|
describe('Edge Cases', () => {
|
|
it('handles concurrent hashtags without space', () => {
|
|
const result = preprocessHashtags('#tag1#tag2')
|
|
// First should convert, second might not (depends on lookbehind)
|
|
expect(result).toContain('data-label="tag1"')
|
|
})
|
|
|
|
it('does not convert lone # symbol', () => {
|
|
const result = preprocessHashtags('Just #')
|
|
expect(result).not.toContain('data-hashtag')
|
|
expect(result).toBe('Just #')
|
|
})
|
|
|
|
it('handles hashtag at start of text', () => {
|
|
const result = preprocessHashtags('#first thing')
|
|
expect(result).toContain('data-label="first"')
|
|
})
|
|
|
|
it('handles hashtag at end of text', () => {
|
|
const result = preprocessHashtags('last is #final')
|
|
expect(result).toContain('data-label="final"')
|
|
})
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// preprocessItemMentions
|
|
// ============================================================================
|
|
describe('preprocessItemMentions', () => {
|
|
describe('Happy Path', () => {
|
|
it('converts standard mention format', () => {
|
|
const result = preprocessItemMentions('Hello [@Alice](/item/abc-123)')
|
|
expect(result).toBe(
|
|
'Hello <span data-item-mention data-label="Alice" data-id="abc-123">@Alice</span>',
|
|
)
|
|
})
|
|
|
|
it('converts mention with layer (legacy format)', () => {
|
|
const result = preprocessItemMentions('[@Bob](/item/people/def-456)')
|
|
expect(result).toContain('data-id="def-456"')
|
|
expect(result).toContain('data-label="Bob"')
|
|
})
|
|
|
|
it('converts relative path format with leading slash', () => {
|
|
// Note: Relative paths without leading slash are not supported by regex
|
|
// The regex requires /item/ or item/ with UUID pattern (hex + dashes only)
|
|
const result = preprocessItemMentions('[@Name](/item/abc-def-123)')
|
|
expect(result).toContain('data-id="abc-def-123"')
|
|
})
|
|
|
|
it('converts multiple mentions', () => {
|
|
const result = preprocessItemMentions('[@A](/item/1) and [@B](/item/2)')
|
|
expect(result).toContain('data-id="1"')
|
|
expect(result).toContain('data-id="2"')
|
|
})
|
|
|
|
it('handles UUID with uppercase letters', () => {
|
|
const result = preprocessItemMentions('[@Name](/item/ABC-DEF-123)')
|
|
expect(result).toContain('data-id="ABC-DEF-123"')
|
|
})
|
|
|
|
it('handles label with spaces', () => {
|
|
// Note: UUID must be hex chars + dashes only (no letters like 'uuid')
|
|
const result = preprocessItemMentions('[@Max Müller](/item/abc-def-123)')
|
|
expect(result).toContain('data-label="Max Müller"')
|
|
})
|
|
})
|
|
|
|
describe('Skip - Non-Matching', () => {
|
|
it('does not convert non-item links', () => {
|
|
const input = '[@Name](/other/path)'
|
|
const result = preprocessItemMentions(input)
|
|
expect(result).toBe(input)
|
|
})
|
|
|
|
it('does not convert regular links (no @)', () => {
|
|
const input = '[Name](/item/123)'
|
|
const result = preprocessItemMentions(input)
|
|
expect(result).toBe(input)
|
|
})
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// removeMarkdownSyntax
|
|
// ============================================================================
|
|
describe('removeMarkdownSyntax', () => {
|
|
describe('Happy Path', () => {
|
|
it('removes bold syntax', () => {
|
|
expect(removeMarkdownSyntax('**bold**')).toBe('bold')
|
|
})
|
|
|
|
it('removes italic syntax (asterisk)', () => {
|
|
expect(removeMarkdownSyntax('*italic*')).toBe('italic')
|
|
})
|
|
|
|
it('removes italic syntax (underscore)', () => {
|
|
expect(removeMarkdownSyntax('_italic_')).toBe('italic')
|
|
})
|
|
|
|
it('removes headers', () => {
|
|
expect(removeMarkdownSyntax('# Heading')).toBe('Heading')
|
|
expect(removeMarkdownSyntax('## Subheading')).toBe('Subheading')
|
|
})
|
|
|
|
it('removes links, keeps text', () => {
|
|
expect(removeMarkdownSyntax('[text](https://example.com)')).toBe('text')
|
|
})
|
|
|
|
it('removes images completely', () => {
|
|
expect(removeMarkdownSyntax('')).toBe('')
|
|
})
|
|
|
|
it('removes inline code', () => {
|
|
expect(removeMarkdownSyntax('`code`')).toBe('code')
|
|
})
|
|
|
|
it('removes blockquotes', () => {
|
|
expect(removeMarkdownSyntax('> quote')).toBe('quote')
|
|
})
|
|
})
|
|
|
|
describe('Preserve Special Elements', () => {
|
|
it('preserves @mentions', () => {
|
|
const result = removeMarkdownSyntax('Hello [@Alice](/item/123)')
|
|
expect(result).toContain('[@Alice](/item/123)')
|
|
})
|
|
|
|
it('preserves hashtags', () => {
|
|
const result = removeMarkdownSyntax('Hello #world')
|
|
expect(result).toContain('#world')
|
|
})
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// truncateMarkdown
|
|
// ============================================================================
|
|
describe('truncateMarkdown', () => {
|
|
describe('Happy Path', () => {
|
|
it('returns unchanged if under limit', () => {
|
|
expect(truncateMarkdown('Short text', 100)).toBe('Short text')
|
|
})
|
|
|
|
it('truncates and adds ellipsis if over limit', () => {
|
|
const text = 'A'.repeat(150)
|
|
const result = truncateMarkdown(text, 100)
|
|
expect(result).toHaveLength(103) // 100 chars + '...'
|
|
expect(result.endsWith('...')).toBe(true)
|
|
})
|
|
|
|
it('respects exact limit', () => {
|
|
const result = truncateMarkdown('Hello World', 5)
|
|
expect(result).toBe('Hello...')
|
|
})
|
|
})
|
|
|
|
describe('Atomic Token Preservation', () => {
|
|
it('preserves complete hashtag', () => {
|
|
const result = truncateMarkdown('A'.repeat(95) + ' #tag', 100)
|
|
// Should either include complete #tag or cut before it
|
|
if (result.includes('#')) {
|
|
expect(result).toContain('#tag')
|
|
}
|
|
})
|
|
|
|
it('preserves complete mention', () => {
|
|
const result = truncateMarkdown('A'.repeat(90) + ' [@Alice](/item/123)', 100)
|
|
// Should either include complete mention or cut before it
|
|
if (result.includes('@')) {
|
|
expect(result).toContain('[@Alice](/item/123)')
|
|
}
|
|
})
|
|
|
|
it('preserves complete link', () => {
|
|
const result = truncateMarkdown('See [link](url) more text here', 8)
|
|
// Visible text "See link" is 8 chars
|
|
expect(result).toContain('[link](url)')
|
|
})
|
|
})
|
|
|
|
describe('Edge Cases', () => {
|
|
it('does not count newlines toward limit', () => {
|
|
const result = truncateMarkdown('Line1\n\nLine2', 10)
|
|
expect(result).toContain('Line1')
|
|
expect(result).toContain('Line2')
|
|
})
|
|
|
|
it('handles empty text', () => {
|
|
expect(truncateMarkdown('', 100)).toBe('')
|
|
})
|
|
|
|
it('handles limit of 0', () => {
|
|
expect(truncateMarkdown('Text', 0)).toBe('...')
|
|
})
|
|
|
|
it('handles negative limit gracefully', () => {
|
|
// Should not throw
|
|
expect(() => truncateMarkdown('Text', -1)).not.toThrow()
|
|
})
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// preprocessMarkdown (Full Pipeline)
|
|
// ============================================================================
|
|
describe('preprocessMarkdown', () => {
|
|
describe('Happy Path', () => {
|
|
it('processes complete content with all features', () => {
|
|
const input = 'Check https://example.com #tag [@Alice](/item/123)'
|
|
const result = preprocessMarkdown(input)
|
|
|
|
// URL converted
|
|
expect(result).toContain('[example.com](https://example.com)')
|
|
// Hashtag converted
|
|
expect(result).toContain('data-hashtag')
|
|
// Mention converted
|
|
expect(result).toContain('data-item-mention')
|
|
})
|
|
|
|
it('processes video links', () => {
|
|
const result = preprocessMarkdown('<https://youtu.be/abc>')
|
|
expect(result).toContain('video-embed')
|
|
})
|
|
|
|
it('converts email addresses to mailto links', () => {
|
|
const result = preprocessMarkdown('Contact test@example.com')
|
|
expect(result).toContain('[test@example.com](mailto:test@example.com)')
|
|
})
|
|
})
|
|
|
|
describe('Edge Cases', () => {
|
|
it('handles empty string', () => {
|
|
expect(preprocessMarkdown('')).toBe('')
|
|
})
|
|
|
|
it('handles null input', () => {
|
|
// @ts-expect-error testing null input
|
|
expect(preprocessMarkdown(null)).toBe('')
|
|
})
|
|
|
|
it('handles undefined input', () => {
|
|
// @ts-expect-error testing undefined input
|
|
expect(preprocessMarkdown(undefined)).toBe('')
|
|
})
|
|
|
|
it('preserves whitespace', () => {
|
|
expect(preprocessMarkdown(' ')).toBe(' ')
|
|
})
|
|
|
|
it('handles very long text without timeout', () => {
|
|
const longText = 'A'.repeat(10000)
|
|
const start = performance.now()
|
|
preprocessMarkdown(longText)
|
|
const elapsed = performance.now() - start
|
|
expect(elapsed).toBeLessThan(1000) // Should complete in <1s
|
|
})
|
|
})
|
|
|
|
describe('Error Handling', () => {
|
|
it('handles malformed markdown without throwing', () => {
|
|
expect(() => preprocessMarkdown('[unclosed link')).not.toThrow()
|
|
expect(() => preprocessMarkdown('**unclosed bold')).not.toThrow()
|
|
})
|
|
|
|
it('handles malformed URLs without throwing', () => {
|
|
expect(() => preprocessMarkdown('http:/broken')).not.toThrow()
|
|
})
|
|
})
|
|
})
|