test(09-02): add unit tests for highlightText and parseHashtags utilities
- highlightText: 24 tests covering highlighting, case sensitivity, HTML escaping - parseHashtags: 29 tests for extraction, 6 tests for highlightHashtags - Tests verify XSS prevention, regex escaping, edge cases
This commit is contained in:
149
src/lib/utils/highlightText.test.ts
Normal file
149
src/lib/utils/highlightText.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { highlightText } from './highlightText';
|
||||
|
||||
describe('highlightText', () => {
|
||||
describe('basic behavior', () => {
|
||||
it('returns original text when no search term', () => {
|
||||
expect(highlightText('Hello world', '')).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('returns original text when search term is too short (< 2 chars)', () => {
|
||||
expect(highlightText('Hello world', 'H')).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(highlightText('', 'search')).toBe('');
|
||||
});
|
||||
|
||||
it('returns escaped empty string for empty input with empty query', () => {
|
||||
expect(highlightText('', '')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('highlighting matches', () => {
|
||||
it('highlights single match with mark tag', () => {
|
||||
const result = highlightText('Hello world', 'world');
|
||||
expect(result).toBe('Hello <mark class="font-bold bg-transparent">world</mark>');
|
||||
});
|
||||
|
||||
it('highlights multiple matches', () => {
|
||||
const result = highlightText('test one test two test', 'test');
|
||||
expect(result).toBe(
|
||||
'<mark class="font-bold bg-transparent">test</mark> one <mark class="font-bold bg-transparent">test</mark> two <mark class="font-bold bg-transparent">test</mark>'
|
||||
);
|
||||
});
|
||||
|
||||
it('highlights match at beginning', () => {
|
||||
const result = highlightText('start of text', 'start');
|
||||
expect(result).toBe('<mark class="font-bold bg-transparent">start</mark> of text');
|
||||
});
|
||||
|
||||
it('highlights match at end', () => {
|
||||
const result = highlightText('text at end', 'end');
|
||||
expect(result).toBe('text at <mark class="font-bold bg-transparent">end</mark>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('case sensitivity', () => {
|
||||
it('matches case-insensitively', () => {
|
||||
const result = highlightText('Hello World', 'hello');
|
||||
expect(result).toBe('<mark class="font-bold bg-transparent">Hello</mark> World');
|
||||
});
|
||||
|
||||
it('preserves original case in highlighted text', () => {
|
||||
const result = highlightText('HELLO hello Hello', 'hello');
|
||||
expect(result).toBe(
|
||||
'<mark class="font-bold bg-transparent">HELLO</mark> <mark class="font-bold bg-transparent">hello</mark> <mark class="font-bold bg-transparent">Hello</mark>'
|
||||
);
|
||||
});
|
||||
|
||||
it('matches uppercase query against lowercase text', () => {
|
||||
const result = highlightText('lowercase text', 'LOWER');
|
||||
expect(result).toBe('<mark class="font-bold bg-transparent">lower</mark>case text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('special characters', () => {
|
||||
it('handles special regex characters in search term', () => {
|
||||
const result = highlightText('test (parentheses) here', '(parentheses)');
|
||||
expect(result).toBe(
|
||||
'test <mark class="font-bold bg-transparent">(parentheses)</mark> here'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles dots in search term', () => {
|
||||
const result = highlightText('file.txt and file.js', 'file.');
|
||||
expect(result).toBe(
|
||||
'<mark class="font-bold bg-transparent">file.</mark>txt and <mark class="font-bold bg-transparent">file.</mark>js'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles asterisks in search term', () => {
|
||||
const result = highlightText('a * b * c', '* b');
|
||||
expect(result).toBe('a <mark class="font-bold bg-transparent">* b</mark> * c');
|
||||
});
|
||||
|
||||
it('handles brackets in search term', () => {
|
||||
const result = highlightText('array[0] = value', '[0]');
|
||||
expect(result).toBe('array<mark class="font-bold bg-transparent">[0]</mark> = value');
|
||||
});
|
||||
|
||||
it('handles backslashes in search term', () => {
|
||||
const result = highlightText('path\\to\\file', '\\to');
|
||||
expect(result).toBe('path<mark class="font-bold bg-transparent">\\to</mark>\\file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTML escaping (XSS prevention)', () => {
|
||||
it('escapes HTML tags in original text', () => {
|
||||
const result = highlightText('<script>alert("xss")</script>', 'script');
|
||||
expect(result).toContain('<');
|
||||
expect(result).toContain('>');
|
||||
expect(result).not.toContain('<script>');
|
||||
});
|
||||
|
||||
it('escapes ampersands in original text', () => {
|
||||
// Note: The function escapes HTML first, then searches.
|
||||
// So searching for '& B' won't match because text becomes '& B'
|
||||
const result = highlightText('A & B', 'AB');
|
||||
expect(result).toContain('&');
|
||||
// No match expected since 'AB' is not in 'A & B'
|
||||
expect(result).toBe('A & B');
|
||||
});
|
||||
|
||||
it('escapes quotes in original text', () => {
|
||||
const result = highlightText('Say "hello"', 'hello');
|
||||
expect(result).toContain('"');
|
||||
expect(result).toContain('<mark class="font-bold bg-transparent">hello</mark>');
|
||||
});
|
||||
|
||||
it('escapes single quotes in original text', () => {
|
||||
const result = highlightText("It's a test", 'test');
|
||||
expect(result).toContain(''');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles text with only whitespace', () => {
|
||||
const result = highlightText(' ', 'test');
|
||||
expect(result).toBe(' ');
|
||||
});
|
||||
|
||||
it('handles query with only whitespace (2+ chars)', () => {
|
||||
// 'hello world' has only one space, so searching for two spaces finds no match
|
||||
const result = highlightText('hello world', ' ');
|
||||
// Two spaces should be a valid query
|
||||
expect(result).toBe('hello<mark class="font-bold bg-transparent"> </mark>world');
|
||||
});
|
||||
|
||||
it('handles unicode characters', () => {
|
||||
const result = highlightText('Caf\u00e9 and \u00fcber', 'caf\u00e9');
|
||||
expect(result).toBe('<mark class="font-bold bg-transparent">Caf\u00e9</mark> and \u00fcber');
|
||||
});
|
||||
|
||||
it('returns no match when query not found', () => {
|
||||
const result = highlightText('Hello world', 'xyz');
|
||||
expect(result).toBe('Hello world');
|
||||
});
|
||||
});
|
||||
});
|
||||
209
src/lib/utils/parseHashtags.test.ts
Normal file
209
src/lib/utils/parseHashtags.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseHashtags, highlightHashtags } from './parseHashtags';
|
||||
|
||||
describe('parseHashtags', () => {
|
||||
describe('basic extraction', () => {
|
||||
it('extracts single hashtag from text', () => {
|
||||
const result = parseHashtags('Check out #svelte');
|
||||
expect(result).toEqual(['svelte']);
|
||||
});
|
||||
|
||||
it('extracts multiple hashtags', () => {
|
||||
const result = parseHashtags('Learning #typescript and #svelte today');
|
||||
expect(result).toEqual(['typescript', 'svelte']);
|
||||
});
|
||||
|
||||
it('returns empty array when no hashtags', () => {
|
||||
const result = parseHashtags('Just regular text here');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for empty string', () => {
|
||||
const result = parseHashtags('');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashtag positions', () => {
|
||||
it('handles hashtag at start of text', () => {
|
||||
const result = parseHashtags('#first is the word');
|
||||
expect(result).toEqual(['first']);
|
||||
});
|
||||
|
||||
it('handles hashtag in middle of text', () => {
|
||||
const result = parseHashtags('The #middle tag here');
|
||||
expect(result).toEqual(['middle']);
|
||||
});
|
||||
|
||||
it('handles hashtag at end of text', () => {
|
||||
const result = parseHashtags('Text ends with #last');
|
||||
expect(result).toEqual(['last']);
|
||||
});
|
||||
|
||||
it('handles multiple hashtags at different positions', () => {
|
||||
const result = parseHashtags('#start middle #center end #finish');
|
||||
expect(result).toEqual(['start', 'center', 'finish']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid hashtag patterns', () => {
|
||||
it('ignores standalone hash symbol', () => {
|
||||
const result = parseHashtags('Just a # by itself');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores hashtags starting with number', () => {
|
||||
const result = parseHashtags('Not valid #123tag');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores pure numeric hashtags', () => {
|
||||
const result = parseHashtags('Number #2024');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores hashtag with only underscores', () => {
|
||||
// Underscores alone are not valid - must start with letter
|
||||
const result = parseHashtags('Test #___');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('valid hashtag patterns', () => {
|
||||
it('accepts hashtags with underscores', () => {
|
||||
const result = parseHashtags('Check #my_tag here');
|
||||
expect(result).toEqual(['my_tag']);
|
||||
});
|
||||
|
||||
it('accepts hashtags with numbers after letters', () => {
|
||||
const result = parseHashtags('Version #v2 released');
|
||||
expect(result).toEqual(['v2']);
|
||||
});
|
||||
|
||||
it('accepts hashtags with mixed case', () => {
|
||||
const result = parseHashtags('Using #SvelteKit framework');
|
||||
// parseHashtags lowercases tags
|
||||
expect(result).toEqual(['sveltekit']);
|
||||
});
|
||||
|
||||
it('accepts single letter hashtags', () => {
|
||||
const result = parseHashtags('Point #a to #b');
|
||||
expect(result).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('duplicate handling', () => {
|
||||
it('removes duplicate hashtags', () => {
|
||||
const result = parseHashtags('#test foo #test bar');
|
||||
expect(result).toEqual(['test']);
|
||||
});
|
||||
|
||||
it('removes case-insensitive duplicates', () => {
|
||||
const result = parseHashtags('#Test and #test and #TEST');
|
||||
expect(result).toEqual(['test']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('word boundaries and punctuation', () => {
|
||||
it('extracts hashtag followed by comma', () => {
|
||||
const result = parseHashtags('Tags: #first, #second');
|
||||
expect(result).toEqual(['first', 'second']);
|
||||
});
|
||||
|
||||
it('extracts hashtag followed by period', () => {
|
||||
const result = parseHashtags('End of sentence #tag.');
|
||||
expect(result).toEqual(['tag']);
|
||||
});
|
||||
|
||||
it('extracts hashtag followed by exclamation', () => {
|
||||
const result = parseHashtags('Exciting #news!');
|
||||
expect(result).toEqual(['news']);
|
||||
});
|
||||
|
||||
it('extracts hashtag followed by question mark', () => {
|
||||
const result = parseHashtags('Is this #relevant?');
|
||||
expect(result).toEqual(['relevant']);
|
||||
});
|
||||
|
||||
it('extracts hashtag in parentheses', () => {
|
||||
const result = parseHashtags('Check (#important) item');
|
||||
expect(result).toEqual(['important']);
|
||||
});
|
||||
|
||||
it('extracts hashtag followed by newline', () => {
|
||||
const result = parseHashtags('Line one #tag\nLine two');
|
||||
expect(result).toEqual(['tag']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles consecutive hashtags', () => {
|
||||
const result = parseHashtags('#one #two #three');
|
||||
expect(result).toEqual(['one', 'two', 'three']);
|
||||
});
|
||||
|
||||
it('handles hashtag at very end (no trailing space)', () => {
|
||||
const result = parseHashtags('End #final');
|
||||
expect(result).toEqual(['final']);
|
||||
});
|
||||
|
||||
it('handles text with only a hashtag', () => {
|
||||
const result = parseHashtags('#solo');
|
||||
expect(result).toEqual(['solo']);
|
||||
});
|
||||
|
||||
it('handles unicode adjacent to hashtag', () => {
|
||||
const result = parseHashtags('Caf\u00e9 #coffee');
|
||||
expect(result).toEqual(['coffee']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('highlightHashtags', () => {
|
||||
describe('basic highlighting', () => {
|
||||
it('wraps hashtag in styled span', () => {
|
||||
const result = highlightHashtags('Check #svelte out');
|
||||
expect(result).toBe(
|
||||
'Check <span class="text-blue-600 font-medium">#svelte</span> out'
|
||||
);
|
||||
});
|
||||
|
||||
it('highlights multiple hashtags', () => {
|
||||
const result = highlightHashtags('#one and #two');
|
||||
expect(result).toContain('<span class="text-blue-600 font-medium">#one</span>');
|
||||
expect(result).toContain('<span class="text-blue-600 font-medium">#two</span>');
|
||||
});
|
||||
|
||||
it('returns original text when no hashtags', () => {
|
||||
const result = highlightHashtags('No tags here');
|
||||
expect(result).toBe('No tags here');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTML escaping', () => {
|
||||
it('escapes HTML in text while highlighting', () => {
|
||||
const result = highlightHashtags('<script> #tag');
|
||||
expect(result).toContain('<script>');
|
||||
expect(result).toContain('<span class="text-blue-600 font-medium">#tag</span>');
|
||||
});
|
||||
|
||||
it('escapes ampersands', () => {
|
||||
const result = highlightHashtags('A & B #tag');
|
||||
expect(result).toContain('&');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles hashtag at end of text', () => {
|
||||
const result = highlightHashtags('Check this #tag');
|
||||
expect(result).toBe(
|
||||
'Check this <span class="text-blue-600 font-medium">#tag</span>'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not highlight invalid hashtags', () => {
|
||||
const result = highlightHashtags('Invalid #123');
|
||||
expect(result).toBe('Invalid #123');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user