diff --git a/frontend/src/utils/grading.test.ts b/frontend/src/utils/grading.test.ts new file mode 100644 index 0000000..628211f --- /dev/null +++ b/frontend/src/utils/grading.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest' +import { + normalizeString, + normalizeLatex, + arraysEqualUnordered, + arraysEqualOrdered, + gradeQuestion, +} from './grading' +import type { Question } from '../types' + +describe('normalizeString', () => { + it('lowercases by default', () => { + expect(normalizeString('Hello World')).toBe('hello world') + }) + it('removes accents by default', () => { + expect(normalizeString('café')).toBe('cafe') + }) + it('removes punctuation by default', () => { + expect(normalizeString('Hello, world!')).toBe('hello world') + }) + it('respects caseSensitive', () => { + expect(normalizeString('Hello', { caseSensitive: true })).toBe('Hello') + }) + it('respects ignoreAccents false', () => { + expect(normalizeString('café', { ignoreAccents: false })).toBe('café') + }) +}) + +describe('normalizeLatex', () => { + it('strips whitespace', () => { + expect(normalizeLatex('2x + 1')).toBe('2x+1') + }) + it('strips left/right', () => { + expect(normalizeLatex('\\left(x\\right)')).toBe('(x)') + }) + it('replaces cdot with *', () => { + expect(normalizeLatex('a\\cdot b')).toBe('a*b') + }) +}) + +describe('arraysEqualUnordered', () => { + it('matches same elements in different order', () => { + expect(arraysEqualUnordered(['a', 'b'], ['b', 'a'])).toBe(true) + }) + it('fails on different elements', () => { + expect(arraysEqualUnordered(['a', 'b'], ['a', 'c'])).toBe(false) + }) + it('fails on different lengths', () => { + expect(arraysEqualUnordered(['a'], ['a', 'b'])).toBe(false) + }) +}) + +describe('arraysEqualOrdered', () => { + it('matches same order', () => { + expect(arraysEqualOrdered(['a', 'b'], ['a', 'b'])).toBe(true) + }) + it('fails on different order', () => { + expect(arraysEqualOrdered(['a', 'b'], ['b', 'a'])).toBe(false) + }) +}) + +describe('gradeQuestion', () => { + it('grades multiple-choice correctly', () => { + const q: Question = { id: '1', type: 'multiple-choice', prompt: 'Q', answer: 'a' } + expect(gradeQuestion(q, 'a')).toMatchObject({ status: 'correct', correct: true }) + expect(gradeQuestion(q, 'b')).toMatchObject({ status: 'wrong', correct: false }) + }) + it('grades multiple-select correctly', () => { + const q: Question = { id: '1', type: 'multiple-select', prompt: 'Q', answer: ['a', 'c'] } + expect(gradeQuestion(q, ['c', 'a'])).toMatchObject({ status: 'correct', correct: true }) + expect(gradeQuestion(q, ['a'])).toMatchObject({ status: 'wrong', correct: false }) + }) + it('grades short-answer with normalization', () => { + const q: Question = { id: '1', type: 'short-answer', prompt: 'Q', answer: 'Paris' } + expect(gradeQuestion(q, 'paris')).toMatchObject({ status: 'correct', correct: true }) + }) + it('returns unanswered for null', () => { + const q: Question = { id: '1', type: 'multiple-choice', prompt: 'Q', answer: 'a' } + expect(gradeQuestion(q, null)).toMatchObject({ status: 'unanswered', correct: false }) + }) + it('grades order correctly', () => { + const q: Question = { id: '1', type: 'order', prompt: 'Q', answer: ['s1', 's2', 's3'] } + expect(gradeQuestion(q, ['s1', 's2', 's3'])).toMatchObject({ status: 'correct', correct: true }) + expect(gradeQuestion(q, ['s1', 's3', 's2'])).toMatchObject({ status: 'wrong', correct: false }) + }) + it('grades match correctly', () => { + const q: Question = { id: '1', type: 'match', prompt: 'Q', answer: { l1: 'r1', l2: 'r2' } } + expect(gradeQuestion(q, { l1: 'r1', l2: 'r2' })).toMatchObject({ status: 'correct', correct: true }) + expect(gradeQuestion(q, { l1: 'r2', l2: 'r1' })).toMatchObject({ status: 'wrong', correct: false }) + }) + it('grades essay as ungraded', () => { + const q: Question = { id: '1', type: 'essay', prompt: 'Q' } + expect(gradeQuestion(q, { text: 'some text' })).toMatchObject({ status: 'ungraded' }) + }) +}) diff --git a/frontend/src/utils/grading.ts b/frontend/src/utils/grading.ts new file mode 100644 index 0000000..878a3b8 --- /dev/null +++ b/frontend/src/utils/grading.ts @@ -0,0 +1,106 @@ +import type { Question, GradeResult } from '../types' + +interface NormalizeOpts { + caseSensitive?: boolean + ignoreAccents?: boolean + ignorePunctuation?: boolean +} + +export const normalizeString = (s: unknown, opts: NormalizeOpts = {}): string => { + if (s == null) return '' + let out = String(s) + if (!opts.caseSensitive) out = out.toLowerCase() + if (opts.ignoreAccents !== false) { + out = out.normalize('NFD').replace(/[̀-ͯ]/g, '') + } + if (opts.ignorePunctuation !== false) { + out = out.replace(/[.,/#!$%^&*;:{}=\-_`~()¿?¡!'"]/g, '') + } + return out.replace(/\s+/g, ' ').trim() +} + +export const normalizeLatex = (s: unknown): string => { + if (s == null) return '' + return String(s) + .replace(/\s+/g, '') + .replace(/\\left|\\right/g, '') + .replace(/\\,|\\;|\\:|\\!|\\ /g, '') + .replace(/\{([^{}])\}/g, '$1') + .replace(/\\cdot/g, '*') + .replace(/\\times/g, '*') + .replace(/\\ln/g, 'ln') + .replace(/\\log/g, 'log') + .toLowerCase() +} + +export const arraysEqualUnordered = (a: unknown[], b: unknown[]): boolean => { + if (!Array.isArray(a) || !Array.isArray(b)) return false + if (a.length !== b.length) return false + const sa = [...a].sort() + const sb = [...b].sort() + return sa.every((v, i) => v === sb[i]) +} + +export const arraysEqualOrdered = (a: unknown[], b: unknown[]): boolean => { + if (!Array.isArray(a) || !Array.isArray(b)) return false + if (a.length !== b.length) return false + return a.every((v, i) => v === b[i]) +} + +export const gradeQuestion = (q: Question, userAnswer: unknown): GradeResult => { + if (userAnswer == null) return { status: 'unanswered', correct: false, points: 0 } + const pts = q.points ?? 1 + + switch (q.type) { + case 'multiple-choice': { + const ok = userAnswer === q.answer + return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? pts : 0 } + } + case 'multiple-select': { + const ok = arraysEqualUnordered(userAnswer as unknown[], (q.answer as string[]) ?? []) + return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? pts : 0 } + } + case 'short-answer': + case 'translation': { + const opts: NormalizeOpts = { + caseSensitive: q.caseSensitive === true, + ignorePunctuation: q.ignorePunctuation !== false, + ignoreAccents: q.ignoreAccents !== false, + } + const target = normalizeString(userAnswer, opts) + const accepted = [q.answer as string, ...(q.acceptedForms ?? [])] + .filter(Boolean) + .map((a) => normalizeString(a, opts)) + const ok = accepted.includes(target) + return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? pts : 0 } + } + case 'math-input': { + const target = normalizeLatex(userAnswer) + const accepted = [q.answer as string, ...(q.acceptedForms ?? [])].filter(Boolean).map(normalizeLatex) + const ok = accepted.includes(target) + return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? pts : 0 } + } + case 'match': { + const answer = (q.answer as Record) ?? {} + const given = (userAnswer as Record) ?? {} + const keys = Object.keys(answer) + if (keys.length === 0) return { status: 'unanswered', correct: false, points: 0 } + const allOk = keys.every((k) => given[k] === answer[k]) + return { status: allOk ? 'correct' : 'wrong', correct: allOk, points: allOk ? pts : 0 } + } + case 'order': { + const ok = arraysEqualOrdered(userAnswer as unknown[], (q.answer as string[]) ?? []) + return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? pts : 0 } + } + case 'essay': { + const val = userAnswer as { selfGrade?: number } + if (val?.selfGrade != null) { + const g = val.selfGrade + return { status: g >= 0.5 ? 'correct' : 'wrong', correct: g >= 0.5, points: Math.round(pts * g) } + } + return { status: 'ungraded', correct: false, points: 0 } + } + default: + return { status: 'unanswered', correct: false, points: 0 } + } +}