working on markdown quiz experience

This commit is contained in:
2023-10-12 15:08:52 -06:00
parent e5defbc0cf
commit 4aa045d952
6 changed files with 193 additions and 72 deletions

View File

@@ -279,4 +279,56 @@ b) false
secondQuestion.Points.Should().Be(2); secondQuestion.Points.Should().Be(2);
secondQuestion.QuestionType.Should().Be(QuestionType.MULTIPLE_CHOICE); secondQuestion.QuestionType.Should().Be(QuestionType.MULTIPLE_CHOICE);
} }
[Test]
public void CanParseEssay()
{
var rawMarkdownQuiz = @"
Name: Test Quiz
LockAtDueDate: true
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
";
var quiz = LocalQuiz.ParseMarkdown(rawMarkdownQuiz);
var firstQuestion = quiz.Questions.First();
firstQuestion.Points.Should().Be(1);
firstQuestion.QuestionType.Should().Be(QuestionType.ESSAY);
firstQuestion.Text.Should().NotContain("essay");
}
[Test]
public void CanParseShortAnswer()
{
var rawMarkdownQuiz = @"
Name: Test Quiz
LockAtDueDate: true
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
";
var quiz = LocalQuiz.ParseMarkdown(rawMarkdownQuiz);
var firstQuestion = quiz.Questions.First();
firstQuestion.Points.Should().Be(1);
firstQuestion.QuestionType.Should().Be(QuestionType.SHORT_ANSWER);
firstQuestion.Text.Should().NotContain("short answer");
}
} }

View File

@@ -6,40 +6,53 @@
} }
@((MarkupString)Question.HtmlText) <div class="row justify-content-between">
<div class="col">
@((MarkupString)Question.HtmlText)
</div>
<div class="col-auto">
@Question.QuestionType
</div>
</div>
@foreach(var answer in Question.Answers) @foreach(var answer in Question.Answers)
{ {
string answerPreview = answer.HtmlText.StartsWith("<p>")
? answer.HtmlText.Replace("<p>", "<p class='m-0'>")
: answer.HtmlText;
<div class="mx-3 mb-1 bg-dark px-2 rounded rounded-2 d-flex flex-row border"> <div class="mx-3 mb-1 bg-dark px-2 rounded rounded-2 d-flex flex-row border">
<div> @if(answer.Correct)
@if(answer.Correct) {
{ <svg
<svg style="width: 1em;"
style="width: 1em;" class="me-1 my-auto"
class="me-1" viewBox="0 0 24 24"
viewBox="0 0 24 24" fill="none"
fill="none" >
> <path
<path d="M4 12.6111L8.92308 17.5L20 6.5"
d="M4 12.6111L8.92308 17.5L20 6.5" stroke="var(--bs-success)"
stroke="var(--bs-success)" stroke-width="2"
stroke-width="2" stroke-linecap="round"
stroke-linecap="round" stroke-linejoin="round"
stroke-linejoin="round" />
/> </svg>
</svg> }
} else
else {
{ <div
<div class="me-1 my-auto"
class="me-1" style="width: 1em;"
style="width: 1em;" >
></div> @if(Question.QuestionType == QuestionType.MULTIPLE_ANSWERS)
} {
</div> <span>[ ]</span>
<div> }
@((MarkupString)answer.HtmlText) </div>
}
<div class="markdownQuizAnswerPreview p-1">
@((MarkupString)answerPreview)
</div> </div>
</div> </div>
} }

View File

@@ -23,7 +23,7 @@
error = null; error = null;
testQuiz = newQuiz; testQuiz = newQuiz;
} }
catch(Exception e) catch(QuizMarkdownParseException e)
{ {
error = e.Message; error = e.Message;
} }
@@ -38,8 +38,6 @@
{ {
if (quizContext.Quiz != null) if (quizContext.Quiz != null)
{ {
Console.WriteLine("reloading quiz editor");
if(quizMarkdownInput == "") if(quizMarkdownInput == "")
{ {
quizMarkdownInput = quizContext.Quiz.ToMarkdown(); quizMarkdownInput = quizContext.Quiz.ToMarkdown();
@@ -66,7 +64,7 @@
private void onHide() private void onHide()
{ {
quizMarkdownInput = ""; _quizMarkdownInput = "";
quizContext.Quiz = null; quizContext.Quiz = null;
} }
} }
@@ -95,7 +93,7 @@
<div class="col-6"> <div class="col-6">
@if(error != null) @if(error != null)
{ {
<p class="text-danger">@error</p> <p class="text-danger text-truncate">@error</p>
} }
<QuizPreview Quiz="testQuiz" /> <QuizPreview Quiz="testQuiz" />
</div> </div>

View File

@@ -13,7 +13,6 @@
} }
private void reload() private void reload()
{ {
Console.WriteLine(JsonSerializer.Serialize(quizContext.Quiz));
this.InvokeAsync(this.StateHasChanged); this.InvokeAsync(this.StateHasChanged);
} }
public void Dispose() public void Dispose()
@@ -38,7 +37,7 @@
@foreach(var question in Quiz.Questions) @foreach(var question in Quiz.Questions)
{ {
<div class="bg-dark-subtle mt-1 p-1 rounded rounded-2"> <div class="bg-dark-subtle mt-1 p-1 ps-2 rounded rounded-2">
<MarkdownQuestionPreview <MarkdownQuestionPreview
Question="question" Question="question"
@key="question" @key="question"

View File

@@ -66,7 +66,7 @@ Description: {Description}
var questions = splitInput[1..] var questions = splitInput[1..]
.Where(str => !string.IsNullOrWhiteSpace(str)) .Where(str => !string.IsNullOrWhiteSpace(str))
.Select(q => LocalQuizQuestion.ParseMarkdown(q)) .Select((q, i) => LocalQuizQuestion.ParseMarkdown(q, i))
.ToArray(); .ToArray();
return quizWithoutQuestions with return quizWithoutQuestions with
{ {
@@ -128,3 +128,11 @@ Description: {Description}
return string.Empty; return string.Empty;
} }
} }
public class QuizMarkdownParseException : Exception
{
public QuizMarkdownParseException(string message): base(message)
{
}
}

View File

@@ -1,3 +1,4 @@
using System.Security.Cryptography;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
namespace LocalModels; namespace LocalModels;
@@ -36,21 +37,47 @@ public record LocalQuizQuestion
} }
private static readonly string[] validFirstAnswerDelimiters = new string[] { "*a)", "a)", "[ ]", "[*]" }; private static readonly string[] validFirstAnswerDelimiters = new string[] { "*a)", "a)", "[ ]", "[*]" };
public static LocalQuizQuestion ParseMarkdown(string input)
public static LocalQuizQuestion ParseMarkdown(string input, int questionIndex)
{ {
var lines = input.Split(Environment.NewLine); var lines = input.Trim().Split(Environment.NewLine);
var firstLineIsPoints = lines.First().Contains("points: ", StringComparison.CurrentCultureIgnoreCase); var firstLineIsPoints = lines.First().Contains("points: ", StringComparison.CurrentCultureIgnoreCase);
int points = firstLineIsPoints ? int.Parse(lines.First().Split(": ")[1]) : 1;
var textHasPoints = lines.Length > 0
&& lines.First().Contains(": ")
&& lines.First().Split(": ").Length > 1
&& int.TryParse(lines.First().Split(": ")[1], out _);
int points = firstLineIsPoints && textHasPoints ? int.Parse(lines.First().Split(": ")[1]) : 1;
var linesWithoutPoints = firstLineIsPoints ? lines[1..] : lines; var linesWithoutPoints = firstLineIsPoints ? lines[1..] : lines;
var linesWithoutAnswers = linesWithoutPoints var linesWithoutAnswers = linesWithoutPoints
.TakeWhile(line => !validFirstAnswerDelimiters.Any(prefix => line.TrimStart().StartsWith(prefix))) .TakeWhile(
(line, index) =>
!validFirstAnswerDelimiters.Any(prefix => line.TrimStart().StartsWith(prefix))
)
.ToArray(); .ToArray();
var description = string.Join(Environment.NewLine, linesWithoutAnswers);
var (answers, questionType) = getAnswers(linesWithoutPoints); var questionType = getQuestionType(linesWithoutPoints, questionIndex);
var questionTypesWithoutAnswers = new string[] { "essay", "short answer", "short_answer" };
var descriptionLines = questionTypesWithoutAnswers.Contains(questionType.ToLower())
? linesWithoutAnswers
.TakeWhile(
(line, index) => index != linesWithoutPoints.Length && !questionTypesWithoutAnswers.Contains(line.ToLower())
)
.ToArray()
: linesWithoutAnswers;
var description = string.Join(Environment.NewLine, descriptionLines);
var typesWithAnswers = new string[] { "multiple_choice", "multiple_answers" };
var answers = typesWithAnswers.Contains(questionType)
? getAnswers(linesWithoutPoints, questionIndex)
: [];
return new LocalQuizQuestion() return new LocalQuizQuestion()
{ {
@@ -61,55 +88,79 @@ public record LocalQuizQuestion
}; };
} }
private static (LocalQuizQuestionAnswer[], string questionType) getAnswers(string[] linesWithoutPoints) private static string getQuestionType(string[] linesWithoutPoints, int questionIndex)
{
if (linesWithoutPoints.Length == 0)
return "";
if (linesWithoutPoints[^1].Equals("essay", StringComparison.CurrentCultureIgnoreCase))
return "essay";
if (linesWithoutPoints[^1].Equals("short answer", StringComparison.CurrentCultureIgnoreCase))
return "short_answer";
if (linesWithoutPoints[^1].Equals("short_answer", StringComparison.CurrentCultureIgnoreCase))
return "short_answer";
var answerLines = getAnswersGroupedByLines(linesWithoutPoints, questionIndex);
var isMultipleChoice =
answerLines.First().StartsWith("a)")
|| answerLines.First().StartsWith("*a)");
if (isMultipleChoice)
return "multiple_choice";
var isMultipleAnswer =
answerLines.First().StartsWith("[ ]")
|| answerLines.First().StartsWith("[*]");
if (isMultipleAnswer)
return "multiple_answers";
return "";
}
private static List<string> getAnswersGroupedByLines(string[] linesWithoutPoints, int questionIndex)
{ {
var indexOfAnswerStart = linesWithoutPoints var indexOfAnswerStart = linesWithoutPoints
.ToList() .ToList()
.FindIndex( .FindIndex(
l => validFirstAnswerDelimiters.Any(prefix => l.TrimStart().StartsWith(prefix)) l => validFirstAnswerDelimiters.Any(prefix => l.TrimStart().StartsWith(prefix))
); );
if (indexOfAnswerStart == -1)
{
var debugLine = linesWithoutPoints.FirstOrDefault(l => l.Trim().Length > 0);
throw new QuizMarkdownParseException($"question {questionIndex + 1}: no answers when detecting question type on {debugLine}");
}
var answerLinesRaw = linesWithoutPoints[indexOfAnswerStart..]; var answerLinesRaw = linesWithoutPoints[indexOfAnswerStart..];
var answerStartPattern = @"^(\*?[a-z]\))|\[\s*\]|\[\*\]"; var answerStartPattern = @"^(\*?[a-z]\))|\[\s*\]|\[\*\]";
var answerLines = answerLinesRaw.Aggregate(new List<string>(), (acc, line) => var answerLines = answerLinesRaw.Aggregate(new List<string>(), (acc, line) =>
{ {
if (!Regex.IsMatch(line, answerStartPattern)) var isNewAnswer = Regex.IsMatch(line, answerStartPattern);
if (isNewAnswer)
{ {
if (acc.Count != 0) // Append to the previous line if there is one acc.Add(line);
{ return acc;
int lastIndex = acc.Count - 1;
acc[lastIndex] += Environment.NewLine + line;
}
else
{
acc.Add(line);
}
} }
if (acc.Count != 0) // Append to the previous line if there is one
acc[^1] += Environment.NewLine + line;
else else
{ acc.Add(line);
acc.Add(line); // Add as a new line if it matches the pattern
}
return acc; return acc;
}); });
return answerLines;
}
var answers = answerLines.Select(LocalQuizQuestionAnswer.ParseMarkdown).ToArray(); private static LocalQuizQuestionAnswer[] getAnswers(string[] linesWithoutPoints, int questionIndex)
{
var answerLines = getAnswersGroupedByLines(linesWithoutPoints, questionIndex);
var isMultipleChoice = var answers = answerLines
answerLines.First().StartsWith("a)") .Select((a, i) => LocalQuizQuestionAnswer.ParseMarkdown(a))
|| answerLines.First().StartsWith("*a)"); .ToArray();
var isMultipleAnswer = return answers;
answerLines.First().StartsWith("[ ]")
|| answerLines.First().StartsWith("[*]");
var questionType = isMultipleChoice
? "multiple_choice"
: isMultipleAnswer
? "multiple_answers"
: "";
return (answers, questionType);
} }
} }