diff --git a/src/features/canvas/services/canvasQuizService.ts b/src/features/canvas/services/canvasQuizService.ts index 7c850e9..50a7eb9 100644 --- a/src/features/canvas/services/canvasQuizService.ts +++ b/src/features/canvas/services/canvasQuizService.ts @@ -39,7 +39,7 @@ export const getAnswers = ( })); }; -export const getQuestionType = (question: LocalQuizQuestion) => { +export const getQuestionTypeForCanvas = (question: LocalQuizQuestion) => { return `${question.questionType.replace("=", "")}_question`; }; @@ -61,7 +61,7 @@ const createQuestionOnly = async ( markdownString: question.text, settings, }), - question_type: getQuestionType(question), + question_type: getQuestionTypeForCanvas(question), points_possible: question.points, position, answers: getAnswers(question, settings), diff --git a/src/features/local/parsingTests/quizMarkdown/questionFeedback.test.ts b/src/features/local/parsingTests/quizMarkdown/questionFeedback.test.ts index 532f2cf..5371e3b 100644 --- a/src/features/local/parsingTests/quizMarkdown/questionFeedback.test.ts +++ b/src/features/local/parsingTests/quizMarkdown/questionFeedback.test.ts @@ -1,8 +1,302 @@ import { describe, it, expect } from "vitest"; import { QuestionType } from "@/features/local/quizzes/models/localQuizQuestion"; import { quizMarkdownUtils } from "@/features/local/quizzes/models/utils/quizMarkdownUtils"; +import { LocalQuiz } from "../../quizzes/models/localQuiz"; describe("Question Feedback options", () => { + it("can parse question with correct feedback", () => { + const name = "Test Quiz"; + const rawMarkdownQuiz = ` +ShuffleAnswers: true +OneQuestionAtATime: false +DueAt: 08/21/2023 23:59:00 +LockAt: 08/21/2023 23:59:00 +AssignmentGroup: Assignments +AllowedAttempts: -1 +Description: quiz description +--- +Points: 3 +What is the purpose of a context switch? ++ Correct! The context switch is used to change the current process by swapping the registers and other state with a new process +*a) To change the current window you are on +b) To change the current process's status +*c) To swap the current process's registers for a new process's registers +`; + + const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name); + const question = quiz.questions[0]; + + expect(question.correctComments).toBe( + "Correct! The context switch is used to change the current process by swapping the registers and other state with a new process" + ); + expect(question.incorrectComments).toBeUndefined(); + expect(question.neutralComments).toBeUndefined(); + }); + + it("can parse question with incorrect feedback", () => { + const name = "Test Quiz"; + const rawMarkdownQuiz = ` +ShuffleAnswers: true +OneQuestionAtATime: false +DueAt: 08/21/2023 23:59:00 +LockAt: 08/21/2023 23:59:00 +AssignmentGroup: Assignments +AllowedAttempts: -1 +Description: quiz description +--- +Points: 3 +What state does a process need to be in to be able to be scheduled? +- Incorrect! A process in ready state can be scheduled +*a) Ready +b) Running +c) Zombie +d) Embryo +`; + + const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name); + const question = quiz.questions[0]; + + expect(question.incorrectComments).toBe( + "Incorrect! A process in ready state can be scheduled" + ); + expect(question.correctComments).toBeUndefined(); + expect(question.neutralComments).toBeUndefined(); + }); + + it("can parse question with correct and incorrect feedback", () => { + const name = "Test Quiz"; + const rawMarkdownQuiz = ` +ShuffleAnswers: true +OneQuestionAtATime: false +DueAt: 08/21/2023 23:59:00 +LockAt: 08/21/2023 23:59:00 +AssignmentGroup: Assignments +AllowedAttempts: -1 +Description: quiz description +--- +Points: 3 +What is the purpose of a context switch? ++ Correct! The context switch is used to change the current process +- Incorrect! The context switch is NOT used to change windows +*a) To change the current window you are on +b) To change the current process's status +*c) To swap the current process's registers for a new process's registers +`; + + const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name); + const question = quiz.questions[0]; + + expect(question.correctComments).toBe( + "Correct! The context switch is used to change the current process" + ); + expect(question.incorrectComments).toBe( + "Incorrect! The context switch is NOT used to change windows" + ); + expect(question.neutralComments).toBeUndefined(); + }); + + it("can parse question with neutral feedback", () => { + const name = "Test Quiz"; + const rawMarkdownQuiz = ` +ShuffleAnswers: true +OneQuestionAtATime: false +DueAt: 08/21/2023 23:59:00 +LockAt: 08/21/2023 23:59:00 +AssignmentGroup: Assignments +AllowedAttempts: -1 +Description: quiz description +--- +Points: 3 +What is a prime number? +... This feedback will be shown regardless of the answer +*a) A number divisible only by 1 and itself +b) Any odd number +c) Any even number +`; + + const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name); + const question = quiz.questions[0]; + + expect(question.neutralComments).toBe( + "This feedback will be shown regardless of the answer" + ); + expect(question.correctComments).toBeUndefined(); + expect(question.incorrectComments).toBeUndefined(); + }); + + it("can parse question with all three feedback types", () => { + const name = "Test Quiz"; + const rawMarkdownQuiz = ` +ShuffleAnswers: true +OneQuestionAtATime: false +DueAt: 08/21/2023 23:59:00 +LockAt: 08/21/2023 23:59:00 +AssignmentGroup: Assignments +AllowedAttempts: -1 +Description: quiz description +--- +Points: 3 +What is the purpose of a context switch? ++ Great job! You understand context switching +- Try reviewing the material on process management +... Context switches are a fundamental operating system concept +*a) To change the current window you are on +b) To change the current process's status +*c) To swap the current process's registers for a new process's registers +`; + + const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name); + const question = quiz.questions[0]; + + expect(question.correctComments).toBe( + "Great job! You understand context switching" + ); + expect(question.incorrectComments).toBe( + "Try reviewing the material on process management" + ); + expect(question.neutralComments).toBe( + "Context switches are a fundamental operating system concept" + ); + }); + + it("can parse multiline feedback", () => { + const name = "Test Quiz"; + const rawMarkdownQuiz = ` +ShuffleAnswers: true +OneQuestionAtATime: false +DueAt: 08/21/2023 23:59:00 +LockAt: 08/21/2023 23:59:00 +AssignmentGroup: Assignments +AllowedAttempts: -1 +Description: quiz description +--- +Points: 3 +What is the purpose of a context switch? ++ Correct! The context switch is used to change the current process. +This is additional information on a new line. +- Incorrect! You should review the material. +Check your notes on process management. +*a) To change the current window you are on +b) To change the current process's status +*c) To swap the current process's registers for a new process's registers +`; + + const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name); + const question = quiz.questions[0]; + + expect(question.correctComments).toBe( + "Correct! The context switch is used to change the current process.\nThis is additional information on a new line." + ); + expect(question.incorrectComments).toBe( + "Incorrect! You should review the material.\nCheck your notes on process management." + ); + }); + + it("feedback can serialize to markdown", () => { + const quiz: LocalQuiz = { + name: "Test Quiz", + description: "quiz description", + lockAt: new Date(8640000000000000).toISOString(), + dueAt: new Date(8640000000000000).toISOString(), + shuffleAnswers: true, + oneQuestionAtATime: false, + localAssignmentGroupName: "Assignments", + allowedAttempts: -1, + showCorrectAnswers: false, + questions: [ + { + text: "What is the purpose of a context switch?", + questionType: QuestionType.MULTIPLE_CHOICE, + points: 3, + correctComments: "Correct! Good job", + incorrectComments: "Incorrect! Try again", + neutralComments: "Context switches are important", + answers: [ + { correct: false, text: "To change the current window you are on" }, + { correct: true, text: "To swap registers" }, + ], + matchDistractors: [], + }, + ], + }; + + const markdown = quizMarkdownUtils.toMarkdown(quiz); + + expect(markdown).toContain("+ Correct! Good job"); + expect(markdown).toContain("- Incorrect! Try again"); + expect(markdown).toContain("... Context switches are important"); + }); + + it("can parse question with alternative format using ellipsis for general feedback", () => { + const name = "Test Quiz"; + const rawMarkdownQuiz = ` +ShuffleAnswers: true +OneQuestionAtATime: false +DueAt: 08/21/2023 23:59:00 +LockAt: 08/21/2023 23:59:00 +AssignmentGroup: Assignments +AllowedAttempts: -1 +Description: An addition question +--- +Points: 2 +What is 2+3? +... General question feedback. ++ Feedback for correct answer. +- Feedback for incorrect answer. +a) 6 +b) 1 +*c) 5 +`; + + const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name); + const question = quiz.questions[0]; + + expect(question.text).toBe("What is 2+3?"); + expect(question.points).toBe(2); + expect(question.neutralComments).toBe("General question feedback."); + expect(question.correctComments).toBe("Feedback for correct answer."); + expect(question.incorrectComments).toBe("Feedback for incorrect answer."); + expect(question.answers).toHaveLength(3); + expect(question.answers[0].text).toBe("6"); + expect(question.answers[0].correct).toBe(false); + expect(question.answers[1].text).toBe("1"); + expect(question.answers[1].correct).toBe(false); + expect(question.answers[2].text).toBe("5"); + expect(question.answers[2].correct).toBe(true); + }); + + it("can parse multiline general feedback with ellipsis", () => { + const name = "Test Quiz"; + const rawMarkdownQuiz = ` +ShuffleAnswers: true +OneQuestionAtATime: false +DueAt: 08/21/2023 23:59:00 +LockAt: 08/21/2023 23:59:00 +AssignmentGroup: Assignments +AllowedAttempts: -1 +Description: quiz description +--- +Points: 2 +What is 2+3? +... +General question feedback. +This continues on multiple lines. ++ Feedback for correct answer. +- Feedback for incorrect answer. +a) 6 +b) 1 +*c) 5 +`; + + const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name); + const question = quiz.questions[0]; + + expect(question.neutralComments).toBe( + "General question feedback.\nThis continues on multiple lines." + ); + expect(question.correctComments).toBe("Feedback for correct answer."); + expect(question.incorrectComments).toBe("Feedback for incorrect answer."); + }); it("essay questions can have feedback", () => { const name = "Test Quiz"; const rawMarkdownQuiz = ` diff --git a/src/features/local/parsingTests/quizMarkdown/quizDeterministicChecks.test.ts b/src/features/local/parsingTests/quizMarkdown/quizDeterministicChecks.test.ts index 227d2cc..f3c4986 100644 --- a/src/features/local/parsingTests/quizMarkdown/quizDeterministicChecks.test.ts +++ b/src/features/local/parsingTests/quizMarkdown/quizDeterministicChecks.test.ts @@ -195,6 +195,7 @@ describe("QuizDeterministicChecks", () => { ], allowedAttempts: -1, showCorrectAnswers: true, + }; const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz); diff --git a/src/features/local/parsingTests/quizMarkdown/quizMarkdown.test.ts b/src/features/local/parsingTests/quizMarkdown/quizMarkdown.test.ts index 425714e..e4b1ec3 100644 --- a/src/features/local/parsingTests/quizMarkdown/quizMarkdown.test.ts +++ b/src/features/local/parsingTests/quizMarkdown/quizMarkdown.test.ts @@ -281,297 +281,4 @@ b) false expect(quizHtml).not.toContain("x_2"); }); - it("can parse question with correct feedback", () => { - const name = "Test Quiz"; - const rawMarkdownQuiz = ` -ShuffleAnswers: true -OneQuestionAtATime: false -DueAt: 08/21/2023 23:59:00 -LockAt: 08/21/2023 23:59:00 -AssignmentGroup: Assignments -AllowedAttempts: -1 -Description: quiz description ---- -Points: 3 -What is the purpose of a context switch? -+ Correct! The context switch is used to change the current process by swapping the registers and other state with a new process -*a) To change the current window you are on -b) To change the current process's status -*c) To swap the current process's registers for a new process's registers -`; - - const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name); - const question = quiz.questions[0]; - - expect(question.correctComments).toBe( - "Correct! The context switch is used to change the current process by swapping the registers and other state with a new process" - ); - expect(question.incorrectComments).toBeUndefined(); - expect(question.neutralComments).toBeUndefined(); - }); - - it("can parse question with incorrect feedback", () => { - const name = "Test Quiz"; - const rawMarkdownQuiz = ` -ShuffleAnswers: true -OneQuestionAtATime: false -DueAt: 08/21/2023 23:59:00 -LockAt: 08/21/2023 23:59:00 -AssignmentGroup: Assignments -AllowedAttempts: -1 -Description: quiz description ---- -Points: 3 -What state does a process need to be in to be able to be scheduled? -- Incorrect! A process in ready state can be scheduled -*a) Ready -b) Running -c) Zombie -d) Embryo -`; - - const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name); - const question = quiz.questions[0]; - - expect(question.incorrectComments).toBe( - "Incorrect! A process in ready state can be scheduled" - ); - expect(question.correctComments).toBeUndefined(); - expect(question.neutralComments).toBeUndefined(); - }); - - it("can parse question with correct and incorrect feedback", () => { - const name = "Test Quiz"; - const rawMarkdownQuiz = ` -ShuffleAnswers: true -OneQuestionAtATime: false -DueAt: 08/21/2023 23:59:00 -LockAt: 08/21/2023 23:59:00 -AssignmentGroup: Assignments -AllowedAttempts: -1 -Description: quiz description ---- -Points: 3 -What is the purpose of a context switch? -+ Correct! The context switch is used to change the current process -- Incorrect! The context switch is NOT used to change windows -*a) To change the current window you are on -b) To change the current process's status -*c) To swap the current process's registers for a new process's registers -`; - - const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name); - const question = quiz.questions[0]; - - expect(question.correctComments).toBe( - "Correct! The context switch is used to change the current process" - ); - expect(question.incorrectComments).toBe( - "Incorrect! The context switch is NOT used to change windows" - ); - expect(question.neutralComments).toBeUndefined(); - }); - - it("can parse question with neutral feedback", () => { - const name = "Test Quiz"; - const rawMarkdownQuiz = ` -ShuffleAnswers: true -OneQuestionAtATime: false -DueAt: 08/21/2023 23:59:00 -LockAt: 08/21/2023 23:59:00 -AssignmentGroup: Assignments -AllowedAttempts: -1 -Description: quiz description ---- -Points: 3 -What is a prime number? -... This feedback will be shown regardless of the answer -*a) A number divisible only by 1 and itself -b) Any odd number -c) Any even number -`; - - const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name); - const question = quiz.questions[0]; - - expect(question.neutralComments).toBe( - "This feedback will be shown regardless of the answer" - ); - expect(question.correctComments).toBeUndefined(); - expect(question.incorrectComments).toBeUndefined(); - }); - - it("can parse question with all three feedback types", () => { - const name = "Test Quiz"; - const rawMarkdownQuiz = ` -ShuffleAnswers: true -OneQuestionAtATime: false -DueAt: 08/21/2023 23:59:00 -LockAt: 08/21/2023 23:59:00 -AssignmentGroup: Assignments -AllowedAttempts: -1 -Description: quiz description ---- -Points: 3 -What is the purpose of a context switch? -+ Great job! You understand context switching -- Try reviewing the material on process management -... Context switches are a fundamental operating system concept -*a) To change the current window you are on -b) To change the current process's status -*c) To swap the current process's registers for a new process's registers -`; - - const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name); - const question = quiz.questions[0]; - - expect(question.correctComments).toBe( - "Great job! You understand context switching" - ); - expect(question.incorrectComments).toBe( - "Try reviewing the material on process management" - ); - expect(question.neutralComments).toBe( - "Context switches are a fundamental operating system concept" - ); - }); - - it("can parse multiline feedback", () => { - const name = "Test Quiz"; - const rawMarkdownQuiz = ` -ShuffleAnswers: true -OneQuestionAtATime: false -DueAt: 08/21/2023 23:59:00 -LockAt: 08/21/2023 23:59:00 -AssignmentGroup: Assignments -AllowedAttempts: -1 -Description: quiz description ---- -Points: 3 -What is the purpose of a context switch? -+ Correct! The context switch is used to change the current process. -This is additional information on a new line. -- Incorrect! You should review the material. -Check your notes on process management. -*a) To change the current window you are on -b) To change the current process's status -*c) To swap the current process's registers for a new process's registers -`; - - const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name); - const question = quiz.questions[0]; - - expect(question.correctComments).toBe( - "Correct! The context switch is used to change the current process.\nThis is additional information on a new line." - ); - expect(question.incorrectComments).toBe( - "Incorrect! You should review the material.\nCheck your notes on process management." - ); - }); - - it("feedback can serialize to markdown", () => { - const quiz: LocalQuiz = { - name: "Test Quiz", - description: "quiz description", - lockAt: new Date(8640000000000000).toISOString(), - dueAt: new Date(8640000000000000).toISOString(), - shuffleAnswers: true, - oneQuestionAtATime: false, - localAssignmentGroupName: "Assignments", - allowedAttempts: -1, - showCorrectAnswers: false, - questions: [ - { - text: "What is the purpose of a context switch?", - questionType: QuestionType.MULTIPLE_CHOICE, - points: 3, - correctComments: "Correct! Good job", - incorrectComments: "Incorrect! Try again", - neutralComments: "Context switches are important", - answers: [ - { correct: false, text: "To change the current window you are on" }, - { correct: true, text: "To swap registers" }, - ], - matchDistractors: [], - }, - ], - }; - - const markdown = quizMarkdownUtils.toMarkdown(quiz); - - expect(markdown).toContain("+ Correct! Good job"); - expect(markdown).toContain("- Incorrect! Try again"); - expect(markdown).toContain("... Context switches are important"); - }); - - it("can parse question with alternative format using ellipsis for general feedback", () => { - const name = "Test Quiz"; - const rawMarkdownQuiz = ` -ShuffleAnswers: true -OneQuestionAtATime: false -DueAt: 08/21/2023 23:59:00 -LockAt: 08/21/2023 23:59:00 -AssignmentGroup: Assignments -AllowedAttempts: -1 -Description: An addition question ---- -Points: 2 -What is 2+3? -... General question feedback. -+ Feedback for correct answer. -- Feedback for incorrect answer. -a) 6 -b) 1 -*c) 5 -`; - - const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name); - const question = quiz.questions[0]; - - expect(question.text).toBe("What is 2+3?"); - expect(question.points).toBe(2); - expect(question.neutralComments).toBe("General question feedback."); - expect(question.correctComments).toBe("Feedback for correct answer."); - expect(question.incorrectComments).toBe("Feedback for incorrect answer."); - expect(question.answers).toHaveLength(3); - expect(question.answers[0].text).toBe("6"); - expect(question.answers[0].correct).toBe(false); - expect(question.answers[1].text).toBe("1"); - expect(question.answers[1].correct).toBe(false); - expect(question.answers[2].text).toBe("5"); - expect(question.answers[2].correct).toBe(true); - }); - - it("can parse multiline general feedback with ellipsis", () => { - const name = "Test Quiz"; - const rawMarkdownQuiz = ` -ShuffleAnswers: true -OneQuestionAtATime: false -DueAt: 08/21/2023 23:59:00 -LockAt: 08/21/2023 23:59:00 -AssignmentGroup: Assignments -AllowedAttempts: -1 -Description: quiz description ---- -Points: 2 -What is 2+3? -... -General question feedback. -This continues on multiple lines. -+ Feedback for correct answer. -- Feedback for incorrect answer. -a) 6 -b) 1 -*c) 5 -`; - - const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name); - const question = quiz.questions[0]; - - expect(question.neutralComments).toBe( - "General question feedback.\nThis continues on multiple lines." - ); - expect(question.correctComments).toBe("Feedback for correct answer."); - expect(question.incorrectComments).toBe("Feedback for incorrect answer."); - }); }); diff --git a/src/features/local/parsingTests/quizMarkdown/testAnswer.test.ts b/src/features/local/parsingTests/quizMarkdown/testAnswer.test.ts index fb4819b..66eabe5 100644 --- a/src/features/local/parsingTests/quizMarkdown/testAnswer.test.ts +++ b/src/features/local/parsingTests/quizMarkdown/testAnswer.test.ts @@ -1,4 +1,7 @@ -import { getQuestionType, getAnswers } from "@/features/canvas/services/canvasQuizService"; +import { + getQuestionTypeForCanvas, + getAnswers, +} from "@/features/canvas/services/canvasQuizService"; import { QuestionType, zodQuestionType, @@ -232,7 +235,9 @@ short_answer= const quiz = quizMarkdownUtils.parseMarkdown(rawMarkdownQuiz, name); const firstQuestion = quiz.questions[0]; - expect(getQuestionType(firstQuestion)).toBe("short_answer_question"); + expect(getQuestionTypeForCanvas(firstQuestion)).toBe( + "short_answer_question" + ); }); it("Includes answer_text in answers sent to canvas", () => { diff --git a/src/features/local/quizzes/models/utils/quizFeedbackMarkdownUtils.ts b/src/features/local/quizzes/models/utils/quizFeedbackMarkdownUtils.ts index 9dd42bc..78fab5d 100644 --- a/src/features/local/quizzes/models/utils/quizFeedbackMarkdownUtils.ts +++ b/src/features/local/quizzes/models/utils/quizFeedbackMarkdownUtils.ts @@ -1,13 +1,5 @@ type FeedbackType = "+" | "-" | "..."; -const isFeedbackStart = ( - trimmedLine: string, - feedbackType: FeedbackType -): boolean => { - const prefix = feedbackType === "..." ? "... " : `${feedbackType} `; - return trimmedLine.startsWith(prefix) || trimmedLine === feedbackType; -}; - const extractFeedbackContent = ( trimmedLine: string, feedbackType: FeedbackType @@ -39,69 +31,65 @@ const saveFeedback = ( } }; +type feedbackTypeOptions = "correct" | "incorrect" | "neutral" | "none"; + export const quizFeedbackMarkdownUtils = { - extractFeedback( - linesWithoutPoints: string[], - isAnswerLine: (trimmedLine: string) => boolean - ): { + extractFeedback(lines: string[]): { correctComments?: string; incorrectComments?: string; neutralComments?: string; - linesWithoutFeedback: string[]; + otherLines: string[]; } { - const comments: { - correct?: string; - incorrect?: string; - neutral?: string; - } = {}; - const linesWithoutFeedback: string[] = []; + const comments = { + correct: [] as string[], + incorrect: [] as string[], + neutral: [] as string[], + }; - let currentFeedbackType: FeedbackType | null = null; - let currentFeedbackLines: string[] = []; + const otherLines: string[] = []; - for (const line of linesWithoutPoints) { - const trimmed = line.trim(); + const feedbackIndicators = { + correct: "+", + incorrect: "-", + neutral: "...", + }; - // Check if this is a new feedback line - let newFeedbackType: FeedbackType | null = null; - if (isFeedbackStart(trimmed, "+")) { - newFeedbackType = "+"; - } else if (isFeedbackStart(trimmed, "-")) { - newFeedbackType = "-"; - } else if (isFeedbackStart(trimmed, "...")) { - newFeedbackType = "..."; - } + let currentFeedbackType: feedbackTypeOptions = "none"; - if (newFeedbackType) { - // Save previous feedback if any - saveFeedback(currentFeedbackType, currentFeedbackLines, comments); + for (const line of lines.map((l) => l)) { + const lineFeedbackType: feedbackTypeOptions = line.startsWith("+") + ? "correct" + : line.startsWith("-") + ? "incorrect" + : line.startsWith("...") + ? "neutral" + : "none"; - // Start new feedback - currentFeedbackType = newFeedbackType; - const content = extractFeedbackContent(trimmed, newFeedbackType); - currentFeedbackLines = content ? [content] : []; - } else if (currentFeedbackType && !isAnswerLine(trimmed)) { - // This is a continuation of the current feedback - currentFeedbackLines.push(line); + if (lineFeedbackType === "none" && currentFeedbackType !== "none") { + const lineWithoutIndicator = line + .replace(feedbackIndicators[currentFeedbackType], "") + .trim(); + comments[currentFeedbackType].push(lineWithoutIndicator); + } else if (lineFeedbackType !== "none") { + const lineWithoutIndicator = line + .replace(feedbackIndicators[lineFeedbackType], "") + .trim(); + currentFeedbackType = lineFeedbackType; + comments[lineFeedbackType].push(lineWithoutIndicator); } else { - // Save any pending feedback - saveFeedback(currentFeedbackType, currentFeedbackLines, comments); - currentFeedbackType = null; - currentFeedbackLines = []; - - // This is a regular line - linesWithoutFeedback.push(line); + otherLines.push(line); } } - // Save any remaining feedback - saveFeedback(currentFeedbackType, currentFeedbackLines, comments); + const correctComments = comments.correct.filter((l) => l).join("\n"); + const incorrectComments = comments.incorrect.filter((l) => l).join("\n"); + const neutralComments = comments.neutral.filter((l) => l).join("\n"); return { - correctComments: comments.correct, - incorrectComments: comments.incorrect, - neutralComments: comments.neutral, - linesWithoutFeedback, + correctComments: correctComments || undefined, + incorrectComments: incorrectComments || undefined, + neutralComments: neutralComments || undefined, + otherLines, }; }, diff --git a/src/features/local/quizzes/models/utils/quizQuestionAnswerMarkdownUtils.ts b/src/features/local/quizzes/models/utils/quizQuestionAnswerMarkdownUtils.ts index 461c563..bb6afe1 100644 --- a/src/features/local/quizzes/models/utils/quizQuestionAnswerMarkdownUtils.ts +++ b/src/features/local/quizzes/models/utils/quizQuestionAnswerMarkdownUtils.ts @@ -14,9 +14,6 @@ const parseMatchingAnswer = (input: string) => { }; export const quizQuestionAnswerMarkdownUtils = { - // getHtmlText(): string { - // return MarkdownService.render(this.text); - // } parseMarkdown(input: string, questionType: string): LocalQuizQuestionAnswer { const isCorrect = input.startsWith("*") || input[1] === "*"; diff --git a/src/features/local/quizzes/models/utils/quizQuestionAnswerParsingUtils.ts b/src/features/local/quizzes/models/utils/quizQuestionAnswerParsingUtils.ts new file mode 100644 index 0000000..7ded066 --- /dev/null +++ b/src/features/local/quizzes/models/utils/quizQuestionAnswerParsingUtils.ts @@ -0,0 +1,150 @@ +import { QuestionType, LocalQuizQuestion } from "../localQuizQuestion"; +import { LocalQuizQuestionAnswer } from "../localQuizQuestionAnswer"; +import { quizQuestionAnswerMarkdownUtils } from "./quizQuestionAnswerMarkdownUtils"; + +const _validFirstAnswerDelimiters = [ + "*a)", + "a)", + "*)", + ")", + "[ ]", + "[]", + "[*]", + "^", +]; +const _multipleChoicePrefix = ["a)", "*a)", "*)", ")"]; +const _multipleAnswerPrefix = ["[ ]", "[*]", "[]"]; + +export const isAnswerLine = (trimmedLine: string): boolean => { + return _validFirstAnswerDelimiters.some((prefix) => + trimmedLine.startsWith(prefix) + ); +}; + +export 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; +}; + +export 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; + if ( + linesWithoutPoints[linesWithoutPoints.length - 1].toLowerCase().trim() === + "short_answer=" + ) + return QuestionType.SHORT_ANSWER_WITH_ANSWERS; + + const answerLines = getAnswerStringsWithMultilineSupport( + linesWithoutPoints, + questionIndex + ); + const firstAnswerLine = answerLines[0]; + const isMultipleChoice = _multipleChoicePrefix.some((prefix) => + firstAnswerLine.startsWith(prefix) + ); + + if (isMultipleChoice) return QuestionType.MULTIPLE_CHOICE; + + const isMultipleAnswer = _multipleAnswerPrefix.some((prefix) => + firstAnswerLine.startsWith(prefix) + ); + if (isMultipleAnswer) return QuestionType.MULTIPLE_ANSWERS; + + const isMatching = firstAnswerLine.startsWith("^"); + if (isMatching) return QuestionType.MATCHING; + + return QuestionType.NONE; +}; + +export const getAnswers = ( + linesWithoutPoints: string[], + questionIndex: number, + questionType: string +): LocalQuizQuestionAnswer[] => { + if (questionType == QuestionType.SHORT_ANSWER_WITH_ANSWERS) + linesWithoutPoints = linesWithoutPoints.slice( + 0, + linesWithoutPoints.length - 1 + ); + const answerLines = getAnswerStringsWithMultilineSupport( + linesWithoutPoints, + questionIndex + ); + + const answers = answerLines.map((a) => + quizQuestionAnswerMarkdownUtils.parseMarkdown(a, questionType) + ); + return answers; +}; + +export 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}`; + } +}; diff --git a/src/features/local/quizzes/models/utils/quizQuestionMarkdownUtils.ts b/src/features/local/quizzes/models/utils/quizQuestionMarkdownUtils.ts index ac7df36..2c83ee2 100644 --- a/src/features/local/quizzes/models/utils/quizQuestionMarkdownUtils.ts +++ b/src/features/local/quizzes/models/utils/quizQuestionMarkdownUtils.ts @@ -1,153 +1,11 @@ import { LocalQuizQuestion, QuestionType } from "../localQuizQuestion"; -import { LocalQuizQuestionAnswer } from "../localQuizQuestionAnswer"; -import { quizQuestionAnswerMarkdownUtils } from "./quizQuestionAnswerMarkdownUtils"; import { quizFeedbackMarkdownUtils } from "./quizFeedbackMarkdownUtils"; - -const _validFirstAnswerDelimiters = [ - "*a)", - "a)", - "*)", - ")", - "[ ]", - "[]", - "[*]", - "^", -]; -const _multipleChoicePrefix = ["a)", "*a)", "*)", ")"]; -const _multipleAnswerPrefix = ["[ ]", "[*]", "[]"]; - -const isAnswerLine = (trimmedLine: string): boolean => { - return _validFirstAnswerDelimiters.some((prefix) => - trimmedLine.startsWith(prefix) - ); -}; - -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; - if ( - linesWithoutPoints[linesWithoutPoints.length - 1].toLowerCase().trim() === - "short_answer=" - ) - return QuestionType.SHORT_ANSWER_WITH_ANSWERS; - - const answerLines = getAnswerStringsWithMultilineSupport( - linesWithoutPoints, - questionIndex - ); - const firstAnswerLine = answerLines[0]; - const isMultipleChoice = _multipleChoicePrefix.some((prefix) => - firstAnswerLine.startsWith(prefix) - ); - - if (isMultipleChoice) return QuestionType.MULTIPLE_CHOICE; - - const isMultipleAnswer = _multipleAnswerPrefix.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[] => { - if (questionType == QuestionType.SHORT_ANSWER_WITH_ANSWERS) - linesWithoutPoints = linesWithoutPoints.slice( - 0, - linesWithoutPoints.length - 1 - ); - const answerLines = getAnswerStringsWithMultilineSupport( - linesWithoutPoints, - questionIndex - ); - - const answers = answerLines.map((a) => - 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}`; - } -}; +import { + getAnswerMarkdown, + getAnswers, + getQuestionType, + isAnswerLine, +} from "./quizQuestionAnswerParsingUtils"; export const quizQuestionMarkdownUtils = { toMarkdown(question: LocalQuizQuestion): string { @@ -180,7 +38,9 @@ export const quizQuestionMarkdownUtils = { }, parseMarkdown(input: string, questionIndex: number): LocalQuizQuestion { - const lines = input.trim().split("\n"); + const lines = input + .trim() + .split("\n"); const firstLineIsPoints = lines[0].toLowerCase().includes("points: "); const textHasPoints = @@ -196,25 +56,12 @@ export const quizQuestionMarkdownUtils = { const linesWithoutPoints = firstLineIsPoints ? lines.slice(1) : lines; - // Extract feedback comments first - const { - correctComments, - incorrectComments, - neutralComments, - linesWithoutFeedback, - } = quizFeedbackMarkdownUtils.extractFeedback( - linesWithoutPoints, - isAnswerLine - ); - - const { linesWithoutAnswers } = linesWithoutFeedback.reduce( + const { linesWithoutAnswers } = linesWithoutPoints.reduce( ({ linesWithoutAnswers, taking }, currentLine) => { if (!taking) return { linesWithoutAnswers: linesWithoutAnswers, taking: false }; - const lineIsAnswer = _validFirstAnswerDelimiters.some((prefix) => - currentLine.trimStart().startsWith(prefix) - ); + const lineIsAnswer = isAnswerLine(currentLine); if (lineIsAnswer) return { linesWithoutAnswers: linesWithoutAnswers, taking: false }; @@ -225,7 +72,8 @@ export const quizQuestionMarkdownUtils = { }, { linesWithoutAnswers: [] as string[], taking: true } ); - const questionType = getQuestionType(linesWithoutFeedback, questionIndex); + + const questionType = getQuestionType(lines, questionIndex); const questionTypesWithoutAnswers = [ "essay", @@ -233,17 +81,20 @@ export const quizQuestionMarkdownUtils = { "short_answer", ]; - const descriptionLines = questionTypesWithoutAnswers.includes( - questionType.toLowerCase() - ) + const descriptionLines = questionTypesWithoutAnswers.includes(questionType) ? linesWithoutAnswers - .slice(0, linesWithoutFeedback.length) + .slice(0, linesWithoutPoints.length) .filter( (line) => !questionTypesWithoutAnswers.includes(line.toLowerCase()) ) : linesWithoutAnswers; - const description = descriptionLines.join("\n"); + const { + correctComments, + incorrectComments, + neutralComments, + otherLines: descriptionWithoutFeedback, + } = quizFeedbackMarkdownUtils.extractFeedback(descriptionLines); const typesWithAnswers = [ "multiple_choice", @@ -252,7 +103,7 @@ export const quizQuestionMarkdownUtils = { "short_answer=", ]; const answers = typesWithAnswers.includes(questionType) - ? getAnswers(linesWithoutFeedback, questionIndex, questionType) + ? getAnswers(lines, questionIndex, questionType) : []; const answersWithoutDistractors = @@ -266,7 +117,7 @@ export const quizQuestionMarkdownUtils = { : []; const question: LocalQuizQuestion = { - text: description, + text: descriptionWithoutFeedback.join("\n"), questionType, points, answers: answersWithoutDistractors,