feat: add grading utilities with tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
LaurensBarteldsandClaude Sonnet 4.6 committed 2026-04-21 21:21:27 +02:00
1 parent 99b40e46dc
commit e211594429
2 files changed
+201

No files matched your search

+95
View File
@@ -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' })
})
})
+106
View File
@@ -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<string, string>) ?? {}
const given = (userAnswer as Record<string, string>) ?? {}
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 }
}
}