fix: bound confirmed points and the attempt percentage

WR-05: Confirm clamps the grader's score to the question's current
points, so lowering a question's points no longer lets a confirm store
more than full marks, and Totals caps the percentage at 100 when a
question's points were raised after the attempt was scored.
This commit is contained in:
Jakub Zych
2026-11-09 22:50:26 +01:00
parent 1cba2c4d32
commit 09cf61e9e0
3 changed files with 15 additions and 4 deletions

View File

@@ -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

View File

@@ -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

View File

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