- quizzes_create_quiz, update_quiz, delete_quiz (confirm repeats the slug), publish, unpublish, import_quiz (the existing import route) and quiz_stats on new manage routes - stats close expired open test attempts first, then report per student over completed whole-quiz attempts and per question over every completed attempt - input is checked before the database; TestManageAPIAgainstDatabase is written and skipped under -short
115 lines
5.2 KiB
Go
115 lines
5.2 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"},
|
|
} {
|
|
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{}, 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)
|
|
}
|
|
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)
|
|
}
|
|
}
|