From 6c6ecae9d866a0c7d741fc83bc6bd8b31eb7cb44 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 9 Nov 2026 14:03:34 +0100 Subject: [PATCH] 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 --- classes/policy.go | 15 ++ classes/policy_test.go | 39 ++++ classes/service.go | 6 +- controllers/api/json.go | 121 ++++++++--- controllers/api/json_test.go | 200 ++++++++++++++++++ lang/en/lang.yaml | 10 + lang/pl/lang.yaml | 10 + models/quiz.go | 50 ++++- models/quiz_test.go | 29 +++ models/quizzes/columns.yaml | 2 + models/quizzes/fields.yaml | 23 ++ plugin_test.go | 8 +- registry.gen.go | 1 + ...20261109120000_add_test_mode_to_quizzes.go | 28 +++ 14 files changed, 506 insertions(+), 36 deletions(-) create mode 100644 classes/policy.go create mode 100644 classes/policy_test.go create mode 100644 controllers/api/json_test.go create mode 100644 models/quiz_test.go create mode 100644 updates/20261109120000_add_test_mode_to_quizzes.go diff --git a/classes/policy.go b/classes/policy.go new file mode 100644 index 0000000..878f311 --- /dev/null +++ b/classes/policy.go @@ -0,0 +1,15 @@ +package classes + +import "git.golem15.com/golem15/sm-quizzes-plugin/models" + +// IsTest reports whether the quiz runs as a test. A zero or unknown mode is +// learning, so a quiz written before test mode existed keeps its behaviour. +func IsTest(q *models.Quiz) bool { return q != nil && q.Mode == models.ModeTest } + +// Withheld reports whether an attempt's solutions must stay hidden: it is +// an attempt at a test that is not finished yet. Every student serializer +// drops is_correct, points, correct answers, explanations and model answers +// while it is true. +func Withheld(a *models.Attempt) bool { + return a != nil && IsTest(a.Quiz) && a.CompletedAt == nil +} diff --git a/classes/policy_test.go b/classes/policy_test.go new file mode 100644 index 0000000..d5f8d5f --- /dev/null +++ b/classes/policy_test.go @@ -0,0 +1,39 @@ +package classes_test + +import ( + "testing" + "time" + + "git.golem15.com/golem15/sm-quizzes-plugin/classes" + "git.golem15.com/golem15/sm-quizzes-plugin/models" +) + +func TestIsTestAndWithheld(t *testing.T) { + if classes.IsTest(nil) { + t.Error("IsTest(nil) = true") + } + for mode, want := range map[string]bool{"": false, models.ModeLearning: false, "exam": false, models.ModeTest: true} { + if got := classes.IsTest(&models.Quiz{Mode: mode}); got != want { + t.Errorf("IsTest(mode %q) = %v, want %v", mode, got, want) + } + } + done := time.Now() + test, learning := &models.Quiz{Mode: models.ModeTest}, &models.Quiz{Mode: models.ModeLearning} + cases := []struct { + name string + a *models.Attempt + want bool + }{ + {"open test", &models.Attempt{Quiz: test}, true}, + {"completed test", &models.Attempt{Quiz: test, CompletedAt: &done}, false}, + {"open learning", &models.Attempt{Quiz: learning}, false}, + {"zero mode", &models.Attempt{Quiz: &models.Quiz{}}, false}, + {"nil attempt", nil, false}, + {"nil quiz", &models.Attempt{}, false}, + } + for _, tc := range cases { + if got := classes.Withheld(tc.a); got != tc.want { + t.Errorf("Withheld(%s) = %v, want %v", tc.name, got, tc.want) + } + } +} diff --git a/classes/service.go b/classes/service.go index 82803bb..8768353 100644 --- a/classes/service.go +++ b/classes/service.go @@ -258,6 +258,10 @@ type Feedback struct { Question models.Question Result ScoreResult CorrectAnswerIDs []uint + // Withheld is set for an open test attempt: the solutions stay hidden + // and the serializer must drop Result, CorrectAnswerIDs and every + // solution field of Question. + Withheld bool } // Answer records or overwrites the user's answer to one question of an @@ -334,7 +338,7 @@ func (s *Service) Answer(ctx context.Context, userID, attemptID uint, in AnswerI }).Create(&row).Error; err != nil { return err } - fb = &Feedback{Question: question, Result: res, CorrectAnswerIDs: correct} + fb = &Feedback{Question: question, Result: res, CorrectAnswerIDs: correct, Withheld: Withheld(attempt)} return nil }) if err != nil { diff --git a/controllers/api/json.go b/controllers/api/json.go index 3a45726..b7158a4 100644 --- a/controllers/api/json.go +++ b/controllers/api/json.go @@ -25,6 +25,9 @@ type quizSummaryJSON struct { ShuffleQuestions bool `json:"shuffle_questions"` BestScorePercent *float64 `json:"best_score_percent"` AttemptsCount int `json:"attempts_count"` + Mode string `json:"mode"` + Navigation string `json:"navigation"` + MaxAttempts *int `json:"max_attempts"` } func quizSummary(s classes.QuizSummary) quizSummaryJSON { @@ -42,10 +45,33 @@ func quizSummary(s classes.QuizSummary) quizSummaryJSON { ShuffleQuestions: q.ShuffleQuestions, BestScorePercent: s.BestScorePercent, AttemptsCount: s.AttemptsCount, + Mode: quizMode(&q), + Navigation: quizNavigation(&q), + MaxAttempts: q.MaxAttempts, } } -// quizJSON is GET /quizzes/{slug}: the quiz without any solution. +// quizMode is the quiz's mode as the API reports it: test, or learning for +// a zero or unknown value (the classes.IsTest rule). +func quizMode(q *models.Quiz) string { + if classes.IsTest(q) { + return models.ModeTest + } + return models.ModeLearning +} + +// quizNavigation is one_way when the quiz says so and free otherwise. +func quizNavigation(q *models.Quiz) string { + if q != nil && q.Navigation == models.NavigationOneWay { + return models.NavigationOneWay + } + return models.NavigationFree +} + +// quizJSON is GET /quizzes/{slug}: the quiz without any solution. A test +// lists no questions (QuestionCount still counts them): the test player +// reads them from its attempt, so a timed test cannot be read before its +// clock starts. type quizJSON struct { ID uint `json:"id"` Slug string `json:"slug"` @@ -59,6 +85,9 @@ type quizJSON struct { QuestionCount int `json:"question_count"` Categories []string `json:"categories"` Questions []questionJSON `json:"questions"` + Mode string `json:"mode"` + Navigation string `json:"navigation"` + MaxAttempts *int `json:"max_attempts"` } // questionJSON is a question as the player sees it before answering: no @@ -96,6 +125,12 @@ func quizDetail(d *classes.QuizDetail) quizJSON { QuestionCount: len(d.Questions), Categories: wire.Slice(d.Categories), Questions: make([]questionJSON, 0, len(d.Questions)), + Mode: quizMode(&q), + Navigation: quizNavigation(&q), + MaxAttempts: q.MaxAttempts, + } + if classes.IsTest(&q) { + return out } for _, qu := range d.Questions { out.Questions = append(out.Questions, question(qu)) @@ -139,19 +174,24 @@ type attemptJSON struct { ScorePercent *float64 `json:"score_percent"` PassingScore int `json:"passing_score"` Passed *bool `json:"passed"` + // QuizMode and QuizNavigation tell the player which flow to run. + QuizMode string `json:"quiz_mode"` + QuizNavigation string `json:"quiz_navigation"` } func attempt(s classes.AttemptSummary) attemptJSON { a := s.Attempt out := attemptJSON{ - ID: a.ID, - QuizID: a.QuizID, - Category: a.Category, - StartedAt: wireTime(a.StartedAt), - AnsweredCount: s.AnsweredCount, - ScorePoints: a.ScorePoints, - MaxPoints: a.MaxPoints, - ScorePercent: a.ScorePercent, + ID: a.ID, + QuizID: a.QuizID, + Category: a.Category, + StartedAt: wireTime(a.StartedAt), + AnsweredCount: s.AnsweredCount, + ScorePoints: a.ScorePoints, + MaxPoints: a.MaxPoints, + ScorePercent: a.ScorePercent, + QuizMode: quizMode(a.Quiz), + QuizNavigation: quizNavigation(a.Quiz), } if a.Quiz != nil { out.QuizSlug, out.QuizTitle, out.PassingScore = a.Quiz.Slug, a.Quiz.Title, a.Quiz.PassingScore @@ -170,15 +210,20 @@ func attempt(s classes.AttemptSummary) attemptJSON { } // resultJSON is GET /attempts/{id} and POST /attempts/{id}/finish. +// SolutionsWithheld is true for an open test attempt (classes.Withheld). type resultJSON struct { attemptJSON - Questions []questionResultJSON `json:"questions"` + SolutionsWithheld bool `json:"solutions_withheld"` + Questions []questionResultJSON `json:"questions"` } // questionResultJSON is one question of a result. The solution fields // (is_correct of the answers, correct_answer_ids, explanation, answer_text) // are revealed for an answered question and for every question once the -// attempt is completed; otherwise they are null. +// attempt is completed; otherwise they are null. While the solutions are +// 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. type questionResultJSON struct { ID uint `json:"id"` Text string `json:"text"` @@ -188,8 +233,8 @@ type questionResultJSON struct { Answered bool `json:"answered"` AnswerIDs []uint `json:"answer_ids"` Known *bool `json:"known"` - IsCorrect bool `json:"is_correct"` - PointsEarned float64 `json:"points_earned"` + IsCorrect *bool `json:"is_correct"` + PointsEarned *float64 `json:"points_earned"` CorrectAnswerIDs []uint `json:"correct_answer_ids"` Explanation *string `json:"explanation"` AnswerText *string `json:"answer_text"` @@ -204,7 +249,12 @@ type resultAnswerJSON struct { } func result(r *classes.AttemptResult) resultJSON { - out := resultJSON{attemptJSON: attempt(r.AttemptSummary), Questions: make([]questionResultJSON, 0, len(r.Questions))} + withheld := classes.Withheld(&r.Attempt) + out := resultJSON{ + attemptJSON: attempt(r.AttemptSummary), + SolutionsWithheld: withheld, + Questions: make([]questionResultJSON, 0, len(r.Questions)), + } completed := r.Attempt.CompletedAt != nil for _, qr := range r.Questions { q := qr.Question @@ -221,10 +271,16 @@ func result(r *classes.AttemptResult) resultJSON { row.Answered = true row.AnswerIDs = wire.Slice(a.AnswerIDs.Data) row.Known = a.SelfCheckKnown - row.IsCorrect = a.IsCorrect - row.PointsEarned = a.Points + if !withheld { + correct, points := a.IsCorrect, a.Points + row.IsCorrect, row.PointsEarned = &correct, &points + } + } else if !withheld { + // Learning output keeps false and 0 for an unanswered question. + correct, points := false, 0.0 + row.IsCorrect, row.PointsEarned = &correct, &points } - reveal := completed || row.Answered + reveal := !withheld && (completed || row.Answered) if reveal { row.CorrectAnswerIDs = classes.CorrectAnswerIDs(q) row.Explanation = q.Explanation @@ -244,11 +300,14 @@ func result(r *classes.AttemptResult) resultJSON { return out } -// feedbackJSON is POST /attempts/{id}/answers. +// 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. type feedbackJSON struct { QuestionID uint `json:"question_id"` - IsCorrect bool `json:"is_correct"` - Points float64 `json:"points"` + Withheld bool `json:"withheld"` + IsCorrect *bool `json:"is_correct"` + Points *float64 `json:"points"` CorrectAnswerIDs []uint `json:"correct_answer_ids"` Explanation *string `json:"explanation"` AnswerText *string `json:"answer_text"` @@ -257,18 +316,20 @@ type feedbackJSON struct { func feedback(fb *classes.Feedback) feedbackJSON { q := fb.Question - out := feedbackJSON{ - QuestionID: q.ID, - IsCorrect: fb.Result.IsCorrect, - Points: fb.Result.Points, - CorrectAnswerIDs: wire.Slice(fb.CorrectAnswerIDs), - Explanation: q.Explanation, - AnswerText: q.AnswerText, - Answers: make([]resultAnswerJSON, 0, len(q.Answers)), + out := feedbackJSON{QuestionID: q.ID, Withheld: fb.Withheld, Answers: make([]resultAnswerJSON, 0, len(q.Answers))} + if fb.Withheld { + for _, a := range q.Answers { + out.Answers = append(out.Answers, resultAnswerJSON{ID: a.ID, Text: a.Text}) + } + return out } + correct, points := fb.Result.IsCorrect, fb.Result.Points + out.IsCorrect, out.Points = &correct, &points + out.CorrectAnswerIDs = wire.Slice(fb.CorrectAnswerIDs) + out.Explanation, out.AnswerText = q.Explanation, q.AnswerText for _, a := range q.Answers { - correct := a.IsCorrect - out.Answers = append(out.Answers, resultAnswerJSON{ID: a.ID, Text: a.Text, IsCorrect: &correct, Explanation: a.Explanation}) + c := a.IsCorrect + out.Answers = append(out.Answers, resultAnswerJSON{ID: a.ID, Text: a.Text, IsCorrect: &c, Explanation: a.Explanation}) } return out } diff --git a/controllers/api/json_test.go b/controllers/api/json_test.go new file mode 100644 index 0000000..6b178f5 --- /dev/null +++ b/controllers/api/json_test.go @@ -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)) + } +} diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 5dcaede..a7ac032 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -34,6 +34,16 @@ quizzes: round_size: Round size round_size_comment: Clients may present the questions in rounds of this many. shuffle_questions: Shuffle questions + mode: Mode + mode_learning: Learning + mode_test: Test + mode_comment: A test hides the correct answers and explanations until the attempt is finished. + navigation: Test navigation + navigation_free: Free (go back and change answers) + navigation_one_way: One way (an answer is final) + navigation_comment: Applies to tests only. + max_attempts: Attempt limit + max_attempts_comment: Tests only. Leave empty for no limit. questions: title: Questions question: Question diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index 65ec15b..1f2962c 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -34,6 +34,16 @@ quizzes: round_size: Wielkość rundy round_size_comment: Klient może pokazywać pytania w rundach po tyle pytań. shuffle_questions: Losowa kolejność pytań + mode: Tryb + mode_learning: Nauka + mode_test: Test + mode_comment: Test ukrywa poprawne odpowiedzi i wyjaśnienia do zakończenia podejścia. + navigation: Nawigacja w teście + navigation_free: Swobodna (można wracać i zmieniać odpowiedzi) + navigation_one_way: Jednokierunkowa (odpowiedź jest ostateczna) + navigation_comment: Dotyczy tylko testów. + max_attempts: Limit podejść + max_attempts_comment: Tylko dla testów. Zostaw puste, aby nie ograniczać. questions: title: Pytania question: Pytanie diff --git a/models/quiz.go b/models/quiz.go index fbb8bf2..ca2a60b 100644 --- a/models/quiz.go +++ b/models/quiz.go @@ -10,8 +10,28 @@ import ( "gorm.io/gorm" ) +// Quiz modes. +const ( + ModeLearning = "learning" + ModeTest = "test" +) + +// QuizModes lists the valid values of Quiz.Mode. +var QuizModes = []string{ModeLearning, ModeTest} + +// Test navigation. +const ( + NavigationFree = "free" + NavigationOneWay = "one_way" +) + +// QuizNavigations lists the valid values of Quiz.Navigation. +var QuizNavigations = []string{NavigationFree, NavigationOneWay} + // Quiz is a set of questions a signed-in user can attempt. Context is a // free-form link to a record of another plugin, such as "acme.shop:course:12". +// Mode test withholds solutions until the attempt is finished; Navigation +// (free or one_way) and MaxAttempts (nil is unlimited) only apply to tests. type Quiz struct { ID uint `gorm:"column:id;primaryKey"` Title string `gorm:"column:title"` @@ -23,6 +43,9 @@ type Quiz struct { PassingScore int `gorm:"column:passing_score"` ShuffleQuestions bool `gorm:"column:shuffle_questions"` RoundSize *int `gorm:"column:round_size"` + Mode string `gorm:"column:mode"` + Navigation string `gorm:"column:navigation"` + MaxAttempts *int `gorm:"column:max_attempts"` CreatedAt time.Time `gorm:"column:created_at"` UpdatedAt time.Time `gorm:"column:updated_at"` Questions []Question `gorm:"foreignKey:QuizID"` @@ -36,6 +59,7 @@ func (Quiz) Fillable() []string { return []string{ "title", "slug", "description", "context", "is_published", "time_limit_seconds", "passing_score", "shuffle_questions", "round_size", + "mode", "navigation", "max_attempts", } } @@ -49,22 +73,42 @@ func (Quiz) Rules() map[string]string { "passing_score": "required|integer|between:0,100", "time_limit_seconds": "nullable|integer|min:1", "round_size": "nullable|integer|min:1", + "mode": "nullable|in:learning,test", + "navigation": "nullable|in:free,one_way", + "max_attempts": "nullable|integer|min:1", } } -// BeforeValidate fills the slug from the title before the admin form -// validates, so the unique rule sees the final slug. +// BeforeValidate fills the slug from the title and the mode defaults before +// the admin form validates, so the rules see the final values. func (q *Quiz) BeforeValidate(*gorm.DB) error { q.fillSlug() + q.fillDefaults() return nil } -// BeforeSave fills the slug from the title on every other write path. +// BeforeSave fills the slug and the mode defaults on every other write path. func (q *Quiz) BeforeSave(*gorm.DB) error { q.fillSlug() + q.fillDefaults() return nil } +// fillDefaults stores an empty mode or navigation as learning / free and a +// non-positive attempt limit as unlimited. GORM writes Go zero values, so +// without it a models.Quiz{} would hit the columns' CHECK constraints. +func (q *Quiz) fillDefaults() { + if q.Mode == "" { + q.Mode = ModeLearning + } + if q.Navigation == "" { + q.Navigation = NavigationFree + } + if q.MaxAttempts != nil && *q.MaxAttempts <= 0 { + q.MaxAttempts = nil + } +} + func (q *Quiz) fillSlug() { if strings.TrimSpace(q.Slug) == "" { q.Slug = Slugify(q.Title) diff --git a/models/quiz_test.go b/models/quiz_test.go new file mode 100644 index 0000000..8fd6178 --- /dev/null +++ b/models/quiz_test.go @@ -0,0 +1,29 @@ +package models + +import "testing" + +func TestQuizDefaults(t *testing.T) { + zero, negative := 0, -1 + for name, hook := range map[string]func(*Quiz) error{ + "BeforeSave": func(q *Quiz) error { return q.BeforeSave(nil) }, + "BeforeValidate": func(q *Quiz) error { return q.BeforeValidate(nil) }, + } { + for _, limit := range []*int{&zero, &negative} { + q := &Quiz{Title: "Q", MaxAttempts: limit} + if err := hook(q); err != nil { + t.Fatalf("%s: %v", name, err) + } + if q.Mode != ModeLearning || q.Navigation != NavigationFree || q.MaxAttempts != nil { + t.Errorf("%s with max_attempts %d = %q %q %v, want learning free nil", name, *limit, q.Mode, q.Navigation, q.MaxAttempts) + } + } + two := 2 + q := &Quiz{Title: "Q", Mode: ModeTest, Navigation: NavigationOneWay, MaxAttempts: &two} + if err := hook(q); err != nil { + t.Fatalf("%s: %v", name, err) + } + if q.Mode != ModeTest || q.Navigation != NavigationOneWay || q.MaxAttempts == nil || *q.MaxAttempts != 2 { + t.Errorf("%s changed a test quiz: %q %q %v", name, q.Mode, q.Navigation, q.MaxAttempts) + } + } +} diff --git a/models/quizzes/columns.yaml b/models/quizzes/columns.yaml index ef3bd18..13bff62 100644 --- a/models/quizzes/columns.yaml +++ b/models/quizzes/columns.yaml @@ -8,6 +8,8 @@ columns: context: label: golem15.quizzes::lang.quizzes.context searchable: true + mode: + label: golem15.quizzes::lang.quizzes.mode is_published: label: golem15.quizzes::lang.quizzes.is_published type: switch diff --git a/models/quizzes/fields.yaml b/models/quizzes/fields.yaml index 4dcf2e2..9c0b39a 100644 --- a/models/quizzes/fields.yaml +++ b/models/quizzes/fields.yaml @@ -42,3 +42,26 @@ fields: label: golem15.quizzes::lang.quizzes.shuffle_questions type: switch span: right + mode: + label: golem15.quizzes::lang.quizzes.mode + type: dropdown + span: left + default: learning + options: + learning: golem15.quizzes::lang.quizzes.mode_learning + test: golem15.quizzes::lang.quizzes.mode_test + comment: golem15.quizzes::lang.quizzes.mode_comment + navigation: + label: golem15.quizzes::lang.quizzes.navigation + type: dropdown + span: right + default: free + options: + free: golem15.quizzes::lang.quizzes.navigation_free + one_way: golem15.quizzes::lang.quizzes.navigation_one_way + comment: golem15.quizzes::lang.quizzes.navigation_comment + max_attempts: + label: golem15.quizzes::lang.quizzes.max_attempts + type: number + span: left + comment: golem15.quizzes::lang.quizzes.max_attempts_comment diff --git a/plugin_test.go b/plugin_test.go index b3eb1eb..5c6e672 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -39,8 +39,12 @@ func TestPluginActivates(t *testing.T) { if got := len(p.(pact.HasModels).Models()); got != 5 { t.Errorf("Models = %d, want 5", got) } - if len(p.(pact.HasMigrations).Migrations()) == 0 { - t.Error("no migrations") + migrations := p.(pact.HasMigrations).Migrations() + if len(migrations) == 0 { + t.Fatal("no migrations") + } + if got := migrations[len(migrations)-1].ID; got != "20261109120000_add_test_mode_to_quizzes" { + t.Errorf("last migration = %s, want 20261109120000_add_test_mode_to_quizzes", got) } if app.Config.Bool("golem15.quizzes.scoring.multiple_partial") { t.Error("scoring.multiple_partial defaults to true, want false") diff --git a/registry.gen.go b/registry.gen.go index 2742e74..6117492 100644 --- a/registry.gen.go +++ b/registry.gen.go @@ -26,6 +26,7 @@ func generatedMigrations() []*gormigrate.Migration { updates.CreateQuizzesTables(), updates.AddCategoryToAttempts(), updates.AddCompletedAttemptsIndex(), + updates.AddTestModeToQuizzes(), } } diff --git a/updates/20261109120000_add_test_mode_to_quizzes.go b/updates/20261109120000_add_test_mode_to_quizzes.go new file mode 100644 index 0000000..1771740 --- /dev/null +++ b/updates/20261109120000_add_test_mode_to_quizzes.go @@ -0,0 +1,28 @@ +// Code generated by summer make. DO NOT EDIT. + +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// AddTestModeToQuizzes returns the 20261109120000_add_test_mode_to_quizzes gormigrate entry. +// A test quiz withholds solutions until the attempt is finished; navigation +// and max_attempts apply only to tests. Every existing quiz stays a learning +// quiz with free navigation and no attempt limit. +func AddTestModeToQuizzes() *gormigrate.Migration { + return &gormigrate.Migration{ + ID: "20261109120000_add_test_mode_to_quizzes", + Migrate: func(tx *gorm.DB) error { + return tx.Exec(`ALTER TABLE golem15_quizzes_quizzes + ADD COLUMN mode VARCHAR(20) NOT NULL DEFAULT 'learning' CHECK (mode IN ('learning', 'test')), + ADD COLUMN navigation VARCHAR(20) NOT NULL DEFAULT 'free' CHECK (navigation IN ('free', 'one_way')), + ADD COLUMN max_attempts INTEGER NULL CHECK (max_attempts IS NULL OR max_attempts >= 1)`).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec(`ALTER TABLE golem15_quizzes_quizzes + DROP COLUMN IF EXISTS max_attempts, DROP COLUMN IF EXISTS navigation, DROP COLUMN IF EXISTS mode`).Error + }, + } +}