feat: add open questions with a pending grading state
- migration: open question type, rubric, max_length, quiz grader and the grading and review columns of attempt answers - open answers are checked, stored as pending and never scored on answer; a changed text resets the grading state - student JSON gains text, grading_status, grader_feedback, review_comment, max_length, needs_review and open_answers; the rubric is never serialized
This commit is contained in:
@@ -41,6 +41,7 @@ var (
|
||||
{Field: "answer_ids", Rules: lagoon.ParseRules("nullable|array")},
|
||||
{Field: "answer_ids.*", Rules: lagoon.ParseRules("integer|min:1")},
|
||||
{Field: "known", Rules: lagoon.ParseRules("nullable|boolean")},
|
||||
{Field: "text", Rules: lagoon.ParseRules("nullable|string")},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -116,7 +117,8 @@ func (h *Handlers) StartAttempt(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Answer answers POST /attempts/{id}/answers with
|
||||
// {"question_id": 1, "answer_ids": [2], "known": true}.
|
||||
// {"question_id": 1, "answer_ids": [2], "known": true}; an open question
|
||||
// takes {"question_id": 1, "text": "..."}.
|
||||
func (h *Handlers) Answer(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := h.user(w, r)
|
||||
if !ok {
|
||||
@@ -147,6 +149,10 @@ func (h *Handlers) Answer(w http.ResponseWriter, r *http.Request) {
|
||||
known := toBool(raw)
|
||||
in.Known = &known
|
||||
}
|
||||
if raw, present := input["text"]; present && raw != nil {
|
||||
text, _ := raw.(string) // the string rule already refused anything else
|
||||
in.Text = &text
|
||||
}
|
||||
fb, err := svc.Answer(r.Context(), userID, attemptID, in)
|
||||
if err != nil {
|
||||
h.writeError(w, r, err)
|
||||
@@ -331,6 +337,8 @@ var apiMessages = map[string]string{
|
||||
"answer_not_in_question": "The chosen answers do not belong to this question.",
|
||||
"single_one_answer": "Choose exactly one answer for this question.",
|
||||
"known_required": "The known field is required for this question.",
|
||||
"text_required": "Write an answer to this question.",
|
||||
"text_too_long": "The answer is longer than this question allows.",
|
||||
"unknown_category": "The quiz has no questions in this category.",
|
||||
"validation_failed": "The given data was invalid.",
|
||||
"attempt_limit_reached": "All attempts at this test have been used.",
|
||||
|
||||
@@ -103,7 +103,8 @@ type quizJSON struct {
|
||||
|
||||
// questionJSON is a question as the player sees it before answering: no
|
||||
// is_correct or explanation, and no answer_text except for self_check
|
||||
// flashcards, which have no solution to hide.
|
||||
// flashcards, which have no solution to hide. An open question carries its
|
||||
// max_length instead (never its rubric or model answer).
|
||||
type questionJSON struct {
|
||||
ID uint `json:"id"`
|
||||
Text string `json:"text"`
|
||||
@@ -112,8 +113,10 @@ type questionJSON struct {
|
||||
Points int `json:"points"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
// AnswerText is the model answer of a self_check question only.
|
||||
AnswerText *string `json:"answer_text,omitempty"`
|
||||
Answers []answerJSON `json:"answers"`
|
||||
AnswerText *string `json:"answer_text,omitempty"`
|
||||
// MaxLength is the longest answer of an open question, in characters.
|
||||
MaxLength *int `json:"max_length,omitempty"`
|
||||
Answers []answerJSON `json:"answers"`
|
||||
}
|
||||
|
||||
type answerJSON struct {
|
||||
@@ -162,6 +165,10 @@ func question(q models.Question) questionJSON {
|
||||
if q.Type == models.TypeSelfCheck {
|
||||
out.AnswerText = q.AnswerText
|
||||
}
|
||||
if q.Type == models.TypeOpen {
|
||||
n := classes.MaxLength(q)
|
||||
out.MaxLength = &n
|
||||
}
|
||||
for _, a := range q.Answers {
|
||||
out.Answers = append(out.Answers, answerJSON{ID: a.ID, Text: a.Text})
|
||||
}
|
||||
@@ -191,6 +198,11 @@ type attemptJSON struct {
|
||||
// SecondsLeft is the time left of an open attempt with a limit, counted
|
||||
// by the server.
|
||||
SecondsLeft *int `json:"seconds_left"`
|
||||
// NeedsReview is true while an open answer of the attempt waits for
|
||||
// the owner (pending, graded or failed); OpenAnswers counts its open
|
||||
// answers.
|
||||
NeedsReview bool `json:"needs_review"`
|
||||
OpenAnswers int `json:"open_answers"`
|
||||
}
|
||||
|
||||
// timeNow is the clock of seconds_left; tests pin it.
|
||||
@@ -209,6 +221,8 @@ func attempt(s classes.AttemptSummary) attemptJSON {
|
||||
ScorePercent: a.ScorePercent,
|
||||
QuizMode: quizMode(a.Quiz),
|
||||
QuizNavigation: quizNavigation(a.Quiz),
|
||||
NeedsReview: s.Unreviewed > 0,
|
||||
OpenAnswers: s.OpenAnswers,
|
||||
}
|
||||
if a.Quiz != nil {
|
||||
out.QuizSlug, out.QuizTitle, out.PassingScore = a.Quiz.Slug, a.Quiz.Title, a.Quiz.PassingScore
|
||||
@@ -240,7 +254,9 @@ func start(st *classes.Started) startJSON {
|
||||
a := *st.Attempt
|
||||
a.Quiz = st.Quiz
|
||||
return startJSON{
|
||||
attemptJSON: attempt(classes.AttemptSummary{Attempt: a, AnsweredCount: st.AnsweredCount}),
|
||||
attemptJSON: attempt(classes.AttemptSummary{
|
||||
Attempt: a, AnsweredCount: st.AnsweredCount, OpenAnswers: st.OpenAnswers, Unreviewed: st.Unreviewed,
|
||||
}),
|
||||
Resumed: st.Resumed,
|
||||
AttemptsLeft: classes.AttemptsLeft(st.Quiz, st.AttemptsUsed),
|
||||
}
|
||||
@@ -261,6 +277,11 @@ type resultJSON struct {
|
||||
// withheld (an open test attempt) every solution field, is_correct and
|
||||
// points_earned included, is null for every question; answered, answer_ids
|
||||
// and known (the student's own input) stay.
|
||||
//
|
||||
// An open question adds the student's text_answer (always: it is their own
|
||||
// input), max_length and, unless withheld, grading_status, grader_feedback
|
||||
// and review_comment; its is_correct and points_earned stay null until the
|
||||
// answer is graded or reviewed. The rubric is never part of it.
|
||||
type questionResultJSON struct {
|
||||
ID uint `json:"id"`
|
||||
Text string `json:"text"`
|
||||
@@ -276,6 +297,14 @@ type questionResultJSON struct {
|
||||
Explanation *string `json:"explanation"`
|
||||
AnswerText *string `json:"answer_text"`
|
||||
Answers []resultAnswerJSON `json:"answers"`
|
||||
// TextAnswer is the student's text of an open question. It is not
|
||||
// "text" as in the feedback: text is already the question's text here.
|
||||
TextAnswer *string `json:"text_answer"`
|
||||
GradingStatus *string `json:"grading_status"`
|
||||
GraderFeedback *string `json:"grader_feedback"`
|
||||
ReviewComment *string `json:"review_comment"`
|
||||
// MaxLength is the longest answer of an open question.
|
||||
MaxLength *int `json:"max_length,omitempty"`
|
||||
}
|
||||
|
||||
type resultAnswerJSON struct {
|
||||
@@ -304,11 +333,24 @@ func result(r *classes.AttemptResult) resultJSON {
|
||||
AnswerIDs: []uint{},
|
||||
Answers: make([]resultAnswerJSON, 0, len(q.Answers)),
|
||||
}
|
||||
if q.Type == models.TypeOpen {
|
||||
n := classes.MaxLength(q)
|
||||
row.MaxLength = &n
|
||||
}
|
||||
if a := qr.Answer; a != nil {
|
||||
row.Answered = true
|
||||
row.AnswerIDs = wire.Slice(a.AnswerIDs.Data)
|
||||
row.Known = a.SelfCheckKnown
|
||||
if !withheld {
|
||||
if q.Type == models.TypeOpen {
|
||||
row.TextAnswer = a.TextAnswer
|
||||
if !withheld {
|
||||
row.GradingStatus, row.GraderFeedback, row.ReviewComment = a.GradingStatus, a.GraderFeedback, a.ReviewComment
|
||||
if openScored(a) {
|
||||
correct, points := a.IsCorrect, a.Points
|
||||
row.IsCorrect, row.PointsEarned = &correct, &points
|
||||
}
|
||||
}
|
||||
} else if !withheld {
|
||||
correct, points := a.IsCorrect, a.Points
|
||||
row.IsCorrect, row.PointsEarned = &correct, &points
|
||||
}
|
||||
@@ -340,6 +382,12 @@ func result(r *classes.AttemptResult) resultJSON {
|
||||
// feedbackJSON is POST /attempts/{id}/answers. When Withheld (an answer to
|
||||
// an open test attempt) every solution field is null and the answers carry
|
||||
// only their id and text.
|
||||
//
|
||||
// The feedback of an open question carries the student's text and, unless
|
||||
// withheld, the model answer (answer_text), the explanation, grading_status,
|
||||
// grader_feedback and review_comment; is_correct and points stay null until
|
||||
// the answer is graded or reviewed. These four keys are null for every other
|
||||
// type. The rubric is never part of it.
|
||||
type feedbackJSON struct {
|
||||
QuestionID uint `json:"question_id"`
|
||||
Withheld bool `json:"withheld"`
|
||||
@@ -349,11 +397,18 @@ type feedbackJSON struct {
|
||||
Explanation *string `json:"explanation"`
|
||||
AnswerText *string `json:"answer_text"`
|
||||
Answers []resultAnswerJSON `json:"answers"`
|
||||
Text *string `json:"text"`
|
||||
GradingStatus *string `json:"grading_status"`
|
||||
GraderFeedback *string `json:"grader_feedback"`
|
||||
ReviewComment *string `json:"review_comment"`
|
||||
}
|
||||
|
||||
func feedback(fb *classes.Feedback) feedbackJSON {
|
||||
q := fb.Question
|
||||
out := feedbackJSON{QuestionID: q.ID, Withheld: fb.Withheld, Answers: make([]resultAnswerJSON, 0, len(q.Answers))}
|
||||
if q.Type == models.TypeOpen {
|
||||
return openFeedback(fb, out)
|
||||
}
|
||||
if fb.Withheld {
|
||||
for _, a := range q.Answers {
|
||||
out.Answers = append(out.Answers, resultAnswerJSON{ID: a.ID, Text: a.Text})
|
||||
@@ -371,4 +426,36 @@ func feedback(fb *classes.Feedback) feedbackJSON {
|
||||
return out
|
||||
}
|
||||
|
||||
// openFeedback fills the feedback of an open question from its stored row.
|
||||
func openFeedback(fb *classes.Feedback, out feedbackJSON) feedbackJSON {
|
||||
q, a := fb.Question, fb.Answer
|
||||
if a != nil {
|
||||
out.Text = a.TextAnswer
|
||||
}
|
||||
if fb.Withheld {
|
||||
return out
|
||||
}
|
||||
out.CorrectAnswerIDs = []uint{}
|
||||
out.Explanation, out.AnswerText = q.Explanation, q.AnswerText
|
||||
if a == nil {
|
||||
return out
|
||||
}
|
||||
out.GradingStatus, out.GraderFeedback, out.ReviewComment = a.GradingStatus, a.GraderFeedback, a.ReviewComment
|
||||
if openScored(a) {
|
||||
correct, points := a.IsCorrect, a.Points
|
||||
out.IsCorrect, out.Points = &correct, &points
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// openScored reports whether an open answer has a score to show: graded
|
||||
// by a grader or reviewed by the owner. Pending and failed answers have
|
||||
// none yet.
|
||||
func openScored(a *models.AttemptAnswer) bool {
|
||||
if a == nil || a.GradingStatus == nil {
|
||||
return false
|
||||
}
|
||||
return *a.GradingStatus == models.GradingGraded || *a.GradingStatus == models.GradingReviewed
|
||||
}
|
||||
|
||||
func wireTime(t time.Time) wire.Time { return wire.Time{Time: t.UTC().Truncate(time.Second)} }
|
||||
|
||||
@@ -16,7 +16,11 @@ import (
|
||||
func fixtureQuestion(id uint, typ string) models.Question {
|
||||
expl, model, why := "question explanation", "model answer", "answer explanation"
|
||||
q := models.Question{ID: id, Text: "Q", Type: typ, Points: 2, Explanation: &expl, AnswerText: &model}
|
||||
if typ != models.TypeSelfCheck {
|
||||
if typ == models.TypeOpen {
|
||||
rubric := "R-SECRET"
|
||||
q.Rubric = &rubric
|
||||
}
|
||||
if isChoice(typ) {
|
||||
q.Answers = []models.Answer{
|
||||
{ID: id*10 + 1, QuestionID: id, Text: "right", IsCorrect: true, Explanation: &why},
|
||||
{ID: id*10 + 2, QuestionID: id, Text: "wrong", Explanation: &why},
|
||||
@@ -25,6 +29,28 @@ func fixtureQuestion(id uint, typ string) models.Question {
|
||||
return q
|
||||
}
|
||||
|
||||
func isChoice(typ string) bool { return typ == models.TypeSingle || typ == models.TypeMultiple }
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
|
||||
// noRubric fails when a student shape names or contains the rubric or the
|
||||
// grading error.
|
||||
func noRubric(t *testing.T, where, raw string) {
|
||||
t.Helper()
|
||||
if strings.Contains(raw, `"rubric"`) || strings.Contains(raw, "R-SECRET") || strings.Contains(raw, "grading_error") || strings.Contains(raw, "provider exploded") {
|
||||
t.Errorf("%s leaks the rubric or the grading error: %s", where, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// openRow is a stored open answer in the given grading status.
|
||||
func openRow(questionID uint, status string) *models.AttemptAnswer {
|
||||
return &models.AttemptAnswer{
|
||||
AttemptID: 5, QuestionID: questionID,
|
||||
TextAnswer: strPtr("moja odpowiedź"), GradingStatus: strPtr(status), GradingSeq: 1,
|
||||
GradingError: strPtr("provider exploded"),
|
||||
}
|
||||
}
|
||||
|
||||
func decode(t *testing.T, v any) (map[string]any, string) {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
@@ -83,21 +109,43 @@ func TestWithheldSerializers(t *testing.T) {
|
||||
CorrectAnswerIDs: classes.CorrectAnswerIDs(answered),
|
||||
Withheld: true,
|
||||
}
|
||||
// gradedOpen is an open answer with every grading field set, so
|
||||
// a serializer that forgets to withhold one shows it.
|
||||
gradedOpen := func() *models.AttemptAnswer {
|
||||
r := openRow(1, models.GradingGraded)
|
||||
two := 2.0
|
||||
r.IsCorrect, r.Points, r.GraderPoints = true, 2, &two
|
||||
r.GraderFeedback, r.ReviewComment = strPtr("feedback"), strPtr("comment")
|
||||
return r
|
||||
}
|
||||
if typ == models.TypeOpen {
|
||||
fb.Answer = gradedOpen()
|
||||
}
|
||||
m, raw := decode(t, feedback(fb))
|
||||
if m["withheld"] != true {
|
||||
t.Errorf("feedback withheld = %v, want true", m["withheld"])
|
||||
}
|
||||
noSolution(t, "feedback", m, "is_correct", "points", "correct_answer_ids", "explanation", "answer_text")
|
||||
noSolution(t, "feedback", m, "is_correct", "points", "correct_answer_ids", "explanation", "answer_text",
|
||||
"grading_status", "grader_feedback", "review_comment")
|
||||
noCorrectness(t, "feedback", raw)
|
||||
if typ != models.TypeSelfCheck && len(m["answers"].([]any)) != 2 {
|
||||
noRubric(t, "feedback", raw)
|
||||
if isChoice(typ) && len(m["answers"].([]any)) != 2 {
|
||||
t.Errorf("feedback answers = %v, want both answers", m["answers"])
|
||||
}
|
||||
if typ == models.TypeOpen && m["text"] != "moja odpowiedź" {
|
||||
t.Errorf("withheld open feedback lost the student's text: %v", m["text"])
|
||||
} else if typ != models.TypeOpen && m["text"] != nil {
|
||||
t.Errorf("%s feedback text = %v, want null", typ, m["text"])
|
||||
}
|
||||
|
||||
known := true
|
||||
row := &models.AttemptAnswer{AttemptID: 5, QuestionID: 1, IsCorrect: true, Points: 2}
|
||||
if typ == models.TypeSelfCheck {
|
||||
switch typ {
|
||||
case models.TypeSelfCheck:
|
||||
row.SelfCheckKnown = &known
|
||||
} else {
|
||||
case models.TypeOpen:
|
||||
row = gradedOpen()
|
||||
default:
|
||||
row.AnswerIDs = lagoon.Jsonable[[]uint]{Data: []uint{11}, Valid: true}
|
||||
}
|
||||
res := &classes.AttemptResult{
|
||||
@@ -109,24 +157,33 @@ func TestWithheldSerializers(t *testing.T) {
|
||||
t.Errorf("result solutions_withheld = %v quiz_mode = %v", m["solutions_withheld"], m["quiz_mode"])
|
||||
}
|
||||
noCorrectness(t, "result", raw)
|
||||
noRubric(t, "result", raw)
|
||||
questions := m["questions"].([]any)
|
||||
if len(questions) != 2 {
|
||||
t.Fatalf("result questions = %d, want 2", len(questions))
|
||||
}
|
||||
for i, raw := range questions {
|
||||
q := raw.(map[string]any)
|
||||
noSolution(t, "result question "+string(rune('0'+i)), q, "is_correct", "points_earned", "correct_answer_ids", "explanation", "answer_text")
|
||||
noSolution(t, "result question "+string(rune('0'+i)), q, "is_correct", "points_earned", "correct_answer_ids", "explanation", "answer_text",
|
||||
"grading_status", "grader_feedback", "review_comment")
|
||||
}
|
||||
first := questions[0].(map[string]any)
|
||||
if first["answered"] != true {
|
||||
t.Errorf("answered question lost answered: %v", first)
|
||||
}
|
||||
if typ == models.TypeSelfCheck {
|
||||
switch typ {
|
||||
case models.TypeSelfCheck:
|
||||
if first["known"] != true {
|
||||
t.Errorf("self_check lost the student's known: %v", first)
|
||||
}
|
||||
} else if ids, _ := first["answer_ids"].([]any); len(ids) != 1 || ids[0] != float64(11) {
|
||||
t.Errorf("answer_ids = %v, want the student's [11]", first["answer_ids"])
|
||||
case models.TypeOpen:
|
||||
if first["text_answer"] != "moja odpowiedź" {
|
||||
t.Errorf("open lost the student's text: %v", first)
|
||||
}
|
||||
default:
|
||||
if ids, _ := first["answer_ids"].([]any); len(ids) != 1 || ids[0] != float64(11) {
|
||||
t.Errorf("answer_ids = %v, want the student's [11]", first["answer_ids"])
|
||||
}
|
||||
}
|
||||
|
||||
done := time.Now()
|
||||
@@ -207,15 +264,15 @@ func TestSummaryAndAttemptFields(t *testing.T) {
|
||||
t.Errorf("test summary = %v", m)
|
||||
}
|
||||
types, _ := m["question_types"].(map[string]any)
|
||||
if len(types) != 3 || types["single"] != float64(3) || types["multiple"] != float64(0) || types["self_check"] != float64(0) {
|
||||
t.Errorf("question_types = %v, want all three keys", m["question_types"])
|
||||
if len(types) != 4 || types["single"] != float64(3) || types["multiple"] != float64(0) || types["self_check"] != float64(0) || types["open"] != float64(0) {
|
||||
t.Errorf("question_types = %v, want all four keys", m["question_types"])
|
||||
}
|
||||
m, _ = decode(t, quizSummary(classes.QuizSummary{Quiz: models.Quiz{ID: 4, Slug: "l"}, AttemptsUsed: 9}))
|
||||
if v, ok := m["attempts_left"]; !ok || v != nil {
|
||||
t.Errorf("learning attempts_left = %v (present %v), want null", v, ok)
|
||||
}
|
||||
if types, _ := m["question_types"].(map[string]any); len(types) != 3 {
|
||||
t.Errorf("learning question_types = %v, want all three keys", m["question_types"])
|
||||
if types, _ := m["question_types"].(map[string]any); len(types) != 4 {
|
||||
t.Errorf("learning question_types = %v, want all four keys", m["question_types"])
|
||||
}
|
||||
if m["mode"] != models.ModeLearning || m["navigation"] != models.NavigationFree {
|
||||
t.Errorf("zero-value quiz mode / navigation = %v / %v", m["mode"], m["navigation"])
|
||||
@@ -241,3 +298,119 @@ func TestSummaryAndAttemptFields(t *testing.T) {
|
||||
t.Errorf("start json = %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSerializers(t *testing.T) {
|
||||
q := fixtureQuestion(1, models.TypeOpen)
|
||||
q.AnswerText = strPtr("MODEL")
|
||||
learning := &models.Quiz{Slug: "l", PassingScore: 50}
|
||||
|
||||
// Feedback of a pending learning answer: the model answer and the
|
||||
// student's text, no score yet, never the rubric.
|
||||
m, raw := decode(t, feedback(&classes.Feedback{Question: q, Answer: openRow(1, models.GradingPending)}))
|
||||
noRubric(t, "pending feedback", raw)
|
||||
if m["answer_text"] != "MODEL" || m["text"] != "moja odpowiedź" || m["grading_status"] != models.GradingPending {
|
||||
t.Errorf("pending feedback = %s", raw)
|
||||
}
|
||||
if m["is_correct"] != nil || m["points"] != nil {
|
||||
t.Errorf("pending feedback has a score: %s", raw)
|
||||
}
|
||||
if a, ok := m["answers"].([]any); !ok || len(a) != 0 {
|
||||
t.Errorf("open feedback answers = %v, want []", m["answers"])
|
||||
}
|
||||
if ids, ok := m["correct_answer_ids"].([]any); !ok || len(ids) != 0 {
|
||||
t.Errorf("open feedback correct_answer_ids = %v, want []", m["correct_answer_ids"])
|
||||
}
|
||||
|
||||
m, raw = decode(t, feedback(&classes.Feedback{Question: q, Answer: openRow(1, models.GradingFailed)}))
|
||||
noRubric(t, "failed feedback", raw)
|
||||
if m["grading_status"] != models.GradingFailed || m["is_correct"] != nil || m["points"] != nil {
|
||||
t.Errorf("failed feedback = %s", raw)
|
||||
}
|
||||
|
||||
graded := openRow(1, models.GradingGraded)
|
||||
graded.Points, graded.IsCorrect, graded.GraderFeedback = 1.5, false, strPtr("Brakuje przykładu.")
|
||||
m, raw = decode(t, feedback(&classes.Feedback{Question: q, Answer: graded}))
|
||||
noRubric(t, "graded feedback", raw)
|
||||
if m["points"] != 1.5 || m["is_correct"] != false || m["grader_feedback"] != "Brakuje przykładu." || m["review_comment"] != nil {
|
||||
t.Errorf("graded feedback = %s", raw)
|
||||
}
|
||||
|
||||
reviewed := openRow(1, models.GradingReviewed)
|
||||
reviewed.Points, reviewed.IsCorrect, reviewed.ReviewComment = 2, true, strPtr("Dobrze.")
|
||||
m, raw = decode(t, feedback(&classes.Feedback{Question: q, Answer: reviewed}))
|
||||
if m["points"] != float64(2) || m["is_correct"] != true || m["review_comment"] != "Dobrze." || m["grading_status"] != models.GradingReviewed {
|
||||
t.Errorf("reviewed feedback = %s", raw)
|
||||
}
|
||||
|
||||
// The question before answering: max_length, no model answer, no rubric.
|
||||
m, raw = decode(t, question(q))
|
||||
noRubric(t, "open question", raw)
|
||||
if m["max_length"] != float64(models.DefaultMaxLength) {
|
||||
t.Errorf("open question max_length = %v, want %d", m["max_length"], models.DefaultMaxLength)
|
||||
}
|
||||
if _, ok := m["answer_text"]; ok {
|
||||
t.Errorf("open question leaks its model answer: %s", raw)
|
||||
}
|
||||
five := 5
|
||||
q5 := q
|
||||
q5.MaxLength = &five
|
||||
if m, _ = decode(t, question(q5)); m["max_length"] != float64(5) {
|
||||
t.Errorf("open question max_length = %v, want 5", m["max_length"])
|
||||
}
|
||||
if _, raw = decode(t, question(fixtureQuestion(2, models.TypeSingle))); strings.Contains(raw, "max_length") {
|
||||
t.Errorf("single question names max_length: %s", raw)
|
||||
}
|
||||
_, raw = decode(t, quizDetail(&classes.QuizDetail{Quiz: *learning, Questions: []models.Question{q}}))
|
||||
noRubric(t, "quiz detail", raw)
|
||||
|
||||
// The result of a learning attempt with a pending open answer.
|
||||
res := &classes.AttemptResult{
|
||||
AttemptSummary: classes.AttemptSummary{
|
||||
Attempt: models.Attempt{ID: 5, StartedAt: time.Now(), Quiz: learning},
|
||||
AnsweredCount: 1, OpenAnswers: 1, Unreviewed: 1,
|
||||
},
|
||||
Questions: []classes.QuestionResult{{Question: q, Answer: openRow(1, models.GradingPending)}, {Question: fixtureQuestion(2, models.TypeSingle)}},
|
||||
}
|
||||
m, raw = decode(t, result(res))
|
||||
noRubric(t, "result", raw)
|
||||
if m["needs_review"] != true || m["open_answers"] != float64(1) {
|
||||
t.Errorf("result needs_review / open_answers = %v / %v", m["needs_review"], m["open_answers"])
|
||||
}
|
||||
row := m["questions"].([]any)[0].(map[string]any)
|
||||
if row["points_earned"] != nil || row["is_correct"] != nil {
|
||||
t.Errorf("pending open result has a score: %v", row)
|
||||
}
|
||||
if row["answer_text"] != "MODEL" || row["text_answer"] != "moja odpowiedź" || row["text"] != "Q" || row["grading_status"] != models.GradingPending {
|
||||
t.Errorf("pending open result = %v", row)
|
||||
}
|
||||
if row["max_length"] != float64(models.DefaultMaxLength) {
|
||||
t.Errorf("open result max_length = %v", row["max_length"])
|
||||
}
|
||||
choice := m["questions"].([]any)[1].(map[string]any)
|
||||
for _, k := range []string{"text_answer", "grading_status", "grader_feedback", "review_comment"} {
|
||||
if v, ok := choice[k]; !ok || v != nil {
|
||||
t.Errorf("single result %s = %v (present %v), want null", k, v, ok)
|
||||
}
|
||||
}
|
||||
if _, ok := choice["max_length"]; ok {
|
||||
t.Errorf("single result names max_length: %v", choice)
|
||||
}
|
||||
|
||||
res.Questions[0].Answer = reviewed
|
||||
res.Unreviewed = 0
|
||||
m, _ = decode(t, result(res))
|
||||
row = m["questions"].([]any)[0].(map[string]any)
|
||||
if row["points_earned"] != float64(2) || row["is_correct"] != true || row["review_comment"] != "Dobrze." || m["needs_review"] != false {
|
||||
t.Errorf("reviewed open result = %v (needs_review %v)", row, m["needs_review"])
|
||||
}
|
||||
|
||||
// Every attempt row carries the two review keys.
|
||||
m, _ = decode(t, attempt(classes.AttemptSummary{Attempt: models.Attempt{ID: 1, StartedAt: time.Now(), Quiz: learning}}))
|
||||
if m["needs_review"] != false || m["open_answers"] != float64(0) {
|
||||
t.Errorf("attempt without open answers = %v / %v", m["needs_review"], m["open_answers"])
|
||||
}
|
||||
m, _ = decode(t, start(&classes.Started{Attempt: &models.Attempt{ID: 2, StartedAt: time.Now()}, Quiz: learning, OpenAnswers: 2, Unreviewed: 1}))
|
||||
if m["needs_review"] != true || m["open_answers"] != float64(2) {
|
||||
t.Errorf("start needs_review / open_answers = %v / %v", m["needs_review"], m["open_answers"])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user