feat: add test mode to quizzes and withhold its solutions
- mode, navigation and max_attempts columns with Go-side defaults - classes.Withheld gates every solution field of the answer feedback and the attempt result while a test attempt is open - a test's quiz detail lists no questions before the attempt starts - admin fields and lang keys for the three settings
This commit is contained in:
200
controllers/api/json_test.go
Normal file
200
controllers/api/json_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
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.TypeSelfCheck {
|
||||
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 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,
|
||||
}
|
||||
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")
|
||||
noCorrectness(t, "feedback", raw)
|
||||
if typ != models.TypeSelfCheck && len(m["answers"].([]any)) != 2 {
|
||||
t.Errorf("feedback answers = %v, want both answers", m["answers"])
|
||||
}
|
||||
|
||||
known := true
|
||||
row := &models.AttemptAnswer{AttemptID: 5, QuestionID: 1, IsCorrect: true, Points: 2}
|
||||
if typ == models.TypeSelfCheck {
|
||||
row.SelfCheckKnown = &known
|
||||
} else {
|
||||
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)
|
||||
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")
|
||||
}
|
||||
first := questions[0].(map[string]any)
|
||||
if first["answered"] != true {
|
||||
t.Errorf("answered question lost answered: %v", first)
|
||||
}
|
||||
if typ == 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"])
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user