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:
Thomas Richter
2026-02-03 23:33:36 +01:00
parent 3664afb028
commit 20d9ebf2ff
2 changed files with 358 additions and 0 deletions

View 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('&lt;');
expect(result).toContain('&gt;');
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 '&amp; B'
const result = highlightText('A & B', 'AB');
expect(result).toContain('&amp;');
// No match expected since 'AB' is not in 'A & B'
expect(result).toBe('A &amp; B');
});
it('escapes quotes in original text', () => {
const result = highlightText('Say "hello"', 'hello');
expect(result).toContain('&quot;');
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('&#039;');
});
});
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');
});
});
});

View 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('&lt;script&gt;');
expect(result).toContain('<span class="text-blue-600 font-medium">#tag</span>');
});
it('escapes ampersands', () => {
const result = highlightHashtags('A & B #tag');
expect(result).toContain('&amp;');
});
});
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');
});
});
});