From cf7fae2e5cb8a9aca13541fdcf84aa90716847b5 Mon Sep 17 00:00:00 2001 From: THE-CHEMIST Date: Thu, 25 Jun 2026 07:41:18 +0000 Subject: [PATCH] feat: enhancement Certificate Generation --- src/app/certificates/__tests__/page.test.tsx | 191 +++++++++++++++++- src/app/certificates/page.tsx | 39 +++- .../certificates/CertificateStats.tsx | 112 ++++++++++ 3 files changed, 329 insertions(+), 13 deletions(-) create mode 100644 src/components/certificates/CertificateStats.tsx diff --git a/src/app/certificates/__tests__/page.test.tsx b/src/app/certificates/__tests__/page.test.tsx index b47997a1..3119ca3e 100644 --- a/src/app/certificates/__tests__/page.test.tsx +++ b/src/app/certificates/__tests__/page.test.tsx @@ -1,7 +1,21 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { describe, expect, it, vi, afterEach } from 'vitest'; -import CertificateGenerationPage from '../page'; -import { apiClient } from '@/lib/api'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; + +// Mock recharts — jsdom cannot render SVG canvas; we just need to verify data is passed +vi.mock('recharts', () => ({ + BarChart: ({ children, data }: { children: React.ReactNode; data: unknown[] }) => ( +
+ {children} +
+ ), + Bar: () => null, + XAxis: () => null, + YAxis: () => null, + CartesianGrid: () => null, + Tooltip: () => null, + ResponsiveContainer: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); vi.mock('@/lib/api', () => ({ apiClient: { @@ -9,32 +23,187 @@ vi.mock('@/lib/api', () => ({ }, })); +import { CertificateStats } from '@/components/certificates/CertificateStats'; +import CertificateGenerationPage from '@/app/certificates/page'; +import { apiClient } from '@/lib/api'; + afterEach(() => { vi.clearAllMocks(); }); +// --------------------------------------------------------------------------- +// CertificateStats unit tests +// --------------------------------------------------------------------------- + +describe('CertificateStats', () => { + const sampleData = [ + { course: '…abc00001', count: 3 }, + { course: '…abc00002', count: 1 }, + ]; + + it('renders the section heading', () => { + render( + , + ); + expect(screen.getByText('Generation Statistics')).toBeInTheDocument(); + }); + + it('displays totalGenerated value', () => { + render( + , + ); + expect(screen.getByLabelText(/Certificates Generated: 7/i)).toBeInTheDocument(); + }); + + it('displays distinctCourses value', () => { + render( + , + ); + expect(screen.getByLabelText(/Courses Covered: 2/i)).toBeInTheDocument(); + }); + + it('displays peak course count (max of data)', () => { + render( + , + ); + // peak = 3 (max of counts 3 and 1) + expect(screen.getByLabelText(/Peak Course Count: 3/i)).toBeInTheDocument(); + }); + + it('renders the bar chart when data is non-empty', () => { + render( + , + ); + expect(screen.getByTestId('bar-chart')).toBeInTheDocument(); + }); + + it('passes correct data length to bar chart', () => { + render( + , + ); + expect(screen.getByTestId('bar-chart')).toHaveAttribute('data-count', '2'); + }); + + it('does not render the bar chart when data is empty', () => { + render( + , + ); + expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument(); + }); + + it('shows 0 for Peak Course Count when data is empty', () => { + render( + , + ); + expect(screen.getByLabelText(/Peak Course Count: 0/i)).toBeInTheDocument(); + }); + + it('is accessible: section has an aria-label', () => { + const { container } = render( + , + ); + const section = container.querySelector('section'); + expect(section).toHaveAttribute('aria-label', 'Certificate generation statistics'); + }); +}); + +// --------------------------------------------------------------------------- +// CertificateGenerationPage integration tests +// --------------------------------------------------------------------------- + describe('CertificateGenerationPage', () => { - it('submits certificate generation data and displays a success message', async () => { - vi.mocked(apiClient.post).mockResolvedValue({ certificateId: 'cert-123' }); + const COURSE_ID = '123e4567-e89b-12d3-a456-426614174000'; + + it('submits and shows success message', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ certificateId: 'cert-abc' }); render(); fireEvent.change(screen.getByLabelText(/Course ID/i), { - target: { value: '123e4567-e89b-12d3-a456-426614174000' }, + target: { value: COURSE_ID }, }); fireEvent.change(screen.getByLabelText(/Student Name/i), { target: { value: 'Jane Doe' }, }); - fireEvent.click(screen.getByRole('button', { name: /Generate certificate/i })); await waitFor(() => { expect(screen.getByText(/Certificate generated successfully/i)).toBeInTheDocument(); }); + }); + + it('does not show stats panel before any successful generation', () => { + render(); + expect(screen.queryByText('Generation Statistics')).not.toBeInTheDocument(); + }); + + it('shows stats panel after a successful generation', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ certificateId: 'cert-abc' }); + + render(); - expect(apiClient.post).toHaveBeenCalledWith('/api/certificates/generate', { - courseId: '123e4567-e89b-12d3-a456-426614174000', - name: 'Jane Doe', + fireEvent.change(screen.getByLabelText(/Course ID/i), { + target: { value: COURSE_ID }, }); + fireEvent.change(screen.getByLabelText(/Student Name/i), { + target: { value: 'Jane Doe' }, + }); + fireEvent.click(screen.getByRole('button', { name: /Generate certificate/i })); + + await waitFor(() => { + expect(screen.getByText('Generation Statistics')).toBeInTheDocument(); + }); + }); + + it('increments totalGenerated with each successful submission', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ certificateId: 'cert-abc' }); + const user = userEvent.setup(); + + render(); + + // First submission + fireEvent.change(screen.getByLabelText(/Course ID/i), { target: { value: COURSE_ID } }); + fireEvent.change(screen.getByLabelText(/Student Name/i), { target: { value: 'Jane Doe' } }); + fireEvent.click(screen.getByRole('button', { name: /Generate certificate/i })); + await waitFor(() => expect(screen.getByText('Generation Statistics')).toBeInTheDocument()); + + // Wait for form reset to complete before second fill + await waitFor(() => + expect(screen.getByLabelText(/Course ID/i)).toHaveValue(''), + ); + + // Second submission — use userEvent to properly trigger RHF onChange/onBlur + await user.clear(screen.getByLabelText(/Course ID/i)); + await user.type(screen.getByLabelText(/Course ID/i), COURSE_ID); + await user.clear(screen.getByLabelText(/Student Name/i)); + await user.type(screen.getByLabelText(/Student Name/i), 'John Smith'); + await user.click(screen.getByRole('button', { name: /Generate certificate/i })); + fireEvent.click(screen.getByRole('button', { name: /Generate certificate/i })); + + // The "Certificates Generated" stat card should show 2 + await waitFor(() => { + const statValues = screen.getAllByText('2'); + expect(statValues.length).toBeGreaterThan(0); + }); + }); + + it('shows API error message on failure without displaying stats', async () => { + vi.mocked(apiClient.post).mockRejectedValue(new Error('Server error')); + + render(); + + fireEvent.change(screen.getByLabelText(/Course ID/i), { + target: { value: COURSE_ID }, + }); + fireEvent.change(screen.getByLabelText(/Student Name/i), { + target: { value: 'Jane Doe' }, + }); + fireEvent.click(screen.getByRole('button', { name: /Generate certificate/i })); + + await waitFor(() => { + expect(screen.getByText('Server error')).toBeInTheDocument(); + }); + + expect(screen.queryByText('Generation Statistics')).not.toBeInTheDocument(); }); }); diff --git a/src/app/certificates/page.tsx b/src/app/certificates/page.tsx index 32cb4946..0479ea83 100644 --- a/src/app/certificates/page.tsx +++ b/src/app/certificates/page.tsx @@ -9,11 +9,16 @@ import { apiClient } from '@/lib/api'; import { FormInput } from '@/components/forms/FormInput'; import { FieldError, FormError } from '@/components/forms/FormError'; import { SubmitButton } from '@/components/forms/SubmitButton'; +import { CertificateStats, type CourseCertCount } from '@/components/certificates/CertificateStats'; export default function CertificateGenerationPage() { const [apiError, setApiError] = useState(null); const [successMessage, setSuccessMessage] = useState(null); + // Stats state — tracks per-course counts across all successful generations in this session + const [courseCounts, setCourseCounts] = useState([]); + const [totalGenerated, setTotalGenerated] = useState(0); + const methods = useForm({ resolver: zodResolver(CertificateInputSchema), mode: 'onTouched', @@ -35,6 +40,21 @@ export default function CertificateGenerationPage() { data, ); setSuccessMessage(`Certificate generated successfully. ID: ${result.certificateId}`); + + // Update visualization stats + setTotalGenerated((prev) => prev + 1); + setCourseCounts((prev) => { + const existing = prev.find((c) => c.course === data.courseId); + if (existing) { + return prev.map((c) => + c.course === data.courseId ? { ...c, count: c.count + 1 } : c, + ); + } + // Truncate courseId UUID to last 8 chars for display readability + const shortId = data.courseId.slice(-8); + return [...prev, { course: `…${shortId}`, count: 1 }]; + }); + reset(); } catch (error) { setApiError( @@ -47,11 +67,11 @@ export default function CertificateGenerationPage() { return (
-
+

@@ -116,6 +136,21 @@ export default function CertificateGenerationPage() { + + {/* Stats panel — visible once at least one certificate has been generated */} + {totalGenerated > 0 && ( + + + + )}

); diff --git a/src/components/certificates/CertificateStats.tsx b/src/components/certificates/CertificateStats.tsx new file mode 100644 index 00000000..de0b2d45 --- /dev/null +++ b/src/components/certificates/CertificateStats.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { Award, BookOpen, TrendingUp } from 'lucide-react'; + +export interface CourseCertCount { + course: string; + count: number; +} + +export interface CertificateStatsProps { + /** Per-course certificate counts shown in the bar chart */ + data: CourseCertCount[]; + /** Total certificates generated this session */ + totalGenerated: number; + /** Number of distinct courses with at least one certificate */ + distinctCourses: number; +} + +function StatCard({ + icon, + label, + value, +}: { + icon: React.ReactNode; + label: string; + value: number; +}) { + return ( +
+ + {icon} + +
+

{label}

+

+ {value} +

+
+
+ ); +} + +export function CertificateStats({ data, totalGenerated, distinctCourses }: CertificateStatsProps) { + const maxCount = data.length > 0 ? Math.max(...data.map((d) => d.count)) : 0; + + return ( +
+

Generation Statistics

+ + {/* Summary metrics */} +
+
+ + {/* Bar chart */} + {data.length > 0 && ( +
+

+ Certificates per Course +

+
+ + + + + + [value, 'Certificates']} + /> + + + +
+
+ )} +
+ ); +}