From 15c4a2241fa8ceb21849d2c4486a817dac503b21 Mon Sep 17 00:00:00 2001 From: Alex Mickelson Date: Wed, 21 Aug 2024 15:43:16 -0600 Subject: [PATCH] starting quiz tests --- .../{pageMarkdown.ts => pageMarkdownUtils.ts} | 2 +- .../models/local/quiz/localQuizQuestion.ts | 11 +- .../local/quiz/localQuizQuestionAnswer.ts | 1 - .../local/quiz/utils/quizMarkdownParser.ts | 0 .../local/quiz/utils/quizMarkdownUtils.ts | 154 ++++++++++++++ .../utils/quizQuestionAnswerMarkdownUtils.ts | 39 ++++ .../quiz/utils/quizQuestionMarkdownUtils.ts | 201 ++++++++++++++++++ .../local/tests/markdown/pageMarkdown.test.ts | 6 +- .../tests/markdown/quiz/testAnswer.test.ts | 110 ++++++++++ 9 files changed, 514 insertions(+), 10 deletions(-) rename nextjs/src/models/local/page/{pageMarkdown.ts => pageMarkdownUtils.ts} (96%) delete mode 100644 nextjs/src/models/local/quiz/utils/quizMarkdownParser.ts create mode 100644 nextjs/src/models/local/quiz/utils/quizMarkdownUtils.ts create mode 100644 nextjs/src/models/local/quiz/utils/quizQuestionAnswerMarkdownUtils.ts create mode 100644 nextjs/src/models/local/quiz/utils/quizQuestionMarkdownUtils.ts create mode 100644 nextjs/src/models/local/tests/markdown/quiz/testAnswer.test.ts diff --git a/nextjs/src/models/local/page/pageMarkdown.ts b/nextjs/src/models/local/page/pageMarkdownUtils.ts similarity index 96% rename from nextjs/src/models/local/page/pageMarkdown.ts rename to nextjs/src/models/local/page/pageMarkdownUtils.ts index c7858e5..cc7c5eb 100644 --- a/nextjs/src/models/local/page/pageMarkdown.ts +++ b/nextjs/src/models/local/page/pageMarkdownUtils.ts @@ -1,7 +1,7 @@ import { extractLabelValue } from "../assignmnet/utils/markdownUtils"; import { LocalCoursePage } from "./localCoursePage"; -export const pageMarkdown = { +export const pageMarkdownUtils = { toMarkdown: (page: LocalCoursePage) => { const printableDueDate = new Date(page.dueAt) .toISOString() diff --git a/nextjs/src/models/local/quiz/localQuizQuestion.ts b/nextjs/src/models/local/quiz/localQuizQuestion.ts index d4a814d..aa5293a 100644 --- a/nextjs/src/models/local/quiz/localQuizQuestion.ts +++ b/nextjs/src/models/local/quiz/localQuizQuestion.ts @@ -8,9 +8,10 @@ export interface LocalQuizQuestion { } export enum QuestionType { - MultipleAnswers = "multiple_answers", - MultipleChoice = "multiple_choice", - Essay = "essay", - ShortAnswer = "short_answer", - Matching = "matching" + MULTIPLE_ANSWERS = "multiple_answers", + MULTIPLE_CHOICE = "multiple_choice", + ESSAY = "essay", + SHORT_ANSWER = "short_answer", + MATCHING = "matching", + NONE = "", } diff --git a/nextjs/src/models/local/quiz/localQuizQuestionAnswer.ts b/nextjs/src/models/local/quiz/localQuizQuestionAnswer.ts index 6f46ef8..de5850c 100644 --- a/nextjs/src/models/local/quiz/localQuizQuestionAnswer.ts +++ b/nextjs/src/models/local/quiz/localQuizQuestionAnswer.ts @@ -2,5 +2,4 @@ export interface LocalQuizQuestionAnswer { correct: boolean; text: string; matchedText?: string; - htmlText: string; } diff --git a/nextjs/src/models/local/quiz/utils/quizMarkdownParser.ts b/nextjs/src/models/local/quiz/utils/quizMarkdownParser.ts deleted file mode 100644 index e69de29..0000000 diff --git a/nextjs/src/models/local/quiz/utils/quizMarkdownUtils.ts b/nextjs/src/models/local/quiz/utils/quizMarkdownUtils.ts new file mode 100644 index 0000000..1aca211 --- /dev/null +++ b/nextjs/src/models/local/quiz/utils/quizMarkdownUtils.ts @@ -0,0 +1,154 @@ +import { LocalQuiz } from "../localQuiz"; +import { quizQuestionMarkdownUtils } from "./quizQuestionMarkdownUtils"; + +const extractLabelValue = (input: string, label: string): string => { + const pattern = new RegExp(`${label}: (.*?)\n`); + const match = pattern.exec(input); + return match ? match[1].trim() : ""; +}; + +const extractDescription = (input: string): string => { + const pattern = new RegExp("Description: (.*?)$", "s"); + const match = pattern.exec(input); + return match ? match[1].trim() : ""; +}; + +const parseBooleanOrThrow = (value: string, label: string): boolean => { + if (value.toLowerCase() === "true") return true; + if (value.toLowerCase() === "false") return false; + throw new Error(`Error with ${label}: ${value}`); +}; + +const parseBooleanOrDefault = ( + value: string, + label: string, + defaultValue: boolean +): boolean => { + if (value.toLowerCase() === "true") return true; + if (value.toLowerCase() === "false") return false; + return defaultValue; +}; + +const parseNumberOrThrow = (value: string, label: string): number => { + const parsed = parseInt(value, 10); + if (isNaN(parsed)) { + throw new Error(`Error with ${label}: ${value}`); + } + return parsed; +}; + +const parseDateOrThrow = (value: string, label: string): string => { + const date = new Date(value); + if (isNaN(date.getTime())) { + throw new Error(`Error with ${label}: ${value}`); + } + return date.toISOString(); +}; + +const parseDateOrNull = (value: string): string | undefined => { + const date = new Date(value); + return isNaN(date.getTime()) ? undefined : date.toISOString(); +}; + +const getQuizWithOnlySettings = (settings: string): LocalQuiz => { + const name = extractLabelValue(settings, "Name"); + + const rawShuffleAnswers = extractLabelValue(settings, "ShuffleAnswers"); + const shuffleAnswers = parseBooleanOrThrow( + rawShuffleAnswers, + "ShuffleAnswers" + ); + + const password = extractLabelValue(settings, "Password") || undefined; + + const rawShowCorrectAnswers = extractLabelValue( + settings, + "ShowCorrectAnswers" + ); + const showCorrectAnswers = parseBooleanOrDefault( + rawShowCorrectAnswers, + "ShowCorrectAnswers", + true + ); + + const rawOneQuestionAtATime = extractLabelValue( + settings, + "OneQuestionAtATime" + ); + const oneQuestionAtATime = parseBooleanOrThrow( + rawOneQuestionAtATime, + "OneQuestionAtATime" + ); + + const rawAllowedAttempts = extractLabelValue(settings, "AllowedAttempts"); + const allowedAttempts = parseNumberOrThrow( + rawAllowedAttempts, + "AllowedAttempts" + ); + + const rawDueAt = extractLabelValue(settings, "DueAt"); + const dueAt = parseDateOrThrow(rawDueAt, "DueAt"); + + const rawLockAt = extractLabelValue(settings, "LockAt"); + const lockAt = parseDateOrNull(rawLockAt); + + const description = extractDescription(settings); + const localAssignmentGroupName = extractLabelValue( + settings, + "AssignmentGroup" + ); + + const quiz: LocalQuiz = { + name, + description, + password, + lockAt, + dueAt, + shuffleAnswers, + showCorrectAnswers, + oneQuestionAtATime, + localAssignmentGroupName, + allowedAttempts, + questions: [], + }; + return quiz; +}; + +export const quizMarkdownUtils = { + toMarkdown(quiz: LocalQuiz): string { + const questionMarkdownArray = quiz.questions.map((q) => + quizQuestionMarkdownUtils.toMarkdown(q) + ); + const questionDelimiter = "\n\n---\n\n"; + const questionMarkdown = questionMarkdownArray.join(questionDelimiter); + + return `Name: ${quiz.name} +LockAt: ${quiz.lockAt ?? ""} +DueAt: ${quiz.dueAt} +Password: ${quiz.password ?? ""} +ShuffleAnswers: ${quiz.shuffleAnswers.toString().toLowerCase()} +ShowCorrectAnswers: ${quiz.showCorrectAnswers.toString().toLowerCase()} +OneQuestionAtATime: ${quiz.oneQuestionAtATime.toString().toLowerCase()} +AssignmentGroup: ${quiz.localAssignmentGroupName} +AllowedAttempts: ${quiz.allowedAttempts} +Description: ${quiz.description} +--- +${questionMarkdown}`; + }, + + parseMarkdown(input: string): LocalQuiz { + const splitInput = input.split("---\n"); + const settings = splitInput[0]; + const quizWithoutQuestions = getQuizWithOnlySettings(settings); + + const rawQuestions = splitInput.slice(1); + const questions = rawQuestions + .filter((str) => str.trim().length > 0) + .map((q, i) => quizQuestionMarkdownUtils.parseMarkdown(q, i)); + + return { + ...quizWithoutQuestions, + questions, + }; + }, +}; diff --git a/nextjs/src/models/local/quiz/utils/quizQuestionAnswerMarkdownUtils.ts b/nextjs/src/models/local/quiz/utils/quizQuestionAnswerMarkdownUtils.ts new file mode 100644 index 0000000..cf70862 --- /dev/null +++ b/nextjs/src/models/local/quiz/utils/quizQuestionAnswerMarkdownUtils.ts @@ -0,0 +1,39 @@ +import { QuestionType } from "../localQuizQuestion"; +import { LocalQuizQuestionAnswer } from "../localQuizQuestionAnswer"; + +export const quizQuestionAnswerMarkdownUtils = { + // getHtmlText(): string { + // return MarkdownService.render(this.text); + // } + + parseMarkdown(input: string, questionType: string): LocalQuizQuestionAnswer { + const isCorrect = input.startsWith("*") || input[1] === "*"; + + if (questionType === QuestionType.MATCHING) { + const matchingPattern = /^\^ ?/; + const textWithoutMatchDelimiter = input + .replace(matchingPattern, "") + .trim(); + const [text, ...matchedParts] = textWithoutMatchDelimiter.split("-"); + const answer: LocalQuizQuestionAnswer = { + correct: true, + text: text.trim(), + matchedText: matchedParts.join("-").trim(), + }; + return answer; + } + + const startingQuestionPattern = /^(\*?[a-z]?\))|\[\s*\]|\[\*\]|\^ /; + + let replaceCount = 0; + const text = input + .replace(startingQuestionPattern, (m) => (replaceCount++ === 0 ? "" : m)) + .trim(); + + const answer: LocalQuizQuestionAnswer = { + correct: isCorrect, + text: text, + }; + return answer; + }, +}; diff --git a/nextjs/src/models/local/quiz/utils/quizQuestionMarkdownUtils.ts b/nextjs/src/models/local/quiz/utils/quizQuestionMarkdownUtils.ts new file mode 100644 index 0000000..3d44906 --- /dev/null +++ b/nextjs/src/models/local/quiz/utils/quizQuestionMarkdownUtils.ts @@ -0,0 +1,201 @@ +import { LocalQuiz } from "../localQuiz"; +import { LocalQuizQuestion, QuestionType } from "../localQuizQuestion"; +import { LocalQuizQuestionAnswer } from "../localQuizQuestionAnswer"; +import { quizQuestionAnswerMarkdownUtils } from "./quizQuestionAnswerMarkdownUtils"; + +const _validFirstAnswerDelimiters = ["*a)", "a)", "*)", ")", "[ ]", "[*]", "^"]; + +const getAnswerStringsWithMultilineSupport = ( + linesWithoutPoints: string[], + questionIndex: number +) => { + const indexOfAnswerStart = linesWithoutPoints.findIndex((l) => + _validFirstAnswerDelimiters.some((prefix) => + l.trimStart().startsWith(prefix) + ) + ); + if (indexOfAnswerStart === -1) { + const debugLine = linesWithoutPoints.find((l) => l.trim().length > 0); + throw Error( + `question ${ + questionIndex + 1 + }: no answers when detecting question type on ${debugLine}` + ); + } + + const answerLinesRaw = linesWithoutPoints.slice(indexOfAnswerStart); + + const answerStartPattern = /^(\*?[a-z]?\))|(? { + const isNewAnswer = answerStartPattern.test(line); + if (isNewAnswer) { + acc.push(line); + } else if (acc.length !== 0) { + acc[acc.length - 1] += "\n" + line; + } else { + acc.push(line); + } + return acc; + }, []); + return answerLines; +}; +const getQuestionType = ( + linesWithoutPoints: string[], + questionIndex: number +): QuestionType => { + if (linesWithoutPoints.length === 0) return QuestionType.NONE; + if ( + linesWithoutPoints[linesWithoutPoints.length - 1].toLowerCase() === "essay" + ) + return QuestionType.ESSAY; + if ( + linesWithoutPoints[linesWithoutPoints.length - 1].toLowerCase() === + "short answer" + ) + return QuestionType.SHORT_ANSWER; + if ( + linesWithoutPoints[linesWithoutPoints.length - 1].toLowerCase() === + "short_answer" + ) + return QuestionType.SHORT_ANSWER; + + const answerLines = getAnswerStringsWithMultilineSupport( + linesWithoutPoints, + questionIndex + ); + const firstAnswerLine = answerLines[0]; + const isMultipleChoice = ["a)", "*a)", "*)", ")"].some((prefix) => + firstAnswerLine.startsWith(prefix) + ); + + if (isMultipleChoice) return QuestionType.MULTIPLE_CHOICE; + + const isMultipleAnswer = ["[ ]", "[*]"].some((prefix) => + firstAnswerLine.startsWith(prefix) + ); + if (isMultipleAnswer) return QuestionType.MULTIPLE_ANSWERS; + + const isMatching = firstAnswerLine.startsWith("^"); + if (isMatching) return QuestionType.MATCHING; + + return QuestionType.NONE; +}; + +const getAnswers = ( + linesWithoutPoints: string[], + questionIndex: number, + questionType: string +): LocalQuizQuestionAnswer[] => { + const answerLines = getAnswerStringsWithMultilineSupport( + linesWithoutPoints, + questionIndex + ); + + const answers = answerLines.map((a, i) => + quizQuestionAnswerMarkdownUtils.parseMarkdown(a, questionType) + ); + return answers; +}; +const getAnswerMarkdown = ( + question: LocalQuizQuestion, + answer: LocalQuizQuestionAnswer, + index: number +): string => { + const multilineMarkdownCompatibleText = answer.text.startsWith("```") + ? "\n" + answer.text + : answer.text; + + if (question.questionType === "multiple_answers") { + const correctIndicator = answer.correct ? "*" : " "; + const questionTypeIndicator = `[${correctIndicator}] `; + + return `${questionTypeIndicator}${multilineMarkdownCompatibleText}`; + } else if (question.questionType === "matching") { + return `^ ${answer.text} - ${answer.matchedText}`; + } else { + const questionLetter = String.fromCharCode(97 + index); + const correctIndicator = answer.correct ? "*" : ""; + const questionTypeIndicator = `${correctIndicator}${questionLetter}) `; + + return `${questionTypeIndicator}${multilineMarkdownCompatibleText}`; + } +}; + +export const quizQuestionMarkdownUtils = { + toMarkdown(question: LocalQuizQuestion): string { + const answerArray = question.answers.map((a, i) => + getAnswerMarkdown(question, a, i) + ); + const answersText = answerArray.join("\n"); + const questionTypeIndicator = + question.questionType === "essay" || + question.questionType === "short_answer" + ? question.questionType + : ""; + + return `Points: ${question.points}\n${question.text}\n${answersText}${questionTypeIndicator}`; + }, + + parseMarkdown(input: string, questionIndex: number): LocalQuizQuestion { + const lines = input.trim().split("\n"); + const firstLineIsPoints = lines[0].toLowerCase().includes("points: "); + + const textHasPoints = + lines.length > 0 && + lines[0].includes(": ") && + lines[0].split(": ").length > 1 && + !isNaN(parseFloat(lines[0].split(": ")[1])); + + const points = + firstLineIsPoints && textHasPoints + ? parseFloat(lines[0].split(": ")[1]) + : 1; + + const linesWithoutPoints = firstLineIsPoints ? lines.slice(1) : lines; + + const linesWithoutAnswers = linesWithoutPoints.filter( + (line, index) => + !_validFirstAnswerDelimiters.some((prefix) => + line.trimStart().startsWith(prefix) + ) + ); + + const questionType = getQuestionType(linesWithoutPoints, questionIndex); + + const questionTypesWithoutAnswers = [ + "essay", + "short answer", + "short_answer", + ]; + + const descriptionLines = questionTypesWithoutAnswers.includes( + questionType.toLowerCase() + ) + ? linesWithoutAnswers + .slice(0, linesWithoutPoints.length) + .filter( + (line, index) => + !questionTypesWithoutAnswers.includes(line.toLowerCase()) + ) + : linesWithoutAnswers; + + const description = descriptionLines.join("\n"); + + const typesWithAnswers = [ + "multiple_choice", + "multiple_answers", + "matching", + ]; + const answers = typesWithAnswers.includes(questionType) + ? getAnswers(linesWithoutPoints, questionIndex, questionType) + : []; + + const question: LocalQuizQuestion = { + text: description, + questionType, + points, + answers, + }; + return question; + }, +}; diff --git a/nextjs/src/models/local/tests/markdown/pageMarkdown.test.ts b/nextjs/src/models/local/tests/markdown/pageMarkdown.test.ts index f904e88..6a63ab0 100644 --- a/nextjs/src/models/local/tests/markdown/pageMarkdown.test.ts +++ b/nextjs/src/models/local/tests/markdown/pageMarkdown.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { LocalCoursePage } from "../../page/localCoursePage"; -import { pageMarkdown } from "../../page/pageMarkdown"; +import { pageMarkdownUtils } from "../../page/pageMarkdownUtils"; describe("PageMarkdownTests", () => { it("can parse page", () => { @@ -10,9 +10,9 @@ describe("PageMarkdownTests", () => { dueAt: new Date().toISOString(), }; - const pageMarkdownString = pageMarkdown.toMarkdown(page); + const pageMarkdownString = pageMarkdownUtils.toMarkdown(page); - const parsedPage = pageMarkdown.parseMarkdown(pageMarkdownString); + const parsedPage = pageMarkdownUtils.parseMarkdown(pageMarkdownString); expect(parsedPage).toEqual(page); }); diff --git a/nextjs/src/models/local/tests/markdown/quiz/testAnswer.test.ts b/nextjs/src/models/local/tests/markdown/quiz/testAnswer.test.ts new file mode 100644 index 0000000..0c38c76 --- /dev/null +++ b/nextjs/src/models/local/tests/markdown/quiz/testAnswer.test.ts @@ -0,0 +1,110 @@ +import { QuestionType } from '../../../../../models/local/quiz/localQuizQuestion'; +import { quizMarkdownUtils } from '../../../../../models/local/quiz/utils/quizMarkdownUtils'; +import { quizQuestionMarkdownUtils } from '../../../../../models/local/quiz/utils/quizQuestionMarkdownUtils'; +import { describe, it, expect } from 'vitest'; + +describe('TextAnswerTests', () => { + it('can parse essay', () => { + const rawMarkdownQuiz = ` +Name: Test Quiz +ShuffleAnswers: true +OneQuestionAtATime: false +DueAt: 2023-08-21T23:59:00 +LockAt: 2023-08-21T23:59:00 +AssignmentGroup: Assignments +AllowedAttempts: -1 +Description: this is the +multi line +description +--- +Which events are triggered when the user clicks on an input field? +essay +`; + + const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz); + const firstQuestion = quiz.questions[0]; + + expect(firstQuestion.points).toBe(1); + expect(firstQuestion.questionType).toBe(QuestionType.ESSAY); + expect(firstQuestion.text).not.toContain('essay'); + }); + + it('can parse short answer', () => { + const rawMarkdownQuiz = ` +Name: Test Quiz +ShuffleAnswers: true +OneQuestionAtATime: false +DueAt: 2023-08-21T23:59:00 +LockAt: 2023-08-21T23:59:00 +AssignmentGroup: Assignments +AllowedAttempts: -1 +Description: this is the +multi line +description +--- +Which events are triggered when the user clicks on an input field? +short answer +`; + + const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz); + const firstQuestion = quiz.questions[0]; + + expect(firstQuestion.points).toBe(1); + expect(firstQuestion.questionType).toBe(QuestionType.SHORT_ANSWER); + expect(firstQuestion.text).not.toContain('short answer'); + }); + + it('short answer to markdown is correct', () => { + const rawMarkdownQuiz = ` +Name: Test Quiz +ShuffleAnswers: true +OneQuestionAtATime: false +DueAt: 2023-08-21T23:59:00 +LockAt: 2023-08-21T23:59:00 +AssignmentGroup: Assignments +AllowedAttempts: -1 +Description: this is the +multi line +description +--- +Which events are triggered when the user clicks on an input field? +short answer +`; + + const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz); + const firstQuestion = quiz.questions[0]; + + const questionMarkdown = quizQuestionMarkdownUtils.toMarkdown(firstQuestion); + const expectedMarkdown = `Points: 1 +Which events are triggered when the user clicks on an input field? +short_answer`; + expect(questionMarkdown).toContain(expectedMarkdown); + }); + + it('essay question to markdown is correct', () => { + const rawMarkdownQuiz = ` +Name: Test Quiz +ShuffleAnswers: true +OneQuestionAtATime: false +DueAt: 2023-08-21T23:59:00 +LockAt: 2023-08-21T23:59:00 +AssignmentGroup: Assignments +AllowedAttempts: -1 +Description: this is the +multi line +description +--- +Which events are triggered when the user clicks on an input field? +essay +`; + + const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz); + const firstQuestion = quiz.questions[0]; + + const questionMarkdown = quizQuestionMarkdownUtils.toMarkdown(firstQuestion); + const expectedMarkdown = `Points: 1 +Which events are triggered when the user clicks on an input field? +essay`; + expect(questionMarkdown).toContain(expectedMarkdown); + }); +});