From 9428655c3c0d6f545c9636e57ae17b1ac4d2ad70 Mon Sep 17 00:00:00 2001 From: JoesWalker Date: Tue, 28 Apr 2026 13:47:07 +0000 Subject: [PATCH] feat(ai): implement AI-powered learning assistant components - LearningAssistant: chat UI with typing indicator, aria-live, keyboard nav - PersonalizedRecommendations: fetches GET /api/ai/recommendations with skeleton loader - IntelligentProgress: fetches GET /api/user/progress, progress bar + insights - SmartNotifications: fetches GET /api/ai/reminders, dismiss via DELETE /api/ai/reminders/:id - NaturalLanguageQuery: POST /api/ai/search with empty/error states - Tests: 25 tests covering loading, success, error, and interaction states Placeholder API endpoints documented in each component: POST /api/ai/chat, GET /api/ai/recommendations, GET /api/ai/reminders, DELETE /api/ai/reminders/:id, POST /api/ai/search Closes #305 --- src/components/ai/IntelligentProgress.tsx | 121 +++++++ src/components/ai/LearningAssistant.tsx | 180 ++++++++++ src/components/ai/NaturalLanguageQuery.tsx | 107 ++++++ .../ai/PersonalizedRecommendations.tsx | 98 ++++++ src/components/ai/SmartNotifications.tsx | 114 ++++++ .../ai/__tests__/ai-components.test.tsx | 332 ++++++++++++++++++ 6 files changed, 952 insertions(+) create mode 100644 src/components/ai/IntelligentProgress.tsx create mode 100644 src/components/ai/LearningAssistant.tsx create mode 100644 src/components/ai/NaturalLanguageQuery.tsx create mode 100644 src/components/ai/PersonalizedRecommendations.tsx create mode 100644 src/components/ai/SmartNotifications.tsx create mode 100644 src/components/ai/__tests__/ai-components.test.tsx diff --git a/src/components/ai/IntelligentProgress.tsx b/src/components/ai/IntelligentProgress.tsx new file mode 100644 index 00000000..7026e5a2 --- /dev/null +++ b/src/components/ai/IntelligentProgress.tsx @@ -0,0 +1,121 @@ +'use client'; + +/** + * IntelligentProgress – visualises user progress with AI-generated insights + * + * API: GET /api/user/progress β†’ ApiResponse + */ + +import React, { useEffect, useState } from 'react'; +import { TrendingUp } from 'lucide-react'; +import { apiClient } from '@/lib/api'; +import { Skeleton } from '@/components/ui/Skeleton'; +import type { ApiResponse, UserProgress } from '@/types/api'; + +function buildInsights(p: UserProgress): string[] { + const insights: string[] = []; + const pct = p.totalCourses > 0 ? Math.round((p.completedCourses / p.totalCourses) * 100) : 0; + insights.push(`You're ${pct}% through your enrolled courses.`); + if (p.streak >= 7) insights.push(`πŸ”₯ ${p.streak}-day streak – keep it up!`); + if (p.totalTimeSpent > 0) { + const hours = Math.floor(p.totalTimeSpent / 60); + insights.push(`Total time spent: ${hours}h ${p.totalTimeSpent % 60}m`); + } + const remaining = p.dailyGoal - (p.totalTimeSpent % p.dailyGoal || 0); + if (remaining > 0 && remaining < p.dailyGoal) { + insights.push(`${remaining} min left to hit today's daily goal.`); + } + return insights; +} + +export default function IntelligentProgress() { + const [progress, setProgress] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + apiClient + .get>('/api/user/progress') + .then((res) => { + if (!cancelled) setProgress(res.data); + }) + .catch(() => { + if (!cancelled) setError('Could not load progress data.'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const pct = + progress && progress.totalCourses > 0 + ? Math.round((progress.completedCourses / progress.totalCourses) * 100) + : 0; + + return ( +
+
+
+ + {loading && ( +
+ + + +
+ )} + + {error && ( +

+ {error} +

+ )} + + {!loading && !error && progress && ( + <> + {/* Progress bar */} +
+
+ + {progress.completedCourses} / {progress.totalCourses} courses + + {pct}% +
+
+
+
+
+ + {/* Insights */} +
    + {buildInsights(progress).map((insight, i) => ( +
  • + + {insight} +
  • + ))} +
+ + )} +
+ ); +} diff --git a/src/components/ai/LearningAssistant.tsx b/src/components/ai/LearningAssistant.tsx new file mode 100644 index 00000000..c702f12d --- /dev/null +++ b/src/components/ai/LearningAssistant.tsx @@ -0,0 +1,180 @@ +'use client'; + +/** + * LearningAssistant – AI chat UI + * + * API (placeholder – implement backend to match): + * POST /api/ai/chat { message: string; context?: string } + * β†’ ApiResponse<{ reply: string }> + */ + +import React, { useState, useRef, useEffect } from 'react'; +import { Send, Bot, User } from 'lucide-react'; +import { apiClient } from '@/lib/api'; +import type { ApiResponse } from '@/types/api'; + +interface Message { + id: string; + role: 'user' | 'assistant'; + content: string; +} + +interface ChatResponse { + reply: string; +} + +export default function LearningAssistant() { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const bottomRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages, loading]); + + const sendMessage = async () => { + const text = input.trim(); + if (!text || loading) return; + + const userMsg: Message = { id: crypto.randomUUID(), role: 'user', content: text }; + setMessages((prev) => [...prev, userMsg]); + setInput(''); + setLoading(true); + setError(null); + + try { + const res = await apiClient.post>('/api/ai/chat', { + message: text, + context: 'learning', + }); + const assistantMsg: Message = { + id: crypto.randomUUID(), + role: 'assistant', + content: res.data.reply, + }; + setMessages((prev) => [...prev, assistantMsg]); + } catch { + setError('Failed to get a response. Please try again.'); + } finally { + setLoading(false); + inputRef.current?.focus(); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }; + + return ( +
+ {/* Header */} +
+
+ + {/* Message thread */} +
+ {messages.length === 0 && ( +

+ Ask me anything about your courses or learning goals. +

+ )} + + {messages.map((msg) => ( +
+ +
+ {msg.content} +
+
+ ))} + + {/* Typing indicator */} + {loading && ( +
+
+
+
+ {[0, 1, 2].map((i) => ( + + ))} +
+
+ )} + + {error && ( +

+ {error} +

+ )} + +
+
+ + {/* Input */} +
+ setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Ask a question…" + disabled={loading} + aria-label="Message input" + className="flex-1 rounded-lg border border-[#E2E8F0] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#0F172A] text-[#0F172A] dark:text-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#0066FF] disabled:opacity-50" + /> + +
+
+ ); +} diff --git a/src/components/ai/NaturalLanguageQuery.tsx b/src/components/ai/NaturalLanguageQuery.tsx new file mode 100644 index 00000000..1f3a2321 --- /dev/null +++ b/src/components/ai/NaturalLanguageQuery.tsx @@ -0,0 +1,107 @@ +'use client'; + +/** + * NaturalLanguageQuery – natural language search over courses/resources + * + * API (placeholder – implement backend to match): + * POST /api/ai/search { query: string } + * β†’ ApiResponse + */ + +import React, { useState } from 'react'; +import { Search } from 'lucide-react'; +import { apiClient } from '@/lib/api'; +import type { ApiResponse } from '@/types/api'; + +interface SearchResult { + id: string; + title: string; + description: string; + url: string; +} + +export default function NaturalLanguageQuery() { + const [query, setQuery] = useState(''); + const [results, setResults] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSearch = async (e: React.FormEvent) => { + e.preventDefault(); + const q = query.trim(); + if (!q || loading) return; + + setLoading(true); + setError(null); + setResults(null); + + try { + const res = await apiClient.post>('/api/ai/search', { + query: q, + }); + setResults(res.data); + } catch { + setError('Search failed. Please try again.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+ +
+ setQuery(e.target.value)} + placeholder="e.g. beginner Python for data science" + aria-label="Search query" + disabled={loading} + className="flex-1 rounded-lg border border-[#E2E8F0] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#0F172A] text-[#0F172A] dark:text-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#0066FF] disabled:opacity-50" + /> + +
+ + {error && ( +

+ {error} +

+ )} + + {results !== null && results.length === 0 && ( +

No results found.

+ )} + + {results !== null && results.length > 0 && ( + + )} +
+ ); +} diff --git a/src/components/ai/PersonalizedRecommendations.tsx b/src/components/ai/PersonalizedRecommendations.tsx new file mode 100644 index 00000000..586c5de0 --- /dev/null +++ b/src/components/ai/PersonalizedRecommendations.tsx @@ -0,0 +1,98 @@ +'use client'; + +/** + * PersonalizedRecommendations – AI-driven course/resource suggestions + * + * API (placeholder – implement backend to match): + * GET /api/ai/recommendations + * β†’ ApiResponse + */ + +import React, { useEffect, useState } from 'react'; +import { ExternalLink, Sparkles } from 'lucide-react'; +import { apiClient } from '@/lib/api'; +import { Skeleton } from '@/components/ui/Skeleton'; +import type { ApiResponse } from '@/types/api'; + +interface Recommendation { + id: string; + title: string; + reason: string; + url: string; +} + +export default function PersonalizedRecommendations() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + apiClient + .get>('/api/ai/recommendations') + .then((res) => { + if (!cancelled) setItems(res.data); + }) + .catch(() => { + if (!cancelled) setError('Could not load recommendations.'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + return ( +
+
+
+ + {loading && ( +
    + {[1, 2, 3].map((i) => ( +
  • + + +
  • + ))} +
+ )} + + {error && ( +

+ {error} +

+ )} + + {!loading && !error && items.length === 0 && ( +

No recommendations yet.

+ )} + + {!loading && !error && items.length > 0 && ( + + )} +
+ ); +} diff --git a/src/components/ai/SmartNotifications.tsx b/src/components/ai/SmartNotifications.tsx new file mode 100644 index 00000000..ae5bb863 --- /dev/null +++ b/src/components/ai/SmartNotifications.tsx @@ -0,0 +1,114 @@ +'use client'; + +/** + * SmartNotifications – study reminders with dismiss support + * + * API (placeholder – implement backend to match): + * GET /api/ai/reminders β†’ ApiResponse + * DELETE /api/ai/reminders/:id β†’ ApiResponse + */ + +import React, { useEffect, useState } from 'react'; +import { Bell, X } from 'lucide-react'; +import { apiClient } from '@/lib/api'; +import { Skeleton } from '@/components/ui/Skeleton'; +import { useToast } from '@/context/ToastContext'; +import type { ApiResponse } from '@/types/api'; + +interface Reminder { + id: string; + title: string; + scheduledAt: string; +} + +export default function SmartNotifications() { + const [reminders, setReminders] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const toast = useToast(); + + useEffect(() => { + let cancelled = false; + apiClient + .get>('/api/ai/reminders') + .then((res) => { + if (!cancelled) setReminders(res.data); + }) + .catch(() => { + if (!cancelled) setError('Could not load reminders.'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const dismiss = async (id: string) => { + try { + await apiClient.delete>(`/api/ai/reminders/${id}`); + setReminders((prev) => prev.filter((r) => r.id !== id)); + toast.success('Reminder dismissed.'); + } catch { + toast.error('Failed to dismiss reminder.'); + } + }; + + return ( +
+
+
+ + {loading && ( +
    + {[1, 2].map((i) => ( +
  • + +
  • + ))} +
+ )} + + {error && ( +

+ {error} +

+ )} + + {!loading && !error && reminders.length === 0 && ( +

No upcoming reminders.

+ )} + + {!loading && !error && reminders.length > 0 && ( +
    + {reminders.map((r) => ( +
  • +
    +

    {r.title}

    +

    + {new Date(r.scheduledAt).toLocaleString()} +

    +
    + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/components/ai/__tests__/ai-components.test.tsx b/src/components/ai/__tests__/ai-components.test.tsx new file mode 100644 index 00000000..2586ca0f --- /dev/null +++ b/src/components/ai/__tests__/ai-components.test.tsx @@ -0,0 +1,332 @@ +/** + * Tests for AI components + * + * Mocks: + * - @/lib/api (apiClient) + * - @/context/ToastContext (useToast) + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ─── Module mocks ───────────────────────────────────────────────────────────── + +vi.mock('@/lib/api', () => ({ + apiClient: { + get: vi.fn(), + post: vi.fn(), + delete: vi.fn(), + }, +})); + +const mockToast = { success: vi.fn(), error: vi.fn(), info: vi.fn() }; +vi.mock('@/context/ToastContext', () => ({ + useToast: () => mockToast, +})); + +// ─── Imports (after mocks) ──────────────────────────────────────────────────── + +import { apiClient } from '@/lib/api'; +import LearningAssistant from '@/components/ai/LearningAssistant'; +import PersonalizedRecommendations from '@/components/ai/PersonalizedRecommendations'; +import IntelligentProgress from '@/components/ai/IntelligentProgress'; +import SmartNotifications from '@/components/ai/SmartNotifications'; +import NaturalLanguageQuery from '@/components/ai/NaturalLanguageQuery'; + +const mockGet = apiClient.get as ReturnType; +const mockPost = apiClient.post as ReturnType; +const mockDelete = apiClient.delete as ReturnType; + +// ─── LearningAssistant ──────────────────────────────────────────────────────── + +describe('LearningAssistant', () => { + beforeEach(() => vi.clearAllMocks()); + + it('renders input and send button', () => { + render(); + expect(screen.getByLabelText('Message input')).toBeInTheDocument(); + expect(screen.getByLabelText('Send message')).toBeInTheDocument(); + }); + + it('shows empty state prompt', () => { + render(); + expect(screen.getByText(/ask me anything/i)).toBeInTheDocument(); + }); + + it('sends message and displays assistant reply', async () => { + mockPost.mockResolvedValueOnce({ data: { reply: 'Hello from AI!' }, success: true }); + + render(); + const input = screen.getByLabelText('Message input'); + fireEvent.change(input, { target: { value: 'Hi there' } }); + fireEvent.click(screen.getByLabelText('Send message')); + + // User message appears immediately + expect(screen.getByText('Hi there')).toBeInTheDocument(); + + // Typing indicator while loading + expect(screen.getByLabelText('Assistant is typing')).toBeInTheDocument(); + + await waitFor(() => expect(screen.getByText('Hello from AI!')).toBeInTheDocument()); + expect(mockPost).toHaveBeenCalledWith('/api/ai/chat', { message: 'Hi there', context: 'learning' }); + }); + + it('shows error message on API failure', async () => { + mockPost.mockRejectedValueOnce(new Error('Network error')); + + render(); + fireEvent.change(screen.getByLabelText('Message input'), { target: { value: 'test' } }); + fireEvent.click(screen.getByLabelText('Send message')); + + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent(/failed to get a response/i), + ); + }); + + it('send button is disabled when input is empty', () => { + render(); + expect(screen.getByLabelText('Send message')).toBeDisabled(); + }); + + it('submits on Enter key', async () => { + mockPost.mockResolvedValueOnce({ data: { reply: 'Got it' }, success: true }); + + render(); + const input = screen.getByLabelText('Message input'); + fireEvent.change(input, { target: { value: 'Enter test' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + await waitFor(() => expect(mockPost).toHaveBeenCalledTimes(1)); + }); +}); + +// ─── PersonalizedRecommendations ───────────────────────────────────────────── + +describe('PersonalizedRecommendations', () => { + beforeEach(() => vi.clearAllMocks()); + + it('shows skeleton while loading', () => { + mockGet.mockReturnValueOnce(new Promise(() => {})); // never resolves + render(); + expect(screen.getByLabelText('Loading recommendations')).toBeInTheDocument(); + }); + + it('renders fetched recommendations', async () => { + mockGet.mockResolvedValueOnce({ + success: true, + data: [ + { id: '1', title: 'React Basics', reason: 'Matches your goals', url: '/courses/1' }, + { id: '2', title: 'TypeScript Deep Dive', reason: 'Popular in your area', url: '/courses/2' }, + ], + }); + + render(); + + await waitFor(() => expect(screen.getByText('React Basics')).toBeInTheDocument()); + expect(screen.getByText('Matches your goals')).toBeInTheDocument(); + expect(screen.getByText('TypeScript Deep Dive')).toBeInTheDocument(); + }); + + it('shows error state on failure', async () => { + mockGet.mockRejectedValueOnce(new Error('fail')); + render(); + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent(/could not load recommendations/i), + ); + }); + + it('shows empty state when no recommendations', async () => { + mockGet.mockResolvedValueOnce({ success: true, data: [] }); + render(); + await waitFor(() => + expect(screen.getByText(/no recommendations yet/i)).toBeInTheDocument(), + ); + }); +}); + +// ─── IntelligentProgress ───────────────────────────────────────────────────── + +describe('IntelligentProgress', () => { + beforeEach(() => vi.clearAllMocks()); + + it('shows skeleton while loading', () => { + mockGet.mockReturnValueOnce(new Promise(() => {})); + render(); + // Skeletons render; no progress bar yet + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + it('renders progress bar and insights', async () => { + mockGet.mockResolvedValueOnce({ + success: true, + data: { + streak: 10, + totalTimeSpent: 200, + dailyGoal: 30, + lastActive: new Date().toISOString(), + completedCourses: 4, + totalCourses: 8, + }, + }); + + render(); + + await waitFor(() => expect(screen.getByRole('progressbar')).toBeInTheDocument()); + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '50'); + expect(screen.getAllByText(/50%/).length).toBeGreaterThan(0); + expect(screen.getByText(/10-day streak/i)).toBeInTheDocument(); + }); + + it('shows error state on failure', async () => { + mockGet.mockRejectedValueOnce(new Error('fail')); + render(); + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent(/could not load progress/i), + ); + }); +}); + +// ─── SmartNotifications ─────────────────────────────────────────────────────── + +describe('SmartNotifications', () => { + beforeEach(() => vi.clearAllMocks()); + + it('shows skeleton while loading', () => { + mockGet.mockReturnValueOnce(new Promise(() => {})); + render(); + expect(screen.getByLabelText('Loading reminders')).toBeInTheDocument(); + }); + + it('renders reminders', async () => { + mockGet.mockResolvedValueOnce({ + success: true, + data: [ + { id: 'r1', title: 'Study React', scheduledAt: '2026-04-29T10:00:00Z' }, + { id: 'r2', title: 'Review TypeScript', scheduledAt: '2026-04-30T09:00:00Z' }, + ], + }); + + render(); + + await waitFor(() => expect(screen.getByText('Study React')).toBeInTheDocument()); + expect(screen.getByText('Review TypeScript')).toBeInTheDocument(); + }); + + it('dismisses a reminder and shows success toast', async () => { + mockGet.mockResolvedValueOnce({ + success: true, + data: [{ id: 'r1', title: 'Study React', scheduledAt: '2026-04-29T10:00:00Z' }], + }); + mockDelete.mockResolvedValueOnce({ success: true, data: null }); + + render(); + + await waitFor(() => expect(screen.getByText('Study React')).toBeInTheDocument()); + + fireEvent.click(screen.getByLabelText('Dismiss reminder: Study React')); + + await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('/api/ai/reminders/r1')); + expect(mockToast.success).toHaveBeenCalledWith('Reminder dismissed.'); + expect(screen.queryByText('Study React')).not.toBeInTheDocument(); + }); + + it('shows error toast when dismiss fails', async () => { + mockGet.mockResolvedValueOnce({ + success: true, + data: [{ id: 'r1', title: 'Study React', scheduledAt: '2026-04-29T10:00:00Z' }], + }); + mockDelete.mockRejectedValueOnce(new Error('fail')); + + render(); + + await waitFor(() => expect(screen.getByText('Study React')).toBeInTheDocument()); + fireEvent.click(screen.getByLabelText('Dismiss reminder: Study React')); + + await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('Failed to dismiss reminder.')); + }); + + it('shows error state when fetch fails', async () => { + mockGet.mockRejectedValueOnce(new Error('fail')); + render(); + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent(/could not load reminders/i), + ); + }); + + it('shows empty state when no reminders', async () => { + mockGet.mockResolvedValueOnce({ success: true, data: [] }); + render(); + await waitFor(() => + expect(screen.getByText(/no upcoming reminders/i)).toBeInTheDocument(), + ); + }); +}); + +// ─── NaturalLanguageQuery ───────────────────────────────────────────────────── + +describe('NaturalLanguageQuery', () => { + beforeEach(() => vi.clearAllMocks()); + + it('renders search input and button', () => { + render(); + expect(screen.getByLabelText('Search query')).toBeInTheDocument(); + expect(screen.getByLabelText('Submit search')).toBeInTheDocument(); + }); + + it('submits query and renders results', async () => { + mockPost.mockResolvedValueOnce({ + success: true, + data: [ + { id: 's1', title: 'Python for Beginners', description: 'Start here', url: '/courses/py' }, + ], + }); + + render(); + fireEvent.change(screen.getByLabelText('Search query'), { + target: { value: 'python basics' }, + }); + fireEvent.submit(screen.getByRole('search')); + + await waitFor(() => expect(screen.getByText('Python for Beginners')).toBeInTheDocument()); + expect(screen.getByText('Start here')).toBeInTheDocument(); + expect(mockPost).toHaveBeenCalledWith('/api/ai/search', { query: 'python basics' }); + }); + + it('shows "No results found" for empty results', async () => { + mockPost.mockResolvedValueOnce({ success: true, data: [] }); + + render(); + fireEvent.change(screen.getByLabelText('Search query'), { target: { value: 'xyz' } }); + fireEvent.submit(screen.getByRole('search')); + + await waitFor(() => expect(screen.getByText(/no results found/i)).toBeInTheDocument()); + }); + + it('shows error state on failure', async () => { + mockPost.mockRejectedValueOnce(new Error('fail')); + + render(); + fireEvent.change(screen.getByLabelText('Search query'), { target: { value: 'test' } }); + fireEvent.submit(screen.getByRole('search')); + + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent(/search failed/i), + ); + }); + + it('submit button is disabled when query is empty', () => { + render(); + expect(screen.getByLabelText('Submit search')).toBeDisabled(); + }); + + it('shows loading state while searching', async () => { + mockPost.mockReturnValueOnce(new Promise(() => {})); + + render(); + fireEvent.change(screen.getByLabelText('Search query'), { target: { value: 'react' } }); + fireEvent.submit(screen.getByRole('search')); + + expect(screen.getByText('Searching…')).toBeInTheDocument(); + }); +});