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) } }