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:
Jakub Zych
2026-11-09 14:03:34 +01:00
parent 67a83dba32
commit 6c6ecae9d8
14 changed files with 506 additions and 36 deletions

View File

@@ -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
}

View 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))
}
}