starting quiz tests

This commit is contained in:
2024-08-21 15:43:16 -06:00
parent 7bb15126b6
commit 15c4a2241f
9 changed files with 514 additions and 10 deletions

View File

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

View File

@@ -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 = "",
}

View File

@@ -2,5 +2,4 @@ export interface LocalQuizQuestionAnswer {
correct: boolean;
text: string;
matchedText?: string;
htmlText: string;
}

View File

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

View File

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

View File

@@ -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]?\))|(?<!\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;
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;
},
};

View File

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

View File

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