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:
15
classes/policy.go
Normal file
15
classes/policy.go
Normal file
@@ -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
|
||||
}
|
||||
39
classes/policy_test.go
Normal file
39
classes/policy_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
29
models/quiz_test.go
Normal file
29
models/quiz_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -26,6 +26,7 @@ func generatedMigrations() []*gormigrate.Migration {
|
||||
updates.CreateQuizzesTables(),
|
||||
updates.AddCategoryToAttempts(),
|
||||
updates.AddCompletedAttemptsIndex(),
|
||||
updates.AddTestModeToQuizzes(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
28
updates/20261109120000_add_test_mode_to_quizzes.go
Normal file
28
updates/20261109120000_add_test_mode_to_quizzes.go
Normal file
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user