diff --git a/frontend/components/quizzes/questions.ts b/frontend/components/quizzes/questions.ts index ff7ee1379..e37a7427c 100644 --- a/frontend/components/quizzes/questions.ts +++ b/frontend/components/quizzes/questions.ts @@ -18,7 +18,8 @@ export enum QuizQuestionTypes { essay_question="essay_question", file_upload_question="file_upload_question", - numerical_question="numerical_question" + numerical_question="numerical_question", + likert_question="likert_question" } export const clearValue = (question: Question) => { @@ -29,6 +30,7 @@ export const clearValue = (question: Question) => { return question.student.map((v: any) => v(undefined)); case QuizQuestionTypes.multiple_dropdowns_question: case QuizQuestionTypes.fill_in_multiple_blanks_question: + case QuizQuestionTypes.likert_question: for (const key in question.student) { question.student[key](''); } @@ -59,6 +61,13 @@ export const getDefaultValue = (question: Question, answer: any): any => { .extend({ rateLimit: { method: "notifyWhenChangesStop", timeout: 400 } }); }); return fimbResult; + case QuizQuestionTypes.likert_question: + let likertResult: {[key: string]: ko.Observable} = {}; + (question.statements || []).forEach((_: any, i: number) => { + const key = String(i); + likertResult[key] = ko.observable(answer ? answer[key] || '' : ''); + }); + return likertResult; case QuizQuestionTypes.numerical_question: case QuizQuestionTypes.essay_question: case QuizQuestionTypes.short_answer_question: @@ -83,6 +92,7 @@ export const subscribeToStudent = (question: Question): ko.Observable[] => { return question.student; case QuizQuestionTypes.multiple_dropdowns_question: case QuizQuestionTypes.fill_in_multiple_blanks_question: + case QuizQuestionTypes.likert_question: return Object.values(question.student); case QuizQuestionTypes.multiple_answers_question: default: @@ -96,6 +106,7 @@ export const getValue = (question: Question): any => { return question.student.map((value: ko.Observable) => value()); case QuizQuestionTypes.multiple_dropdowns_question: case QuizQuestionTypes.fill_in_multiple_blanks_question: + case QuizQuestionTypes.likert_question: let result: {[key: string]: string} = {}; Object.entries(question.student).forEach(([key, value]: [string, ko.Observable])=> { result[key] = value(); @@ -123,6 +134,7 @@ export interface Question { student: any answers?: string[] | {[key: string]: string[]} statements?: string[] + options?: string[] retainOrder?: boolean feedback: ko.Observable diff --git a/frontend/components/quizzes/questions_ui.html b/frontend/components/quizzes/questions_ui.html index f30881c0f..7bce90830 100644 --- a/frontend/components/quizzes/questions_ui.html +++ b/frontend/components/quizzes/questions_ui.html @@ -96,7 +96,7 @@
+ + + + + + +
+
I have no idea what this is! diff --git a/frontend/components/quizzes/quiz_editor_state.ts b/frontend/components/quizzes/quiz_editor_state.ts new file mode 100644 index 000000000..5bb8191a1 --- /dev/null +++ b/frontend/components/quizzes/quiz_editor_state.ts @@ -0,0 +1,660 @@ +/** + * Quiz Editor State + * + * Observable ViewModels for the visual quiz editor. Instructors interact with + * these classes instead of editing raw JSON. + * + * The quiz data lives in two separate JSON blobs: + * - instructions (assignment.instructions) — what students see + * - checks (assignment.onRun) — answer keys / feedback logic + * + * Both are parsed into observable state here, and can be serialised back via + * toInstructionsJson() / toChecksJson(). + */ + +import * as ko from 'knockout'; +import { + QuizFeedbackType, + QuizInstructions, + QuizInstructionsSettings, + QuizPoolRandomness, + QuestionPool, + fillInMissingQuizInstructionFields +} from './quiz'; +import {QuizQuestionTypes} from './questions'; + +// --------------------------------------------------------------------------- +// Small helper types +// --------------------------------------------------------------------------- + +/** A key→value pair used when editing dictionaries as arrays in the UI. */ +export class KeyValuePair { + key: ko.Observable; + value: ko.Observable; + constructor(key: string, value: string) { + this.key = ko.observable(key); + this.value = ko.observable(value); + } +} + +/** A single answer option used by MCQ / MAQ / Matching. */ +export class AnswerOption { + text: ko.Observable; + constructor(text: string) { + this.text = ko.observable(text); + } +} + +/** A blank used by multiple_dropdowns / fill_in_multiple_blanks. */ +export class BlankEntry { + /** The identifier that appears in the body text, e.g. [color] → "color". */ + key: ko.Observable; + /** Possible answers for this blank (dropdown options). */ + options: ko.ObservableArray; + /** Correct answer for this blank. */ + correctAnswer: ko.Observable; + /** Correct answers as a newline-separated list (for fill_in blanks). */ + correctList: ko.Observable; + + constructor(key: string, options: string[] = [], correctAnswer: string = '', + correctList: string[] = []) { + this.key = ko.observable(key); + this.options = ko.observableArray(options.map(o => new AnswerOption(o))); + this.correctAnswer = ko.observable(correctAnswer); + this.correctList = ko.observable(correctList.join('\n')); + } + + addOption() { this.options.push(new AnswerOption('')); } + removeOption(opt: AnswerOption) { this.options.remove(opt); } +} + +/** A single feedback entry: answer → message. */ +export class FeedbackEntry { + answer: ko.Observable; + message: ko.Observable; + constructor(answer: string, message: string) { + this.answer = ko.observable(answer); + this.message = ko.observable(message); + } +} + +// --------------------------------------------------------------------------- +// Quiz Settings +// --------------------------------------------------------------------------- + +export class QuizEditorSettings { + feedbackType: ko.Observable; + attemptLimit: ko.Observable; + coolDown: ko.Observable; + poolRandomness: ko.Observable; + readingId: ko.Observable; + + constructor(settings: QuizInstructionsSettings) { + this.feedbackType = ko.observable(settings.feedbackType || QuizFeedbackType.IMMEDIATE); + this.attemptLimit = ko.observable(settings.attemptLimit ?? -1); + this.coolDown = ko.observable(settings.coolDown ?? -1); + this.poolRandomness = ko.observable(settings.poolRandomness || QuizPoolRandomness.SEED); + // readingId can be a number or a URL string; keep as string for the input + this.readingId = ko.observable( + settings.readingId != null ? String(settings.readingId) : '' + ); + } + + toJson(): QuizInstructionsSettings { + const rid = this.readingId().trim(); + let readingId: number | string | null = null; + if (rid !== '') { + const n = Number(rid); + readingId = isNaN(n) ? rid : n; + } + return { + feedbackType: this.feedbackType() as QuizFeedbackType, + attemptLimit: Number(this.attemptLimit()), + coolDown: Number(this.coolDown()), + poolRandomness: this.poolRandomness() as QuizPoolRandomness, + readingId, + }; + } +} + +// --------------------------------------------------------------------------- +// Question Pool +// --------------------------------------------------------------------------- + +export class QuizEditorPool { + name: ko.Observable; + amount: ko.Observable; + /** Each element is the question ID string. */ + questions: ko.ObservableArray>; + + constructor(pool: QuestionPool) { + this.name = ko.observable(pool.name || ''); + this.amount = ko.observable(pool.amount ?? 1); + this.questions = ko.observableArray(pool.questions.map(q => ko.observable(q))); + } + + addQuestion() { this.questions.push(ko.observable('')); } + removeQuestion(q: ko.Observable) { this.questions.remove(q); } + + toJson(): QuestionPool { + return { + name: this.name(), + amount: Number(this.amount()), + questions: this.questions().map(q => q()), + }; + } +} + +// --------------------------------------------------------------------------- +// Quiz Editor Question (instructions + checks combined) +// --------------------------------------------------------------------------- + +export const QUESTION_TYPE_LABELS: {[k: string]: string} = { + true_false_question: 'True / False', + multiple_choice_question: 'Multiple Choice', + multiple_answers_question: 'Multiple Answers (checkboxes)', + matching_question: 'Matching', + multiple_dropdowns_question: 'Multiple Dropdowns', + fill_in_multiple_blanks_question: 'Fill In Multiple Blanks', + short_answer_question: 'Short Answer', + numerical_question: 'Numerical', + essay_question: 'Essay', + text_only_question: 'Text Only', + likert_question: 'Likert (Survey Matrix)', +}; + +export class QuizEditorQuestion { + // ── Instruction fields ──────────────────────────────────────────────── + id: ko.Observable; + type: ko.Observable; + body: ko.Observable; + points: ko.Observable; + retainOrder: ko.Observable; + + /** MCQ / MAQ / Matching: list of answer options */ + answers: ko.ObservableArray; + /** Matching: list of statement texts (left-hand side) */ + statements: ko.ObservableArray; + /** multiple_dropdowns: one BlankEntry per [identifier] in the body */ + mdBlanks: ko.ObservableArray; + + // ── Check fields ────────────────────────────────────────────────────── + // true_false + tf_correct: ko.Observable; // "true" | "false" + tf_wrong: ko.Observable; + + // multiple_choice + mc_correct: ko.Observable; + mc_wrong_any: ko.Observable; + mc_feedback: ko.ObservableArray; + + // multiple_answers + /** IDs (by index) of answers that are correct; parallel to this.answers */ + ma_correct: ko.ObservableArray; + ma_wrong_any: ko.Observable; + + // matching + /** correct answer per statement, parallel to this.statements */ + mat_correct: ko.ObservableArray>; + + // multiple_dropdowns — correct is stored inside mdBlanks[].correctAnswer + + md_wrong_any: ko.Observable; + + // short_answer / numerical + sa_check_type: ko.Observable; // 'exact' | 'regex' + /** Newline-separated list of acceptable exact answers */ + sa_correct_exact: ko.Observable; + /** Newline-separated list of regex patterns */ + sa_correct_regex: ko.Observable; + sa_wrong_any: ko.Observable; + sa_feedback: ko.ObservableArray; + + // fill_in_multiple_blanks — blanks stored in fimbBlanks + fimbBlanks: ko.ObservableArray; + fimb_check_type: ko.Observable; // 'exact' | 'regex' + fimb_wrong_any: ko.Observable; + + // likert + /** Scale options shared by all statements (column headers) */ + likert_options: ko.ObservableArray; + /** Correct option per statement by index (empty = survey mode, no grading) */ + likert_correct: ko.ObservableArray>; + likert_wrong_any: ko.Observable; + + // ── UI state ────────────────────────────────────────────────────────── + expanded: ko.Observable; + + constructor(id: string, question: any, check: any) { + check = check || {}; + + // ── Instructions ──────────────────────────────────────────────── + this.id = ko.observable(id); + this.type = ko.observable(question.type || QuizQuestionTypes.true_false_question); + this.body = ko.observable(question.body || ''); + this.points = ko.observable(question.points ?? 1); + this.retainOrder = ko.observable(question.retainOrder ?? false); + + // answers / statements + const rawAnswers: string[] = Array.isArray(question.answers) ? question.answers : []; + this.answers = ko.observableArray(rawAnswers.map(a => new AnswerOption(a))); + + const rawStatements: string[] = Array.isArray(question.statements) ? question.statements : []; + this.statements = ko.observableArray(rawStatements.map(s => new AnswerOption(s))); + + // multiple_dropdowns blanks + const rawMdAnswers: {[key: string]: string[]} = + (!Array.isArray(question.answers) && typeof question.answers === 'object') + ? question.answers || {} + : {}; + const mdCorrect: {[key: string]: string} = check.correct || {}; + this.mdBlanks = ko.observableArray( + Object.entries(rawMdAnswers).map(([k, opts]) => + new BlankEntry(k, opts, mdCorrect[k] || '') + ) + ); + + // fill_in blanks — extract blanks from the body on construction + const rawFimbCorrect: {[key: string]: string|string[]} = + check.correct || check.correct_exact || {}; + const rawFimbRegex: {[key: string]: string[]} = check.correct_regex || {}; + const fimbCheckType = check.correct_regex ? 'regex' : 'exact'; + const bodyBlanks = extractBracketed(question.body || ''); + this.fimbBlanks = ko.observableArray(bodyBlanks.map(key => { + const correctVal = rawFimbCorrect[key]; + const correctList = Array.isArray(correctVal) + ? correctVal + : (correctVal ? [correctVal] : []); + const regexList: string[] = rawFimbRegex[key] || []; + const combined = fimbCheckType === 'regex' ? regexList : correctList; + return new BlankEntry(key, [], '', combined); + })); + this.fimb_check_type = ko.observable(fimbCheckType); + this.fimb_wrong_any = ko.observable(check.wrong_any || ''); + + // ── Checks ────────────────────────────────────────────────────── + + // true_false + const rawTfCorrect = check.correct; + this.tf_correct = ko.observable( + rawTfCorrect === true || rawTfCorrect === 'true' ? 'true' : 'false' + ); + this.tf_wrong = ko.observable(check.wrong || ''); + + // multiple_choice + const rawMcCorrect = check.correct; + this.mc_correct = ko.observable( + Array.isArray(rawMcCorrect) ? rawMcCorrect[0] || '' : (rawMcCorrect || '') + ); + this.mc_wrong_any = ko.observable(check.wrong_any || ''); + const rawMcFeedback: {[k: string]: string} = check.feedback || {}; + this.mc_feedback = ko.observableArray( + Object.entries(rawMcFeedback).map(([a, m]) => new FeedbackEntry(a, m)) + ); + + // multiple_answers + const rawMaCorrect: string[] = Array.isArray(check.correct) ? check.correct : []; + this.ma_correct = ko.observableArray( + rawAnswers.map(a => rawMaCorrect.includes(a)) + ); + this.ma_wrong_any = ko.observable(check.wrong_any || ''); + + // matching + const rawMatCorrect: (string|string[])[] = Array.isArray(check.correct) ? check.correct : []; + this.mat_correct = ko.observableArray( + rawStatements.map((_, i) => { + const c = rawMatCorrect[i]; + return ko.observable(Array.isArray(c) ? c.join('\n') : (c || '')); + }) + ); + + // multiple_dropdowns: stored in mdBlanks.correctAnswer already + + this.md_wrong_any = ko.observable(check.wrong_any || ''); + + // short_answer / numerical + const saCheckType = check.correct_regex ? 'regex' : 'exact'; + this.sa_check_type = ko.observable(saCheckType); + const saExact = check.correct || check.correct_exact; + this.sa_correct_exact = ko.observable( + Array.isArray(saExact) ? saExact.join('\n') : (saExact || '') + ); + const saRegex: string[] = check.correct_regex || []; + this.sa_correct_regex = ko.observable(saRegex.join('\n')); + this.sa_wrong_any = ko.observable(check.wrong_any || ''); + const rawSaFeedback: {[k: string]: string} = check.feedback || {}; + this.sa_feedback = ko.observableArray( + Object.entries(rawSaFeedback).map(([a, m]) => new FeedbackEntry(a, m)) + ); + + // likert + const rawLikertOptions: string[] = Array.isArray(question.options) ? question.options : []; + this.likert_options = ko.observableArray(rawLikertOptions.map(o => new AnswerOption(o))); + const rawLikertCorrect: {[k: string]: string} = + (typeof check.correct === 'object' && !Array.isArray(check.correct)) ? check.correct : {}; + this.likert_correct = ko.observableArray( + rawStatements.map((_, i) => ko.observable(rawLikertCorrect[String(i)] || '')) + ); + this.likert_wrong_any = ko.observable(check.wrong_any || ''); + + // UI + this.expanded = ko.observable(false); + + // Derived: when answers list changes, keep ma_correct in sync + this.answers.subscribe((newAnswers: AnswerOption[]) => { + const currentCorrect = this.getMultipleAnswersCorrectSet(); + this.ma_correct(newAnswers.map(a => currentCorrect.has(a.text()))); + }); + + // Derived: when statements list changes, keep mat_correct and likert_correct in sync + this.statements.subscribe((newStatements: AnswerOption[]) => { + const currentMat = this.mat_correct(); + this.mat_correct(newStatements.map((_, i) => + currentMat[i] || ko.observable('') + )); + const currentLikert = this.likert_correct(); + this.likert_correct(newStatements.map((_, i) => + currentLikert[i] || ko.observable('') + )); + }); + } + + // ── Helpers ──────────────────────────────────────────────────────────── + + toggleExpanded() { this.expanded(!this.expanded()); } + + // --- Answers management ------------------------------------------------- + addAnswer() { this.answers.push(new AnswerOption('')); } + removeAnswer(a: AnswerOption) { + const idx = this.answers.indexOf(a); + this.answers.remove(a); + if (idx >= 0) { this.ma_correct.splice(idx, 1); } + } + + // --- Statements management ---------------------------------------------- + addStatement() { + this.statements.push(new AnswerOption('')); + this.mat_correct.push(ko.observable('')); + this.likert_correct.push(ko.observable('')); + } + removeStatement(s: AnswerOption) { + const idx = this.statements.indexOf(s); + this.statements.remove(s); + if (idx >= 0) { + this.mat_correct.splice(idx, 1); + this.likert_correct.splice(idx, 1); + } + } + + // --- Likert options management ------------------------------------------ + addLikertOption() { this.likert_options.push(new AnswerOption('')); } + removeLikertOption(o: AnswerOption) { this.likert_options.remove(o); } + + // --- Multiple dropdowns blank management -------------------------------- + addMdBlank() { this.mdBlanks.push(new BlankEntry('', [], '')); } + removeMdBlank(b: BlankEntry) { this.mdBlanks.remove(b); } + + // --- Fill-in blanks management ------------------------------------------ + rebuildFimbBlanks() { + const existing: {[k: string]: BlankEntry} = {}; + this.fimbBlanks().forEach(b => { existing[b.key()] = b; }); + const keys = extractBracketed(this.body()); + this.fimbBlanks(keys.map(k => existing[k] || new BlankEntry(k, [], ''))); + } + + // --- Multiple choice feedback ------------------------------------------- + addMcFeedback() { this.mc_feedback.push(new FeedbackEntry('', '')); } + removeMcFeedback(f: FeedbackEntry) { this.mc_feedback.remove(f); } + + // --- Short answer feedback ----------------------------------------------- + addSaFeedback() { this.sa_feedback.push(new FeedbackEntry('', '')); } + removeSaFeedback(f: FeedbackEntry) { this.sa_feedback.remove(f); } + + // ── Serialisation ────────────────────────────────────────────────────── + + private getMultipleAnswersCorrectSet(): Set { + const s = new Set(); + this.answers().forEach((a, i) => { + if (this.ma_correct()[i]) { s.add(a.text()); } + }); + return s; + } + + toInstructionsJson(): {[key: string]: any} { + const type = this.type(); + const base: {[key: string]: any} = { + type, + body: this.body(), + points: Number(this.points()), + }; + + if (type === QuizQuestionTypes.matching_question + || type === QuizQuestionTypes.multiple_dropdowns_question) { + base.retainOrder = this.retainOrder(); + } + + if (type === QuizQuestionTypes.matching_question) { + base.answers = this.answers().map(a => a.text()); + base.statements = this.statements().map(s => s.text()); + } else if (type === QuizQuestionTypes.multiple_choice_question + || type === QuizQuestionTypes.multiple_answers_question) { + base.answers = this.answers().map(a => a.text()); + } else if (type === QuizQuestionTypes.multiple_dropdowns_question) { + const ans: {[k: string]: string[]} = {}; + this.mdBlanks().forEach(b => { + ans[b.key()] = b.options().map(o => o.text()); + }); + base.answers = ans; + } else if (type === QuizQuestionTypes.likert_question) { + base.statements = this.statements().map(s => s.text()); + base.options = this.likert_options().map(o => o.text()); + } + // fill_in, short_answer, numerical, essay, text_only, true_false: + // no 'answers' field in instructions + + return base; + } + + toChecksJson(): {[key: string]: any} { + const type = this.type(); + const check: {[key: string]: any} = {}; + + switch (type) { + case QuizQuestionTypes.true_false_question: + check.correct = this.tf_correct() === 'true'; + if (this.tf_wrong().trim()) { check.wrong = this.tf_wrong().trim(); } + break; + + case QuizQuestionTypes.multiple_choice_question: { + check.correct = this.mc_correct(); + if (this.mc_wrong_any().trim()) { check.wrong_any = this.mc_wrong_any().trim(); } + const fb: {[k: string]: string} = {}; + this.mc_feedback().forEach(f => { + if (f.answer().trim()) { fb[f.answer().trim()] = f.message(); } + }); + if (Object.keys(fb).length) { check.feedback = fb; } + break; + } + + case QuizQuestionTypes.multiple_answers_question: { + const correctAnswers = this.answers() + .filter((_, i) => this.ma_correct()[i]) + .map(a => a.text()); + check.correct = correctAnswers; + if (this.ma_wrong_any().trim()) { check.wrong_any = this.ma_wrong_any().trim(); } + break; + } + + case QuizQuestionTypes.matching_question: { + check.correct = this.mat_correct().map(c => { + const v = c(); + // If there are multiple lines treat as a list of acceptable answers + const lines = v.split('\n').map(l => l.trim()).filter(Boolean); + return lines.length === 1 ? lines[0] : lines; + }); + break; + } + + case QuizQuestionTypes.multiple_dropdowns_question: { + const correct: {[k: string]: string} = {}; + this.mdBlanks().forEach(b => { correct[b.key()] = b.correctAnswer(); }); + check.correct = correct; + if (this.md_wrong_any().trim()) { check.wrong_any = this.md_wrong_any().trim(); } + break; + } + + case QuizQuestionTypes.short_answer_question: + case QuizQuestionTypes.numerical_question: { + if (this.sa_check_type() === 'regex') { + check.correct_regex = this.sa_correct_regex() + .split('\n').map(s => s.trim()).filter(Boolean); + } else { + const lines = this.sa_correct_exact() + .split('\n').map(s => s.trim()).filter(Boolean); + check.correct_exact = lines.length === 1 ? lines[0] : lines; + } + if (this.sa_wrong_any().trim()) { check.wrong_any = this.sa_wrong_any().trim(); } + const fb: {[k: string]: string} = {}; + this.sa_feedback().forEach(f => { + if (f.answer().trim()) { fb[f.answer().trim()] = f.message(); } + }); + if (Object.keys(fb).length) { check.feedback = fb; } + break; + } + + case QuizQuestionTypes.fill_in_multiple_blanks_question: { + if (this.fimb_check_type() === 'regex') { + const correct: {[k: string]: string[]} = {}; + this.fimbBlanks().forEach(b => { + correct[b.key()] = b.correctList().split('\n').map(s => s.trim()).filter(Boolean); + }); + check.correct_regex = correct; + } else { + const correct: {[k: string]: string|string[]} = {}; + this.fimbBlanks().forEach(b => { + const lines = b.correctList().split('\n').map(s => s.trim()).filter(Boolean); + correct[b.key()] = lines.length === 1 ? lines[0] : lines; + }); + check.correct_exact = correct; + } + if (this.fimb_wrong_any().trim()) { check.wrong_any = this.fimb_wrong_any().trim(); } + break; + } + + // essay and text_only have no check fields + + case QuizQuestionTypes.likert_question: { + // Only include `correct` if at least one answer is specified (non-survey mode) + const correctMap: {[k: string]: string} = {}; + this.likert_correct().forEach((obs, i) => { + const v = obs().trim(); + if (v) { correctMap[String(i)] = v; } + }); + if (Object.keys(correctMap).length) { + check.correct = correctMap; + if (this.likert_wrong_any().trim()) { + check.wrong_any = this.likert_wrong_any().trim(); + } + } + break; + } + + default: + break; + } + + return check; + } +} + +// --------------------------------------------------------------------------- +// Main QuizEditorState +// --------------------------------------------------------------------------- + +export class QuizEditorState { + settings: QuizEditorSettings; + questions: ko.ObservableArray; + pools: ko.ObservableArray; + + constructor(instructionsJson: string, checksJson: string) { + let instructions: QuizInstructions; + try { + instructions = JSON.parse(instructionsJson || '{}') as QuizInstructions; + } catch (e) { + instructions = {}; + } + fillInMissingQuizInstructionFields(instructions); + + let checks: {questions?: {[id: string]: any}}; + try { + checks = JSON.parse(checksJson || '{}'); + } catch (e) { + checks = {}; + } + const checkQuestions = checks.questions || {}; + + this.settings = new QuizEditorSettings(instructions.settings); + this.pools = ko.observableArray( + (instructions.pools || []).map(p => new QuizEditorPool(p)) + ); + this.questions = ko.observableArray( + Object.entries(instructions.questions || {}).map(([id, q]) => + new QuizEditorQuestion(id, q, checkQuestions[id] || {}) + ) + ); + } + + addQuestion() { + const id = `question_${Date.now()}`; + this.questions.push(new QuizEditorQuestion(id, {type: 'multiple_choice_question', body: '', points: 1}, {})); + } + + removeQuestion(q: QuizEditorQuestion) { this.questions.remove(q); } + + addPool() { + this.pools.push(new QuizEditorPool({name: '', amount: 1, questions: []})); + } + removePool(p: QuizEditorPool) { this.pools.remove(p); } + + toInstructionsJson(): string { + const out: QuizInstructions = { + settings: this.settings.toJson(), + pools: this.pools().map(p => p.toJson()), + questions: {}, + }; + this.questions().forEach(q => { + out.questions[q.id()] = q.toInstructionsJson(); + }); + return JSON.stringify(out, null, 2); + } + + toChecksJson(): string { + const out: {questions: {[id: string]: any}} = {questions: {}}; + this.questions().forEach(q => { + const checkData = q.toChecksJson(); + // Only include non-empty check objects + if (Object.keys(checkData).length > 0) { + out.questions[q.id()] = checkData; + } + }); + return JSON.stringify(out, null, 2); + } +} + +// --------------------------------------------------------------------------- +// Utility +// --------------------------------------------------------------------------- + +/** Extract [identifier] keys from a body string (like getBracketed in questions.ts). */ +export function extractBracketed(body: string): string[] { + const SQUARE_BRACKETS = /(? { + if (part.startsWith('[[') && part.endsWith(']]')) return; + if (part.startsWith('[') && part.endsWith(']')) { + result.push(part.slice(1, -1)); + } + }); + return result; +} diff --git a/frontend/components/quizzes/quiz_editor_ui.html b/frontend/components/quizzes/quiz_editor_ui.html new file mode 100644 index 000000000..e6508ac45 --- /dev/null +++ b/frontend/components/quizzes/quiz_editor_ui.html @@ -0,0 +1,552 @@ + +
+ + + + +
+
+ Quiz Settings +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + + + +
+
+ Question Pools + +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+ + + + +
+
Questions + + + +
+ +
+ + + +
+ +
+ + + + + + +
+ + +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+
+
+ + +
+ + +
+ + + + +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ + +
+
+
+ +
+
+ +
+ +
+
+ +
+ + +
+ + +
+ + +
+ +
+
+ +
+ + +
+ + +
+
+
+ +
+
+ +
+ +
+
+ +
+ + +
+
+ + +
+
+
+ + +
+ +
+ +
+
+ +
+
+ + +
+ +
+ +
+
+ +
+
+
+ + +
+
+ +
+ +
+ +
+ + +
+

Add a [identifier] in the body above for each dropdown, then define its options and correct answer below.

+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+ + +
+
+ + +
+ +
+
+
+ +
+
+ + +
+ +
+ + +
+ + +
+ + +
+ +
+ + +
+ + +
+ + +
+ +
+
+ +
+ + + +
+

Blanks are detected automatically from [identifier] markers in the body. Click outside the body field to refresh the list below.

+
+ + +
+ +
+
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+ + +
+

Essay questions are always marked correct. No answer key needed.

+
+
+

Text-only questions display content only. No answer key needed.

+
+ + +
+
+
+ + +
+ +
+ +
+
+ +
+
+ + +
+ +
+ +
+
+ +
+
+
+

+ Correct Answers are optional. Leave all blank for survey mode (always full credit). +

+ + +
+
+ +
+ +
+ +
+ + +
+
+ +
+ +
+
+ + + + +
+ +
+ +
diff --git a/frontend/components/quizzes/quiz_schema.md b/frontend/components/quizzes/quiz_schema.md index 7ceb468e2..e99c5914a 100644 --- a/frontend/components/quizzes/quiz_schema.md +++ b/frontend/components/quizzes/quiz_schema.md @@ -145,3 +145,40 @@ The feedback is more limited here, with only `wrong_any` supported. We could pro Not surprisingly for the `text_only_question`, but surprising for the `essay_question`, there is no feedback to specify here. The answer will always be considered correct and they get the full points each time. Usually you leave these as zero point questions. We could probably make it so that we give zero points for leaving it blank, but that'll probably just encourage people to write in gibberish. So I'm not sure it's worth it. + +### `likert_question` + +A survey-style matrix of radio buttons. Multiple statements share the same set of scale options; students select one option per statement. + +**Instructions fields:** + +* `"statements"`: A list of strings (the row labels — one per statement). +* `"options"`: A list of strings (the column headers — the scale, e.g. "Strongly Disagree" through "Strongly Agree"). + +**Example:** +```json +{ + "type": "likert_question", + "body": "Rate your agreement with each statement.", + "points": 3, + "statements": ["I enjoy Python.", "Tests are useful.", "KO is fun."], + "options": ["Strongly Disagree", "Disagree", "Neutral", "Agree", "Strongly Agree"] +} +``` + +**Check fields:** + +* `"correct"` *(optional)*: An object mapping statement index (as a string, e.g. `"0"`, `"1"`) to the correct option string. If omitted entirely, the question operates in *survey mode* — all submissions receive full credit. +* `"wrong_any"` *(optional)*: Fallback feedback string shown when any answer is wrong (only relevant when `correct` is set). + +**Example check:** +```json +{ + "correct": {"0": "Agree", "1": "Strongly Agree", "2": "Neutral"}, + "wrong_any": "Some of your selections were unexpected." +} +``` + +**Grading:** Partial credit is awarded as `number_correct / number_of_statements`. Each statement is weighted equally. + +**Student answer format:** An object mapping statement index strings to the selected option string, e.g. `{"0": "Agree", "1": "Neutral", "2": "Agree"}`. diff --git a/frontend/components/quizzes/quiz_ui.ts b/frontend/components/quizzes/quiz_ui.ts index 73a849465..b0551f048 100644 --- a/frontend/components/quizzes/quiz_ui.ts +++ b/frontend/components/quizzes/quiz_ui.ts @@ -1,4 +1,5 @@ import QUESTIONS_SUBMISSION_UI from "./questions_ui.html" +import QUIZ_EDITOR_UI from "./quiz_editor_ui.html" export const QUIZ_PREVIEW = `
@@ -242,7 +243,7 @@ export const QUIZZER_HTML = ` - Quiz Editor is not yet ready. + ${QUIZ_EDITOR_UI} ${INSTRUCTIONS_BAR_HTML('above')} diff --git a/frontend/components/quizzes/quizzer.ts b/frontend/components/quizzes/quizzer.ts index adce3e65e..888548fa6 100644 --- a/frontend/components/quizzes/quizzer.ts +++ b/frontend/components/quizzes/quizzer.ts @@ -7,6 +7,7 @@ import {Quiz, QuizMode} from './quiz'; import {Question, subscribeToStudent} from './questions'; import "./quizzer_question_status"; import {QUIZZER_HTML} from './quiz_ui'; +import {QuizEditorState} from './quiz_editor_state'; // Maybe TODO: Add bookmarking // Add a question mark button that let's them flag this to return to later @@ -49,10 +50,14 @@ export class Quizzer extends AssignmentInterface { errorMessage: ko.Observable; + /** Visual quiz editor state; populated when editorMode switches to QUIZ_EDITOR. */ + quizEditor: ko.Observable; + subscriptions: { quiz: ko.Subscription currentAssignmentId: ko.Subscription questions: ko.Subscription[] + editorMode: ko.Subscription } visibleQuestions: ko.PureComputed; @@ -60,9 +65,10 @@ export class Quizzer extends AssignmentInterface { constructor(params: AssignmentInterfaceJson) { super(params); - this.subscriptions = {quiz: null, currentAssignmentId: null, questions: null}; + this.subscriptions = {quiz: null, currentAssignmentId: null, questions: null, editorMode: null}; this.quiz = ko.observable(null); + this.quizEditor = ko.observable(null); // UI state this.isDirty = ko.observable(false); @@ -77,7 +83,7 @@ export class Quizzer extends AssignmentInterface { this.subscriptions.questions = [] as ko.Subscription[]; this.subscriptions.quiz = this.quiz.subscribe((quiz) => { - this.quiz().questions().map((question: Question) => { + quiz.questions().map((question: Question) => { subscribeToStudent(question).map((subscribable) => { let subscription = subscribable.subscribe((value: any) => { this.onChange(); @@ -85,7 +91,17 @@ export class Quizzer extends AssignmentInterface { this.subscriptions.questions.push(subscription); }) }); - this.quiz().hidePools(); + quiz.hidePools(); + }); + + // Rebuild the quiz editor state whenever the editor mode switches to QUIZ_EDITOR + this.subscriptions.editorMode = this.editorMode.subscribe((mode) => { + if (mode === 'QUIZ_EDITOR' && this.assignment()) { + this.quizEditor(new QuizEditorState( + this.assignment().instructions(), + this.assignment().onRun() + )); + } }); // this.visibleQuestions = ko.pureComputed( () => { @@ -94,7 +110,7 @@ export class Quizzer extends AssignmentInterface { // }, this); this.isReadOnly = ko.pureComputed(() => { - return !this.quiz().attempting(); + return this.quiz() ? !this.quiz().attempting() : true; }, this); } @@ -103,6 +119,9 @@ export class Quizzer extends AssignmentInterface { this.subscriptions.currentAssignmentId.dispose(); this.subscriptions.quiz.dispose(); this.subscriptions.questions.map((question: ko.Subscription) => question.dispose()); + if (this.subscriptions.editorMode) { + this.subscriptions.editorMode.dispose(); + } } lookupReading(readingUrl: string): Promise { @@ -204,6 +223,19 @@ export class Quizzer extends AssignmentInterface { }); } + /** + * Called by the "Save Quiz" button in the visual Quiz Editor. + * Serialises the editor state back to the instructions and on_run JSON + * and persists them via saveAssignment(). + */ + saveQuizEditor() { + if (!this.quizEditor()) { return; } + const editor = this.quizEditor(); + this.assignment().instructions(editor.toInstructionsJson()); + this.assignment().onRun(editor.toChecksJson()); + this.saveAssignment(); + } + submit() { let BlockPyServer = window['$MAIN_BLOCKPY_EDITOR'].components.server; let now = new Date(); diff --git a/frontend/components/quizzes/quizzer_question_status.ts b/frontend/components/quizzes/quizzer_question_status.ts index 4ff9b83af..0e5882e6c 100644 --- a/frontend/components/quizzes/quizzer_question_status.ts +++ b/frontend/components/quizzes/quizzer_question_status.ts @@ -32,7 +32,7 @@ export interface QuizzerQuestionStatusJson { status: ko.Observable[]; asStudent: ko.Observable; question: Question; - quiz: ko.Observable; + quiz: Quiz; isAnchor: boolean; indexId: number } @@ -40,7 +40,7 @@ export interface QuizzerQuestionStatusJson { export class QuizzerQuestionStatus { private status: ko.Observable[]; private asStudent: ko.Observable; - private quiz: ko.Observable; + private quiz: Quiz; private question: Question; private isAnchor: boolean; private indexId: number; @@ -60,7 +60,7 @@ export class QuizzerQuestionStatus { const graded = this.question && this.question.feedback(); const errored = graded && this.question.feedback().status === "error"; const correct = graded && this.question.feedback().correct; - if (graded && (!this.asStudent() || this.quiz().feedbackType() === QuizFeedbackType.IMMEDIATE)) { + if (graded && (!this.asStudent() || this.quiz.feedbackType() === QuizFeedbackType.IMMEDIATE)) { if (errored) { return 'error'; } else if (correct) { diff --git a/models/data_formats/quizzes.py b/models/data_formats/quizzes.py index 5de9ea179..b1ec2a545 100644 --- a/models/data_formats/quizzes.py +++ b/models/data_formats/quizzes.py @@ -225,6 +225,23 @@ def check_quiz_question(question, check, student) -> (float, bool, list): return sum(corrects) / len(corrects) if corrects else 0, all(corrects), message elif question.get('type') in ('text_only_question', 'essay_question'): return 1, True, "Correct" + elif question.get('type') == 'likert_question': + statements = question.get('statements', []) + if not statements: + return 1, True, "Correct" + if 'correct' not in check: + # Survey mode: no answer key, always full credit + return 1, True, "Correct" + correct_map = check.get('correct', {}) + corrects = [ + student.get(str(i)) == correct_map.get(str(i)) + for i in range(len(statements)) + ] + all_correct = all(corrects) + wrong_any = check.get('wrong_any', 'Incorrect') + message = 'Correct' if all_correct else wrong_any + score = sum(corrects) / len(corrects) if corrects else 0 + return score, all_correct, message return None diff --git a/tests/test_quiz_grading.py b/tests/test_quiz_grading.py new file mode 100644 index 000000000..ffc43de14 --- /dev/null +++ b/tests/test_quiz_grading.py @@ -0,0 +1,642 @@ +""" +Tests for the quiz grading system in models/data_formats/quizzes.py. +Covers all question types, partial credit, error handling, and edge cases. +""" +import json +import pytest + +from models.data_formats.quizzes import ( + process_quiz, + process_quiz_str, + check_quiz_question, + QuizResult, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def make_quiz(questions: dict, settings: dict = None) -> dict: + return {"questions": questions, "settings": settings or {}} + + +def make_checks(questions: dict) -> dict: + return {"questions": questions} + + +def grade(body_questions: dict, check_questions: dict, student_answers: dict, + settings: dict = None) -> QuizResult: + body = make_quiz(body_questions, settings) + checks = make_checks(check_questions) + submission = {"studentAnswers": student_answers} + return process_quiz(body, checks, submission) + + +# --------------------------------------------------------------------------- +# process_quiz_str helpers +# --------------------------------------------------------------------------- + +class TestProcessQuizStr: + def test_returns_error_on_bad_body_json(self): + result = process_quiz_str("not json", "{}", "{}") + assert result.graded_successfully is False + assert "Quiz Body" in result.error + + def test_returns_error_on_bad_checks_json(self): + result = process_quiz_str("{}", "not json", "{}") + assert result.graded_successfully is False + assert "Quiz Checks" in result.error + + def test_returns_error_on_bad_submission_json(self): + result = process_quiz_str("{}", "{}", "not json") + assert result.graded_successfully is False + assert "Student Submission" in result.error + + def test_empty_submission_is_ok(self): + body = json.dumps({"questions": {}}) + checks = json.dumps({"questions": {}}) + result = process_quiz_str(body, checks, None) + assert result.graded_successfully is True + + def test_valid_inputs_produce_result(self): + body = json.dumps({"questions": { + "q1": {"type": "true_false_question", "points": 1} + }}) + checks = json.dumps({"questions": { + "q1": {"correct": True} + }}) + submission = json.dumps({"studentAnswers": {"q1": "true"}}) + result = process_quiz_str(body, checks, submission) + assert result.graded_successfully is True + + +# --------------------------------------------------------------------------- +# process_quiz — missing / skipped answers +# --------------------------------------------------------------------------- + +class TestProcessQuizMissingAnswers: + def test_missing_answer_skipped(self): + """Questions with no student answer are skipped (not penalised).""" + body_q = {"q1": {"type": "true_false_question", "points": 1}} + check_q = {"q1": {"correct": True}} + result = grade(body_q, check_q, {}) + # No questions checked → correct = False + assert result.graded_successfully is True + assert result.correct is False + assert result.score == 0 + + def test_zero_points_possible_gives_zero_score(self): + """If no questions are answered the score should be 0.""" + body_q = {"q1": {"type": "true_false_question", "points": 1}} + check_q = {"q1": {"correct": True}} + result = grade(body_q, check_q, {}) + assert result.score == 0 + + def test_unknown_type_gives_error_feedback(self): + body_q = {"q1": {"type": "totally_unknown_type", "points": 1}} + check_q = {"q1": {}} + result = grade(body_q, check_q, {"q1": "answer"}) + assert result.feedbacks["q1"]["status"] == "error" + + +# --------------------------------------------------------------------------- +# true_false_question +# --------------------------------------------------------------------------- + +class TestTrueFalseQuestion: + Q = {"q1": {"type": "true_false_question", "points": 1}} + C_TRUE = {"q1": {"correct": True, "wrong": "Nope!"}} + C_FALSE = {"q1": {"correct": False, "wrong": "Nope!"}} + + def test_correct_true(self): + result = grade(self.Q, self.C_TRUE, {"q1": "true"}) + assert result.feedbacks["q1"]["correct"] is True + assert result.score == 1.0 + + def test_correct_false(self): + result = grade(self.Q, self.C_FALSE, {"q1": "false"}) + assert result.feedbacks["q1"]["correct"] is True + + def test_incorrect_true(self): + result = grade(self.Q, self.C_TRUE, {"q1": "false"}) + assert result.feedbacks["q1"]["correct"] is False + assert result.feedbacks["q1"]["message"] == "Nope!" + assert result.score == 0.0 + + def test_case_insensitive(self): + result = grade(self.Q, self.C_TRUE, {"q1": "True"}) + assert result.feedbacks["q1"]["correct"] is True + + def test_missing_wrong_key(self): + checks = {"q1": {"correct": True}} + result = grade(self.Q, checks, {"q1": "false"}) + assert result.feedbacks["q1"]["correct"] is False + + def test_tags_on_wrong(self): + body_q = {"q1": {"type": "true_false_question", "points": 1, + "tags": ["concept-A"]}} + result = grade(body_q, self.C_TRUE, {"q1": "false"}) + assert result.feedbacks["q1"]["tags"] == ["concept-A"] + + def test_no_tags_on_correct(self): + body_q = {"q1": {"type": "true_false_question", "points": 1, + "tags": ["concept-A"]}} + result = grade(body_q, self.C_TRUE, {"q1": "true"}) + assert result.feedbacks["q1"]["tags"] == [] + + +# --------------------------------------------------------------------------- +# multiple_choice_question +# --------------------------------------------------------------------------- + +class TestMultipleChoiceQuestion: + Q = {"q1": {"type": "multiple_choice_question", "points": 2, + "answers": ["A", "B", "C"]}} + C = {"q1": {"correct": "B", "feedback": {"A": "Not A", "C": "Not C"}}} + + def test_correct_answer(self): + result = grade(self.Q, self.C, {"q1": "B"}) + assert result.feedbacks["q1"]["correct"] is True + assert result.score == 1.0 + + def test_wrong_answer_with_specific_feedback(self): + result = grade(self.Q, self.C, {"q1": "A"}) + assert result.feedbacks["q1"]["correct"] is False + assert result.feedbacks["q1"]["message"] == "Not A" + + def test_wrong_answer_no_specific_feedback(self): + c = {"q1": {"correct": "B"}} + result = grade(self.Q, c, {"q1": "A"}) + assert result.feedbacks["q1"]["correct"] is False + assert result.feedbacks["q1"]["message"] == "Incorrect" + + def test_list_of_correct_answers(self): + c = {"q1": {"correct": ["A", "B"]}} + result = grade(self.Q, c, {"q1": "A"}) + assert result.feedbacks["q1"]["correct"] is True + + def test_list_of_correct_answers_wrong(self): + c = {"q1": {"correct": ["A", "B"]}} + result = grade(self.Q, c, {"q1": "C"}) + assert result.feedbacks["q1"]["correct"] is False + + +# --------------------------------------------------------------------------- +# multiple_answers_question +# --------------------------------------------------------------------------- + +class TestMultipleAnswersQuestion: + Q = {"q1": {"type": "multiple_answers_question", "points": 2, + "answers": ["A", "B", "C", "D"]}} + C = {"q1": {"correct": ["A", "C"], "wrong_any": "Wrong answer"}} + + def test_all_correct(self): + result = grade(self.Q, self.C, {"q1": ["A", "C"]}) + assert result.feedbacks["q1"]["correct"] is True + assert result.score == 1.0 + + def test_all_wrong(self): + result = grade(self.Q, self.C, {"q1": ["B", "D"]}) + assert result.feedbacks["q1"]["correct"] is False + + def test_partial_credit(self): + # Select A (correct) + B (wrong) + C (correct) — missing D (correct to omit) + # answers: A(correct+selected)=T, B(correct to omit+selected)=F, + # C(correct+selected)=T, D(correct to omit+unselected)=T → 3/4 + result = grade(self.Q, self.C, {"q1": ["A", "B", "C"]}) + assert result.feedbacks["q1"]["correct"] is False + assert result.feedbacks["q1"]["score"] == pytest.approx(3 / 4) + + def test_empty_selection(self): + result = grade(self.Q, self.C, {"q1": []}) + assert result.feedbacks["q1"]["correct"] is False + + def test_wrong_feedback_list(self): + c = {"q1": {"correct": ["A", "C"], + "wrong": ["", "You picked B", ""], # index matches answers + "wrong_any": "Something wrong"}} + result = grade(self.Q, c, {"q1": ["B"]}) + # B is wrong to pick → "You picked B" feedback + assert "You picked B" in result.feedbacks["q1"]["message"] + + def test_wrong_any_fallback(self): + result = grade(self.Q, self.C, {"q1": ["B"]}) + assert result.feedbacks["q1"]["message"] == "Wrong answer" + + def test_correct_marks_as_correct(self): + result = grade(self.Q, self.C, {"q1": ["A", "C"]}) + assert result.feedbacks["q1"]["message"] == "Correct" + + +# --------------------------------------------------------------------------- +# matching_question +# --------------------------------------------------------------------------- + +class TestMatchingQuestion: + Q = {"q1": {"type": "matching_question", "points": 3, + "statements": ["S1", "S2", "S3"], + "answers": ["X", "Y", "Z"]}} + C = {"q1": {"correct": ["X", "Y", "Z"]}} + + def test_all_correct(self): + result = grade(self.Q, self.C, {"q1": ["X", "Y", "Z"]}) + assert result.feedbacks["q1"]["correct"] is True + assert result.score == 1.0 + + def test_all_wrong(self): + result = grade(self.Q, self.C, {"q1": ["Z", "X", "Y"]}) + assert result.feedbacks["q1"]["correct"] is False + + def test_partial_credit(self): + result = grade(self.Q, self.C, {"q1": ["X", "X", "Z"]}) + # X-X-Z vs X-Y-Z → 2/3 correct + assert result.feedbacks["q1"]["score"] == pytest.approx(2 / 3) + assert result.feedbacks["q1"]["correct"] is False + + def test_list_correct_per_statement(self): + c = {"q1": {"correct": [["X", "Y"], "Y", "Z"]}} + # statement 0 accepts X or Y + result = grade(self.Q, c, {"q1": ["Y", "Y", "Z"]}) + assert result.feedbacks["q1"]["correct"] is True + + def test_feedback_for_wrong(self): + c = {"q1": {"correct": ["X", "Y", "Z"], + "feedback": [{"Z": "Z is wrong for S1"}, {}, {}]}} + result = grade(self.Q, c, {"q1": ["Z", "Y", "Z"]}) + assert "Z is wrong for S1" in result.feedbacks["q1"]["message"] + + def test_empty_answers(self): + result = grade(self.Q, self.C, {"q1": []}) + # zip stops at shorter list → score = 0/0 → 0 + assert result.feedbacks["q1"]["score"] == 0 + + +# --------------------------------------------------------------------------- +# multiple_dropdowns_question +# --------------------------------------------------------------------------- + +class TestMultipleDropdownsQuestion: + Q = {"q1": {"type": "multiple_dropdowns_question", "points": 2, + "answers": {"color": ["red", "green", "blue"], + "size": ["small", "large"]}}} + C = {"q1": {"correct": {"color": "red", "size": "large"}, + "wrong_any": "Try again"}} + + def test_all_correct(self): + result = grade(self.Q, self.C, {"q1": {"color": "red", "size": "large"}}) + assert result.feedbacks["q1"]["correct"] is True + assert result.score == 1.0 + + def test_all_wrong(self): + result = grade(self.Q, self.C, {"q1": {"color": "blue", "size": "small"}}) + assert result.feedbacks["q1"]["correct"] is False + assert result.feedbacks["q1"]["message"] == "Try again" + + def test_partial_credit(self): + result = grade(self.Q, self.C, {"q1": {"color": "red", "size": "small"}}) + assert result.feedbacks["q1"]["correct"] is False + assert result.feedbacks["q1"]["score"] == pytest.approx(0.5) + + def test_per_blank_feedback(self): + c = {"q1": {"correct": {"color": "red", "size": "large"}, + "feedback": {"size": {"small": "Not small!"}}}} + result = grade(self.Q, c, {"q1": {"color": "red", "size": "small"}}) + assert "Not small!" in result.feedbacks["q1"]["message"] + + def test_per_blank_string_feedback(self): + c = {"q1": {"correct": {"color": "red", "size": "large"}, + "feedback": {"size": "Size is wrong"}}} + result = grade(self.Q, c, {"q1": {"color": "red", "size": "small"}}) + assert "Size is wrong" in result.feedbacks["q1"]["message"] + + def test_no_correct_keys_gives_zero(self): + c = {"q1": {"correct": {}}} + result = grade(self.Q, c, {"q1": {"color": "red"}}) + assert result.feedbacks["q1"]["score"] == 0 + + def test_correct_message(self): + result = grade(self.Q, self.C, {"q1": {"color": "red", "size": "large"}}) + assert result.feedbacks["q1"]["message"] == "Correct" + + +# --------------------------------------------------------------------------- +# short_answer_question and numerical_question +# --------------------------------------------------------------------------- + +class TestShortAnswerQuestion: + Q = {"q1": {"type": "short_answer_question", "points": 1}} + Q_NUM = {"q1": {"type": "numerical_question", "points": 1}} + + def test_correct_exact_string(self): + c = {"q1": {"correct_exact": "hello"}} + result = grade(self.Q, c, {"q1": "hello"}) + assert result.feedbacks["q1"]["correct"] is True + + def test_correct_exact_list(self): + c = {"q1": {"correct_exact": ["hello", "hi"]}} + result = grade(self.Q, c, {"q1": "hi"}) + assert result.feedbacks["q1"]["correct"] is True + + def test_correct_alias(self): + c = {"q1": {"correct": "hello"}} + result = grade(self.Q, c, {"q1": "hello"}) + assert result.feedbacks["q1"]["correct"] is True + + def test_whitespace_trimmed(self): + c = {"q1": {"correct_exact": "hello"}} + result = grade(self.Q, c, {"q1": " hello "}) + assert result.feedbacks["q1"]["correct"] is True + + def test_incorrect_answer(self): + c = {"q1": {"correct_exact": "hello", "wrong_any": "Wrong!"}} + result = grade(self.Q, c, {"q1": "world"}) + assert result.feedbacks["q1"]["correct"] is False + assert result.feedbacks["q1"]["message"] == "Wrong!" + + def test_specific_answer_feedback(self): + c = {"q1": {"correct_exact": "hello", + "feedback": {"world": "Did you mean hello?"}}} + result = grade(self.Q, c, {"q1": "world"}) + assert result.feedbacks["q1"]["message"] == "Did you mean hello?" + + def test_regex_match(self): + c = {"q1": {"correct_regex": [r"^\d+$"]}} + result = grade(self.Q, c, {"q1": "123"}) + assert result.feedbacks["q1"]["correct"] is True + + def test_regex_no_match(self): + c = {"q1": {"correct_regex": [r"^\d+$"], "wrong_any": "Numbers only"}} + result = grade(self.Q, c, {"q1": "abc"}) + assert result.feedbacks["q1"]["correct"] is False + assert result.feedbacks["q1"]["message"] == "Numbers only" + + def test_missing_check_gives_error(self): + c = {"q1": {}} + result = grade(self.Q, c, {"q1": "hello"}) + assert result.feedbacks["q1"]["correct"] is False + + def test_numerical_question_correct(self): + c = {"q1": {"correct_exact": "42"}} + result = grade(self.Q_NUM, c, {"q1": "42"}) + assert result.feedbacks["q1"]["correct"] is True + + def test_correct_shows_correct_message(self): + c = {"q1": {"correct_exact": "hello"}} + result = grade(self.Q, c, {"q1": "hello"}) + assert result.feedbacks["q1"]["message"] == "Correct" + + +# --------------------------------------------------------------------------- +# fill_in_multiple_blanks_question +# --------------------------------------------------------------------------- + +class TestFillInMultipleBlanks: + Q = {"q1": {"type": "fill_in_multiple_blanks_question", "points": 2, + "body": "The [color] sky is [adjective]."}} + C_EXACT = {"q1": {"correct_exact": {"color": "blue", "adjective": "clear"}, + "wrong_any": "Not right"}} + + def test_all_correct(self): + result = grade(self.Q, self.C_EXACT, + {"q1": {"color": "blue", "adjective": "clear"}}) + assert result.feedbacks["q1"]["correct"] is True + assert result.score == 1.0 + + def test_all_wrong(self): + result = grade(self.Q, self.C_EXACT, + {"q1": {"color": "red", "adjective": "stormy"}}) + assert result.feedbacks["q1"]["correct"] is False + assert result.feedbacks["q1"]["message"] == "Not right" + + def test_partial_credit(self): + result = grade(self.Q, self.C_EXACT, + {"q1": {"color": "blue", "adjective": "stormy"}}) + assert result.feedbacks["q1"]["correct"] is False + assert result.feedbacks["q1"]["score"] == pytest.approx(0.5) + + def test_correct_exact_list_per_blank(self): + c = {"q1": {"correct_exact": {"color": ["blue", "azure"], + "adjective": "clear"}}} + result = grade(self.Q, c, {"q1": {"color": "azure", "adjective": "clear"}}) + assert result.feedbacks["q1"]["correct"] is True + + def test_correct_alias(self): + c = {"q1": {"correct": {"color": "blue", "adjective": "clear"}}} + result = grade(self.Q, c, {"q1": {"color": "blue", "adjective": "clear"}}) + assert result.feedbacks["q1"]["correct"] is True + + def test_regex_blanks(self): + c = {"q1": {"correct_regex": {"color": [r"^bl"], + "adjective": [r"cl"]}}} + result = grade(self.Q, c, {"q1": {"color": "blue", "adjective": "clear"}}) + assert result.feedbacks["q1"]["correct"] is True + + def test_regex_blanks_wrong(self): + c = {"q1": {"correct_regex": {"color": [r"^bl"], + "adjective": [r"cl"]}}} + result = grade(self.Q, c, {"q1": {"color": "red", "adjective": "clear"}}) + assert result.feedbacks["q1"]["correct"] is False + + def test_missing_check_type_gives_error(self): + c = {"q1": {}} + result = grade(self.Q, c, {"q1": {"color": "blue", "adjective": "clear"}}) + assert result.feedbacks["q1"]["correct"] is False + + def test_correct_message(self): + result = grade(self.Q, self.C_EXACT, + {"q1": {"color": "blue", "adjective": "clear"}}) + assert result.feedbacks["q1"]["message"] == "Correct" + + +# --------------------------------------------------------------------------- +# text_only_question and essay_question +# --------------------------------------------------------------------------- + +class TestTextOnlyAndEssayQuestion: + def test_text_only_always_correct(self): + body_q = {"q1": {"type": "text_only_question", "points": 0}} + check_q = {"q1": {}} + # text_only questions have no student input; pass an empty string so the + # answer is not treated as missing/skipped by the grader. + result = grade(body_q, check_q, {"q1": ""}) + assert result.feedbacks["q1"]["correct"] is True + assert result.feedbacks["q1"]["score"] == 1 + + def test_essay_always_correct(self): + body_q = {"q1": {"type": "essay_question", "points": 5}} + check_q = {"q1": {}} + result = grade(body_q, check_q, {"q1": "Some long essay text."}) + assert result.feedbacks["q1"]["correct"] is True + assert result.feedbacks["q1"]["score"] == 1 + + +# --------------------------------------------------------------------------- +# Scoring — weights and multi-question quizzes +# --------------------------------------------------------------------------- + +class TestQuizScoring: + def test_single_question_full_score(self): + body_q = {"q1": {"type": "true_false_question", "points": 5}} + check_q = {"q1": {"correct": True}} + result = grade(body_q, check_q, {"q1": "true"}) + assert result.score == pytest.approx(1.0) + assert result.points_possible == 5 + + def test_multi_question_average(self): + body_q = { + "q1": {"type": "true_false_question", "points": 1}, + "q2": {"type": "true_false_question", "points": 1}, + } + check_q = { + "q1": {"correct": True}, + "q2": {"correct": False}, + } + # q1 correct (answer "true", check True), q2 wrong (answer "true", check False) + result = grade(body_q, check_q, {"q1": "true", "q2": "true"}) + assert result.score == pytest.approx(0.5) + assert result.points_possible == 2 + + def test_weighted_questions(self): + body_q = { + "q1": {"type": "true_false_question", "points": 3}, + "q2": {"type": "true_false_question", "points": 1}, + } + check_q = { + "q1": {"correct": True}, + "q2": {"correct": True}, + } + result = grade(body_q, check_q, {"q1": "true", "q2": "true"}) + assert result.score == pytest.approx(1.0) + assert result.points_possible == 4 + + def test_weighted_partial_score(self): + body_q = { + "q1": {"type": "true_false_question", "points": 3}, + "q2": {"type": "true_false_question", "points": 1}, + } + check_q = { + "q1": {"correct": True}, + "q2": {"correct": True}, + } + # q1 wrong (3 pts), q2 correct (1 pt) → 1/4 + result = grade(body_q, check_q, {"q1": "false", "q2": "true"}) + assert result.score == pytest.approx(1 / 4) + + def test_overall_correct_requires_all_correct(self): + body_q = { + "q1": {"type": "true_false_question", "points": 1}, + "q2": {"type": "true_false_question", "points": 1}, + } + check_q = { + "q1": {"correct": True}, + "q2": {"correct": False}, + } + # q1 correct, q2 wrong (student says "true" but answer is False) + result = grade(body_q, check_q, {"q1": "true", "q2": "true"}) + assert result.correct is False + + def test_overall_correct_when_all_correct(self): + body_q = { + "q1": {"type": "true_false_question", "points": 1}, + "q2": {"type": "true_false_question", "points": 1}, + } + check_q = { + "q1": {"correct": True}, + "q2": {"correct": False}, + } + # q1 correct ("true"), q2 correct ("false" matches correct: False) + result = grade(body_q, check_q, {"q1": "true", "q2": "false"}) + assert result.correct is True + + +# --------------------------------------------------------------------------- +# check_quiz_question — unit-level tests +# --------------------------------------------------------------------------- + +class TestCheckQuizQuestion: + def test_returns_none_for_unknown_type(self): + question = {"type": "unknown_custom_type", "points": 1} + result = check_quiz_question(question, {}, "answer") + assert result is None + + def test_returns_tuple_for_known_type(self): + question = {"type": "true_false_question", "points": 1} + check = {"correct": True} + result = check_quiz_question(question, check, "true") + assert isinstance(result, tuple) + assert len(result) == 3 + + +# --------------------------------------------------------------------------- +# likert_question +# --------------------------------------------------------------------------- + +class TestLikertQuestion: + Q = { + "q1": { + "type": "likert_question", + "points": 3, + "statements": ["I enjoy Python.", "Tests are useful.", "KO is fun."], + "options": ["Strongly Disagree", "Disagree", "Neutral", "Agree", "Strongly Agree"], + } + } + C = { + "q1": { + "correct": {"0": "Agree", "1": "Strongly Agree", "2": "Neutral"}, + } + } + + def test_all_correct(self): + student = {"0": "Agree", "1": "Strongly Agree", "2": "Neutral"} + result = grade(self.Q, self.C, {"q1": student}) + assert result.feedbacks["q1"]["correct"] is True + assert result.score == pytest.approx(1.0) + + def test_all_wrong(self): + student = {"0": "Disagree", "1": "Disagree", "2": "Disagree"} + result = grade(self.Q, self.C, {"q1": student}) + assert result.feedbacks["q1"]["correct"] is False + assert result.feedbacks["q1"]["score"] == pytest.approx(0.0) + + def test_partial_credit(self): + student = {"0": "Agree", "1": "Disagree", "2": "Neutral"} + result = grade(self.Q, self.C, {"q1": student}) + # statements 0 and 2 correct, 1 wrong → 2/3 + assert result.feedbacks["q1"]["score"] == pytest.approx(2 / 3) + assert result.feedbacks["q1"]["correct"] is False + + def test_wrong_any_feedback(self): + c = {"q1": { + "correct": {"0": "Agree", "1": "Strongly Agree", "2": "Neutral"}, + "wrong_any": "Some answers were incorrect.", + }} + student = {"0": "Disagree", "1": "Strongly Agree", "2": "Neutral"} + result = grade(self.Q, c, {"q1": student}) + assert result.feedbacks["q1"]["message"] == "Some answers were incorrect." + + def test_survey_mode_no_correct_key(self): + c = {"q1": {}} + student = {"0": "Disagree", "1": "Neutral", "2": "Agree"} + result = grade(self.Q, c, {"q1": student}) + assert result.feedbacks["q1"]["correct"] is True + assert result.feedbacks["q1"]["score"] == pytest.approx(1.0) + + def test_survey_mode_empty_student(self): + c = {"q1": {}} + result = grade(self.Q, c, {"q1": {}}) + assert result.feedbacks["q1"]["correct"] is True + + def test_no_statements_returns_correct(self): + q = {"q1": {"type": "likert_question", "points": 1, "statements": [], "options": []}} + c = {"q1": {"correct": {}}} + result = grade(q, c, {"q1": {}}) + assert result.feedbacks["q1"]["correct"] is True + + def test_partial_student_answers(self): + """Unanswered statements are treated as wrong when correct key exists.""" + student = {"0": "Agree"} # statements 1 and 2 not answered + result = grade(self.Q, self.C, {"q1": student}) + # only statement 0 correct → 1/3 + assert result.feedbacks["q1"]["score"] == pytest.approx(1 / 3) + assert result.feedbacks["q1"]["correct"] is False