Public Access
feat: add grading utilities with tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
99b40e46dc
commit
e211594429
2 files changed
+201
No files matched your search
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user