diff --git a/classes/review.go b/classes/review.go index 601ca04..88c0074 100644 --- a/classes/review.go +++ b/classes/review.go @@ -419,7 +419,8 @@ func notFound(err error) error { } // Confirm makes the grader's score of a graded answer final: status -// reviewed, points = grader_points (D-12). A reviewed answer is returned +// reviewed, points = grader_points (D-12), clamped again to the question's +// current points. A reviewed answer is returned // unchanged, without a write or an event; a pending or failed answer has // no score to confirm (ErrNoGraderScore). A completed attempt is // recalculated, and AnswerReviewedEvent, plus AttemptReviewedEvent when @@ -464,7 +465,13 @@ func (s *Service) confirmTarget(ctx context.Context, tx *gorm.DB, t *reviewTarge case status != models.GradingGraded || t.Answer.GraderPoints == nil: return false, ErrNoGraderScore } - return true, s.storeReview(ctx, tx, t, *t.Answer.GraderPoints, t.Answer.ReviewComment, false, by) + // The grader's score was clamped to the question's points at grading + // time; the owner may have lowered them since. + points, ok := ClampOpenPoints(*t.Answer.GraderPoints, t.Question.Points) + if !ok { + return false, ErrNoGraderScore + } + return true, s.storeReview(ctx, tx, t, points, t.Answer.ReviewComment, false, by) } // storeReview writes the owner's decision on one locked open answer and diff --git a/classes/scoring.go b/classes/scoring.go index f2f466f..67230a3 100644 --- a/classes/scoring.go +++ b/classes/scoring.go @@ -140,7 +140,8 @@ func Score(in ScoreInput, cfg ScoringConfig) ScoreResult { } // Totals sums the earned points of an attempt, floored at 0, and the -// percentage of maxPoints it is. With no points to earn the percentage is 0. +// percentage of maxPoints it is, capped at 100. With no points to earn the +// percentage is 0. func Totals(points []float64, maxPoints int) (score, percent float64) { for _, p := range points { score += p @@ -149,7 +150,9 @@ func Totals(points []float64, maxPoints int) (score, percent float64) { if maxPoints <= 0 { return score, 0 } - return score, round(score / float64(maxPoints) * 100) + // A question's points raised after the attempt was scored can push the + // score past the attempt's stored maximum; the percentage stays <= 100. + return score, round(math.Min(score/float64(maxPoints)*100, 100)) } // ClampOpenPoints keeps a grader's score of an open question in diff --git a/classes/scoring_test.go b/classes/scoring_test.go index d220d3b..95a7be5 100644 --- a/classes/scoring_test.go +++ b/classes/scoring_test.go @@ -84,6 +84,7 @@ func TestTotals(t *testing.T) { {[]float64{2, -0.5}, 4, 1.5, 37.5}, {nil, 0, 0, 0}, {[]float64{1}, 1, 1, 100}, + {[]float64{3}, 2, 3, 100}, } for _, tc := range cases { score, pct := classes.Totals(tc.points, tc.max)