feat: enforce attempt limits, time limits and one-way tests
- Service.Start resumes an open test attempt, closes expired ones at their deadline and refuses a start past max_attempts under an advisory lock; StartAttempt stays as a wrapper - answers to a test are accepted until the deadline plus golem15.quizzes.test.grace_seconds; one_way answers are final - a test covers the whole quiz and keeps a stable per-attempt order - 409 bodies carry a code; list and attempt rows carry attempts_left, question_types and seconds_left; import accepts the test keys
This commit is contained in:
@@ -13,7 +13,9 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ImportQuiz is the JSON document quizzes:import reads.
|
||||
// ImportQuiz is the JSON document quizzes:import reads. Mode, Navigation
|
||||
// and MaxAttempts keep the quiz's current value (the model default on
|
||||
// create) when absent.
|
||||
type ImportQuiz struct {
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
@@ -24,6 +26,9 @@ type ImportQuiz struct {
|
||||
PassingScore *int `json:"passing_score"`
|
||||
ShuffleQuestions *bool `json:"shuffle_questions"`
|
||||
RoundSize *int `json:"round_size"`
|
||||
Mode *string `json:"mode"`
|
||||
Navigation *string `json:"navigation"`
|
||||
MaxAttempts *int `json:"max_attempts"`
|
||||
Questions []ImportQuestion `json:"questions"`
|
||||
}
|
||||
|
||||
@@ -79,6 +84,15 @@ func ParseImport(raw []byte) (*ImportQuiz, error) {
|
||||
if doc.TimeLimitSeconds != nil && *doc.TimeLimitSeconds < 1 {
|
||||
problems = append(problems, "time_limit_seconds must be at least 1")
|
||||
}
|
||||
if doc.Mode != nil && !slices.Contains(models.QuizModes, *doc.Mode) {
|
||||
problems = append(problems, "mode must be one of "+strings.Join(models.QuizModes, ", "))
|
||||
}
|
||||
if doc.Navigation != nil && !slices.Contains(models.QuizNavigations, *doc.Navigation) {
|
||||
problems = append(problems, "navigation must be one of "+strings.Join(models.QuizNavigations, ", "))
|
||||
}
|
||||
if doc.MaxAttempts != nil && *doc.MaxAttempts < 1 {
|
||||
problems = append(problems, "max_attempts must be at least 1")
|
||||
}
|
||||
if len(doc.Questions) == 0 {
|
||||
problems = append(problems, "questions must not be empty")
|
||||
}
|
||||
@@ -164,6 +178,16 @@ func Import(ctx context.Context, db *gorm.DB, doc *ImportQuiz, replace bool) (*I
|
||||
if doc.ShuffleQuestions != nil {
|
||||
quiz.ShuffleQuestions = *doc.ShuffleQuestions
|
||||
}
|
||||
if doc.Mode != nil {
|
||||
quiz.Mode = *doc.Mode
|
||||
}
|
||||
if doc.Navigation != nil {
|
||||
quiz.Navigation = *doc.Navigation
|
||||
}
|
||||
if doc.MaxAttempts != nil {
|
||||
limit := *doc.MaxAttempts
|
||||
quiz.MaxAttempts = &limit
|
||||
}
|
||||
if err := tx.Save(&quiz).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -55,3 +55,30 @@ func TestParseImportRejects(t *testing.T) {
|
||||
t.Errorf("explicit slug = %v %v, want moj-slug", doc, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImportTestMode(t *testing.T) {
|
||||
doc, err := classes.ParseImport([]byte(`{"title":"T","mode":"test","navigation":"one_way","max_attempts":2,"questions":[{"text":"Q","type":"self_check"}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseImport: %v", err)
|
||||
}
|
||||
if doc.Mode == nil || *doc.Mode != "test" || doc.Navigation == nil || *doc.Navigation != "one_way" || doc.MaxAttempts == nil || *doc.MaxAttempts != 2 {
|
||||
t.Errorf("doc = %+v", doc)
|
||||
}
|
||||
for raw, want := range map[string]string{
|
||||
`{"title":"T","mode":"exam","questions":[{"text":"Q","type":"self_check"}]}`: "mode must be one of learning, test",
|
||||
`{"title":"T","navigation":"back","questions":[{"text":"Q","type":"self_check"}]}`: "navigation must be one of free, one_way",
|
||||
`{"title":"T","max_attempts":0,"questions":[{"text":"Q","type":"self_check"}]}`: "max_attempts must be at least 1",
|
||||
} {
|
||||
if _, err := classes.ParseImport([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("ParseImport(%s) = %v, want %q", raw, err, want)
|
||||
}
|
||||
}
|
||||
raw, err := os.ReadFile("../testdata/quiz.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc, err = classes.ParseImport(raw)
|
||||
if err != nil || doc.Mode != nil || doc.Navigation != nil || doc.MaxAttempts != nil {
|
||||
t.Errorf("sample = %+v %v, want no test-mode keys", doc, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
package classes
|
||||
|
||||
import "git.golem15.com/golem15/sm-quizzes-plugin/models"
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
)
|
||||
|
||||
// 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.
|
||||
@@ -13,3 +21,104 @@ func IsTest(q *models.Quiz) bool { return q != nil && q.Mode == models.ModeTest
|
||||
func Withheld(a *models.Attempt) bool {
|
||||
return a != nil && IsTest(a.Quiz) && a.CompletedAt == nil
|
||||
}
|
||||
|
||||
// OneWay reports whether a test's answers are final: navigation one_way.
|
||||
// Learning quizzes ignore the navigation setting.
|
||||
func OneWay(q *models.Quiz) bool { return IsTest(q) && q.Navigation == models.NavigationOneWay }
|
||||
|
||||
// DefaultTestGrace is the test grace without a golem15.quizzes.test.grace_seconds setting.
|
||||
const DefaultTestGrace = 5 * time.Second
|
||||
|
||||
// maxTestGrace caps the configured grace.
|
||||
const maxTestGrace = 120 * time.Second
|
||||
|
||||
// TestGraceFrom reads golem15.quizzes.test.grace_seconds: how long after a
|
||||
// test's deadline an answer in flight is still accepted. A nil config, a
|
||||
// missing key or a value that is not a number give DefaultTestGrace; the
|
||||
// value is clamped to 0..120 seconds. Learning quizzes have no grace.
|
||||
func TestGraceFrom(cfg *compass.Config) time.Duration {
|
||||
if cfg == nil {
|
||||
return DefaultTestGrace
|
||||
}
|
||||
v, ok := cfg.Lookup("golem15.quizzes.test.grace_seconds")
|
||||
if !ok {
|
||||
return DefaultTestGrace
|
||||
}
|
||||
secs, ok := number(v)
|
||||
if !ok {
|
||||
return DefaultTestGrace
|
||||
}
|
||||
grace := time.Duration(secs * float64(time.Second))
|
||||
return min(max(grace, 0), maxTestGrace)
|
||||
}
|
||||
|
||||
// number is v as a float when it is a number or a numeric string.
|
||||
func number(v any) (float64, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n, !math.IsNaN(n)
|
||||
case float32:
|
||||
return float64(n), true
|
||||
case int:
|
||||
return float64(n), true
|
||||
case int64:
|
||||
return float64(n), true
|
||||
case uint64:
|
||||
return float64(n), true
|
||||
case string:
|
||||
f, err := strconv.ParseFloat(n, 64)
|
||||
return f, err == nil && !math.IsNaN(f)
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// AnswerDeadline is the last moment an answer to the attempt is accepted:
|
||||
// the Deadline plus grace for a test, the Deadline itself for a learning
|
||||
// quiz, nil without a time limit.
|
||||
func AnswerDeadline(a *models.Attempt, grace time.Duration) *time.Time {
|
||||
d := Deadline(a)
|
||||
if d == nil || !IsTest(a.Quiz) {
|
||||
return d
|
||||
}
|
||||
t := d.Add(grace)
|
||||
return &t
|
||||
}
|
||||
|
||||
// Expired reports whether an open test attempt is past its answer deadline,
|
||||
// so it can be closed at its Deadline. Learning and completed attempts never
|
||||
// expire this way.
|
||||
func Expired(a *models.Attempt, now time.Time, grace time.Duration) bool {
|
||||
if a == nil || !IsTest(a.Quiz) || a.CompletedAt != nil {
|
||||
return false
|
||||
}
|
||||
d := AnswerDeadline(a, grace)
|
||||
return d != nil && now.After(*d)
|
||||
}
|
||||
|
||||
// SecondsLeft is the whole seconds until the attempt's Deadline, rounded up
|
||||
// and floored at 0, or nil without a time limit or once completed. The
|
||||
// player runs its countdown from it, not from the client clock.
|
||||
func SecondsLeft(a *models.Attempt, now time.Time) *int {
|
||||
d := Deadline(a)
|
||||
if d == nil || a.CompletedAt != nil {
|
||||
return nil
|
||||
}
|
||||
left := max(int(math.Ceil(d.Sub(now).Seconds())), 0)
|
||||
return &left
|
||||
}
|
||||
|
||||
// AttemptsLeft is how many attempts the user may still start: nil for a
|
||||
// learning quiz or a test without a limit, otherwise max(0, limit - used).
|
||||
func AttemptsLeft(q *models.Quiz, used int) *int {
|
||||
if !IsTest(q) || q.MaxAttempts == nil {
|
||||
return nil
|
||||
}
|
||||
left := max(*q.MaxAttempts-used, 0)
|
||||
return &left
|
||||
}
|
||||
|
||||
// AttemptLockKey names the advisory lock that serialises one user's test
|
||||
// starts on one quiz.
|
||||
func AttemptLockKey(quizID, userID uint) string {
|
||||
return fmt.Sprintf("golem15.quizzes.attempt:%d:%d", quizID, userID)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
)
|
||||
|
||||
func TestIsTestAndWithheld(t *testing.T) {
|
||||
@@ -37,3 +38,111 @@ func TestIsTestAndWithheld(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestRules(t *testing.T) {
|
||||
limit, two := 60, 2
|
||||
start := time.Date(2026, 11, 9, 10, 0, 0, 0, time.UTC)
|
||||
test := &models.Quiz{Mode: models.ModeTest, TimeLimitSeconds: &limit}
|
||||
learning := &models.Quiz{Mode: models.ModeLearning, TimeLimitSeconds: &limit}
|
||||
grace := 5 * time.Second
|
||||
open := &models.Attempt{StartedAt: start, Quiz: test}
|
||||
deadline := start.Add(60 * time.Second)
|
||||
|
||||
if d := classes.AnswerDeadline(open, grace); d == nil || !d.Equal(deadline.Add(grace)) {
|
||||
t.Errorf("test AnswerDeadline = %v, want deadline + grace", d)
|
||||
}
|
||||
if d := classes.AnswerDeadline(&models.Attempt{StartedAt: start, Quiz: learning}, grace); d == nil || !d.Equal(deadline) {
|
||||
t.Errorf("learning AnswerDeadline = %v, want the deadline itself", d)
|
||||
}
|
||||
if classes.AnswerDeadline(&models.Attempt{StartedAt: start, Quiz: &models.Quiz{Mode: models.ModeTest}}, grace) != nil {
|
||||
t.Error("AnswerDeadline without a time limit is set")
|
||||
}
|
||||
|
||||
if classes.Expired(open, deadline.Add(4*time.Second), grace) {
|
||||
t.Error("test attempt expired inside the grace")
|
||||
}
|
||||
if !classes.Expired(open, deadline.Add(6*time.Second), grace) {
|
||||
t.Error("test attempt past deadline + grace did not expire")
|
||||
}
|
||||
if classes.Expired(&models.Attempt{StartedAt: start, Quiz: learning}, deadline.Add(time.Hour), grace) {
|
||||
t.Error("learning attempt expired")
|
||||
}
|
||||
done := deadline
|
||||
if classes.Expired(&models.Attempt{StartedAt: start, Quiz: test, CompletedAt: &done}, deadline.Add(time.Hour), grace) {
|
||||
t.Error("completed attempt expired")
|
||||
}
|
||||
if classes.Expired(&models.Attempt{StartedAt: start, Quiz: &models.Quiz{Mode: models.ModeTest}}, deadline.Add(time.Hour), grace) {
|
||||
t.Error("attempt without a time limit expired")
|
||||
}
|
||||
|
||||
if got := classes.SecondsLeft(open, start.Add(500*time.Millisecond)); got == nil || *got != 60 {
|
||||
t.Errorf("SecondsLeft at +0.5s = %v, want 60 (rounded up)", got)
|
||||
}
|
||||
if got := classes.SecondsLeft(open, start.Add(59*time.Second)); got == nil || *got != 1 {
|
||||
t.Errorf("SecondsLeft at +59s = %v, want 1", got)
|
||||
}
|
||||
if got := classes.SecondsLeft(open, deadline.Add(time.Minute)); got == nil || *got != 0 {
|
||||
t.Errorf("SecondsLeft after the deadline = %v, want 0", got)
|
||||
}
|
||||
if classes.SecondsLeft(&models.Attempt{StartedAt: start, Quiz: &models.Quiz{}}, start) != nil {
|
||||
t.Error("SecondsLeft without a time limit is set")
|
||||
}
|
||||
if classes.SecondsLeft(&models.Attempt{StartedAt: start, Quiz: test, CompletedAt: &done}, start) != nil {
|
||||
t.Error("SecondsLeft of a completed attempt is set")
|
||||
}
|
||||
|
||||
if classes.AttemptsLeft(&models.Quiz{Mode: models.ModeLearning, MaxAttempts: &two}, 1) != nil {
|
||||
t.Error("AttemptsLeft of a learning quiz is set")
|
||||
}
|
||||
if classes.AttemptsLeft(&models.Quiz{Mode: models.ModeTest}, 1) != nil {
|
||||
t.Error("AttemptsLeft of an unlimited test is set")
|
||||
}
|
||||
for used, want := range map[int]int{0: 2, 1: 1, 2: 0, 3: 0} {
|
||||
if got := classes.AttemptsLeft(&models.Quiz{Mode: models.ModeTest, MaxAttempts: &two}, used); got == nil || *got != want {
|
||||
t.Errorf("AttemptsLeft(max 2, used %d) = %v, want %d", used, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
q *models.Quiz
|
||||
want bool
|
||||
}{
|
||||
{&models.Quiz{Mode: models.ModeTest, Navigation: models.NavigationOneWay}, true},
|
||||
{&models.Quiz{Mode: models.ModeTest, Navigation: models.NavigationFree}, false},
|
||||
{&models.Quiz{Mode: models.ModeLearning, Navigation: models.NavigationOneWay}, false},
|
||||
{nil, false},
|
||||
} {
|
||||
if got := classes.OneWay(tc.q); got != tc.want {
|
||||
t.Errorf("OneWay(%+v) = %v, want %v", tc.q, got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
if got := classes.AttemptLockKey(3, 7); got != "golem15.quizzes.attempt:3:7" {
|
||||
t.Errorf("AttemptLockKey = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestGraceFrom(t *testing.T) {
|
||||
if got := classes.TestGraceFrom(nil); got != 5*time.Second || classes.DefaultTestGrace != 5*time.Second {
|
||||
t.Errorf("nil config = %v, want 5s", got)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Env: "testing", Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := classes.TestGraceFrom(cfg); got != 5*time.Second {
|
||||
t.Errorf("missing key = %v, want 5s", got)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
value any
|
||||
want time.Duration
|
||||
}{{0, 0}, {30, 30 * time.Second}, {"x", 5 * time.Second}, {900, 120 * time.Second}, {-1, 0}, {"10", 10 * time.Second}} {
|
||||
if err := cfg.Set("golem15.quizzes.test.grace_seconds", tc.value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := classes.TestGraceFrom(cfg); got != tc.want {
|
||||
t.Errorf("grace_seconds %v = %v, want %v", tc.value, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,13 @@ var (
|
||||
ErrNotFound = errors.New("quizzes: not found")
|
||||
// ErrCompleted is a write to a finished attempt.
|
||||
ErrCompleted = errors.New("quizzes: attempt already completed")
|
||||
// ErrTimeLimit is an answer after the quiz's time limit ran out.
|
||||
// ErrTimeLimit is an answer after the quiz's time limit ran out (plus
|
||||
// the grace of a test).
|
||||
ErrTimeLimit = errors.New("quizzes: time limit exceeded")
|
||||
// ErrAttemptLimit is a test start after the user used every attempt.
|
||||
ErrAttemptLimit = errors.New("quizzes: attempt limit reached")
|
||||
// ErrAnswerLocked is a changed answer in a one_way test.
|
||||
ErrAnswerLocked = errors.New("quizzes: answer locked")
|
||||
)
|
||||
|
||||
// FieldError is an input error on one request field (a 422).
|
||||
@@ -40,6 +45,9 @@ type Service struct {
|
||||
// Shuffle reorders questions of a quiz with shuffle_questions; it
|
||||
// defaults to math/rand.
|
||||
Shuffle func(n int, swap func(i, j int))
|
||||
// Grace is how long after a test's deadline an answer is still
|
||||
// accepted; it never applies to learning quizzes. See TestGraceFrom.
|
||||
Grace time.Duration
|
||||
}
|
||||
|
||||
func (s *Service) now() time.Time {
|
||||
@@ -57,11 +65,17 @@ type QuizSummary struct {
|
||||
QuestionCount int
|
||||
BestScorePercent *float64
|
||||
AttemptsCount int
|
||||
// QuestionTypes counts the questions per type; every models.QuestionTypes
|
||||
// key is present.
|
||||
QuestionTypes map[string]int
|
||||
// AttemptsUsed counts every attempt of the user, open or completed.
|
||||
AttemptsUsed int
|
||||
}
|
||||
|
||||
// ListQuizzes returns the published quizzes, optionally only those whose
|
||||
// context equals quizContext, ordered by title. AttemptsCount counts the
|
||||
// user's completed attempts and BestScorePercent is their best result.
|
||||
// user's completed attempts, AttemptsUsed every attempt, and
|
||||
// BestScorePercent is their best result.
|
||||
func (s *Service) ListQuizzes(ctx context.Context, userID uint, quizContext string) ([]QuizSummary, error) {
|
||||
q := s.db(ctx).Where("is_published = ?", true)
|
||||
if quizContext != "" {
|
||||
@@ -81,27 +95,34 @@ func (s *Service) ListQuizzes(ctx context.Context, userID uint, quizContext stri
|
||||
}
|
||||
type countRow struct {
|
||||
QuizID uint
|
||||
Type string
|
||||
N int
|
||||
}
|
||||
var counts []countRow
|
||||
if err := s.db(ctx).Model(&models.Question{}).Select("quiz_id, COUNT(*) AS n").
|
||||
Where("quiz_id IN ?", ids).Group("quiz_id").Scan(&counts).Error; err != nil {
|
||||
if err := s.db(ctx).Model(&models.Question{}).Select("quiz_id, type, COUNT(*) AS n").
|
||||
Where("quiz_id IN ?", ids).Group("quiz_id, type").Scan(&counts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type statRow struct {
|
||||
QuizID uint
|
||||
N int
|
||||
Used int
|
||||
Best *float64
|
||||
}
|
||||
var stats []statRow
|
||||
if err := s.db(ctx).Model(&models.Attempt{}).Select("quiz_id, COUNT(*) AS n, MAX(score_percent) AS best").
|
||||
Where("quiz_id IN ? AND user_id = ? AND completed_at IS NOT NULL", ids, userID).
|
||||
if err := s.db(ctx).Model(&models.Attempt{}).
|
||||
Select("quiz_id, COUNT(*) FILTER (WHERE completed_at IS NOT NULL) AS n, COUNT(*) AS used, "+
|
||||
"MAX(score_percent) FILTER (WHERE completed_at IS NOT NULL) AS best").
|
||||
Where("quiz_id IN ? AND user_id = ?", ids, userID).
|
||||
Group("quiz_id").Scan(&stats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
countBy := map[uint]int{}
|
||||
typesBy := map[uint]map[string]int{}
|
||||
for _, c := range counts {
|
||||
countBy[c.QuizID] = c.N
|
||||
if typesBy[c.QuizID] == nil {
|
||||
typesBy[c.QuizID] = map[string]int{}
|
||||
}
|
||||
typesBy[c.QuizID][c.Type] += c.N
|
||||
}
|
||||
statBy := map[uint]statRow{}
|
||||
for _, st := range stats {
|
||||
@@ -109,7 +130,19 @@ func (s *Service) ListQuizzes(ctx context.Context, userID uint, quizContext stri
|
||||
}
|
||||
for _, quiz := range quizzes {
|
||||
st := statBy[quiz.ID]
|
||||
out = append(out, QuizSummary{Quiz: quiz, QuestionCount: countBy[quiz.ID], BestScorePercent: st.Best, AttemptsCount: st.N})
|
||||
types := make(map[string]int, len(models.QuestionTypes))
|
||||
for _, typ := range models.QuestionTypes {
|
||||
types[typ] = 0
|
||||
}
|
||||
total := 0
|
||||
for typ, n := range typesBy[quiz.ID] {
|
||||
types[typ] += n
|
||||
total += n
|
||||
}
|
||||
out = append(out, QuizSummary{
|
||||
Quiz: quiz, QuestionCount: total, BestScorePercent: st.Best, AttemptsCount: st.N,
|
||||
QuestionTypes: types, AttemptsUsed: st.Used,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -123,13 +156,16 @@ type QuizDetail struct {
|
||||
|
||||
// GetQuiz loads a published quiz by slug with its questions in sort order
|
||||
// (shuffled when the quiz says so) and each question's answers. A non-empty
|
||||
// category keeps only that category's questions. Categories lists every
|
||||
// category of the quiz.
|
||||
// category keeps only that category's questions; a test ignores it, as it
|
||||
// always covers the whole quiz. Categories lists every category of the quiz.
|
||||
func (s *Service) GetQuiz(ctx context.Context, slug, category string) (*QuizDetail, error) {
|
||||
quiz, err := s.publishedQuiz(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if IsTest(quiz) {
|
||||
category = ""
|
||||
}
|
||||
questions, err := s.questions(ctx, quiz.ID, category)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -179,38 +215,147 @@ func (s *Service) categories(ctx context.Context, quizID uint) ([]string, error)
|
||||
return cats, err
|
||||
}
|
||||
|
||||
// StartAttempt starts an attempt at a published quiz for the user. A
|
||||
// non-empty category limits the attempt to that category's questions; a
|
||||
// category without questions is a FieldError on category.
|
||||
// Started is the attempt POST /quizzes/{slug}/attempts answers with.
|
||||
type Started struct {
|
||||
Attempt *models.Attempt
|
||||
Quiz *models.Quiz
|
||||
// Resumed is true when an open test attempt was returned instead of a
|
||||
// new one.
|
||||
Resumed bool
|
||||
// AttemptsUsed counts the user's attempts on the quiz, this one
|
||||
// included; it is only counted for tests.
|
||||
AttemptsUsed int
|
||||
// AnsweredCount is the number of questions a resumed attempt answered.
|
||||
AnsweredCount int
|
||||
}
|
||||
|
||||
// StartAttempt starts an attempt at a published quiz for the user, like
|
||||
// Start without the resume and limit details.
|
||||
func (s *Service) StartAttempt(ctx context.Context, userID uint, slug, category string) (*models.Attempt, *models.Quiz, error) {
|
||||
quiz, err := s.publishedQuiz(ctx, slug)
|
||||
st, err := s.Start(ctx, userID, slug, category)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return st.Attempt, st.Quiz, nil
|
||||
}
|
||||
|
||||
// Start starts an attempt at a published quiz for the user.
|
||||
//
|
||||
// For a learning quiz a non-empty category limits the attempt to that
|
||||
// category's questions; a category without questions is a FieldError on
|
||||
// category. Every call creates a new attempt.
|
||||
//
|
||||
// A test always covers the whole quiz, so a category is a FieldError
|
||||
// (category_not_allowed). Under an advisory lock per (quiz, user) it closes
|
||||
// the user's open attempts past their deadline and grace at the deadline,
|
||||
// resumes a remaining open attempt (Resumed) and otherwise refuses with
|
||||
// ErrAttemptLimit once the user's attempts, open or completed and in any
|
||||
// mode, reach max_attempts.
|
||||
func (s *Service) Start(ctx context.Context, userID uint, slug, category string) (*Started, error) {
|
||||
quiz, err := s.publishedQuiz(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if IsTest(quiz) {
|
||||
if category != "" {
|
||||
return nil, &FieldError{Field: "category", Key: "category_not_allowed"}
|
||||
}
|
||||
return s.startTest(ctx, userID, quiz)
|
||||
}
|
||||
attempt := &models.Attempt{QuizID: quiz.ID, UserID: userID, StartedAt: s.now().UTC()}
|
||||
if category != "" {
|
||||
var n int64
|
||||
if err := s.db(ctx).Model(&models.Question{}).Where("quiz_id = ? AND category = ?", quiz.ID, category).Count(&n).Error; err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
if n == 0 {
|
||||
return nil, nil, &FieldError{Field: "category", Key: "unknown_category"}
|
||||
return nil, &FieldError{Field: "category", Key: "unknown_category"}
|
||||
}
|
||||
attempt.Category = &category
|
||||
}
|
||||
maxPoints, err := s.maxPoints(ctx, attempt)
|
||||
maxPoints, err := s.maxPoints(ctx, s.DB, attempt)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
attempt.MaxPoints = maxPoints
|
||||
if err := s.db(ctx).Create(attempt).Error; err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
return attempt, quiz, nil
|
||||
return &Started{Attempt: attempt, Quiz: quiz}, nil
|
||||
}
|
||||
|
||||
func (s *Service) maxPoints(ctx context.Context, a *models.Attempt) (int, error) {
|
||||
q := s.db(ctx).Model(&models.Question{}).Where("quiz_id = ?", a.QuizID)
|
||||
func (s *Service) startTest(ctx context.Context, userID uint, quiz *models.Quiz) (*Started, error) {
|
||||
var st *Started
|
||||
err := lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
||||
tx = tx.WithContext(ctx)
|
||||
if err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext(?))`, AttemptLockKey(quiz.ID, userID)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var open []models.Attempt
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Table: clause.Table{Name: clause.CurrentTable}}).
|
||||
Where("quiz_id = ? AND user_id = ? AND completed_at IS NULL", quiz.ID, userID).
|
||||
Order("started_at ASC, id ASC").Find(&open).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
now := s.now()
|
||||
var resume *models.Attempt
|
||||
for i := range open {
|
||||
a := &open[i]
|
||||
a.Quiz = quiz
|
||||
if Expired(a, now, s.Grace) {
|
||||
if err := s.close(ctx, tx, a, *Deadline(a)); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
// An open attempt left from the quiz's learning days may cover
|
||||
// one category; a test only resumes a whole-quiz attempt.
|
||||
if resume == nil && a.Category == nil {
|
||||
resume = a
|
||||
}
|
||||
}
|
||||
var used int64
|
||||
if err := tx.Model(&models.Attempt{}).Where("quiz_id = ? AND user_id = ?", quiz.ID, userID).Count(&used).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if resume != nil {
|
||||
var answered int64
|
||||
if err := tx.Model(&models.AttemptAnswer{}).Where("attempt_id = ?", resume.ID).Count(&answered).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
st = &Started{Attempt: resume, Quiz: quiz, Resumed: true, AttemptsUsed: int(used), AnsweredCount: int(answered)}
|
||||
return nil
|
||||
}
|
||||
if left := AttemptsLeft(quiz, int(used)); left != nil && *left == 0 {
|
||||
return ErrAttemptLimit
|
||||
}
|
||||
attempt := &models.Attempt{QuizID: quiz.ID, UserID: userID, StartedAt: now.UTC()}
|
||||
maxPoints, err := s.maxPoints(ctx, tx, attempt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attempt.MaxPoints = maxPoints
|
||||
if err := tx.Create(attempt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
st = &Started{Attempt: attempt, Quiz: quiz, AttemptsUsed: int(used) + 1}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// AttemptsUsed counts the user's attempts on the quiz, open or completed.
|
||||
func (s *Service) AttemptsUsed(ctx context.Context, quizID, userID uint) (int, error) {
|
||||
var n int64
|
||||
err := s.db(ctx).Model(&models.Attempt{}).Where("quiz_id = ? AND user_id = ?", quizID, userID).Count(&n).Error
|
||||
return int(n), err
|
||||
}
|
||||
|
||||
func (s *Service) maxPoints(ctx context.Context, db *gorm.DB, a *models.Attempt) (int, error) {
|
||||
q := db.WithContext(ctx).Model(&models.Question{}).Where("quiz_id = ?", a.QuizID)
|
||||
if a.Category != nil {
|
||||
q = q.Where("category = ?", *a.Category)
|
||||
}
|
||||
@@ -267,9 +412,12 @@ type Feedback struct {
|
||||
// Answer records or overwrites the user's answer to one question of an
|
||||
// open attempt and returns the feedback. It checks, in order: the attempt
|
||||
// belongs to the user (ErrNotFound), is not finished (ErrCompleted), is
|
||||
// within the time limit (ErrTimeLimit), the question belongs to the attempt
|
||||
// (FieldError on question_id), and the answer fits the question type
|
||||
// (FieldError on answer_ids or known).
|
||||
// within the time limit, plus the grace of a test (ErrTimeLimit), the
|
||||
// question belongs to the attempt (FieldError on question_id), a one_way
|
||||
// test has no answer to the question yet (ErrAnswerLocked; the attempt row
|
||||
// is locked, so the check is race-free), and the answer fits the question
|
||||
// type (FieldError on answer_ids or known). The feedback of an open test
|
||||
// attempt is Withheld.
|
||||
func (s *Service) Answer(ctx context.Context, userID, attemptID uint, in AnswerInput) (*Feedback, error) {
|
||||
var fb *Feedback
|
||||
err := lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
||||
@@ -280,7 +428,7 @@ func (s *Service) Answer(ctx context.Context, userID, attemptID uint, in AnswerI
|
||||
if attempt.CompletedAt != nil {
|
||||
return ErrCompleted
|
||||
}
|
||||
if d := Deadline(attempt); d != nil && s.now().After(*d) {
|
||||
if d := AnswerDeadline(attempt, s.Grace); d != nil && s.now().After(*d) {
|
||||
return ErrTimeLimit
|
||||
}
|
||||
var question models.Question
|
||||
@@ -296,6 +444,16 @@ func (s *Service) Answer(ctx context.Context, userID, attemptID uint, in AnswerI
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if OneWay(attempt.Quiz) {
|
||||
var given int64
|
||||
if err := tx.WithContext(ctx).Model(&models.AttemptAnswer{}).
|
||||
Where("attempt_id = ? AND question_id = ?", attempt.ID, question.ID).Count(&given).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if given > 0 {
|
||||
return ErrAnswerLocked
|
||||
}
|
||||
}
|
||||
|
||||
row := models.AttemptAnswer{AttemptID: attempt.ID, QuestionID: question.ID}
|
||||
switch question.Type {
|
||||
@@ -372,24 +530,7 @@ func (s *Service) Finish(ctx context.Context, userID, attemptID uint) (*AttemptR
|
||||
if attempt.CompletedAt != nil {
|
||||
return ErrCompleted
|
||||
}
|
||||
var points []float64
|
||||
if err := tx.WithContext(ctx).Model(&models.AttemptAnswer{}).Where("attempt_id = ?", attempt.ID).
|
||||
Pluck("points", &points).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
maxPoints, err := s.maxPoints(ctx, attempt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
score, percent := Totals(points, maxPoints)
|
||||
now := s.now().UTC()
|
||||
return tx.WithContext(ctx).Model(&models.Attempt{}).Where("id = ?", attempt.ID).Updates(map[string]any{
|
||||
"completed_at": now,
|
||||
"score_points": score,
|
||||
"max_points": maxPoints,
|
||||
"score_percent": percent,
|
||||
"updated_at": now,
|
||||
}).Error
|
||||
return s.close(ctx, tx, attempt, s.now())
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -397,6 +538,31 @@ func (s *Service) Finish(ctx context.Context, userID, attemptID uint) (*AttemptR
|
||||
return s.Result(ctx, userID, attemptID)
|
||||
}
|
||||
|
||||
// close completes an attempt at the given time: it sums the recorded
|
||||
// answers, floors the total at 0 and stores the score. Finish closes at the
|
||||
// current time, an expired test closes at its deadline.
|
||||
func (s *Service) close(ctx context.Context, tx *gorm.DB, attempt *models.Attempt, at time.Time) error {
|
||||
tx = tx.WithContext(ctx)
|
||||
var points []float64
|
||||
if err := tx.Model(&models.AttemptAnswer{}).Where("attempt_id = ?", attempt.ID).Pluck("points", &points).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
maxPoints, err := s.maxPoints(ctx, tx, attempt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
score, percent := Totals(points, maxPoints)
|
||||
at = at.UTC()
|
||||
now := s.now().UTC()
|
||||
return tx.Model(&models.Attempt{}).Where("id = ?", attempt.ID).Updates(map[string]any{
|
||||
"completed_at": at,
|
||||
"score_points": score,
|
||||
"max_points": maxPoints,
|
||||
"score_percent": percent,
|
||||
"updated_at": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// AttemptSummary is an attempt with its quiz and answered question count.
|
||||
type AttemptSummary struct {
|
||||
Attempt models.Attempt
|
||||
@@ -417,12 +583,33 @@ type AttemptResult struct {
|
||||
}
|
||||
|
||||
// Result loads the user's attempt with every question it covers and the
|
||||
// answers given.
|
||||
// answers given. A test attempt past its deadline and grace is closed at
|
||||
// its deadline first. The questions of a shuffled test come in an order
|
||||
// seeded by the attempt, the same on every call; other attempts list them
|
||||
// in sort order.
|
||||
func (s *Service) Result(ctx context.Context, userID, attemptID uint) (*AttemptResult, error) {
|
||||
attempt, err := s.ownedAttempt(ctx, s.DB, userID, attemptID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if Expired(attempt, s.now(), s.Grace) {
|
||||
err := lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
||||
locked, err := s.ownedAttempt(ctx, tx, userID, attemptID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !Expired(locked, s.now(), s.Grace) {
|
||||
return nil
|
||||
}
|
||||
return s.close(ctx, tx, locked, *Deadline(locked))
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if attempt, err = s.ownedAttempt(ctx, s.DB, userID, attemptID, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
category := ""
|
||||
if attempt.Category != nil {
|
||||
category = *attempt.Category
|
||||
@@ -431,6 +618,10 @@ func (s *Service) Result(ctx context.Context, userID, attemptID uint) (*AttemptR
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if IsTest(attempt.Quiz) && attempt.Quiz.ShuffleQuestions {
|
||||
rand.New(rand.NewPCG(uint64(attempt.ID), uint64(attempt.QuizID))).
|
||||
Shuffle(len(questions), func(i, j int) { questions[i], questions[j] = questions[j], questions[i] })
|
||||
}
|
||||
var answers []models.AttemptAnswer
|
||||
if err := s.db(ctx).Where("attempt_id = ?", attempt.ID).Find(&answers).Error; err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -8,3 +8,7 @@ scoring:
|
||||
# Fraction of a question's points subtracted for a wrong answer (0 to 1).
|
||||
# An attempt's total never goes below 0.
|
||||
penalty_wrong: 0
|
||||
test:
|
||||
# Seconds after a test's time limit during which an answer still in flight
|
||||
# is accepted (0 to 120). Learning quizzes have no grace.
|
||||
grace_seconds: 5
|
||||
|
||||
@@ -62,9 +62,10 @@ func (h *Handlers) ListQuizzes(w http.ResponseWriter, r *http.Request) {
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": out})
|
||||
}
|
||||
|
||||
// ShowQuiz answers GET /quizzes/{slug}?category=.
|
||||
// ShowQuiz answers GET /quizzes/{slug}?category=. A test ignores the
|
||||
// category and carries attempts_left.
|
||||
func (h *Handlers) ShowQuiz(w http.ResponseWriter, r *http.Request) {
|
||||
_, svc, ok := h.begin(w, r)
|
||||
userID, svc, ok := h.begin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -73,11 +74,21 @@ func (h *Handlers) ShowQuiz(w http.ResponseWriter, r *http.Request) {
|
||||
h.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": quizDetail(detail)})
|
||||
out := quizDetail(detail)
|
||||
if classes.IsTest(&detail.Quiz) {
|
||||
used, err := svc.AttemptsUsed(r.Context(), detail.Quiz.ID, userID)
|
||||
if err != nil {
|
||||
wire.WriteOpaque500(w)
|
||||
return
|
||||
}
|
||||
out.AttemptsLeft = classes.AttemptsLeft(&detail.Quiz, used)
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": out})
|
||||
}
|
||||
|
||||
// StartAttempt answers POST /quizzes/{slug}/attempts with an optional
|
||||
// {"category": "..."} body.
|
||||
// {"category": "..."} body: 201 with a new attempt, or 200 when an open
|
||||
// test attempt is resumed.
|
||||
func (h *Handlers) StartAttempt(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := h.user(w, r)
|
||||
if !ok {
|
||||
@@ -92,13 +103,16 @@ func (h *Handlers) StartAttempt(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
category, _ := input["category"].(string)
|
||||
a, quiz, err := svc.StartAttempt(r.Context(), userID, r.PathValue("slug"), strings.TrimSpace(category))
|
||||
st, err := svc.Start(r.Context(), userID, r.PathValue("slug"), strings.TrimSpace(category))
|
||||
if err != nil {
|
||||
h.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
a.Quiz = quiz
|
||||
wire.WriteJSON(w, http.StatusCreated, map[string]any{"data": attempt(classes.AttemptSummary{Attempt: *a})})
|
||||
status := http.StatusCreated
|
||||
if st.Resumed {
|
||||
status = http.StatusOK
|
||||
}
|
||||
wire.WriteJSON(w, status, map[string]any{"data": start(st)})
|
||||
}
|
||||
|
||||
// Answer answers POST /attempts/{id}/answers with
|
||||
@@ -240,10 +254,12 @@ func (h *Handlers) service(w http.ResponseWriter) (*classes.Service, bool) {
|
||||
return h.Service(gdb), true
|
||||
}
|
||||
var scoring classes.ScoringConfig
|
||||
grace := classes.DefaultTestGrace
|
||||
if h.App != nil {
|
||||
scoring = classes.ScoringConfigFrom(h.App.Config)
|
||||
grace = classes.TestGraceFrom(h.App.Config)
|
||||
}
|
||||
return &classes.Service{DB: gdb, Scoring: scoring}, true
|
||||
return &classes.Service{DB: gdb, Scoring: scoring, Grace: grace}, true
|
||||
}
|
||||
|
||||
// input decodes a JSON body (an empty body is {}) and validates it. It
|
||||
@@ -317,10 +333,32 @@ var apiMessages = map[string]string{
|
||||
"known_required": "The known field is required for this question.",
|
||||
"unknown_category": "The quiz has no questions in this category.",
|
||||
"validation_failed": "The given data was invalid.",
|
||||
"attempt_limit_reached": "All attempts at this test have been used.",
|
||||
"answer_locked": "An answer in this test cannot be changed.",
|
||||
"category_not_allowed": "A test covers the whole quiz; a category cannot be chosen.",
|
||||
}
|
||||
|
||||
// writeError maps a service error onto the API's status codes.
|
||||
// conflicts are the service errors answered with 409 and the code (also
|
||||
// the lang.api key of the message) the body carries.
|
||||
var conflicts = []struct {
|
||||
err error
|
||||
code string
|
||||
}{
|
||||
{classes.ErrCompleted, "attempt_completed"},
|
||||
{classes.ErrTimeLimit, "time_limit_exceeded"},
|
||||
{classes.ErrAttemptLimit, "attempt_limit_reached"},
|
||||
{classes.ErrAnswerLocked, "answer_locked"},
|
||||
}
|
||||
|
||||
// writeError maps a service error onto the API's status codes. A 409 body
|
||||
// is {"error": message, "code": key}.
|
||||
func (h *Handlers) writeError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
for _, c := range conflicts {
|
||||
if errors.Is(err, c.err) {
|
||||
wire.WriteJSON(w, http.StatusConflict, map[string]any{"error": h.message(r.Context(), c.code), "code": c.code})
|
||||
return
|
||||
}
|
||||
}
|
||||
var fe *classes.FieldError
|
||||
switch {
|
||||
case errors.As(err, &fe):
|
||||
@@ -332,10 +370,6 @@ func (h *Handlers) writeError(w http.ResponseWriter, r *http.Request, err error)
|
||||
key = "quiz_not_found"
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusNotFound, map[string]any{"error": h.message(r.Context(), key)})
|
||||
case errors.Is(err, classes.ErrCompleted):
|
||||
wire.WriteJSON(w, http.StatusConflict, map[string]any{"error": h.message(r.Context(), "attempt_completed")})
|
||||
case errors.Is(err, classes.ErrTimeLimit):
|
||||
wire.WriteJSON(w, http.StatusConflict, map[string]any{"error": h.message(r.Context(), "time_limit_exceeded")})
|
||||
default:
|
||||
wire.WriteOpaque500(w)
|
||||
}
|
||||
|
||||
@@ -113,16 +113,21 @@ func TestErrorKeysInRuleOrder(t *testing.T) {
|
||||
func TestWriteErrorMapping(t *testing.T) {
|
||||
h := New(nil)
|
||||
cases := []struct {
|
||||
err error
|
||||
slug string
|
||||
code int
|
||||
msg string
|
||||
err error
|
||||
slug string
|
||||
code int
|
||||
msg string
|
||||
errCode string
|
||||
errField string
|
||||
}{
|
||||
{classes.ErrNotFound, "quiz", 404, "Quiz not found"},
|
||||
{classes.ErrNotFound, "", 404, "Attempt not found"},
|
||||
{classes.ErrCompleted, "", 409, apiMessages["attempt_completed"]},
|
||||
{classes.ErrTimeLimit, "", 409, apiMessages["time_limit_exceeded"]},
|
||||
{&classes.FieldError{Field: "question_id", Key: "question_not_in_quiz"}, "", 422, apiMessages["question_not_in_quiz"]},
|
||||
{classes.ErrNotFound, "quiz", 404, "Quiz not found", "", ""},
|
||||
{classes.ErrNotFound, "", 404, "Attempt not found", "", ""},
|
||||
{classes.ErrCompleted, "", 409, apiMessages["attempt_completed"], "attempt_completed", ""},
|
||||
{classes.ErrTimeLimit, "", 409, apiMessages["time_limit_exceeded"], "time_limit_exceeded", ""},
|
||||
{classes.ErrAttemptLimit, "quiz", 409, apiMessages["attempt_limit_reached"], "attempt_limit_reached", ""},
|
||||
{classes.ErrAnswerLocked, "", 409, apiMessages["answer_locked"], "answer_locked", ""},
|
||||
{&classes.FieldError{Field: "question_id", Key: "question_not_in_quiz"}, "", 422, apiMessages["question_not_in_quiz"], "", "question_id"},
|
||||
{&classes.FieldError{Field: "category", Key: "category_not_allowed"}, "quiz", 422, apiMessages["category_not_allowed"], "", "category"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
@@ -133,9 +138,17 @@ func TestWriteErrorMapping(t *testing.T) {
|
||||
h.writeError(rec, req, tc.err)
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
if rec.Code != tc.code || out["error"] != tc.msg {
|
||||
if rec.Code != tc.code || out["error"] != tc.msg || tc.msg == "" {
|
||||
t.Errorf("%v = %d %v, want %d %q", tc.err, rec.Code, out, tc.code, tc.msg)
|
||||
}
|
||||
if tc.errCode != "" && out["code"] != tc.errCode {
|
||||
t.Errorf("%v code = %v, want %q", tc.err, out["code"], tc.errCode)
|
||||
}
|
||||
if tc.errField != "" {
|
||||
if errs, _ := out["errors"].(map[string]any); errs[tc.errField] == nil {
|
||||
t.Errorf("%v has no error on %s: %v", tc.err, tc.errField, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.writeError(rec, httptest.NewRequest("GET", "/", nil), http.ErrBodyNotAllowed)
|
||||
|
||||
@@ -28,11 +28,14 @@ type quizSummaryJSON struct {
|
||||
Mode string `json:"mode"`
|
||||
Navigation string `json:"navigation"`
|
||||
MaxAttempts *int `json:"max_attempts"`
|
||||
// AttemptsLeft is null for a learning quiz or a test without a limit.
|
||||
AttemptsLeft *int `json:"attempts_left"`
|
||||
QuestionTypes map[string]int `json:"question_types"`
|
||||
}
|
||||
|
||||
func quizSummary(s classes.QuizSummary) quizSummaryJSON {
|
||||
q := s.Quiz
|
||||
return quizSummaryJSON{
|
||||
out := quizSummaryJSON{
|
||||
ID: q.ID,
|
||||
Slug: q.Slug,
|
||||
Title: q.Title,
|
||||
@@ -48,7 +51,13 @@ func quizSummary(s classes.QuizSummary) quizSummaryJSON {
|
||||
Mode: quizMode(&q),
|
||||
Navigation: quizNavigation(&q),
|
||||
MaxAttempts: q.MaxAttempts,
|
||||
AttemptsLeft: classes.AttemptsLeft(&q, s.AttemptsUsed),
|
||||
QuestionTypes: make(map[string]int, len(models.QuestionTypes)),
|
||||
}
|
||||
for _, typ := range models.QuestionTypes {
|
||||
out.QuestionTypes[typ] = s.QuestionTypes[typ]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// quizMode is the quiz's mode as the API reports it: test, or learning for
|
||||
@@ -88,6 +97,8 @@ type quizJSON struct {
|
||||
Mode string `json:"mode"`
|
||||
Navigation string `json:"navigation"`
|
||||
MaxAttempts *int `json:"max_attempts"`
|
||||
// AttemptsLeft is set by ShowQuiz for a test with a limit.
|
||||
AttemptsLeft *int `json:"attempts_left"`
|
||||
}
|
||||
|
||||
// questionJSON is a question as the player sees it before answering: no
|
||||
@@ -177,8 +188,14 @@ type attemptJSON struct {
|
||||
// QuizMode and QuizNavigation tell the player which flow to run.
|
||||
QuizMode string `json:"quiz_mode"`
|
||||
QuizNavigation string `json:"quiz_navigation"`
|
||||
// SecondsLeft is the time left of an open attempt with a limit, counted
|
||||
// by the server.
|
||||
SecondsLeft *int `json:"seconds_left"`
|
||||
}
|
||||
|
||||
// timeNow is the clock of seconds_left; tests pin it.
|
||||
var timeNow = time.Now
|
||||
|
||||
func attempt(s classes.AttemptSummary) attemptJSON {
|
||||
a := s.Attempt
|
||||
out := attemptJSON{
|
||||
@@ -200,6 +217,7 @@ func attempt(s classes.AttemptSummary) attemptJSON {
|
||||
t := wireTime(*d)
|
||||
out.ExpiresAt = &t
|
||||
}
|
||||
out.SecondsLeft = classes.SecondsLeft(&a, timeNow())
|
||||
if a.CompletedAt != nil {
|
||||
t := wireTime(*a.CompletedAt)
|
||||
out.CompletedAt = &t
|
||||
@@ -209,6 +227,25 @@ func attempt(s classes.AttemptSummary) attemptJSON {
|
||||
return out
|
||||
}
|
||||
|
||||
// startJSON is POST /quizzes/{slug}/attempts: the attempt, whether an open
|
||||
// test attempt was resumed, and the attempts left after this one (null for
|
||||
// learning or without a limit).
|
||||
type startJSON struct {
|
||||
attemptJSON
|
||||
Resumed bool `json:"resumed"`
|
||||
AttemptsLeft *int `json:"attempts_left"`
|
||||
}
|
||||
|
||||
func start(st *classes.Started) startJSON {
|
||||
a := *st.Attempt
|
||||
a.Quiz = st.Quiz
|
||||
return startJSON{
|
||||
attemptJSON: attempt(classes.AttemptSummary{Attempt: a, AnsweredCount: st.AnsweredCount}),
|
||||
Resumed: st.Resumed,
|
||||
AttemptsLeft: classes.AttemptsLeft(st.Quiz, st.AttemptsUsed),
|
||||
}
|
||||
}
|
||||
|
||||
// resultJSON is GET /attempts/{id} and POST /attempts/{id}/finish.
|
||||
// SolutionsWithheld is true for an open test attempt (classes.Withheld).
|
||||
type resultJSON struct {
|
||||
|
||||
@@ -198,3 +198,46 @@ func TestTestQuizDetailHidesQuestions(t *testing.T) {
|
||||
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) != 3 || types["single"] != float64(3) || types["multiple"] != float64(0) || types["self_check"] != float64(0) {
|
||||
t.Errorf("question_types = %v, want all three 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) != 3 {
|
||||
t.Errorf("learning question_types = %v, want all three 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ api:
|
||||
single_one_answer: Choose exactly one answer for this question.
|
||||
known_required: The known field is required for this question.
|
||||
unknown_category: The quiz has no questions in this category.
|
||||
attempt_limit_reached: All attempts at this test have been used.
|
||||
answer_locked: An answer in this test cannot be changed.
|
||||
category_not_allowed: A test covers the whole quiz; a category cannot be chosen.
|
||||
permissions:
|
||||
access_quizzes: Manage quizzes, questions and answers
|
||||
access_attempts: View quiz attempts
|
||||
|
||||
@@ -12,6 +12,9 @@ api:
|
||||
single_one_answer: Wybierz dokładnie jedną odpowiedź.
|
||||
known_required: Zaznacz, czy znasz odpowiedź.
|
||||
unknown_category: Quiz nie ma pytań w tej kategorii.
|
||||
attempt_limit_reached: Wykorzystano już wszystkie podejścia do tego testu.
|
||||
answer_locked: W tym teście nie można zmienić udzielonej odpowiedzi.
|
||||
category_not_allowed: Test obejmuje cały quiz; nie można wybrać kategorii.
|
||||
permissions:
|
||||
access_quizzes: Zarządzanie quizami, pytaniami i odpowiedziami
|
||||
access_attempts: Podgląd podejść do quizów
|
||||
|
||||
Reference in New Issue
Block a user