Files
sm-quizzes-plugin/classes/importer_test.go
Jakub Zych 9688b4d552 feat: carry open questions and the grader through import and the quiz tools
- import document, manage routes and MCP question tools take rubric and
  max_length; open questions refuse answers, max_length is 1..20000 and
  both apply to open questions only
- quizzes take a grader; the manage routes refuse a code the registry
  does not know, the import accepts any well-formed code
- quiz stats report unreviewed open answers and their average points
- import_quiz warns that replace deletes the grades and reviews
2026-11-09 18:36:20 +01:00

155 lines
9.2 KiB
Go

package classes_test
import (
"encoding/json"
"os"
"strings"
"testing"
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
)
func TestParseImportSample(t *testing.T) {
raw, err := os.ReadFile("../testdata/quiz.json")
if err != nil {
t.Fatal(err)
}
doc, err := classes.ParseImport(raw)
if err != nil {
t.Fatalf("ParseImport: %v", err)
}
if doc.Slug != "bezpieczenstwo-pojecia" {
t.Errorf("slug = %q, want it built from the title", doc.Slug)
}
if len(doc.Questions) != 3 || *doc.RoundSize != 5 || *doc.PassingScore != 60 {
t.Errorf("doc = %+v", doc)
}
}
func TestParseImportRejects(t *testing.T) {
cases := map[string]string{
`not json`: "invalid JSON",
`{"title":"A","questions":[{"text":"Q","type":"self_check"}],"extra":1}`: "unknown field",
`{"questions":[{"text":"Q","type":"self_check"}]}`: "title is required",
`{"title":"A","questions":[]}`: "questions must not be empty",
`{"title":"A","passing_score":101,"questions":[{"text":"Q","type":"self_check"}]}`: "passing_score",
`{"title":"A","round_size":0,"questions":[{"text":"Q","type":"self_check"}]}`: "round_size",
`{"title":"A","time_limit_seconds":0,"questions":[{"text":"Q","type":"self_check"}]}`: "time_limit_seconds",
`{"title":"A","questions":[{"text":"","type":"self_check"}]}`: "questions[0].text",
`{"title":"A","questions":[{"text":"Q","type":"essay"}]}`: "questions[0].type",
`{"title":"A","questions":[{"text":"Q","type":"self_check","points":-1}]}`: "points",
`{"title":"A","questions":[{"text":"Q","type":"self_check","answers":[{"text":"x"}]}]}`: "self_check question has no answers",
`{"title":"A","questions":[{"text":"Q","type":"single","answers":[{"text":"x","is_correct":true}]}]}`: "single question",
`{"title":"A","questions":[{"text":"Q","type":"single","answers":[{"text":"x"},{"text":"y"}]}]}`: "single question",
`{"title":"A","questions":[{"text":"Q","type":"multiple","answers":[{"text":"x"},{"text":"y"}]}]}`: "multiple question",
`{"title":"A","questions":[{"text":"Q","type":"multiple","answers":[{"text":"","is_correct":true},{"text":"y"}]}]}`: "answers[0].text",
`{"title":"!!!","questions":[{"text":"Q","type":"self_check"}]}`: "slug is empty",
}
for raw, want := range cases {
_, err := classes.ParseImport([]byte(raw))
if err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("ParseImport(%s) = %v, want an error with %q", raw, err, want)
}
}
doc, err := classes.ParseImport([]byte(`{"title":"Ignored","slug":"Mój Slug","questions":[{"text":"Q","type":"self_check"}]}`))
if err != nil || doc.Slug != "moj-slug" {
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)
}
}
// TestCheckQuestion: the per-question rules ParseImport applies, shared
// with the manage routes; ParseImport reports exactly these problems.
func TestCheckQuestion(t *testing.T) {
neg := -1
zero, five, tooLong := 0, 500, 20001
rubric := "Mentions sovereignty."
for name, tc := range map[string]struct {
q classes.ImportQuestion
want []string
}{
"no text": {classes.ImportQuestion{Type: "self_check"}, []string{"questions[0].text is required"}},
"bad type": {classes.ImportQuestion{Text: "Q", Type: "essay"}, []string{"questions[0].type must be one of single, multiple, self_check, open"}},
"negative points": {classes.ImportQuestion{Text: "Q", Type: "self_check", Points: &neg}, []string{"questions[0].points must not be negative"}},
"single two right": {classes.ImportQuestion{Text: "Q", Type: "single", Answers: []classes.ImportAnswer{{Text: "a", IsCorrect: true}, {Text: "b", IsCorrect: true}}}, []string{"questions[0]: a single question needs at least two answers and exactly one correct"}},
"multiple one item": {classes.ImportQuestion{Text: "Q", Type: "multiple", Answers: []classes.ImportAnswer{{Text: "a", IsCorrect: true}}}, []string{"questions[0]: a multiple question needs at least two answers and one or more correct"}},
"self_check answer": {classes.ImportQuestion{Text: "Q", Type: "self_check", Answers: []classes.ImportAnswer{{Text: "a"}}}, []string{"questions[0]: a self_check question has no answers; use answer_text"}},
"blank answer": {classes.ImportQuestion{Text: "Q", Type: "single", Answers: []classes.ImportAnswer{{Text: " ", IsCorrect: true}, {Text: "b"}}}, []string{"questions[0].answers[0].text is required"}},
"valid single": {classes.ImportQuestion{Text: "Q", Type: "single", Answers: []classes.ImportAnswer{{Text: "a", IsCorrect: true}, {Text: "b"}}}, nil},
"valid open": {classes.ImportQuestion{Text: "Q", Type: "open", AnswerText: str("Model"), Rubric: &rubric, MaxLength: &five}, nil},
"open with answer": {classes.ImportQuestion{Text: "Q", Type: "open", Answers: []classes.ImportAnswer{{Text: "a"}}}, []string{"questions[0]: an open question has no answers; put the model answer in answer_text"}},
"open length 0": {classes.ImportQuestion{Text: "Q", Type: "open", MaxLength: &zero}, []string{"questions[0].max_length must be between 1 and 20000"}},
"open length 20001": {classes.ImportQuestion{Text: "Q", Type: "open", MaxLength: &tooLong}, []string{"questions[0].max_length must be between 1 and 20000"}},
"single rubric": {classes.ImportQuestion{Text: "Q", Type: "single", Rubric: &rubric, Answers: []classes.ImportAnswer{{Text: "a", IsCorrect: true}, {Text: "b"}}}, []string{"questions[0].rubric applies to open questions only"}},
"single max_length": {classes.ImportQuestion{Text: "Q", Type: "single", MaxLength: &five, Answers: []classes.ImportAnswer{{Text: "a", IsCorrect: true}, {Text: "b"}}}, []string{"questions[0].max_length applies to open questions only"}},
} {
got := classes.CheckQuestion("questions[0]", tc.q)
if strings.Join(got, "; ") != strings.Join(tc.want, "; ") {
t.Errorf("%s: %q, want %q", name, got, tc.want)
}
doc, _ := json.Marshal(map[string]any{"title": "T", "questions": []classes.ImportQuestion{tc.q}})
_, err := classes.ParseImport(doc)
switch {
case tc.want == nil && err != nil:
t.Errorf("%s: ParseImport: %v", name, err)
case tc.want != nil && (err == nil || err.Error() != "invalid quiz: "+strings.Join(tc.want, "; ")):
t.Errorf("%s: ParseImport = %v, want the same problems", name, err)
}
}
}
func TestParseImportOpen(t *testing.T) {
for _, grader := range []string{"ai", "manual"} {
raw := `{"title":"O","grader":"` + grader + `","questions":[{"text":"Q","type":"open","answer_text":"M","rubric":"R","max_length":300}]}`
doc, err := classes.ParseImport([]byte(raw))
if err != nil {
t.Fatalf("grader %s: %v", grader, err)
}
q := doc.Questions[0]
if doc.Grader == nil || *doc.Grader != grader || q.Rubric == nil || *q.Rubric != "R" || q.MaxLength == nil || *q.MaxLength != 300 {
t.Errorf("grader %s: doc = %+v, question = %+v", grader, doc, q)
}
}
if _, err := classes.ParseImport([]byte(`{"title":"O","grader":"Bad Grader","questions":[{"text":"Q","type":"open"}]}`)); err == nil ||
!strings.Contains(err.Error(), "grader must be lowercase letters, digits and underscores") {
t.Errorf("bad grader = %v", err)
}
doc, err := classes.ParseImport([]byte(`{"title":"O","questions":[{"text":"Q","type":"open","rubric":" "}]}`))
if err != nil || doc.Questions[0].Rubric != nil {
t.Errorf("blank rubric = %+v %v, want it dropped", doc, err)
}
raw, err := os.ReadFile("../testdata/quiz.json")
if err != nil {
t.Fatal(err)
}
if doc, err := classes.ParseImport(raw); err != nil || doc.Grader != nil {
t.Errorf("sample = %+v %v, want no grader", doc, err)
}
}