- 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
417 lines
17 KiB
Go
417 lines
17 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
|
|
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
)
|
|
|
|
// fixtureQuestion is a question of type typ with every solution field set,
|
|
// so a serializer that forgets to drop one shows it.
|
|
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.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},
|
|
}
|
|
}
|
|
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)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var out map[string]any
|
|
if err := json.Unmarshal(b, &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out, string(b)
|
|
}
|
|
|
|
// noSolution fails when any solution key of m (a feedback or a result
|
|
// question) holds a value.
|
|
func noSolution(t *testing.T, where string, m map[string]any, keys ...string) {
|
|
t.Helper()
|
|
for _, k := range keys {
|
|
v, ok := m[k]
|
|
if !ok {
|
|
t.Errorf("%s: key %s missing", where, k)
|
|
continue
|
|
}
|
|
if v != nil {
|
|
t.Errorf("%s: %s = %v, want null", where, k, v)
|
|
}
|
|
}
|
|
answers, _ := m["answers"].([]any)
|
|
for i, raw := range answers {
|
|
a := raw.(map[string]any)
|
|
if a["is_correct"] != nil || a["explanation"] != nil {
|
|
t.Errorf("%s: answers[%d] = %v, want no is_correct or explanation", where, i, a)
|
|
}
|
|
if a["id"] == nil || a["text"] == nil {
|
|
t.Errorf("%s: answers[%d] lost its id or text: %v", where, i, a)
|
|
}
|
|
}
|
|
}
|
|
|
|
func noCorrectness(t *testing.T, where, raw string) {
|
|
t.Helper()
|
|
if strings.Contains(raw, `"is_correct":true`) || strings.Contains(raw, `"is_correct":false`) {
|
|
t.Errorf("%s reveals correctness: %s", where, raw)
|
|
}
|
|
}
|
|
|
|
func TestWithheldSerializers(t *testing.T) {
|
|
for _, typ := range models.QuestionTypes {
|
|
t.Run(typ, func(t *testing.T) {
|
|
answered, unanswered := fixtureQuestion(1, typ), fixtureQuestion(2, typ)
|
|
test := &models.Quiz{Slug: "t", PassingScore: 50, Mode: models.ModeTest}
|
|
|
|
fb := &classes.Feedback{
|
|
Question: answered,
|
|
Result: classes.ScoreResult{IsCorrect: true, Points: 2},
|
|
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",
|
|
"grading_status", "grader_feedback", "review_comment")
|
|
noCorrectness(t, "feedback", raw)
|
|
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}
|
|
switch typ {
|
|
case models.TypeSelfCheck:
|
|
row.SelfCheckKnown = &known
|
|
case models.TypeOpen:
|
|
row = gradedOpen()
|
|
default:
|
|
row.AnswerIDs = lagoon.Jsonable[[]uint]{Data: []uint{11}, Valid: true}
|
|
}
|
|
res := &classes.AttemptResult{
|
|
AttemptSummary: classes.AttemptSummary{Attempt: models.Attempt{ID: 5, QuizID: 3, StartedAt: time.Now(), Quiz: test}, AnsweredCount: 1},
|
|
Questions: []classes.QuestionResult{{Question: answered, Answer: row}, {Question: unanswered}},
|
|
}
|
|
m, raw = decode(t, result(res))
|
|
if m["solutions_withheld"] != true || m["quiz_mode"] != models.ModeTest {
|
|
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",
|
|
"grading_status", "grader_feedback", "review_comment")
|
|
}
|
|
first := questions[0].(map[string]any)
|
|
if first["answered"] != true {
|
|
t.Errorf("answered question lost answered: %v", first)
|
|
}
|
|
switch typ {
|
|
case models.TypeSelfCheck:
|
|
if first["known"] != true {
|
|
t.Errorf("self_check lost the student's known: %v", first)
|
|
}
|
|
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()
|
|
res.Attempt.CompletedAt = &done
|
|
m, _ = decode(t, result(res))
|
|
if m["solutions_withheld"] != false {
|
|
t.Errorf("completed test still withheld")
|
|
}
|
|
for _, raw := range m["questions"].([]any) {
|
|
q := raw.(map[string]any)
|
|
if q["is_correct"] == nil || q["points_earned"] == nil || q["explanation"] == nil || q["correct_answer_ids"] == nil {
|
|
t.Errorf("completed test hides a solution: %v", q)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLearningFeedbackUnchanged(t *testing.T) {
|
|
q := fixtureQuestion(1, models.TypeSingle)
|
|
fb := &classes.Feedback{Question: q, Result: classes.ScoreResult{IsCorrect: false, Points: 0}, CorrectAnswerIDs: classes.CorrectAnswerIDs(q)}
|
|
m, raw := decode(t, feedback(fb))
|
|
if m["withheld"] != false {
|
|
t.Errorf("withheld = %v, want false", m["withheld"])
|
|
}
|
|
if m["is_correct"] != false || m["points"] != float64(0) {
|
|
t.Errorf("is_correct / points = %v / %v, want false / 0", m["is_correct"], m["points"])
|
|
}
|
|
if ids, ok := m["correct_answer_ids"].([]any); !ok || len(ids) != 1 {
|
|
t.Errorf("correct_answer_ids = %v, want [11]", m["correct_answer_ids"])
|
|
}
|
|
if !strings.Contains(raw, `"is_correct":true`) || m["explanation"] == nil || m["answer_text"] == nil {
|
|
t.Errorf("learning feedback lost a solution: %s", raw)
|
|
}
|
|
|
|
res := &classes.AttemptResult{
|
|
AttemptSummary: classes.AttemptSummary{Attempt: models.Attempt{ID: 5, StartedAt: time.Now(), Quiz: &models.Quiz{Slug: "s", PassingScore: 50}}},
|
|
Questions: []classes.QuestionResult{{Question: q}},
|
|
}
|
|
m, _ = decode(t, result(res))
|
|
row := m["questions"].([]any)[0].(map[string]any)
|
|
if m["solutions_withheld"] != false || m["quiz_mode"] != models.ModeLearning || m["quiz_navigation"] != models.NavigationFree {
|
|
t.Errorf("learning result flags = %v %v %v", m["solutions_withheld"], m["quiz_mode"], m["quiz_navigation"])
|
|
}
|
|
if row["is_correct"] != false || row["points_earned"] != float64(0) {
|
|
t.Errorf("unanswered learning question = %v / %v, want false / 0", row["is_correct"], row["points_earned"])
|
|
}
|
|
}
|
|
|
|
func TestTestQuizDetailHidesQuestions(t *testing.T) {
|
|
d := &classes.QuizDetail{
|
|
Quiz: models.Quiz{Slug: "t", Mode: models.ModeTest, Navigation: models.NavigationOneWay},
|
|
Questions: []models.Question{fixtureQuestion(1, models.TypeSelfCheck), fixtureQuestion(2, models.TypeSingle)},
|
|
}
|
|
m, raw := decode(t, quizDetail(d))
|
|
if qs, ok := m["questions"].([]any); !ok || len(qs) != 0 {
|
|
t.Errorf("test detail questions = %v, want []", m["questions"])
|
|
}
|
|
if m["question_count"] != float64(2) || m["mode"] != models.ModeTest || m["navigation"] != models.NavigationOneWay {
|
|
t.Errorf("test detail = %s", raw)
|
|
}
|
|
if strings.Contains(raw, "model answer") {
|
|
t.Errorf("test detail leaks answer_text: %s", raw)
|
|
}
|
|
|
|
d.Quiz.Mode = models.ModeLearning
|
|
m, _ = decode(t, quizDetail(d))
|
|
if qs, _ := m["questions"].([]any); len(qs) != 2 {
|
|
t.Errorf("learning detail questions = %d, want 2", len(qs))
|
|
}
|
|
}
|
|
|
|
func TestSummaryAndAttemptFields(t *testing.T) {
|
|
two := 2
|
|
test := models.Quiz{ID: 3, Slug: "t", Mode: models.ModeTest, MaxAttempts: &two}
|
|
m, _ := decode(t, quizSummary(classes.QuizSummary{Quiz: test, QuestionCount: 3, AttemptsUsed: 1, QuestionTypes: map[string]int{models.TypeSingle: 3}}))
|
|
if m["attempts_left"] != float64(1) || m["mode"] != models.ModeTest || m["max_attempts"] != float64(2) {
|
|
t.Errorf("test summary = %v", m)
|
|
}
|
|
types, _ := m["question_types"].(map[string]any)
|
|
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) != 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"])
|
|
}
|
|
|
|
begin := time.Date(2026, 11, 9, 10, 0, 0, 0, time.UTC)
|
|
defer func(orig func() time.Time) { timeNow = orig }(timeNow)
|
|
timeNow = func() time.Time { return begin.Add(20 * time.Second) }
|
|
limit := 60
|
|
timed := &models.Quiz{Slug: "t", Mode: models.ModeTest, Navigation: models.NavigationOneWay, TimeLimitSeconds: &limit}
|
|
m, _ = decode(t, attempt(classes.AttemptSummary{Attempt: models.Attempt{ID: 1, StartedAt: begin, Quiz: timed}}))
|
|
if m["seconds_left"] != float64(40) || m["quiz_mode"] != models.ModeTest || m["quiz_navigation"] != models.NavigationOneWay {
|
|
t.Errorf("open timed attempt = %v", m)
|
|
}
|
|
done := begin.Add(30 * time.Second)
|
|
m, _ = decode(t, attempt(classes.AttemptSummary{Attempt: models.Attempt{ID: 1, StartedAt: begin, CompletedAt: &done, Quiz: timed}}))
|
|
if v, ok := m["seconds_left"]; !ok || v != nil {
|
|
t.Errorf("completed seconds_left = %v (present %v), want null", v, ok)
|
|
}
|
|
|
|
m, _ = decode(t, start(&classes.Started{Attempt: &models.Attempt{ID: 2, StartedAt: begin}, Quiz: &test, Resumed: true, AttemptsUsed: 2, AnsweredCount: 1}))
|
|
if m["resumed"] != true || m["attempts_left"] != float64(0) || m["answered_count"] != float64(1) || m["quiz_slug"] != "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"])
|
|
}
|
|
}
|