- 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
263 lines
14 KiB
Go
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)
|
|
}
|
|
}
|