- 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
149 lines
5.6 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|