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