1import { describe, it, expect, beforeEach } from 'vitest';
2import {
3 MENTION_USERNAME_MAX_LENGTH,
4 USERNAME_PATTERN,
5 MAX_MENTION_SUGGESTIONS,
6 MENTION_PICKER,
7 MENTION_REGEX_PLAIN,
8 MENTION_REGEX_HTML,
9 isWordStart,
10 findMentionContext,
11 highlightMentionsPlain,
12 highlightMentionsHtml,
13 highlightMentionsInput,
14 containsMention,
15 calculatePickerHeight,
16 calculatePickerWidth
17} from './mention';
18
19describe('mention constants', () => {
20 it('has correct username max length', () => {
21 expect(MENTION_USERNAME_MAX_LENGTH).toBe(32);
22 });
23
24 it('has correct max suggestions', () => {
25 expect(MAX_MENTION_SUGGESTIONS).toBe(6);
26 });
27
28 it('has correct picker dimensions', () => {
29 expect(MENTION_PICKER.ITEM_HEIGHT).toBe(32);
30 expect(MENTION_PICKER.PADDING).toBe(12);
31 expect(MENTION_PICKER.EMPTY_STATE_HEIGHT).toBe(44);
32 expect(MENTION_PICKER.MIN_WIDTH).toBe(220);
33 });
34});
35
36describe('USERNAME_PATTERN', () => {
37 it('matches valid usernames', () => {
38 expect(USERNAME_PATTERN.test('alice')).toBe(true);
39 expect(USERNAME_PATTERN.test('Bob123')).toBe(true);
40 expect(USERNAME_PATTERN.test('user_name')).toBe(true);
41 expect(USERNAME_PATTERN.test('user-name')).toBe(true);
42 expect(USERNAME_PATTERN.test('A')).toBe(true);
43 expect(USERNAME_PATTERN.test('a'.repeat(32))).toBe(true);
44 });
45
46 it('rejects invalid usernames', () => {
47 expect(USERNAME_PATTERN.test('')).toBe(false);
48 expect(USERNAME_PATTERN.test('a'.repeat(33))).toBe(false);
49 expect(USERNAME_PATTERN.test('user name')).toBe(false);
50 expect(USERNAME_PATTERN.test('user.name')).toBe(false);
51 expect(USERNAME_PATTERN.test('@alice')).toBe(false);
52 expect(USERNAME_PATTERN.test('alice!')).toBe(false);
53 });
54});
55
56describe('isWordStart', () => {
57 it('returns true at position 0', () => {
58 expect(isWordStart('hello', 0)).toBe(true);
59 expect(isWordStart('@alice', 0)).toBe(true);
60 });
61
62 it('returns true after whitespace', () => {
63 expect(isWordStart('hello @alice', 6)).toBe(true);
64 expect(isWordStart('hi\t@bob', 3)).toBe(true);
65 expect(isWordStart('hi\n@bob', 3)).toBe(true);
66 });
67
68 it('returns false mid-word', () => {
69 expect(isWordStart('hello', 2)).toBe(false);
70 expect(isWordStart('test@alice', 4)).toBe(false);
71 });
72});
73
74describe('findMentionContext', () => {
75 it('finds mention at start of input', () => {
76 const result = findMentionContext('@alice', 6);
77 expect(result).toEqual({ atIndex: 0, query: 'alice' });
78 });
79
80 it('finds mention after space', () => {
81 const result = findMentionContext('hello @bob', 10);
82 expect(result).toEqual({ atIndex: 6, query: 'bob' });
83 });
84
85 it('finds partial mention (typing in progress)', () => {
86 const result = findMentionContext('hello @al', 9);
87 expect(result).toEqual({ atIndex: 6, query: 'al' });
88 });
89
90 it('finds empty query (just @)', () => {
91 const result = findMentionContext('hello @', 7);
92 expect(result).toEqual({ atIndex: 6, query: '' });
93 });
94
95 it('returns null when no @', () => {
96 expect(findMentionContext('hello world', 11)).toBeNull();
97 });
98
99 it('returns null when @ is mid-word', () => {
100 expect(findMentionContext('test@alice', 10)).toBeNull();
101 });
102
103 it('returns null when query contains space', () => {
104 expect(findMentionContext('@alice bob', 10)).toBeNull();
105 });
106
107 it('returns null when query exceeds max length', () => {
108 const longName = 'a'.repeat(33);
109 expect(findMentionContext(`@${longName}`, longName.length + 1)).toBeNull();
110 });
111
112 it('returns null when query has invalid characters', () => {
113 expect(findMentionContext('@alice!', 7)).toBeNull();
114 expect(findMentionContext('@alice.bob', 10)).toBeNull();
115 });
116
117 it('handles cursor in middle of mention', () => {
118 const result = findMentionContext('@alice hello', 4);
119 expect(result).toEqual({ atIndex: 0, query: 'ali' });
120 });
121});
122
123describe('MENTION_REGEX_PLAIN', () => {
124 beforeEach(() => {
125 // Reset regex lastIndex for global regex
126 MENTION_REGEX_PLAIN.lastIndex = 0;
127 });
128
129 it('matches mention at start', () => {
130 const matches = '@alice hello'.match(MENTION_REGEX_PLAIN);
131 expect(matches).toContain('@alice');
132 });
133
134 it('matches mention after space', () => {
135 const matches = 'hello @bob there'.match(MENTION_REGEX_PLAIN);
136 expect(matches).toContain(' @bob');
137 });
138
139 it('matches multiple mentions', () => {
140 const text = '@alice and @bob are here';
141 const matches = text.match(MENTION_REGEX_PLAIN);
142 expect(matches).toHaveLength(2);
143 });
144
145 it('does not match mid-word @', () => {
146 const matches = 'email@test.com'.match(MENTION_REGEX_PLAIN);
147 expect(matches).toBeNull();
148 });
149});
150
151describe('MENTION_REGEX_HTML', () => {
152 beforeEach(() => {
153 MENTION_REGEX_HTML.lastIndex = 0;
154 });
155
156 it('matches mention after HTML tag', () => {
157 const matches = '<p>@alice</p>'.match(MENTION_REGEX_HTML);
158 expect(matches).toContain('>@alice');
159 });
160
161 it('matches mention at start', () => {
162 const matches = '@bob said'.match(MENTION_REGEX_HTML);
163 expect(matches).toContain('@bob');
164 });
165});
166
167describe('highlightMentionsPlain', () => {
168 it('wraps mention in span', () => {
169 const result = highlightMentionsPlain('@alice');
170 expect(result).toBe('<span class="mention-token">@alice</span>');
171 });
172
173 it('preserves text around mention', () => {
174 const result = highlightMentionsPlain('hello @bob there');
175 expect(result).toBe('hello <span class="mention-token">@bob</span> there');
176 });
177
178 it('handles multiple mentions', () => {
179 const result = highlightMentionsPlain('@alice and @bob');
180 expect(result).toContain('<span class="mention-token">@alice</span>');
181 expect(result).toContain('<span class="mention-token">@bob</span>');
182 });
183
184 it('does not highlight mid-word @', () => {
185 const result = highlightMentionsPlain('email@test.com');
186 expect(result).toBe('email@test.com');
187 });
188});
189
190describe('highlightMentionsHtml', () => {
191 it('wraps mention after HTML tag', () => {
192 const result = highlightMentionsHtml('<p>@alice</p>');
193 expect(result).toBe('<p><span class="mention-token">@alice</span></p>');
194 });
195
196 it('handles mention at start', () => {
197 const result = highlightMentionsHtml('@bob said');
198 expect(result).toBe('<span class="mention-token">@bob</span> said');
199 });
200});
201
202describe('highlightMentionsInput', () => {
203 it('uses input-mention class', () => {
204 const result = highlightMentionsInput('@alice');
205 expect(result).toBe('<span class="input-mention">@alice</span>');
206 });
207});
208
209describe('containsMention', () => {
210 it('detects mention (case-insensitive by default)', () => {
211 expect(containsMention('hello @Alice', 'alice')).toBe(true);
212 expect(containsMention('hello @ALICE', 'alice')).toBe(true);
213 expect(containsMention('hello @alice', 'ALICE')).toBe(true);
214 });
215
216 it('respects case sensitivity when enabled', () => {
217 expect(containsMention('hello @Alice', 'alice', true)).toBe(false);
218 expect(containsMention('hello @alice', 'alice', true)).toBe(true);
219 });
220
221 it('returns false when no mention', () => {
222 expect(containsMention('hello world', 'alice')).toBe(false);
223 });
224
225 it('returns false for partial match', () => {
226 expect(containsMention('hello @alicexyz', 'alice')).toBe(false);
227 });
228
229 it('handles special regex characters in username', () => {
230 expect(containsMention('hello @user-name', 'user-name')).toBe(true);
231 expect(containsMention('hello @user_name', 'user_name')).toBe(true);
232 });
233
234 it('requires word boundary before @', () => {
235 expect(containsMention('test@alice', 'alice')).toBe(false);
236 });
237});
238
239describe('calculatePickerHeight', () => {
240 it('returns empty state height for 0 suggestions', () => {
241 expect(calculatePickerHeight(0)).toBe(MENTION_PICKER.EMPTY_STATE_HEIGHT);
242 });
243
244 it('calculates height for single suggestion', () => {
245 expect(calculatePickerHeight(1)).toBe(MENTION_PICKER.ITEM_HEIGHT + MENTION_PICKER.PADDING);
246 });
247
248 it('calculates height for multiple suggestions', () => {
249 expect(calculatePickerHeight(3)).toBe(3 * MENTION_PICKER.ITEM_HEIGHT + MENTION_PICKER.PADDING);
250 });
251
252 it('caps at max suggestions', () => {
253 const maxHeight = MAX_MENTION_SUGGESTIONS * MENTION_PICKER.ITEM_HEIGHT + MENTION_PICKER.PADDING;
254 expect(calculatePickerHeight(10)).toBe(maxHeight);
255 expect(calculatePickerHeight(100)).toBe(maxHeight);
256 });
257});
258
259describe('calculatePickerWidth', () => {
260 it('returns minimum width for small content', () => {
261 expect(calculatePickerWidth(50, 30)).toBe(MENTION_PICKER.MIN_WIDTH);
262 });
263
264 it('calculates width for larger content', () => {
265 const nameWidth = 150;
266 const statusWidth = 50;
267 const expected = nameWidth + statusWidth + MENTION_PICKER.HORIZONTAL_PADDING;
268 expect(calculatePickerWidth(nameWidth, statusWidth)).toBe(expected);
269 });
270
271 it('rounds up to nearest integer', () => {
272 const result = calculatePickerWidth(150.5, 50.3);
273 expect(Number.isInteger(result)).toBe(true);
274 });
275});