feat: create, publish, import and report on quizzes over MCP
- 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
This commit is contained in:
@@ -8,11 +8,300 @@ package classes
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// ValidationError is manage input that breaks a rule (a 422). Problems
|
||||
// read like the importer's: "title is required".
|
||||
type ValidationError struct {
|
||||
Problems []string
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string { return "invalid quiz: " + strings.Join(e.Problems, "; ") }
|
||||
|
||||
// invalid wraps problems in a ValidationError, or returns nil for none.
|
||||
func invalid(problems []string) error {
|
||||
if len(problems) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &ValidationError{Problems: problems}
|
||||
}
|
||||
|
||||
// QuizInput is the quiz fields of a create or a partial update; nil means
|
||||
// not sent. There is no IsPublished: a created quiz starts unpublished and
|
||||
// only SetPublished changes it.
|
||||
type QuizInput struct {
|
||||
Title *string `json:"title"`
|
||||
Slug *string `json:"slug"`
|
||||
Description *string `json:"description"`
|
||||
Context *string `json:"context"`
|
||||
Mode *string `json:"mode"`
|
||||
Navigation *string `json:"navigation"`
|
||||
MaxAttempts *int `json:"max_attempts"`
|
||||
TimeLimitSeconds *int `json:"time_limit_seconds"`
|
||||
PassingScore *int `json:"passing_score"`
|
||||
RoundSize *int `json:"round_size"`
|
||||
ShuffleQuestions *bool `json:"shuffle_questions"`
|
||||
}
|
||||
|
||||
// CheckQuizInput returns the rule problems of in. On create the title is
|
||||
// required and max_attempts, time_limit_seconds and round_size are at least
|
||||
// 1; on update 0 is allowed for them, as it clears the value.
|
||||
func CheckQuizInput(in QuizInput, create bool) []string {
|
||||
var problems []string
|
||||
switch {
|
||||
case in.Title == nil:
|
||||
if create {
|
||||
problems = append(problems, "title is required")
|
||||
}
|
||||
case strings.TrimSpace(*in.Title) == "":
|
||||
problems = append(problems, "title is required")
|
||||
case utf8.RuneCountInString(strings.TrimSpace(*in.Title)) > 255:
|
||||
problems = append(problems, "title must be at most 255 characters")
|
||||
}
|
||||
if create && in.Slug != nil && strings.TrimSpace(*in.Slug) != "" && models.Slugify(*in.Slug) == "" {
|
||||
problems = append(problems, "slug is empty after normalising it")
|
||||
}
|
||||
if create && (in.Slug == nil || strings.TrimSpace(*in.Slug) == "") && in.Title != nil &&
|
||||
strings.TrimSpace(*in.Title) != "" && models.Slugify(*in.Title) == "" {
|
||||
problems = append(problems, "slug is empty after normalising the title")
|
||||
}
|
||||
if in.Context != nil && utf8.RuneCountInString(strings.TrimSpace(*in.Context)) > 255 {
|
||||
problems = append(problems, "context must be at most 255 characters")
|
||||
}
|
||||
if in.Mode != nil && !slices.Contains(models.QuizModes, *in.Mode) {
|
||||
problems = append(problems, "mode must be one of "+strings.Join(models.QuizModes, ", "))
|
||||
}
|
||||
if in.Navigation != nil && !slices.Contains(models.QuizNavigations, *in.Navigation) {
|
||||
problems = append(problems, "navigation must be one of "+strings.Join(models.QuizNavigations, ", "))
|
||||
}
|
||||
if in.PassingScore != nil && (*in.PassingScore < 0 || *in.PassingScore > 100) {
|
||||
problems = append(problems, "passing_score must be between 0 and 100")
|
||||
}
|
||||
least, word := 1, "at least 1"
|
||||
if !create {
|
||||
least, word = 0, "0 (clears it) or more"
|
||||
}
|
||||
for _, f := range []struct {
|
||||
name string
|
||||
v *int
|
||||
}{{"max_attempts", in.MaxAttempts}, {"time_limit_seconds", in.TimeLimitSeconds}, {"round_size", in.RoundSize}} {
|
||||
if f.v != nil && *f.v < least {
|
||||
problems = append(problems, f.name+" must be "+word)
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// positive is v, or nil when v is nil or not positive (0 clears).
|
||||
func positive(v *int) *int {
|
||||
if v == nil || *v <= 0 {
|
||||
return nil
|
||||
}
|
||||
n := *v
|
||||
return &n
|
||||
}
|
||||
|
||||
// CreateQuiz creates an unpublished quiz from in. The slug comes from Slug,
|
||||
// or from the title when Slug is empty; a taken slug is ErrQuizExists. The
|
||||
// passing score defaults to 50, mode and navigation to learning / free.
|
||||
func CreateQuiz(ctx context.Context, db *gorm.DB, in QuizInput) (*models.Quiz, error) {
|
||||
if err := invalid(CheckQuizInput(in, true)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
quiz := models.Quiz{Title: strings.TrimSpace(*in.Title), PassingScore: 50}
|
||||
if in.Slug != nil && strings.TrimSpace(*in.Slug) != "" {
|
||||
quiz.Slug = models.Slugify(*in.Slug)
|
||||
} else {
|
||||
quiz.Slug = models.Slugify(quiz.Title)
|
||||
}
|
||||
applyQuizInput(&quiz, in)
|
||||
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
tx = tx.WithContext(ctx)
|
||||
var n int64
|
||||
if err := tx.Model(&models.Quiz{}).Where("slug = ?", quiz.Slug).Count(&n).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return ErrQuizExists
|
||||
}
|
||||
return tx.Create(&quiz).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &quiz, nil
|
||||
}
|
||||
|
||||
// applyQuizInput sets the sent fields of in on quiz, except the slug and
|
||||
// the title's presence check. An empty description or context is NULL, a
|
||||
// 0 limit is NULL.
|
||||
func applyQuizInput(quiz *models.Quiz, in QuizInput) {
|
||||
if in.Title != nil {
|
||||
quiz.Title = strings.TrimSpace(*in.Title)
|
||||
}
|
||||
if in.Description != nil {
|
||||
quiz.Description = nilIfBlank(*in.Description, false)
|
||||
}
|
||||
if in.Context != nil {
|
||||
quiz.Context = nilIfBlank(*in.Context, true)
|
||||
}
|
||||
if in.Mode != nil {
|
||||
quiz.Mode = *in.Mode
|
||||
}
|
||||
if in.Navigation != nil {
|
||||
quiz.Navigation = *in.Navigation
|
||||
}
|
||||
if in.MaxAttempts != nil {
|
||||
quiz.MaxAttempts = positive(in.MaxAttempts)
|
||||
}
|
||||
if in.TimeLimitSeconds != nil {
|
||||
quiz.TimeLimitSeconds = positive(in.TimeLimitSeconds)
|
||||
}
|
||||
if in.RoundSize != nil {
|
||||
quiz.RoundSize = positive(in.RoundSize)
|
||||
}
|
||||
if in.PassingScore != nil {
|
||||
quiz.PassingScore = *in.PassingScore
|
||||
}
|
||||
if in.ShuffleQuestions != nil {
|
||||
quiz.ShuffleQuestions = *in.ShuffleQuestions
|
||||
}
|
||||
}
|
||||
|
||||
// nilIfBlank is nil for a blank s, else s (trimmed when trim is set).
|
||||
func nilIfBlank(s string, trim bool) *string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
if trim {
|
||||
s = strings.TrimSpace(s)
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
// UpdateQuiz changes only the sent fields of the quiz with this slug: 0
|
||||
// clears max_attempts, time_limit_seconds and round_size, an empty string
|
||||
// clears description and context. Slug is ignored, and it cannot publish
|
||||
// (A10). A missing slug is ErrNotFound.
|
||||
func UpdateQuiz(ctx context.Context, db *gorm.DB, slug string, in QuizInput) (*models.Quiz, error) {
|
||||
if err := invalid(CheckQuizInput(in, false)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var quiz models.Quiz
|
||||
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
tx = tx.WithContext(ctx)
|
||||
if err := lockedQuiz(tx, slug, &quiz); err != nil {
|
||||
return err
|
||||
}
|
||||
applyQuizInput(&quiz, in)
|
||||
return tx.Omit(clause.Associations).Save(&quiz).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &quiz, nil
|
||||
}
|
||||
|
||||
// lockedQuiz loads the quiz with this slug FOR UPDATE, or ErrNotFound.
|
||||
func lockedQuiz(tx *gorm.DB, slug string, quiz *models.Quiz) error {
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE", Table: clause.Table{Name: clause.CurrentTable}}).
|
||||
Where("slug = ?", slug).Take(quiz).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ValidateDeleteConfirm refuses a delete whose confirm value is not the
|
||||
// quiz slug, so a stray DELETE cannot remove a quiz by accident.
|
||||
func ValidateDeleteConfirm(slug, confirm string) error {
|
||||
if strings.TrimSpace(confirm) == "" || confirm != slug {
|
||||
return &ValidationError{Problems: []string{"confirm must repeat the quiz slug"}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeletedQuiz is the answer of a quiz delete: what was removed.
|
||||
type DeletedQuiz struct {
|
||||
ID uint
|
||||
Slug string
|
||||
Title string
|
||||
Questions int64
|
||||
Attempts int64
|
||||
}
|
||||
|
||||
// DeleteQuiz removes the quiz with this slug for good. The foreign keys
|
||||
// cascade to its questions, answers, attempts and attempt answers; the
|
||||
// counts are taken first. A missing slug is ErrNotFound.
|
||||
func DeleteQuiz(ctx context.Context, db *gorm.DB, slug string) (*DeletedQuiz, error) {
|
||||
var out DeletedQuiz
|
||||
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
tx = tx.WithContext(ctx)
|
||||
var quiz models.Quiz
|
||||
if err := lockedQuiz(tx, slug, &quiz); err != nil {
|
||||
return err
|
||||
}
|
||||
out = DeletedQuiz{ID: quiz.ID, Slug: quiz.Slug, Title: quiz.Title}
|
||||
if err := tx.Model(&models.Question{}).Where("quiz_id = ?", quiz.ID).Count(&out.Questions).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&models.Attempt{}).Where("quiz_id = ?", quiz.ID).Count(&out.Attempts).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("id = ?", quiz.ID).Delete(&models.Quiz{}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// SetPublished publishes or unpublishes the quiz with this slug; doing it
|
||||
// twice is the same as once. A missing slug is ErrNotFound.
|
||||
func SetPublished(ctx context.Context, db *gorm.DB, slug string, published bool) (*models.Quiz, error) {
|
||||
var quiz models.Quiz
|
||||
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
tx = tx.WithContext(ctx)
|
||||
if err := lockedQuiz(tx, slug, &quiz); err != nil {
|
||||
return err
|
||||
}
|
||||
if quiz.IsPublished == published {
|
||||
return nil
|
||||
}
|
||||
quiz.IsPublished = published
|
||||
return tx.Model(&models.Quiz{}).Where("id = ?", quiz.ID).
|
||||
Updates(map[string]any{"is_published": published, "updated_at": time.Now().UTC()}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &quiz, nil
|
||||
}
|
||||
|
||||
// ManagedRow counts the questions and completed attempts of one quiz, for
|
||||
// the answers of the quiz writes.
|
||||
func ManagedRow(ctx context.Context, db *gorm.DB, quiz models.Quiz) (ManagedQuizRow, error) {
|
||||
row := ManagedQuizRow{Quiz: quiz}
|
||||
var n int64
|
||||
if err := db.WithContext(ctx).Model(&models.Question{}).Where("quiz_id = ?", quiz.ID).Count(&n).Error; err != nil {
|
||||
return row, err
|
||||
}
|
||||
row.QuestionCount = int(n)
|
||||
attempts, err := CompletedAttemptCount(ctx, db, quiz.ID)
|
||||
row.AttemptsCount = attempts
|
||||
return row, err
|
||||
}
|
||||
|
||||
// ManagedQuizRow is one quiz of the owner list with its question count and
|
||||
// the number of completed attempts (of every user).
|
||||
type ManagedQuizRow struct {
|
||||
@@ -95,3 +384,210 @@ func CompletedAttemptCount(ctx context.Context, db *gorm.DB, quizID uint) (int,
|
||||
Where("quiz_id = ? AND completed_at IS NOT NULL", quizID).Count(&n).Error
|
||||
return int(n), err
|
||||
}
|
||||
|
||||
// StudentStat is one student's results on a quiz, over their completed
|
||||
// whole-quiz attempts (category IS NULL, A9).
|
||||
type StudentStat struct {
|
||||
UserID uint
|
||||
Email string
|
||||
Name *string
|
||||
Surname *string
|
||||
Attempts int
|
||||
BestScorePercent *float64
|
||||
LastScorePercent *float64
|
||||
LastCompletedAt *time.Time
|
||||
// Passed is BestScorePercent >= the quiz's passing score.
|
||||
Passed bool
|
||||
}
|
||||
|
||||
// WrongAnswerStat is the wrong answer chosen most often for a question.
|
||||
type WrongAnswerStat struct {
|
||||
AnswerID uint
|
||||
Text string
|
||||
Count int
|
||||
}
|
||||
|
||||
// QuestionStat is one question's results over every completed attempt's
|
||||
// answers. CorrectPercent is nil when nobody answered it; MostChosenWrong
|
||||
// is nil for a self_check question and when no wrong answer was chosen.
|
||||
type QuestionStat struct {
|
||||
ID uint
|
||||
Text string
|
||||
Type string
|
||||
Answered int
|
||||
Correct int
|
||||
CorrectPercent *float64
|
||||
MostChosenWrong *WrongAnswerStat
|
||||
}
|
||||
|
||||
// Stats is the owner's report on one quiz (D-15).
|
||||
type Stats struct {
|
||||
Quiz models.Quiz
|
||||
QuestionCount int
|
||||
Students []StudentStat
|
||||
Questions []QuestionStat
|
||||
}
|
||||
|
||||
// StudentRow is one row of the per-student stats query.
|
||||
type StudentRow struct {
|
||||
UserID uint
|
||||
Email string
|
||||
Name *string
|
||||
Surname *string
|
||||
Attempts int
|
||||
BestScorePercent *float64
|
||||
LastScorePercent *float64
|
||||
LastCompletedAt *time.Time
|
||||
}
|
||||
|
||||
// AnswerRow is one row of the per-question answered/correct query.
|
||||
type AnswerRow struct {
|
||||
QuestionID uint
|
||||
Answered int
|
||||
Correct int
|
||||
}
|
||||
|
||||
// WrongRow is one wrong answer chosen N times for a question.
|
||||
type WrongRow struct {
|
||||
QuestionID uint
|
||||
AnswerID uint
|
||||
Text string
|
||||
N int
|
||||
}
|
||||
|
||||
const (
|
||||
statsStudentsSQL = `SELECT a.user_id, u.email, u.name, u.surname, COUNT(*) AS attempts,
|
||||
MAX(a.score_percent) AS best_score_percent,
|
||||
(ARRAY_AGG(a.score_percent ORDER BY a.completed_at DESC, a.id DESC))[1] AS last_score_percent,
|
||||
MAX(a.completed_at) AS last_completed_at
|
||||
FROM golem15_quizzes_attempts a JOIN users u ON u.id = a.user_id
|
||||
WHERE a.quiz_id = ? AND a.completed_at IS NOT NULL AND a.category IS NULL
|
||||
GROUP BY a.user_id, u.email, u.name, u.surname
|
||||
ORDER BY u.surname ASC NULLS LAST, u.name ASC NULLS LAST, u.email ASC, a.user_id ASC`
|
||||
|
||||
statsAnswersSQL = `SELECT aa.question_id, COUNT(*) AS answered, COUNT(*) FILTER (WHERE aa.is_correct) AS correct
|
||||
FROM golem15_quizzes_attempt_answers aa
|
||||
JOIN golem15_quizzes_attempts a ON a.id = aa.attempt_id
|
||||
WHERE a.quiz_id = ? AND a.completed_at IS NOT NULL
|
||||
GROUP BY aa.question_id`
|
||||
|
||||
// A stored answer_ids that is SQL NULL or not an array (self_check
|
||||
// rows) yields no elements instead of an error.
|
||||
statsWrongSQL = `SELECT aa.question_id, ans.id AS answer_id, ans.text, COUNT(*) AS n
|
||||
FROM golem15_quizzes_attempt_answers aa
|
||||
JOIN golem15_quizzes_attempts a ON a.id = aa.attempt_id
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(CASE WHEN jsonb_typeof(aa.answer_ids) = 'array' THEN aa.answer_ids ELSE '[]'::jsonb END) AS e(value)
|
||||
JOIN golem15_quizzes_answers ans ON ans.id = e.value::bigint AND ans.question_id = aa.question_id AND ans.is_correct = FALSE
|
||||
WHERE a.quiz_id = ? AND a.completed_at IS NOT NULL
|
||||
GROUP BY aa.question_id, ans.id, ans.text
|
||||
ORDER BY aa.question_id, n DESC, ans.id`
|
||||
)
|
||||
|
||||
// QuizStats reports on the quiz with this slug. For a test it first closes
|
||||
// every expired open attempt at its deadline, through svc, so a student who
|
||||
// left a test counts with the answers given in time. Students are counted
|
||||
// over completed whole-quiz attempts, questions over the answers of every
|
||||
// completed attempt. A missing slug is ErrNotFound.
|
||||
func QuizStats(ctx context.Context, db *gorm.DB, slug string, svc *Service) (*Stats, error) {
|
||||
if svc == nil {
|
||||
svc = &Service{DB: db, Grace: DefaultTestGrace}
|
||||
}
|
||||
var quiz models.Quiz
|
||||
err := db.WithContext(ctx).Where("slug = ?", slug).Take(&quiz).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if IsTest(&quiz) {
|
||||
if err := svc.closeExpired(ctx, db, &quiz); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
tx := db.WithContext(ctx)
|
||||
var questions []models.Question
|
||||
if err := tx.Where("quiz_id = ?", quiz.ID).Order("sort_order ASC, id ASC").Find(&questions).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var students []StudentRow
|
||||
if err := tx.Raw(statsStudentsSQL, quiz.ID).Scan(&students).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var answers []AnswerRow
|
||||
if err := tx.Raw(statsAnswersSQL, quiz.ID).Scan(&answers).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var wrong []WrongRow
|
||||
if err := tx.Raw(statsWrongSQL, quiz.ID).Scan(&wrong).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats := BuildStats(quiz, questions, students, answers, wrong)
|
||||
return &stats, nil
|
||||
}
|
||||
|
||||
// closeExpired closes every expired open attempt of a test at its
|
||||
// deadline, in one transaction, with the rows locked as Start locks them.
|
||||
func (s *Service) closeExpired(ctx context.Context, db *gorm.DB, quiz *models.Quiz) error {
|
||||
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
var open []models.Attempt
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE", Table: clause.Table{Name: clause.CurrentTable}}).
|
||||
Where("quiz_id = ? AND completed_at IS NULL", quiz.ID).Order("id ASC").Find(&open).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
now := s.now()
|
||||
for i := range open {
|
||||
a := &open[i]
|
||||
a.Quiz = quiz
|
||||
if Expired(a, now, s.Grace) {
|
||||
if err := s.close(ctx, tx, a, *Deadline(a)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// BuildStats assembles the report from the query rows. Every question of
|
||||
// questions appears, in that order, with zero counts when nobody answered
|
||||
// it; correct_percent is rounded to one decimal.
|
||||
func BuildStats(quiz models.Quiz, questions []models.Question, students []StudentRow, answers []AnswerRow, wrong []WrongRow) Stats {
|
||||
out := Stats{
|
||||
Quiz: quiz,
|
||||
QuestionCount: len(questions),
|
||||
Students: make([]StudentStat, 0, len(students)),
|
||||
Questions: make([]QuestionStat, 0, len(questions)),
|
||||
}
|
||||
for _, r := range students {
|
||||
out.Students = append(out.Students, StudentStat{
|
||||
UserID: r.UserID, Email: r.Email, Name: r.Name, Surname: r.Surname, Attempts: r.Attempts,
|
||||
BestScorePercent: r.BestScorePercent, LastScorePercent: r.LastScorePercent, LastCompletedAt: r.LastCompletedAt,
|
||||
Passed: r.BestScorePercent != nil && *r.BestScorePercent >= float64(quiz.PassingScore),
|
||||
})
|
||||
}
|
||||
answeredBy := map[uint]AnswerRow{}
|
||||
for _, r := range answers {
|
||||
answeredBy[r.QuestionID] = r
|
||||
}
|
||||
wrongBy := map[uint]*WrongAnswerStat{}
|
||||
for _, r := range wrong {
|
||||
best := wrongBy[r.QuestionID]
|
||||
if best == nil || r.N > best.Count || (r.N == best.Count && r.AnswerID < best.AnswerID) {
|
||||
wrongBy[r.QuestionID] = &WrongAnswerStat{AnswerID: r.AnswerID, Text: r.Text, Count: r.N}
|
||||
}
|
||||
}
|
||||
for _, q := range questions {
|
||||
a := answeredBy[q.ID]
|
||||
st := QuestionStat{ID: q.ID, Text: q.Text, Type: q.Type, Answered: a.Answered, Correct: a.Correct}
|
||||
if a.Answered > 0 {
|
||||
pct := math.Round(float64(a.Correct)*1000/float64(a.Answered)) / 10
|
||||
st.CorrectPercent = &pct
|
||||
}
|
||||
if q.Type != models.TypeSelfCheck {
|
||||
st.MostChosenWrong = wrongBy[q.ID]
|
||||
}
|
||||
out.Questions = append(out.Questions, st)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
114
classes/manage_test.go
Normal file
114
classes/manage_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user