feat: add open questions with a pending grading state

- migration: open question type, rubric, max_length, quiz grader and
  the grading and review columns of attempt answers
- open answers are checked, stored as pending and never scored on
  answer; a changed text resets the grading state
- student JSON gains text, grading_status, grader_feedback,
  review_comment, max_length, needs_review and open_answers; the
  rubric is never serialized
This commit is contained in:
Jakub Zych
2026-11-09 17:48:11 +01:00
parent ef38b81fd9
commit 297780cb8e
19 changed files with 893 additions and 48 deletions

59
classes/open_test.go Normal file
View File

@@ -0,0 +1,59 @@
package classes_test
import (
"errors"
"strings"
"testing"
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
"git.golem15.com/golem15/sm-quizzes-plugin/models"
)
func TestCheckOpenText(t *testing.T) {
q := models.Question{Type: models.TypeOpen}
long := 3000
wide := models.Question{Type: models.TypeOpen, MaxLength: &long}
fieldKey := func(err error) string {
var fe *classes.FieldError
if errors.As(err, &fe) && fe.Field == "text" {
return fe.Key
}
return ""
}
if _, err := classes.CheckOpenText(" \n\t ", q); fieldKey(err) != "text_required" {
t.Errorf("blank text: err = %v, want text_required", err)
}
if _, err := classes.CheckOpenText("\x00 \x00", q); fieldKey(err) != "text_required" {
t.Errorf("NUL-only text: err = %v, want text_required", err)
}
over := strings.Repeat("a", 2001)
if _, err := classes.CheckOpenText(over, q); fieldKey(err) != "text_too_long" {
t.Errorf("2001 runes with default max: err = %v, want text_too_long", err)
}
if got, err := classes.CheckOpenText(over, wide); err != nil || got != over {
t.Errorf("2001 runes with max 3000: %q, %v", got[:10], err)
}
if got, err := classes.CheckOpenText("a\x00b", q); err != nil || got != "ab" {
t.Errorf("NUL stripped: %q, %v; want ab", got, err)
}
if got, err := classes.CheckOpenText(" moja odpowiedź \n", q); err != nil || got != "moja odpowiedź" {
t.Errorf("trimmed: %q, %v", got, err)
}
polish := strings.Repeat("ż", 2000)
if got, err := classes.CheckOpenText(polish, q); err != nil || got != polish {
t.Errorf("2000 runes of ż (4000 bytes) refused: %v", err)
}
five := 5
if _, err := classes.CheckOpenText("abcdef", models.Question{MaxLength: &five}); fieldKey(err) != "text_too_long" {
t.Errorf("6 runes with max 5: err = %v, want text_too_long", err)
}
if classes.MaxLength(q) != models.DefaultMaxLength || classes.MaxLength(wide) != 3000 {
t.Errorf("MaxLength = %d / %d", classes.MaxLength(q), classes.MaxLength(wide))
}
zero := 0
if classes.MaxLength(models.Question{MaxLength: &zero}) != models.DefaultMaxLength {
t.Error("MaxLength of a non-positive value should fall back to the default")
}
}