diff --git a/src/components/ai/IntelligentProgress.tsx b/src/components/ai/IntelligentProgress.tsx index 5466c826..7026e5a2 100644 --- a/src/components/ai/IntelligentProgress.tsx +++ b/src/components/ai/IntelligentProgress.tsx @@ -1,100 +1,121 @@ 'use client'; -import { useState, useEffect } from 'react'; +/** + * 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'; -// GET /api/ai/progress β†’ { courses: CourseProgress[]; insights: string[] } - -interface CourseProgress { - id: string; - title: string; - percent: number; -} - -interface ProgressData { - courses: CourseProgress[]; - insights: string[]; -} - -function ProgressBar({ percent }: { percent: number }) { - const clamped = Math.min(100, Math.max(0, percent)); - return ( -
-
-
- ); +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 [data, setData] = useState(null); + const [progress, setProgress] = useState(null); const [loading, setLoading] = useState(true); - const [error, setError] = useState(false); + const [error, setError] = useState(null); useEffect(() => { + let cancelled = false; apiClient - .get('/api/ai/progress') - .then(setData) - .catch(() => setError(true)) - .finally(() => setLoading(false)); + .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 ( -
-
- -

Your Progress

+
+
+
-
- {loading && ( -
- {Array.from({ length: 2 }).map((_, i) => ( -
-
-
-
- ))} -
- )} + {loading && ( +
+ + + +
+ )} - {error && ( -

Failed to load progress.

- )} + {error && ( +

+ {error} +

+ )} - {data && ( - <> -
- {data.courses.map((course) => ( -
-
- {course.title} - {course.percent}% -
- -
- ))} + {!loading && !error && progress && ( + <> + {/* Progress bar */} +
+
+ + {progress.completedCourses} / {progress.totalCourses} courses + + {pct}% +
+
+
+
- {data.insights.length > 0 && ( -
- {data.insights.map((insight, i) => ( -

- πŸ’‘ {insight} -

- ))} -
- )} - - )} -
-
+ {/* Insights */} +
    + {buildInsights(progress).map((insight, i) => ( +
  • + + {insight} +
  • + ))} +
+ + )} +
); } diff --git a/src/components/ai/LearningAssistant.tsx b/src/components/ai/LearningAssistant.tsx index e4158633..c702f12d 100644 --- a/src/components/ai/LearningAssistant.tsx +++ b/src/components/ai/LearningAssistant.tsx @@ -1,10 +1,17 @@ 'use client'; -import { useState, useRef, useEffect, useCallback } from 'react'; +/** + * 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'; - -// POST /api/ai/chat β€” { message: string; context?: string } β†’ { reply: string } +import type { ApiResponse } from '@/types/api'; interface Message { id: string; @@ -12,14 +19,15 @@ interface Message { content: string; } -interface LearningAssistantProps { - context?: string; +interface ChatResponse { + reply: string; } -export default function LearningAssistant({ context }: LearningAssistantProps) { +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); @@ -27,7 +35,7 @@ export default function LearningAssistant({ context }: LearningAssistantProps) { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, loading]); - const send = useCallback(async () => { + const sendMessage = async () => { const text = input.trim(); if (!text || loading) return; @@ -35,101 +43,118 @@ export default function LearningAssistant({ context }: LearningAssistantProps) { setMessages((prev) => [...prev, userMsg]); setInput(''); setLoading(true); + setError(null); try { - const { reply } = await apiClient.post<{ reply: string }>('/api/ai/chat', { + const res = await apiClient.post>('/api/ai/chat', { message: text, - context, + context: 'learning', }); - setMessages((prev) => [ - ...prev, - { id: crypto.randomUUID(), role: 'assistant', content: reply }, - ]); + const assistantMsg: Message = { + id: crypto.randomUUID(), + role: 'assistant', + content: res.data.reply, + }; + setMessages((prev) => [...prev, assistantMsg]); } catch { - setMessages((prev) => [ - ...prev, - { id: crypto.randomUUID(), role: 'assistant', content: 'Sorry, something went wrong.' }, - ]); + setError('Failed to get a response. Please try again.'); } finally { setLoading(false); inputRef.current?.focus(); } - }, [input, loading, context]); + }; - const handleKeyDown = (e: React.KeyboardEvent) => { + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); - send(); + sendMessage(); } }; return ( -
+
{/* Header */} -
- -

Learning Assistant

+
+
{/* Message thread */}
{messages.length === 0 && ( -

- Ask me anything about your courses! +

+ Ask me anything about your courses or learning goals.

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

+ {error} +

+ )} +
{/* Input */} -
+
setInput(e.target.value)} onKeyDown={handleKeyDown} placeholder="Ask a question…" - aria-label="Message input" disabled={loading} - className="flex-1 text-sm bg-gray-100 dark:bg-gray-800 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900 dark:text-white disabled:opacity-50" + 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 index 8148420f..1f3a2321 100644 --- a/src/components/ai/NaturalLanguageQuery.tsx +++ b/src/components/ai/NaturalLanguageQuery.tsx @@ -1,10 +1,17 @@ 'use client'; -import { useState, useCallback } from 'react'; -import { Search, ExternalLink } from 'lucide-react'; -import { apiClient } from '@/lib/api'; +/** + * NaturalLanguageQuery – natural language search over courses/resources + * + * API (placeholder – implement backend to match): + * POST /api/ai/search { query: string } + * β†’ ApiResponse + */ -// POST /api/ai/search β€” { query: string } β†’ { results: SearchResult[] } +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; @@ -17,85 +24,84 @@ export default function NaturalLanguageQuery() { const [query, setQuery] = useState(''); const [results, setResults] = useState(null); const [loading, setLoading] = useState(false); - const [error, setError] = useState(false); + const [error, setError] = useState(null); - const search = useCallback(async () => { + const handleSearch = async (e: React.FormEvent) => { + e.preventDefault(); const q = query.trim(); if (!q || loading) return; + setLoading(true); - setError(false); + setError(null); + setResults(null); + try { - const { results: res } = await apiClient.post<{ results: SearchResult[] }>( - '/api/ai/search', - { query: q }, - ); - setResults(res); + const res = await apiClient.post>('/api/ai/search', { + query: q, + }); + setResults(res.data); } catch { - setError(true); - setResults(null); + setError('Search failed. Please try again.'); } finally { setLoading(false); } - }, [query, loading]); - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') search(); }; return ( -
-
-
-
- - setQuery(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Ask anything, e.g. 'intro to machine learning'…" - aria-label="Natural language search" - className="w-full pl-9 pr-3 py-2 text-sm bg-gray-100 dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900 dark:text-white" - /> -
- -
+
+
+
-
- {error && ( -

Search failed. Please try again.

- )} +
+ 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" + /> + +
- {results !== null && results.length === 0 && ( -

No results found.

- )} + {error && ( +

+ {error} +

+ )} - {results?.map((item) => ( -
-

{item.title}

-

- {item.description} -

- - Open - -
- ))} -
-
+ {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 index e6733b60..586c5de0 100644 --- a/src/components/ai/PersonalizedRecommendations.tsx +++ b/src/components/ai/PersonalizedRecommendations.tsx @@ -1,10 +1,18 @@ 'use client'; -import { useState, useEffect } from 'react'; +/** + * 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'; - -// GET /api/ai/recommendations β†’ { items: Recommendation[] } +import { Skeleton } from '@/components/ui/Skeleton'; +import type { ApiResponse } from '@/types/api'; interface Recommendation { id: string; @@ -13,67 +21,78 @@ interface Recommendation { url: string; } -function SkeletonCard() { - return ( -
-
-
-
-
- ); -} - export default function PersonalizedRecommendations() { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); - const [error, setError] = useState(false); + const [error, setError] = useState(null); useEffect(() => { + let cancelled = false; apiClient - .get<{ items: Recommendation[] }>('/api/ai/recommendations') - .then((r) => setItems(r.items)) - .catch(() => setError(true)) - .finally(() => setLoading(false)); + .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 ( -
-
- -

- Recommended for You -

+
+
+
-
- {loading && Array.from({ length: 3 }).map((_, i) => )} + {loading && ( +
    + {[1, 2, 3].map((i) => ( +
  • + + +
  • + ))} +
+ )} - {error && ( -

- Failed to load recommendations. -

- )} + {error && ( +

+ {error} +

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

No recommendations yet.

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

No recommendations yet.

+ )} - {items.map((item) => ( -
-

{item.title}

-

{item.reason}

- - View course - -
- ))} -
-
+ {!loading && !error && items.length > 0 && ( + + )} + ); } diff --git a/src/components/ai/SmartNotifications.tsx b/src/components/ai/SmartNotifications.tsx index 34c8f164..f5b01a6d 100644 --- a/src/components/ai/SmartNotifications.tsx +++ b/src/components/ai/SmartNotifications.tsx @@ -1,85 +1,114 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +/** + * 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 { useNotification } from '@/hooks/use-notification'; - -// GET /api/ai/reminders β†’ { reminders: Reminder[] } -// DELETE /api/ai/reminders/:id +import type { ApiResponse } from '@/types/api'; interface Reminder { id: string; title: string; - scheduledAt: string; // ISO string + scheduledAt: string; } export default function SmartNotifications() { const [reminders, setReminders] = useState([]); const [loading, setLoading] = useState(true); - const { success, error } = useNotification(); + const [error, setError] = useState(null); + const { success, error: notifyError } = useNotification(); useEffect(() => { + let cancelled = false; apiClient - .get<{ reminders: Reminder[] }>('/api/ai/reminders') - .then((r) => setReminders(r.reminders)) - .catch(() => {}) - .finally(() => setLoading(false)); + .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 = useCallback( - async (id: string) => { - try { - await apiClient.delete(`/api/ai/reminders/${id}`); - setReminders((prev) => prev.filter((r) => r.id !== id)); - success('Reminder dismissed'); - } catch { - error('Failed to dismiss reminder'); - } - }, - [success, error], - ); + const dismiss = async (id: string) => { + try { + await apiClient.delete>(`/api/ai/reminders/${id}`); + setReminders((prev) => prev.filter((r) => r.id !== id)); + success('Reminder dismissed.'); + } catch { + notifyError('Failed to dismiss reminder.'); + } + }; return ( -
-
- -

Study Reminders

+
+
+
-
- {loading && ( -
- {Array.from({ length: 2 }).map((_, i) => ( -
- ))} -
- )} + {loading && ( +
    + {[1, 2].map((i) => ( +
  • + +
  • + ))} +
+ )} - {!loading && reminders.length === 0 && ( -

No upcoming reminders.

- )} + {error && ( +

+ {error} +

+ )} - {reminders.map((reminder) => ( -
-
-

- {reminder.title} -

-

- {new Date(reminder.scheduledAt).toLocaleString()} -

-
- -
- ))} -
-
+
+

{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..4db4c8a4 --- /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('@/hooks/use-notification', () => ({ + useNotification: () => 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(); + }); +}); diff --git a/src/components/ai/__tests__/aiComponents.test.tsx b/src/components/ai/__tests__/aiComponents.test.tsx deleted file mode 100644 index c4d8f5d3..00000000 --- a/src/components/ai/__tests__/aiComponents.test.tsx +++ /dev/null @@ -1,300 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor, act } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -// ─── Mocks ──────────────────────────────────────────────────────────────────── - -vi.mock('@/lib/api', () => ({ - apiClient: { - get: vi.fn(), - post: vi.fn(), - delete: vi.fn(), - }, -})); - -vi.mock('@/hooks/use-notification', () => ({ - useNotification: () => ({ - success: vi.fn(), - error: vi.fn(), - }), -})); - -// ─── 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'; - -// ─── LearningAssistant ──────────────────────────────────────────────────────── - -describe('LearningAssistant', () => { - beforeEach(() => { - vi.mocked(apiClient.post).mockResolvedValue({ reply: 'Hello from AI!' }); - }); - - it('renders input and send button', () => { - render(); - expect(screen.getByLabelText('Message input')).toBeInTheDocument(); - expect(screen.getByLabelText('Send message')).toBeInTheDocument(); - }); - - it('sends message and displays assistant response', async () => { - const user = userEvent.setup(); - render(); - - await user.type(screen.getByLabelText('Message input'), 'What is React?'); - await user.click(screen.getByLabelText('Send message')); - - await waitFor(() => expect(screen.getByText('Hello from AI!')).toBeInTheDocument()); - expect(apiClient.post).toHaveBeenCalledWith('/api/ai/chat', { - message: 'What is React?', - context: undefined, - }); - }); - - it('shows typing indicator while loading', async () => { - vi.mocked(apiClient.post).mockReturnValue(new Promise(() => {})); - const user = userEvent.setup(); - render(); - - await user.type(screen.getByLabelText('Message input'), 'Hello'); - await user.click(screen.getByLabelText('Send message')); - - expect(screen.getByLabelText('Assistant is typing')).toBeInTheDocument(); - }); - - it('shows error message when API fails', async () => { - vi.mocked(apiClient.post).mockRejectedValue(new Error('Network error')); - const user = userEvent.setup(); - render(); - - await user.type(screen.getByLabelText('Message input'), 'Hello'); - await user.click(screen.getByLabelText('Send message')); - - await waitFor(() => - expect(screen.getByText('Sorry, something went wrong.')).toBeInTheDocument(), - ); - }); - - it('sends message on Enter key', async () => { - const user = userEvent.setup(); - render(); - - await user.type(screen.getByLabelText('Message input'), 'Hello{Enter}'); - - await waitFor(() => expect(apiClient.post).toHaveBeenCalled()); - }); - - it('clears input after sending', async () => { - const user = userEvent.setup(); - render(); - - const input = screen.getByLabelText('Message input'); - await user.type(input, 'Hello'); - await user.click(screen.getByLabelText('Send message')); - - await waitFor(() => expect((input as HTMLInputElement).value).toBe('')); - }); -}); - -// ─── PersonalizedRecommendations ───────────────────────────────────────────── - -describe('PersonalizedRecommendations', () => { - const mockItems = [ - { id: '1', title: 'Intro to TypeScript', reason: 'Based on your React progress', url: '/courses/ts' }, - { id: '2', title: 'Advanced CSS', reason: 'Matches your interests', url: '/courses/css' }, - ]; - - beforeEach(() => { - vi.mocked(apiClient.get).mockResolvedValue({ items: mockItems }); - }); - - it('shows skeleton while loading', () => { - vi.mocked(apiClient.get).mockReturnValue(new Promise(() => {})); - render(); - expect(document.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0); - }); - - it('renders fetched recommendations', async () => { - render(); - await waitFor(() => expect(screen.getByText('Intro to TypeScript')).toBeInTheDocument()); - expect(screen.getByText('Based on your React progress')).toBeInTheDocument(); - expect(screen.getByText('Advanced CSS')).toBeInTheDocument(); - }); - - it('renders CTA links for each item', async () => { - render(); - await waitFor(() => screen.getByText('Intro to TypeScript')); - const links = screen.getAllByText('View course'); - expect(links).toHaveLength(2); - }); - - it('shows error state on failure', async () => { - vi.mocked(apiClient.get).mockRejectedValue(new Error('fail')); - render(); - await waitFor(() => - expect(screen.getByText('Failed to load recommendations.')).toBeInTheDocument(), - ); - }); - - it('shows empty state when no items', async () => { - vi.mocked(apiClient.get).mockResolvedValue({ items: [] }); - render(); - await waitFor(() => - expect(screen.getByText('No recommendations yet.')).toBeInTheDocument(), - ); - }); -}); - -// ─── IntelligentProgress ───────────────────────────────────────────────────── - -describe('IntelligentProgress', () => { - const mockData = { - courses: [ - { id: '1', title: 'React Fundamentals', percent: 80 }, - { id: '2', title: 'Node.js Basics', percent: 45 }, - ], - insights: ["You're 80% through React Fundamentals", 'Strong in hooks, review context'], - }; - - beforeEach(() => { - vi.mocked(apiClient.get).mockResolvedValue(mockData); - }); - - it('shows skeleton while loading', () => { - vi.mocked(apiClient.get).mockReturnValue(new Promise(() => {})); - render(); - expect(document.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0); - }); - - it('renders course progress bars', async () => { - render(); - await waitFor(() => expect(screen.getByText('React Fundamentals')).toBeInTheDocument()); - expect(screen.getByText('80%')).toBeInTheDocument(); - expect(screen.getByText('Node.js Basics')).toBeInTheDocument(); - expect(screen.getByText('45%')).toBeInTheDocument(); - }); - - it('renders insights', async () => { - render(); - await waitFor(() => - expect(screen.getByText(/80% through React Fundamentals/)).toBeInTheDocument(), - ); - expect(screen.getByText(/Strong in hooks/)).toBeInTheDocument(); - }); - - it('shows error state on failure', async () => { - vi.mocked(apiClient.get).mockRejectedValue(new Error('fail')); - render(); - await waitFor(() => - expect(screen.getByText('Failed to load progress.')).toBeInTheDocument(), - ); - }); -}); - -// ─── SmartNotifications ─────────────────────────────────────────────────────── - -describe('SmartNotifications', () => { - const mockReminders = [ - { id: 'r1', title: 'Review React hooks', scheduledAt: '2026-05-01T10:00:00Z' }, - { id: 'r2', title: 'Complete CSS quiz', scheduledAt: '2026-05-02T14:00:00Z' }, - ]; - - beforeEach(() => { - vi.mocked(apiClient.get).mockResolvedValue({ reminders: mockReminders }); - vi.mocked(apiClient.delete).mockResolvedValue({}); - }); - - it('renders reminders after loading', async () => { - render(); - await waitFor(() => expect(screen.getByText('Review React hooks')).toBeInTheDocument()); - expect(screen.getByText('Complete CSS quiz')).toBeInTheDocument(); - }); - - it('dismisses a reminder on button click', async () => { - const user = userEvent.setup(); - render(); - await waitFor(() => screen.getByText('Review React hooks')); - - await user.click(screen.getByLabelText('Dismiss Review React hooks')); - - await waitFor(() => - expect(screen.queryByText('Review React hooks')).not.toBeInTheDocument(), - ); - expect(apiClient.delete).toHaveBeenCalledWith('/api/ai/reminders/r1'); - }); - - it('shows empty state when no reminders', async () => { - vi.mocked(apiClient.get).mockResolvedValue({ reminders: [] }); - render(); - await waitFor(() => - expect(screen.getByText('No upcoming reminders.')).toBeInTheDocument(), - ); - }); -}); - -// ─── NaturalLanguageQuery ───────────────────────────────────────────────────── - -describe('NaturalLanguageQuery', () => { - const mockResults = [ - { id: '1', title: 'Intro to ML', description: 'Machine learning basics', url: '/courses/ml' }, - { id: '2', title: 'Deep Learning', description: 'Neural networks', url: '/courses/dl' }, - ]; - - beforeEach(() => { - vi.mocked(apiClient.post).mockResolvedValue({ results: mockResults }); - }); - - it('renders search input', () => { - render(); - expect(screen.getByLabelText('Natural language search')).toBeInTheDocument(); - }); - - it('submits query and renders results', async () => { - const user = userEvent.setup(); - render(); - - await user.type(screen.getByLabelText('Natural language search'), 'machine learning'); - await user.click(screen.getByLabelText('Search')); - - await waitFor(() => expect(screen.getByText('Intro to ML')).toBeInTheDocument()); - expect(screen.getByText('Deep Learning')).toBeInTheDocument(); - expect(apiClient.post).toHaveBeenCalledWith('/api/ai/search', { query: 'machine learning' }); - }); - - it('shows empty state when no results', async () => { - vi.mocked(apiClient.post).mockResolvedValue({ results: [] }); - const user = userEvent.setup(); - render(); - - await user.type(screen.getByLabelText('Natural language search'), 'xyzzy'); - await user.click(screen.getByLabelText('Search')); - - await waitFor(() => expect(screen.getByText('No results found.')).toBeInTheDocument()); - }); - - it('shows error state on failure', async () => { - vi.mocked(apiClient.post).mockRejectedValue(new Error('fail')); - const user = userEvent.setup(); - render(); - - await user.type(screen.getByLabelText('Natural language search'), 'test'); - await user.click(screen.getByLabelText('Search')); - - await waitFor(() => - expect(screen.getByText('Search failed. Please try again.')).toBeInTheDocument(), - ); - }); - - it('submits on Enter key', async () => { - const user = userEvent.setup(); - render(); - - await user.type(screen.getByLabelText('Natural language search'), 'react{Enter}'); - - await waitFor(() => expect(apiClient.post).toHaveBeenCalled()); - }); -});