fixed feedback, feedback only supported in descriptions, not in questions for now

This commit is contained in:
2025-10-22 11:25:59 -06:00
parent d6584fd338
commit 4c978f392d
9 changed files with 520 additions and 527 deletions

View File

@@ -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),

View File

@@ -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 = `

View File

@@ -195,6 +195,7 @@ describe("QuizDeterministicChecks", () => {
],
allowedAttempts: -1,
showCorrectAnswers: true,
};
const quizMarkdown = quizMarkdownUtils.toMarkdown(quiz);

View File

@@ -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.");
});
});

View File

@@ -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", () => {

View File

@@ -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,
};
},

View File

@@ -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] === "*";

View File

@@ -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]?\))|(?<!\S)\[\s*\]|\[\*\]|\^/;
const answerLines = answerLinesRaw.reduce((acc: string[], line: string) => {
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}`;
}
};

View File

@@ -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]?\))|(?<!\S)\[\s*\]|\[\*\]|\^/;
const answerLines = answerLinesRaw.reduce((acc: string[], line: string) => {
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,