Skip to content

Commit 66ec01e

Browse files
test(q&a): add comprehensive qa tests and coverage setup (#208)
* test(q&a): add comprehensive qa tests and coverage setup * test(q&a): align mocks and reset in qa tests
1 parent a4c3835 commit 66ec01e

14 files changed

Lines changed: 1415 additions & 1 deletion

frontend/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,5 @@ TRUST_FORWARDED_HEADERS=0
9797

9898
# emergency switch
9999
RATE_LIMIT_DISABLED=0
100+
101+
GROQ_API_KEY=
Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
// @vitest-environment jsdom
2+
import { describe, it, expect, vi, beforeEach } from 'vitest';
3+
import { render, screen, fireEvent } from '@testing-library/react';
4+
5+
const getCachedTermsMock = vi.fn();
6+
7+
vi.mock('@/lib/ai/explainCache', () => ({
8+
getCachedTerms: () => getCachedTermsMock(),
9+
}));
10+
11+
vi.mock('@/components/ui/accordion', () => ({
12+
Accordion: ({ children }: { children: React.ReactNode }) => (
13+
<div data-testid="accordion">{children}</div>
14+
),
15+
AccordionItem: ({ children }: { children: React.ReactNode }) => (
16+
<div>{children}</div>
17+
),
18+
AccordionTrigger: ({ children }: { children: React.ReactNode }) => (
19+
<button type="button">{children}</button>
20+
),
21+
AccordionContent: ({ children }: { children: React.ReactNode }) => (
22+
<div>{children}</div>
23+
),
24+
}));
25+
26+
vi.mock('@/components/q&a/CodeBlock', () => ({
27+
__esModule: true,
28+
default: ({
29+
code,
30+
content,
31+
}: {
32+
code?: string;
33+
content?: string;
34+
}) => <pre data-testid="code-block">{content ?? code}</pre>,
35+
}));
36+
37+
vi.mock('@/components/q&a/SelectableText', () => ({
38+
__esModule: true,
39+
default: ({
40+
children,
41+
onTextSelect,
42+
onSelectionClear,
43+
}: {
44+
children: React.ReactNode;
45+
onTextSelect: (text: string, position: { x: number; y: number }) => void;
46+
onSelectionClear: () => void;
47+
}) => (
48+
<div>
49+
<button
50+
type="button"
51+
onClick={() => onTextSelect('CSS', { x: 10, y: 20 })}
52+
>
53+
select-text
54+
</button>
55+
<button type="button" onClick={onSelectionClear}>
56+
clear-selection
57+
</button>
58+
{children}
59+
</div>
60+
),
61+
}));
62+
63+
vi.mock('@/components/q&a/FloatingExplainButton', () => ({
64+
__esModule: true,
65+
default: ({
66+
onClick,
67+
onDismiss,
68+
}: {
69+
onClick: () => void;
70+
onDismiss: () => void;
71+
}) => (
72+
<div>
73+
<button type="button" onClick={onClick}>
74+
explain
75+
</button>
76+
<button type="button" onClick={onDismiss}>
77+
dismiss
78+
</button>
79+
</div>
80+
),
81+
}));
82+
83+
vi.mock('@/components/q&a/AIWordHelper', () => ({
84+
__esModule: true,
85+
default: ({
86+
term,
87+
isOpen,
88+
}: {
89+
term: string;
90+
isOpen: boolean;
91+
}) => (
92+
<div data-testid="ai-helper">
93+
{isOpen ? `open:${term}` : 'closed'}
94+
</div>
95+
),
96+
}));
97+
98+
vi.mock('@/components/q&a/HighlightCachedTerms', () => ({
99+
__esModule: true,
100+
default: ({
101+
text,
102+
cachedTerms,
103+
onTermClick,
104+
}: {
105+
text: string;
106+
cachedTerms: Set<string>;
107+
onTermClick: (term: string) => void;
108+
}) => {
109+
const normalized = text.toLowerCase().trim();
110+
if (!cachedTerms.has(normalized)) {
111+
return <span>{text}</span>;
112+
}
113+
114+
return (
115+
<button type="button" onClick={() => onTermClick(text)}>
116+
{text}
117+
</button>
118+
);
119+
},
120+
}));
121+
122+
import AccordionList from '@/components/q&a/AccordionList';
123+
import type { QuestionEntry } from '@/components/q&a/types';
124+
125+
describe('AccordionList', () => {
126+
beforeEach(() => {
127+
getCachedTermsMock.mockReturnValue([]);
128+
});
129+
130+
it('renders questions and answer blocks', () => {
131+
const items: QuestionEntry[] = [
132+
{
133+
id: 'q1',
134+
question: 'What is CSS?',
135+
category: 'css',
136+
answerBlocks: [
137+
{
138+
type: 'paragraph',
139+
children: [{ text: 'CSS styles pages.' }],
140+
},
141+
],
142+
},
143+
];
144+
145+
render(<AccordionList items={items} />);
146+
147+
expect(screen.getByText('What is CSS?')).toBeTruthy();
148+
expect(screen.getByText('CSS styles pages.')).toBeTruthy();
149+
});
150+
151+
it('opens AI helper from selection', () => {
152+
const items: QuestionEntry[] = [
153+
{
154+
id: 'q1',
155+
question: 'What is CSS?',
156+
category: 'css',
157+
answerBlocks: [
158+
{
159+
type: 'paragraph',
160+
children: [{ text: 'CSS styles pages.' }],
161+
},
162+
],
163+
},
164+
];
165+
166+
render(<AccordionList items={items} />);
167+
168+
fireEvent.click(screen.getByText('select-text'));
169+
fireEvent.click(screen.getByText('explain'));
170+
171+
expect(screen.getByTestId('ai-helper').textContent).toBe('open:CSS');
172+
});
173+
174+
it('opens AI helper when cached term clicked', () => {
175+
getCachedTermsMock.mockReturnValue(['HTML']);
176+
177+
const items: QuestionEntry[] = [
178+
{
179+
id: 'q1',
180+
question: 'What is HTML?',
181+
category: 'html',
182+
answerBlocks: [
183+
{
184+
type: 'paragraph',
185+
children: [{ text: 'HTML' }],
186+
},
187+
],
188+
},
189+
];
190+
191+
render(<AccordionList items={items} />);
192+
193+
fireEvent.click(screen.getByText('HTML'));
194+
195+
expect(screen.getByTestId('ai-helper').textContent).toBe('open:HTML');
196+
});
197+
198+
it('clears selection when requested', () => {
199+
const items: QuestionEntry[] = [
200+
{
201+
id: 'q1',
202+
question: 'What is CSS?',
203+
category: 'css',
204+
answerBlocks: [
205+
{
206+
type: 'paragraph',
207+
children: [{ text: 'CSS styles pages.' }],
208+
},
209+
],
210+
},
211+
];
212+
213+
render(<AccordionList items={items} />);
214+
215+
fireEvent.click(screen.getByText('select-text'));
216+
expect(screen.getByText('explain')).toBeTruthy();
217+
218+
fireEvent.click(screen.getByText('clear-selection'));
219+
expect(screen.queryByText('explain')).toBeNull();
220+
});
221+
222+
it('keeps selection when modal is open', () => {
223+
const items: QuestionEntry[] = [
224+
{
225+
id: 'q1',
226+
question: 'What is CSS?',
227+
category: 'css',
228+
answerBlocks: [
229+
{
230+
type: 'paragraph',
231+
children: [{ text: 'CSS styles pages.' }],
232+
},
233+
],
234+
},
235+
];
236+
237+
render(<AccordionList items={items} />);
238+
239+
fireEvent.click(screen.getByText('select-text'));
240+
fireEvent.click(screen.getByText('explain'));
241+
expect(screen.getByTestId('ai-helper').textContent).toBe('open:CSS');
242+
243+
fireEvent.click(screen.getByText('clear-selection'));
244+
expect(screen.getByTestId('ai-helper').textContent).toBe('open:CSS');
245+
});
246+
247+
it('renders mixed answer blocks', () => {
248+
const items: QuestionEntry[] = [
249+
{
250+
id: 'q1',
251+
question: 'What is HTML?',
252+
category: 'html',
253+
answerBlocks: [
254+
{
255+
type: 'paragraph',
256+
children: [
257+
{ text: 'Plain' },
258+
{ text: 'Bold', bold: true },
259+
{ text: 'Italic', italic: true },
260+
{ text: 'Both', boldItalic: true },
261+
{ text: 'Inline', code: true },
262+
],
263+
},
264+
{
265+
type: 'heading',
266+
level: 3,
267+
children: [{ text: 'Heading' }],
268+
},
269+
{
270+
type: 'bulletList',
271+
children: [
272+
{
273+
type: 'listItem',
274+
children: [
275+
{ text: 'Item 1' },
276+
{ type: 'code', language: 'js', content: 'const a = 1;' },
277+
{
278+
type: 'bulletList',
279+
children: [{ text: 'Nested' }],
280+
},
281+
],
282+
},
283+
{ text: 'Loose item' },
284+
],
285+
},
286+
{
287+
type: 'numberedList',
288+
children: [
289+
{
290+
type: 'listItem',
291+
children: [{ text: 'Numbered' }],
292+
},
293+
],
294+
},
295+
{
296+
type: 'table',
297+
header: [[{ text: 'Col' }]],
298+
rows: [[[{ text: 'Cell' }]]],
299+
},
300+
{
301+
type: 'code',
302+
language: 'html',
303+
content: '<div></div>',
304+
},
305+
],
306+
},
307+
];
308+
309+
render(<AccordionList items={items} />);
310+
311+
expect(screen.getByText('Bold').tagName).toBe('STRONG');
312+
expect(screen.getByText('Italic').tagName).toBe('EM');
313+
expect(screen.getByText('Both').tagName).toBe('STRONG');
314+
expect(screen.getByText('Inline').tagName).toBe('CODE');
315+
expect(screen.getByText('Heading').tagName).toBe('SPAN');
316+
expect(screen.getByText('Nested')).toBeTruthy();
317+
expect(screen.getByText('Col')).toBeTruthy();
318+
expect(screen.getByText('Cell')).toBeTruthy();
319+
expect(screen.getAllByTestId('code-block').length).toBeGreaterThan(0);
320+
});
321+
322+
it('dismisses explain button', () => {
323+
const items: QuestionEntry[] = [
324+
{
325+
id: 'q1',
326+
question: 'What is CSS?',
327+
category: 'css',
328+
answerBlocks: [
329+
{
330+
type: 'paragraph',
331+
children: [{ text: 'CSS styles pages.' }],
332+
},
333+
],
334+
},
335+
];
336+
337+
render(<AccordionList items={items} />);
338+
339+
fireEvent.click(screen.getByText('select-text'));
340+
expect(screen.getByText('dismiss')).toBeTruthy();
341+
342+
fireEvent.click(screen.getByText('dismiss'));
343+
expect(screen.queryByText('dismiss')).toBeNull();
344+
});
345+
346+
it('renders code blocks in answers', () => {
347+
const items: QuestionEntry[] = [
348+
{
349+
id: 'q1',
350+
question: 'What is HTML?',
351+
category: 'html',
352+
answerBlocks: [
353+
{
354+
type: 'code',
355+
language: 'html',
356+
content: '<div></div>',
357+
},
358+
],
359+
},
360+
];
361+
362+
render(<AccordionList items={items} />);
363+
364+
expect(screen.getByTestId('code-block').textContent).toBe('<div></div>');
365+
});
366+
});

0 commit comments

Comments
 (0)