Files
sm-quizzes-plugin/classes/manage_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

263 lines
14 KiB
Go

package classes_test
import (
"errors"
"strings"
"testing"
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
"git.golem15.com/golem15/sm-quizzes-plugin/models"
)
func str(s string) *string { return &s }
func num(n int) *int { return &n }
func TestCheckQuizInput(t *testing.T) {
for name, tc := range map[string]struct {
in classes.QuizInput
create bool
want string
}{
"missing title": {classes.QuizInput{}, true, "title is required"},
"blank title": {classes.QuizInput{Title: str(" ")}, true, "title is required"},
"long title": {classes.QuizInput{Title: str(strings.Repeat("a", 256))}, true, "title must be at most 255"},
"mode exam": {classes.QuizInput{Title: str("A"), Mode: str("exam")}, true, "mode must be one of"},
"navigation back": {classes.QuizInput{Title: str("A"), Navigation: str("back")}, true, "navigation must be one of"},
"passing score 101": {classes.QuizInput{Title: str("A"), PassingScore: num(101)}, true, "passing_score must be between"},
"max attempts 0": {classes.QuizInput{Title: str("A"), MaxAttempts: num(0)}, true, "max_attempts must be at least 1"},
"time limit 0": {classes.QuizInput{Title: str("A"), TimeLimitSeconds: num(0)}, true, "time_limit_seconds must be at least 1"},
"long context": {classes.QuizInput{Title: str("A"), Context: str(strings.Repeat("c", 256))}, true, "context must be at most 255"},
"update blank title": {classes.QuizInput{Title: str("")}, false, "title is required"},
"update negative limit": {classes.QuizInput{MaxAttempts: num(-1)}, false, "max_attempts must be 0"},
"bad grader": {classes.QuizInput{Title: str("A"), Grader: str("Bad Grader")}, true, "grader must be lowercase"},
"update empty grader": {classes.QuizInput{Grader: str("")}, false, "grader must be lowercase"},
} {
got := strings.Join(classes.CheckQuizInput(tc.in, tc.create), "; ")
if !strings.Contains(got, tc.want) {
t.Errorf("%s: problems %q, want %q", name, got, tc.want)
}
}
if got := classes.CheckQuizInput(classes.QuizInput{Title: str("Pojęcia")}, true); len(got) != 0 {
t.Errorf("minimal create: %v", got)
}
if got := classes.CheckQuizInput(classes.QuizInput{Title: str("A"), Grader: str("ai")}, true); len(got) != 0 {
t.Errorf("well-formed grader: %v", got)
}
if got := classes.CheckQuizInput(classes.QuizInput{}, false); len(got) != 0 {
t.Errorf("empty update: %v", got)
}
clears := classes.QuizInput{MaxAttempts: num(0), TimeLimitSeconds: num(0), RoundSize: num(0), Description: str(""), Context: str("")}
if got := classes.CheckQuizInput(clears, false); len(got) != 0 {
t.Errorf("update clearing with 0: %v", got)
}
}
func TestValidateDeleteConfirm(t *testing.T) {
if err := classes.ValidateDeleteConfirm("a", "a"); err != nil {
t.Errorf("matching confirm: %v", err)
}
for _, confirm := range []string{"", "b", " a"} {
err := classes.ValidateDeleteConfirm("a", confirm)
var invalid *classes.ValidationError
if !errors.As(err, &invalid) || !strings.Contains(err.Error(), "confirm") {
t.Errorf("confirm %q: %v, want a ValidationError naming confirm", confirm, err)
}
}
}
func TestBuildStats(t *testing.T) {
quiz := models.Quiz{ID: 1, Slug: "q", PassingScore: 60}
questions := []models.Question{
{ID: 10, Text: "single", Type: models.TypeSingle},
{ID: 11, Text: "card", Type: models.TypeSelfCheck},
{ID: 12, Text: "unanswered", Type: models.TypeMultiple},
{ID: 13, Text: "all right", Type: models.TypeSingle},
}
best1, best2, last := 75.0, 50.0, 40.0
students := []classes.StudentRow{
{UserID: 5, Email: "a@x", Attempts: 2, BestScorePercent: &best1, LastScorePercent: &last},
{UserID: 6, Email: "b@x", Attempts: 1, BestScorePercent: &best2, LastScorePercent: &best2},
{UserID: 7, Email: "c@x", Attempts: 1},
}
answers := []classes.AnswerRow{
{QuestionID: 10, Answered: 3, Correct: 2},
{QuestionID: 11, Answered: 4, Correct: 1},
{QuestionID: 13, Answered: 2, Correct: 2},
}
wrong := []classes.WrongRow{
{QuestionID: 10, AnswerID: 102, Text: "second", N: 1},
{QuestionID: 10, AnswerID: 101, Text: "first", N: 1},
{QuestionID: 11, AnswerID: 999, Text: "ignored", N: 9},
}
s := classes.BuildStats(quiz, questions, students, answers, wrong)
if s.QuestionCount != 4 || len(s.Questions) != 4 || len(s.Students) != 3 {
t.Fatalf("stats = %+v", s)
}
if !s.Students[0].Passed || s.Students[1].Passed || s.Students[2].Passed {
t.Errorf("passed = %v %v %v, want true false false", s.Students[0].Passed, s.Students[1].Passed, s.Students[2].Passed)
}
q := s.Questions[0]
if q.CorrectPercent == nil || *q.CorrectPercent != 66.7 {
t.Errorf("correct_percent 2 of 3 = %v, want 66.7", q.CorrectPercent)
}
if q.MostChosenWrong == nil || q.MostChosenWrong.AnswerID != 101 || q.MostChosenWrong.Count != 1 {
t.Errorf("most_chosen_wrong = %+v, want answer 101 (lowest id on a tie)", q.MostChosenWrong)
}
if s.Questions[1].MostChosenWrong != nil {
t.Errorf("self_check most_chosen_wrong = %+v, want nil", s.Questions[1].MostChosenWrong)
}
if un := s.Questions[2]; un.ID != 12 || un.Answered != 0 || un.CorrectPercent != nil || un.MostChosenWrong != nil {
t.Errorf("unanswered question = %+v", un)
}
if ok := s.Questions[3]; ok.CorrectPercent == nil || *ok.CorrectPercent != 100 || ok.MostChosenWrong != nil {
t.Errorf("all-correct question = %+v", ok)
}
if s.Unreviewed != 0 || s.Questions[0].AveragePoints != nil || s.Questions[0].Unreviewed != 0 {
t.Errorf("closed questions report unreviewed %d, average %v", s.Unreviewed, s.Questions[0].AveragePoints)
}
more := append(wrong, classes.WrongRow{QuestionID: 10, AnswerID: 103, Text: "third", N: 2})
if w := classes.BuildStats(quiz, questions, nil, answers, more).Questions[0].MostChosenWrong; w == nil || w.AnswerID != 103 {
t.Errorf("most_chosen_wrong by count = %+v, want 103", w)
}
}
func uid(n uint) *uint { return &n }
// TestBuildStatsOpen: an open question reports its unreviewed answers and
// the mean score of its scored answers; it has no most chosen wrong answer.
func TestBuildStatsOpen(t *testing.T) {
quiz := models.Quiz{ID: 1, Slug: "q", PassingScore: 60}
questions := []models.Question{
{ID: 20, Text: "essay", Type: models.TypeOpen},
{ID: 21, Text: "single", Type: models.TypeSingle},
}
avg, ignored := 4.0/3.0, 1.5
answers := []classes.AnswerRow{
{QuestionID: 20, Answered: 3, Correct: 1, Unreviewed: 2, AveragePoints: &avg},
{QuestionID: 21, Answered: 2, Correct: 1, Unreviewed: 5, AveragePoints: &ignored},
}
wrong := []classes.WrongRow{{QuestionID: 20, AnswerID: 1, Text: "never", N: 3}}
s := classes.BuildStats(quiz, questions, nil, answers, wrong)
open := s.Questions[0]
if open.Unreviewed != 2 || open.AveragePoints == nil || *open.AveragePoints != 1.33 || open.MostChosenWrong != nil {
t.Errorf("open = %+v (average %v)", open, open.AveragePoints)
}
if single := s.Questions[1]; single.Unreviewed != 0 || single.AveragePoints != nil {
t.Errorf("single = %+v, want no open counts", single)
}
if s.Unreviewed != 2 {
t.Errorf("total unreviewed = %d, want 2", s.Unreviewed)
}
if none := classes.BuildStats(quiz, questions[:1], nil, nil, nil).Questions[0]; none.AveragePoints != nil || none.Unreviewed != 0 {
t.Errorf("unanswered open = %+v", none)
}
}
func TestCheckGrader(t *testing.T) {
known := []string{"manual"}
if got := classes.CheckGrader(str("ai"), known); len(got) != 1 || !strings.Contains(got[0], "grader must be one of manual") {
t.Errorf("unknown grader = %v", got)
}
if got := classes.CheckGrader(str("manual"), known); got != nil {
t.Errorf("manual = %v", got)
}
if got := classes.CheckGrader(nil, known); got != nil {
t.Errorf("nil = %v", got)
}
if got := classes.CheckGrader(str("ai"), []string{"manual", "ai"}); got != nil {
t.Errorf("registered ai = %v", got)
}
}
// TestQuestionInputRules: MergeQuestion applies a partial update and the
// answers list to the current question, and with CheckQuestion refuses
// what the importer would refuse.
func TestQuestionInputRules(t *testing.T) {
single := models.Question{ID: 1, Text: "Stolica?", Type: models.TypeSingle, Points: 2, Answers: []models.Answer{
{ID: 10, QuestionID: 1, Text: "Warszawa", IsCorrect: true, Explanation: str("Od 1596.")},
{ID: 11, QuestionID: 1, Text: "Kraków"},
}}
card := models.Question{ID: 2, Text: "Odporność?", Type: models.TypeSelfCheck, AnswerText: str("Zdolność"), Points: 1}
check := func(current models.Question, in classes.QuestionInput) (classes.ImportQuestion, []string) {
merged, problems := classes.MergeQuestion(current, in)
return merged, append(problems, classes.CheckQuestion("question", merged)...)
}
merged, problems := check(single, classes.QuestionInput{Text: str(" Stolica Polski? ")})
if len(problems) != 0 || merged.Text != "Stolica Polski?" || *merged.Points != 2 || len(merged.Answers) != 2 || *merged.Answers[0].Explanation != "Od 1596." {
t.Errorf("text only: %+v %v", merged, problems)
}
answers := []classes.ManagedAnswerInput{{ID: uid(99), Text: "Gdańsk", IsCorrect: true}, {ID: uid(11), Text: "Kraków"}}
if _, problems := check(single, classes.QuestionInput{Answers: &answers}); !strings.Contains(strings.Join(problems, "; "), "answers[0].id does not belong to this question") {
t.Errorf("foreign answer id: %v", problems)
}
twice := []classes.ManagedAnswerInput{{ID: uid(10), Text: "Warszawa", IsCorrect: true}, {ID: uid(10), Text: "Kraków"}}
if _, problems := check(single, classes.QuestionInput{Answers: &twice}); !strings.Contains(strings.Join(problems, "; "), "listed twice") {
t.Errorf("answer id twice: %v", problems)
}
two := []classes.ManagedAnswerInput{{ID: uid(10), Text: "Warszawa", IsCorrect: true}, {ID: uid(11), Text: "Kraków", IsCorrect: true}}
if _, problems := check(single, classes.QuestionInput{Answers: &two}); !strings.Contains(strings.Join(problems, "; "), "exactly one correct") {
t.Errorf("single with two correct: %v", problems)
}
if _, problems := check(single, classes.QuestionInput{Type: str(models.TypeSelfCheck)}); !strings.Contains(strings.Join(problems, "; "), "self_check question has no answers") {
t.Errorf("type to self_check with answers left: %v", problems)
}
kept := []classes.ManagedAnswerInput{{ID: uid(10), Text: "Warszawa", IsCorrect: true}, {Text: "Gdańsk", Explanation: str("")}}
merged, problems = check(single, classes.QuestionInput{Answers: &kept, Explanation: str("")})
if len(problems) != 0 || len(merged.Answers) != 2 || *merged.Answers[0].Explanation != "Od 1596." || merged.Answers[1].Explanation != nil || merged.Explanation != nil {
t.Errorf("kept and new answers: %+v %v", merged, problems)
}
toSingle := []classes.ManagedAnswerInput{{Text: "Tak", IsCorrect: true}, {Text: "Nie"}}
merged, problems = check(card, classes.QuestionInput{Type: str(models.TypeSingle), Answers: &toSingle, AnswerText: str("")})
if len(problems) != 0 || merged.Type != models.TypeSingle || merged.AnswerText != nil {
t.Errorf("self_check to single: %+v %v", merged, problems)
}
if problems := classes.CheckNewQuestion(classes.QuestionInput{Text: str("Q")}); !strings.Contains(strings.Join(problems, "; "), "type is required") {
t.Errorf("create without type: %v", problems)
}
withID := []classes.ManagedAnswerInput{{ID: uid(10), Text: "a", IsCorrect: true}, {Text: "b"}}
if problems := classes.CheckNewQuestion(classes.QuestionInput{Text: str("Q"), Type: str("single"), Answers: &withID}); !strings.Contains(strings.Join(problems, "; "), "does not belong") {
t.Errorf("create with an answer id: %v", problems)
}
if problems := classes.CheckNewQuestion(classes.QuestionInput{Text: str("Q"), Type: str("self_check")}); len(problems) != 0 {
t.Errorf("minimal self_check create: %v", problems)
}
open := models.Question{ID: 3, Text: "Esej", Type: models.TypeOpen, Points: 2, Rubric: str("Kryteria"), MaxLength: num(500)}
merged, problems = check(open, classes.QuestionInput{Text: str("Esej?")})
if len(problems) != 0 || merged.Rubric == nil || *merged.Rubric != "Kryteria" || merged.MaxLength == nil || *merged.MaxLength != 500 {
t.Errorf("open kept: %+v %v", merged, problems)
}
merged, problems = check(open, classes.QuestionInput{Rubric: str(""), MaxLength: num(0)})
if len(problems) != 0 || merged.Rubric != nil || merged.MaxLength != nil {
t.Errorf("open cleared: %+v %v", merged, problems)
}
if _, problems := check(open, classes.QuestionInput{MaxLength: num(20001)}); !strings.Contains(strings.Join(problems, "; "), "max_length must be between 1 and 20000") {
t.Errorf("open max_length 20001: %v", problems)
}
withAnswers := []classes.ManagedAnswerInput{{Text: "a", IsCorrect: true}}
if problems := classes.CheckNewQuestion(classes.QuestionInput{Text: str("Q"), Type: str("open"), Answers: &withAnswers}); !strings.Contains(strings.Join(problems, "; "), "an open question has no answers") {
t.Errorf("open create with answers: %v", problems)
}
if problems := classes.CheckNewQuestion(classes.QuestionInput{Text: str("Q"), Type: str("single"), Rubric: str("R"), Answers: &toSingle}); !strings.Contains(strings.Join(problems, "; "), "rubric applies to open questions only") {
t.Errorf("single with rubric: %v", problems)
}
if _, problems := check(open, classes.QuestionInput{Type: str(models.TypeSingle), Answers: &toSingle}); !strings.Contains(strings.Join(problems, "; "), "rubric applies to open questions only") {
t.Errorf("open to single keeping the rubric: %v", problems)
}
}
// TestCheckQuestionOrder: an empty list and a repeated id are refused
// before the database; a missing or foreign id is refused by
// ReorderQuestions against the quiz's questions.
func TestCheckQuestionOrder(t *testing.T) {
for _, ids := range [][]uint{nil, {}, {1, 2, 1}} {
if problems := classes.CheckQuestionOrder(ids); len(problems) != 1 {
t.Errorf("%v: %v, want one problem", ids, problems)
}
}
if problems := classes.CheckQuestionOrder([]uint{3, 1, 2}); len(problems) != 0 {
t.Errorf("valid order: %v", problems)
}
}