Files
sm-quizzes-plugin/classes/policy_test.go
Jakub Zych 487d573208 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
2026-11-09 14:07:37 +01:00

149 lines
5.6 KiB
Go

package classes_test
import (
"testing"
"time"
"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) {
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)
}
}
}
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)
}
}
}