diff --git a/src/app/[locale]/(app)/studies/actions.ts b/src/app/[locale]/(app)/studies/actions.ts index 68c94fb..e510407 100644 --- a/src/app/[locale]/(app)/studies/actions.ts +++ b/src/app/[locale]/(app)/studies/actions.ts @@ -24,7 +24,9 @@ const gradeScaleSchema = z grade: z.number().min(1).max(6), }) ) - .max(30) + // A percent→grade table can be fine-grained (e.g. one row per percentage + // point), so allow a generous number of rows. + .max(200) const programSchema = z.object({ name: z.string().min(1).max(200), diff --git a/src/components/learn/module-assessment-card.tsx b/src/components/learn/module-assessment-card.tsx index 162d08d..7d279c4 100644 --- a/src/components/learn/module-assessment-card.tsx +++ b/src/components/learn/module-assessment-card.tsx @@ -184,8 +184,6 @@ export function ModuleAssessmentCard({ {!passFail && final.percent != null && (
{Math.round(final.percent)} % - {final.bonus && final.bonus.percentPoints > 0 && - ` · ${t("bonusApplied", { points: final.bonus.percentPoints })}`}
)} diff --git a/src/lib/grades.test.ts b/src/lib/grades.test.ts index 73e3ed9..08bae36 100644 --- a/src/lib/grades.test.ts +++ b/src/lib/grades.test.ts @@ -152,7 +152,7 @@ describe("effectiveBonus", () => { }) describe("moduleFinalGrade", () => { - it("applies a percent-point bonus before mapping to a grade", () => { + it("derives the grade from the percentage only — a percent-point bonus does not raise it", () => { const r = moduleFinalGrade({ module: { passFail: false, @@ -165,13 +165,14 @@ describe("moduleFinalGrade", () => { assignments: [{ kind: "graded", status: "graded", percent: 100 }], scale: null, }) - // 78 + 5 = 83 → 2.0 on the default scale (≥80) - expect(r.percent).toBe(83) - expect(r.grade).toBe(2.0) + // 78 % → 2.3 (≥75); the +5 bonus is reported but not applied to the grade. + expect(r.percent).toBe(78) + expect(r.grade).toBe(2.3) expect(r.source).toBe("assessment") + expect(r.bonus?.percentPoints).toBe(5) }) - it("applies grade steps and clamps at 1.0", () => { + it("does not apply grade steps to the final grade", () => { const r = moduleFinalGrade({ module: { passFail: false, @@ -180,12 +181,31 @@ describe("moduleFinalGrade", () => { bonusMinAvgPercent: null, bonusMinCompletedShare: null, }, - attempts: [{ attempt: 1, resultPercent: "96", passed: true }], + attempts: [{ attempt: 1, resultPercent: "82", passed: true }], + assignments: [{ kind: "graded", status: "graded", percent: 100 }], + scale: null, + }) + // 82 % → 2.0; without decoupling the 0.3 step would have yielded 1.7. + expect(r.grade).toBe(2.0) + expect(r.bonus?.gradeSteps).toBe(0.3) + }) + + it("yields the same final grade with or without a configured bonus", () => { + const attempts = [{ attempt: 1, resultPercent: "78", passed: true }] + const withBonus = moduleFinalGrade({ + module: { + passFail: false, + bonusType: "percent_points", + bonusValue: "5", + bonusMinAvgPercent: null, + bonusMinCompletedShare: null, + }, + attempts, assignments: [{ kind: "graded", status: "graded", percent: 100 }], scale: null, }) - // 96 → 1.0, minus 0.3 step clamps back to 1.0 - expect(r.grade).toBe(1.0) + const withoutBonus = moduleFinalGrade({ module: noBonus, attempts, assignments: [], scale: null }) + expect(withBonus.grade).toBe(withoutBonus.grade) }) it("uses the latest attempt", () => { diff --git a/src/lib/grades.ts b/src/lib/grades.ts index 6acb0a1..fd7d53e 100644 --- a/src/lib/grades.ts +++ b/src/lib/grades.ts @@ -174,9 +174,10 @@ export type FinalGrade = { } /** - * Computes a module's final grade from its latest assessment attempt plus the - * assignment bonus, falling back to legacy free-form grades when no attempt - * exists. Pass/fail modules yield only a passed flag. + * Computes a module's final grade from its latest assessment attempt (achieved + * percentage → grade scale), falling back to legacy free-form grades when no + * attempt exists. The assignment bonus is computed and returned for display but + * does not alter the final grade. Pass/fail modules yield only a passed flag. */ export function moduleFinalGrade(input: FinalGradeInput): FinalGrade { const { module, attempts, assignments, scale, legacyGrades } = input @@ -198,12 +199,13 @@ export function moduleFinalGrade(input: FinalGradeInput): FinalGrade { const basePercent = num(latest.resultPercent) if (basePercent != null) { - const boostedPercent = Math.min(100, basePercent + bonus.percentPoints) - let grade = percentToGrade(scale, boostedPercent) - grade = Math.max(BEST_GRADE, grade - bonus.gradeSteps) + // The final grade is derived purely from the achieved percentage via the + // grade scale. A configured assignment bonus is reported for information + // only (see `bonus` below) and never shifts the final grade. + const grade = percentToGrade(scale, basePercent) return { grade, - percent: boostedPercent, + percent: basePercent, passed: grade <= PASS_THRESHOLD, attempt: latest.attempt, source: "assessment",