Skip to content

Commit a221296

Browse files
author
Peter Gado Favour
committed
Security grading system
1 parent 56d7178 commit a221296

5 files changed

Lines changed: 267 additions & 39 deletions

File tree

src/app/quiz/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ const demoQuiz: Quiz = {
3939
explanation: 'One solution: return input.toUpperCase();',
4040
codeTemplate: 'return input.toUpperCase();',
4141
language: 'javascript',
42+
gradingPolicy: {
43+
partialCredit: true,
44+
normalizeWhitespace: true,
45+
},
4246
testCases: [
4347
{ input: 'hello', expectedOutput: 'HELLO' },
4448
{ input: 'TeachLink', expectedOutput: 'TEACHLINK' },

src/components/quizzes/QuestionCard.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ interface QuestionCardProps {
1414
const QuestionCard = React.memo(({ question, quizState }: QuestionCardProps) => {
1515
const answer = quizState.answers[question.id];
1616
const showFeedback = Boolean(answer?.feedback);
17+
const feedbackIsPartial = answer?.feedback === 'partial';
1718

1819
return (
1920
<div className="bg-white dark:bg-[#1E293B] rounded-xl shadow-sm border border-[#E2E8F0] dark:border-[#334155] p-6">
@@ -22,10 +23,18 @@ const QuestionCard = React.memo(({ question, quizState }: QuestionCardProps) =>
2223
{showFeedback ? (
2324
<div
2425
className={`text-sm font-medium ${
25-
answer?.feedback === 'correct' ? 'text-[#0066FF] dark:text-[#00C2FF]' : 'text-red-700'
26+
answer?.feedback === 'correct'
27+
? 'text-[#0066FF] dark:text-[#00C2FF]'
28+
: feedbackIsPartial
29+
? 'text-amber-700 dark:text-amber-300'
30+
: 'text-red-700'
2631
}`}
2732
>
28-
{answer?.feedback === 'correct' ? 'Correct' : 'Incorrect'}
33+
{answer?.feedback === 'correct'
34+
? 'Correct'
35+
: feedbackIsPartial
36+
? 'Partially correct'
37+
: 'Incorrect'}
2938
</div>
3039
) : null}
3140
</div>

src/components/quizzes/question-types/CodeChallengeQuestion.tsx

Lines changed: 57 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,17 @@
11
'use client';
22

33
import { useMemo, useState } from 'react';
4-
import type { CodeChallengeQuizQuestion, UseQuizReturn } from '@/hooks/useQuiz';
4+
import {
5+
normalizeQuizOutput,
6+
type CodeChallengeQuizQuestion,
7+
type UseQuizReturn,
8+
} from '@/hooks/useQuiz';
59

610
interface CodeChallengeQuestionProps {
711
question: CodeChallengeQuizQuestion;
812
quizState: UseQuizReturn;
913
}
1014

11-
function normalizeOutput(value: unknown) {
12-
if (typeof value === 'string') return value;
13-
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
14-
try {
15-
return JSON.stringify(value);
16-
} catch {
17-
return String(value);
18-
}
19-
}
20-
2115
export default function CodeChallengeQuestion({ question, quizState }: CodeChallengeQuestionProps) {
2216
const { answers, isReviewMode, isCompleted, actions } = quizState;
2317
const existing = answers[question.id];
@@ -29,30 +23,44 @@ export default function CodeChallengeQuestion({ question, quizState }: CodeChall
2923
const [isRunning, setIsRunning] = useState(false);
3024

3125
const hasTestCases = Boolean(question.testCases && question.testCases.length);
26+
const passedTests = testResults.filter(Boolean).length;
27+
const totalTests = testResults.length;
3228

3329
const overallPassed = useMemo(
3430
() => (testResults.length ? testResults.every(Boolean) : false),
3531
[testResults],
3632
);
33+
const partialPass =
34+
totalTests > 0 && passedTests > 0 && !overallPassed && Boolean(question.gradingPolicy?.partialCredit);
3735

3836
const runTests = () => {
3937
if (!hasTestCases || !question.testCases) return;
4038

4139
setIsRunning(true);
4240

43-
const results = question.testCases.map((testCase) => {
44-
try {
45-
const userFunction = new Function('input', code);
46-
const output = userFunction(testCase.input);
47-
return normalizeOutput(output) === normalizeOutput(testCase.expectedOutput);
48-
} catch {
49-
return false;
50-
}
51-
});
52-
53-
setTestResults(results);
54-
actions.setCodeChallengeResult(question.id, { code, testResults: results });
55-
setIsRunning(false);
41+
try {
42+
const userFunction = new Function('input', code) as (input: string) => unknown;
43+
const results = question.testCases.map((testCase) => {
44+
try {
45+
const output = userFunction(testCase.input);
46+
return (
47+
normalizeQuizOutput(output, question.gradingPolicy) ===
48+
normalizeQuizOutput(testCase.expectedOutput, question.gradingPolicy)
49+
);
50+
} catch {
51+
return false;
52+
}
53+
});
54+
55+
setTestResults(results);
56+
actions.setCodeChallengeResult(question.id, { code, testResults: results });
57+
} catch {
58+
const results = question.testCases.map(() => false);
59+
setTestResults(results);
60+
actions.setCodeChallengeResult(question.id, { code, testResults: results });
61+
} finally {
62+
setIsRunning(false);
63+
}
5664
};
5765

5866
const onChangeCode = (value: string) => {
@@ -85,19 +93,36 @@ export default function CodeChallengeQuestion({ question, quizState }: CodeChall
8593
<div className="space-y-2">
8694
<div
8795
className={`text-sm font-medium ${
88-
overallPassed ? 'text-[#0066FF] dark:text-[#00C2FF]' : 'text-red-700'
96+
overallPassed
97+
? 'text-[#0066FF] dark:text-[#00C2FF]'
98+
: partialPass
99+
? 'text-amber-700 dark:text-amber-300'
100+
: 'text-red-700'
89101
}`}
90102
>
91-
{overallPassed ? 'All tests passed' : 'Some tests failed'}
103+
{overallPassed
104+
? 'All tests passed'
105+
: partialPass
106+
? `${passedTests} of ${totalTests} tests passed`
107+
: 'Some tests failed'}
92108
</div>
93109

110+
{partialPass && question.gradingPolicy?.partialCredit ? (
111+
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-200">
112+
This submission is partially correct. The grader will tolerate the failed tests and
113+
award partial credit.
114+
</div>
115+
) : null}
116+
94117
{question.testCases.map((testCase, index) => (
95118
<div
96119
key={index}
97120
className={`p-3 rounded-lg border ${
98121
testResults[index]
99122
? 'bg-[#F0F9FF] dark:bg-[#1E3A8A]/20 border-[#0066FF]/20 dark:border-[#00C2FF]/20'
100-
: 'bg-red-50 border-red-200'
123+
: partialPass
124+
? 'bg-amber-50 border-amber-200 dark:bg-amber-950/40 dark:border-amber-700'
125+
: 'bg-red-50 border-red-200'
101126
}`}
102127
>
103128
<div className="flex items-start justify-between gap-4">
@@ -114,7 +139,11 @@ export default function CodeChallengeQuestion({ question, quizState }: CodeChall
114139
</div>
115140
<div
116141
className={`text-sm font-medium ${
117-
testResults[index] ? 'text-[#0066FF] dark:text-[#00C2FF]' : 'text-red-700'
142+
testResults[index]
143+
? 'text-[#0066FF] dark:text-[#00C2FF]'
144+
: partialPass
145+
? 'text-amber-700 dark:text-amber-300'
146+
: 'text-red-700'
118147
}`}
119148
>
120149
{testResults[index] ? 'Pass' : 'Fail'}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { act, renderHook } from '@testing-library/react';
2+
import { describe, expect, it } from 'vitest';
3+
import {
4+
gradeCodeChallengeSubmission,
5+
normalizeQuizOutput,
6+
useQuiz,
7+
type CodeChallengeQuizQuestion,
8+
type Quiz,
9+
} from '../useQuiz';
10+
11+
const quizFixture: Quiz = {
12+
id: 'quiz-1',
13+
title: 'Grading fixture',
14+
questions: [
15+
{
16+
id: 'mc-1',
17+
type: 'multiple-choice',
18+
text: 'Pick the correct answer',
19+
points: 2,
20+
options: [
21+
{ id: 'a', text: 'Wrong', isCorrect: false },
22+
{ id: 'b', text: 'Right', isCorrect: true },
23+
],
24+
},
25+
{
26+
id: 'code-1',
27+
type: 'code-challenge',
28+
text: 'Return the input value.',
29+
points: 4,
30+
gradingPolicy: {
31+
partialCredit: true,
32+
normalizeWhitespace: true,
33+
},
34+
testCases: [
35+
{ input: 'hello', expectedOutput: 'hello' },
36+
{ input: 'TeachLink', expectedOutput: 'TeachLink' },
37+
{ input: 'white space', expectedOutput: 'white space' },
38+
],
39+
},
40+
],
41+
};
42+
43+
describe('quiz grading helpers', () => {
44+
it('normalizes output according to the configured tolerations', () => {
45+
expect(
46+
normalizeQuizOutput(' Hello\r\nWorld ', {
47+
normalizeWhitespace: true,
48+
normalizeCase: true,
49+
}),
50+
).toBe('hello world');
51+
});
52+
53+
it('grants partial credit for partially passing code challenges', () => {
54+
const result = gradeCodeChallengeSubmission(
55+
quizFixture.questions[1] as CodeChallengeQuizQuestion,
56+
[true, false, true],
57+
);
58+
59+
expect(result.feedback).toBe('partial');
60+
expect(result.isCorrect).toBe(false);
61+
expect(result.earnedPoints).toBe(2);
62+
expect(result.meta).toMatchObject({
63+
passRate: 2 / 3,
64+
passedTests: 2,
65+
totalTests: 3,
66+
tainted: true,
67+
tolerated: true,
68+
partialCreditEnabled: true,
69+
});
70+
});
71+
});
72+
73+
describe('useQuiz', () => {
74+
it('tracks partial code-credit and regular grading without regressing score updates', () => {
75+
const { result } = renderHook(() => useQuiz({ quiz: quizFixture, autoStart: false }));
76+
77+
act(() => {
78+
result.current.actions.answerQuestion('mc-1', 'b');
79+
});
80+
81+
act(() => {
82+
result.current.actions.setCodeChallengeResult('code-1', {
83+
code: 'return input;',
84+
testResults: [true, false, true],
85+
});
86+
});
87+
88+
expect(result.current.score).toBe(4);
89+
expect(result.current.answeredCount).toBe(2);
90+
expect(result.current.answers['mc-1']).toMatchObject({
91+
isCorrect: true,
92+
earnedPoints: 2,
93+
feedback: 'correct',
94+
});
95+
expect(result.current.answers['code-1']).toMatchObject({
96+
isCorrect: false,
97+
earnedPoints: 2,
98+
feedback: 'partial',
99+
});
100+
expect(result.current.answers['code-1'].meta).toMatchObject({
101+
tainted: true,
102+
tolerated: true,
103+
partialCreditEnabled: true,
104+
});
105+
});
106+
});

0 commit comments

Comments
 (0)