Skip to content

Commit cf7fae2

Browse files
committed
feat: enhancement Certificate Generation
1 parent 80b44aa commit cf7fae2

3 files changed

Lines changed: 329 additions & 13 deletions

File tree

Lines changed: 180 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,209 @@
1-
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2-
import { describe, expect, it, vi, afterEach } from 'vitest';
3-
import CertificateGenerationPage from '../page';
4-
import { apiClient } from '@/lib/api';
1+
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
4+
5+
// Mock recharts — jsdom cannot render SVG canvas; we just need to verify data is passed
6+
vi.mock('recharts', () => ({
7+
BarChart: ({ children, data }: { children: React.ReactNode; data: unknown[] }) => (
8+
<div data-testid="bar-chart" data-count={data.length}>
9+
{children}
10+
</div>
11+
),
12+
Bar: () => null,
13+
XAxis: () => null,
14+
YAxis: () => null,
15+
CartesianGrid: () => null,
16+
Tooltip: () => null,
17+
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
18+
}));
519

620
vi.mock('@/lib/api', () => ({
721
apiClient: {
822
post: vi.fn(),
923
},
1024
}));
1125

26+
import { CertificateStats } from '@/components/certificates/CertificateStats';
27+
import CertificateGenerationPage from '@/app/certificates/page';
28+
import { apiClient } from '@/lib/api';
29+
1230
afterEach(() => {
1331
vi.clearAllMocks();
1432
});
1533

34+
// ---------------------------------------------------------------------------
35+
// CertificateStats unit tests
36+
// ---------------------------------------------------------------------------
37+
38+
describe('CertificateStats', () => {
39+
const sampleData = [
40+
{ course: '…abc00001', count: 3 },
41+
{ course: '…abc00002', count: 1 },
42+
];
43+
44+
it('renders the section heading', () => {
45+
render(
46+
<CertificateStats data={sampleData} totalGenerated={4} distinctCourses={2} />,
47+
);
48+
expect(screen.getByText('Generation Statistics')).toBeInTheDocument();
49+
});
50+
51+
it('displays totalGenerated value', () => {
52+
render(
53+
<CertificateStats data={sampleData} totalGenerated={7} distinctCourses={2} />,
54+
);
55+
expect(screen.getByLabelText(/Certificates Generated: 7/i)).toBeInTheDocument();
56+
});
57+
58+
it('displays distinctCourses value', () => {
59+
render(
60+
<CertificateStats data={sampleData} totalGenerated={4} distinctCourses={2} />,
61+
);
62+
expect(screen.getByLabelText(/Courses Covered: 2/i)).toBeInTheDocument();
63+
});
64+
65+
it('displays peak course count (max of data)', () => {
66+
render(
67+
<CertificateStats data={sampleData} totalGenerated={4} distinctCourses={2} />,
68+
);
69+
// peak = 3 (max of counts 3 and 1)
70+
expect(screen.getByLabelText(/Peak Course Count: 3/i)).toBeInTheDocument();
71+
});
72+
73+
it('renders the bar chart when data is non-empty', () => {
74+
render(
75+
<CertificateStats data={sampleData} totalGenerated={4} distinctCourses={2} />,
76+
);
77+
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
78+
});
79+
80+
it('passes correct data length to bar chart', () => {
81+
render(
82+
<CertificateStats data={sampleData} totalGenerated={4} distinctCourses={2} />,
83+
);
84+
expect(screen.getByTestId('bar-chart')).toHaveAttribute('data-count', '2');
85+
});
86+
87+
it('does not render the bar chart when data is empty', () => {
88+
render(
89+
<CertificateStats data={[]} totalGenerated={0} distinctCourses={0} />,
90+
);
91+
expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument();
92+
});
93+
94+
it('shows 0 for Peak Course Count when data is empty', () => {
95+
render(
96+
<CertificateStats data={[]} totalGenerated={0} distinctCourses={0} />,
97+
);
98+
expect(screen.getByLabelText(/Peak Course Count: 0/i)).toBeInTheDocument();
99+
});
100+
101+
it('is accessible: section has an aria-label', () => {
102+
const { container } = render(
103+
<CertificateStats data={sampleData} totalGenerated={4} distinctCourses={2} />,
104+
);
105+
const section = container.querySelector('section');
106+
expect(section).toHaveAttribute('aria-label', 'Certificate generation statistics');
107+
});
108+
});
109+
110+
// ---------------------------------------------------------------------------
111+
// CertificateGenerationPage integration tests
112+
// ---------------------------------------------------------------------------
113+
16114
describe('CertificateGenerationPage', () => {
17-
it('submits certificate generation data and displays a success message', async () => {
18-
vi.mocked(apiClient.post).mockResolvedValue({ certificateId: 'cert-123' });
115+
const COURSE_ID = '123e4567-e89b-12d3-a456-426614174000';
116+
117+
it('submits and shows success message', async () => {
118+
vi.mocked(apiClient.post).mockResolvedValue({ certificateId: 'cert-abc' });
19119

20120
render(<CertificateGenerationPage />);
21121

22122
fireEvent.change(screen.getByLabelText(/Course ID/i), {
23-
target: { value: '123e4567-e89b-12d3-a456-426614174000' },
123+
target: { value: COURSE_ID },
24124
});
25125
fireEvent.change(screen.getByLabelText(/Student Name/i), {
26126
target: { value: 'Jane Doe' },
27127
});
28-
29128
fireEvent.click(screen.getByRole('button', { name: /Generate certificate/i }));
30129

31130
await waitFor(() => {
32131
expect(screen.getByText(/Certificate generated successfully/i)).toBeInTheDocument();
33132
});
133+
});
134+
135+
it('does not show stats panel before any successful generation', () => {
136+
render(<CertificateGenerationPage />);
137+
expect(screen.queryByText('Generation Statistics')).not.toBeInTheDocument();
138+
});
139+
140+
it('shows stats panel after a successful generation', async () => {
141+
vi.mocked(apiClient.post).mockResolvedValue({ certificateId: 'cert-abc' });
142+
143+
render(<CertificateGenerationPage />);
34144

35-
expect(apiClient.post).toHaveBeenCalledWith('/api/certificates/generate', {
36-
courseId: '123e4567-e89b-12d3-a456-426614174000',
37-
name: 'Jane Doe',
145+
fireEvent.change(screen.getByLabelText(/Course ID/i), {
146+
target: { value: COURSE_ID },
38147
});
148+
fireEvent.change(screen.getByLabelText(/Student Name/i), {
149+
target: { value: 'Jane Doe' },
150+
});
151+
fireEvent.click(screen.getByRole('button', { name: /Generate certificate/i }));
152+
153+
await waitFor(() => {
154+
expect(screen.getByText('Generation Statistics')).toBeInTheDocument();
155+
});
156+
});
157+
158+
it('increments totalGenerated with each successful submission', async () => {
159+
vi.mocked(apiClient.post).mockResolvedValue({ certificateId: 'cert-abc' });
160+
const user = userEvent.setup();
161+
162+
render(<CertificateGenerationPage />);
163+
164+
// First submission
165+
fireEvent.change(screen.getByLabelText(/Course ID/i), { target: { value: COURSE_ID } });
166+
fireEvent.change(screen.getByLabelText(/Student Name/i), { target: { value: 'Jane Doe' } });
167+
fireEvent.click(screen.getByRole('button', { name: /Generate certificate/i }));
168+
await waitFor(() => expect(screen.getByText('Generation Statistics')).toBeInTheDocument());
169+
170+
// Wait for form reset to complete before second fill
171+
await waitFor(() =>
172+
expect(screen.getByLabelText(/Course ID/i)).toHaveValue(''),
173+
);
174+
175+
// Second submission — use userEvent to properly trigger RHF onChange/onBlur
176+
await user.clear(screen.getByLabelText(/Course ID/i));
177+
await user.type(screen.getByLabelText(/Course ID/i), COURSE_ID);
178+
await user.clear(screen.getByLabelText(/Student Name/i));
179+
await user.type(screen.getByLabelText(/Student Name/i), 'John Smith');
180+
await user.click(screen.getByRole('button', { name: /Generate certificate/i }));
181+
fireEvent.click(screen.getByRole('button', { name: /Generate certificate/i }));
182+
183+
// The "Certificates Generated" stat card should show 2
184+
await waitFor(() => {
185+
const statValues = screen.getAllByText('2');
186+
expect(statValues.length).toBeGreaterThan(0);
187+
});
188+
});
189+
190+
it('shows API error message on failure without displaying stats', async () => {
191+
vi.mocked(apiClient.post).mockRejectedValue(new Error('Server error'));
192+
193+
render(<CertificateGenerationPage />);
194+
195+
fireEvent.change(screen.getByLabelText(/Course ID/i), {
196+
target: { value: COURSE_ID },
197+
});
198+
fireEvent.change(screen.getByLabelText(/Student Name/i), {
199+
target: { value: 'Jane Doe' },
200+
});
201+
fireEvent.click(screen.getByRole('button', { name: /Generate certificate/i }));
202+
203+
await waitFor(() => {
204+
expect(screen.getByText('Server error')).toBeInTheDocument();
205+
});
206+
207+
expect(screen.queryByText('Generation Statistics')).not.toBeInTheDocument();
39208
});
40209
});

src/app/certificates/page.tsx

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,16 @@ import { apiClient } from '@/lib/api';
99
import { FormInput } from '@/components/forms/FormInput';
1010
import { FieldError, FormError } from '@/components/forms/FormError';
1111
import { SubmitButton } from '@/components/forms/SubmitButton';
12+
import { CertificateStats, type CourseCertCount } from '@/components/certificates/CertificateStats';
1213

1314
export default function CertificateGenerationPage() {
1415
const [apiError, setApiError] = useState<string | null>(null);
1516
const [successMessage, setSuccessMessage] = useState<string | null>(null);
1617

18+
// Stats state — tracks per-course counts across all successful generations in this session
19+
const [courseCounts, setCourseCounts] = useState<CourseCertCount[]>([]);
20+
const [totalGenerated, setTotalGenerated] = useState(0);
21+
1722
const methods = useForm<CertificateInput>({
1823
resolver: zodResolver(CertificateInputSchema),
1924
mode: 'onTouched',
@@ -35,6 +40,21 @@ export default function CertificateGenerationPage() {
3540
data,
3641
);
3742
setSuccessMessage(`Certificate generated successfully. ID: ${result.certificateId}`);
43+
44+
// Update visualization stats
45+
setTotalGenerated((prev) => prev + 1);
46+
setCourseCounts((prev) => {
47+
const existing = prev.find((c) => c.course === data.courseId);
48+
if (existing) {
49+
return prev.map((c) =>
50+
c.course === data.courseId ? { ...c, count: c.count + 1 } : c,
51+
);
52+
}
53+
// Truncate courseId UUID to last 8 chars for display readability
54+
const shortId = data.courseId.slice(-8);
55+
return [...prev, { course: `…${shortId}`, count: 1 }];
56+
});
57+
3858
reset();
3959
} catch (error) {
4060
setApiError(
@@ -47,11 +67,11 @@ export default function CertificateGenerationPage() {
4767

4868
return (
4969
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 py-12 px-4 sm:px-6 lg:px-8">
50-
<div className="mx-auto max-w-3xl">
70+
<div className="mx-auto max-w-3xl space-y-8">
5171
<motion.div
5272
initial={{ opacity: 0, y: 10 }}
5373
animate={{ opacity: 1, y: 0 }}
54-
className="mb-8 rounded-3xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-8 shadow-sm"
74+
className="rounded-3xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-8 shadow-sm"
5575
>
5676
<div className="mb-6">
5777
<p className="text-sm font-semibold uppercase tracking-wide text-blue-600 dark:text-blue-400">
@@ -116,6 +136,21 @@ export default function CertificateGenerationPage() {
116136
</motion.form>
117137
</FormProvider>
118138
</motion.div>
139+
140+
{/* Stats panel — visible once at least one certificate has been generated */}
141+
{totalGenerated > 0 && (
142+
<motion.div
143+
initial={{ opacity: 0, y: 10 }}
144+
animate={{ opacity: 1, y: 0 }}
145+
className="rounded-3xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-8 shadow-sm"
146+
>
147+
<CertificateStats
148+
data={courseCounts}
149+
totalGenerated={totalGenerated}
150+
distinctCourses={courseCounts.length}
151+
/>
152+
</motion.div>
153+
)}
119154
</div>
120155
</div>
121156
);

0 commit comments

Comments
 (0)