@studious-lms/server 1.20.0 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/Dockerfile +5 -2
  2. package/dist/models/agenda.d.ts +6 -0
  3. package/dist/models/agenda.d.ts.map +1 -1
  4. package/dist/models/announcement.d.ts +2 -0
  5. package/dist/models/announcement.d.ts.map +1 -1
  6. package/dist/models/assignment.d.ts +27 -0
  7. package/dist/models/assignment.d.ts.map +1 -1
  8. package/dist/models/auth.d.ts +4 -0
  9. package/dist/models/auth.d.ts.map +1 -1
  10. package/dist/models/class.d.ts +14 -0
  11. package/dist/models/class.d.ts.map +1 -1
  12. package/dist/models/event.d.ts +11 -0
  13. package/dist/models/event.d.ts.map +1 -1
  14. package/dist/models/file.d.ts +19 -0
  15. package/dist/models/file.d.ts.map +1 -1
  16. package/dist/models/folder.d.ts +4 -0
  17. package/dist/models/folder.d.ts.map +1 -1
  18. package/dist/models/gradebook.d.ts +180 -0
  19. package/dist/models/gradebook.d.ts.map +1 -0
  20. package/dist/models/gradebook.js +88 -0
  21. package/dist/models/gradebook.js.map +1 -0
  22. package/dist/models/labChat.d.ts +4 -0
  23. package/dist/models/labChat.d.ts.map +1 -1
  24. package/dist/models/newtonChat.d.ts +2 -0
  25. package/dist/models/newtonChat.d.ts.map +1 -1
  26. package/dist/models/school.d.ts +2 -0
  27. package/dist/models/school.d.ts.map +1 -1
  28. package/dist/models/section.d.ts +3 -0
  29. package/dist/models/section.d.ts.map +1 -1
  30. package/dist/models/worksheet.d.ts +6 -0
  31. package/dist/models/worksheet.d.ts.map +1 -1
  32. package/dist/routers/_app.d.ts +1359 -37
  33. package/dist/routers/_app.d.ts.map +1 -1
  34. package/dist/routers/_app.js +4 -2
  35. package/dist/routers/_app.js.map +1 -1
  36. package/dist/routers/admin.d.ts +164 -0
  37. package/dist/routers/admin.d.ts.map +1 -1
  38. package/dist/routers/admin.js +23 -4
  39. package/dist/routers/admin.js.map +1 -1
  40. package/dist/routers/agenda.d.ts +6 -0
  41. package/dist/routers/agenda.d.ts.map +1 -1
  42. package/dist/routers/assignment.d.ts +102 -1
  43. package/dist/routers/assignment.d.ts.map +1 -1
  44. package/dist/routers/assignment.js +180 -2
  45. package/dist/routers/assignment.js.map +1 -1
  46. package/dist/routers/class.d.ts +17 -0
  47. package/dist/routers/class.d.ts.map +1 -1
  48. package/dist/routers/event.d.ts +4 -0
  49. package/dist/routers/event.d.ts.map +1 -1
  50. package/dist/routers/file.d.ts +66 -0
  51. package/dist/routers/file.d.ts.map +1 -1
  52. package/dist/routers/file.js +111 -2
  53. package/dist/routers/file.js.map +1 -1
  54. package/dist/routers/gradebook.d.ts +299 -0
  55. package/dist/routers/gradebook.d.ts.map +1 -0
  56. package/dist/routers/gradebook.js +107 -0
  57. package/dist/routers/gradebook.js.map +1 -0
  58. package/dist/routers/school.d.ts +4 -0
  59. package/dist/routers/school.d.ts.map +1 -1
  60. package/dist/routers/section.d.ts +1 -1
  61. package/dist/routers/worksheet.d.ts +3 -1
  62. package/dist/routers/worksheet.d.ts.map +1 -1
  63. package/dist/services/adminInstitution.d.ts +51 -0
  64. package/dist/services/adminInstitution.d.ts.map +1 -1
  65. package/dist/services/adminInstitution.js +52 -2
  66. package/dist/services/adminInstitution.js.map +1 -1
  67. package/dist/services/adminMetrics.d.ts +17 -0
  68. package/dist/services/adminMetrics.d.ts.map +1 -0
  69. package/dist/services/adminMetrics.js +37 -0
  70. package/dist/services/adminMetrics.js.map +1 -0
  71. package/dist/services/adminRunJob.d.ts +9 -0
  72. package/dist/services/adminRunJob.d.ts.map +1 -1
  73. package/dist/services/adminRunJob.js +18 -2
  74. package/dist/services/adminRunJob.js.map +1 -1
  75. package/dist/services/adminUser.d.ts +67 -0
  76. package/dist/services/adminUser.d.ts.map +1 -0
  77. package/dist/services/adminUser.js +87 -0
  78. package/dist/services/adminUser.js.map +1 -0
  79. package/dist/services/agenda.d.ts +6 -0
  80. package/dist/services/agenda.d.ts.map +1 -1
  81. package/dist/services/assignment.d.ts +27 -0
  82. package/dist/services/assignment.d.ts.map +1 -1
  83. package/dist/services/class.d.ts +17 -0
  84. package/dist/services/class.d.ts.map +1 -1
  85. package/dist/services/event.d.ts +4 -0
  86. package/dist/services/event.d.ts.map +1 -1
  87. package/dist/services/gradeCalculation.d.ts +70 -0
  88. package/dist/services/gradeCalculation.d.ts.map +1 -0
  89. package/dist/services/gradeCalculation.js +148 -0
  90. package/dist/services/gradeCalculation.js.map +1 -0
  91. package/dist/services/gradebook.d.ts +197 -0
  92. package/dist/services/gradebook.d.ts.map +1 -0
  93. package/dist/services/gradebook.js +263 -0
  94. package/dist/services/gradebook.js.map +1 -0
  95. package/dist/services/school.d.ts +4 -0
  96. package/dist/services/school.d.ts.map +1 -1
  97. package/dist/services/worksheet.d.ts +2 -0
  98. package/dist/services/worksheet.d.ts.map +1 -1
  99. package/docker-entrypoint.sh +7 -0
  100. package/package.json +1 -1
  101. package/prisma/migrations/20260708011456_add_file_annotations/migration.sql +35 -0
  102. package/prisma/migrations/20260708011629_add_gradebook_categories_columns_policies/migration.sql +78 -0
  103. package/prisma/migrations/20260723142314_make_file_annotation_submission_optional/migration.sql +2 -0
  104. package/prisma/schema.prisma +130 -38
  105. package/src/models/gradebook.ts +91 -0
  106. package/src/routers/_app.ts +2 -0
  107. package/src/routers/admin.ts +28 -0
  108. package/src/routers/assignment.ts +204 -0
  109. package/src/routers/file.ts +128 -0
  110. package/src/routers/gradebook.ts +173 -0
  111. package/src/services/adminInstitution.ts +52 -0
  112. package/src/services/adminMetrics.ts +46 -0
  113. package/src/services/adminRunJob.ts +24 -0
  114. package/src/services/adminUser.ts +93 -0
  115. package/src/services/gradeCalculation.ts +253 -0
  116. package/src/services/gradebook.ts +383 -0
  117. package/tests/unit/services/gradeCalculation.test.ts +206 -0
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Gradebook service – grade categories, custom columns/entries, class
3
+ * grade-calculation policies, the computed gradebook, and CSV export.
4
+ *
5
+ * All grade math lives in `gradeCalculation.ts` (pure, unit-tested); this
6
+ * service only assembles inputs and persists configuration.
7
+ */
8
+ import { GradeRoundingMode as PrismaGradeRoundingMode } from "@prisma/client";
9
+ import { TRPCError } from "@trpc/server";
10
+ import { prisma } from "../lib/prisma.js";
11
+ import { isTeacherInClass } from "../models/class.js";
12
+ import {
13
+ findGradeCategoriesByClassId,
14
+ findGradeCategory,
15
+ findCustomGradeColumnsByClassId,
16
+ findCustomGradeColumn,
17
+ findClassForGradebook,
18
+ findClassGradePolicies,
19
+ } from "../models/gradebook.js";
20
+ import {
21
+ computeFinalGrade,
22
+ resolveLetterGrade,
23
+ type FinalGradeResult,
24
+ type GradeCategoryInput,
25
+ type ScoreInput,
26
+ } from "./gradeCalculation.js";
27
+
28
+ // ===== Categories =====
29
+
30
+ export async function listGradeCategories(classId: string) {
31
+ return findGradeCategoriesByClassId(classId);
32
+ }
33
+
34
+ export async function createGradeCategory(
35
+ classId: string,
36
+ data: { name: string; weight: number; dropLowest?: number; position?: number }
37
+ ) {
38
+ return prisma.gradeCategory.create({
39
+ data: {
40
+ classId,
41
+ name: data.name,
42
+ weight: data.weight,
43
+ dropLowest: data.dropLowest ?? 0,
44
+ position: data.position ?? (await findGradeCategoriesByClassId(classId)).length,
45
+ },
46
+ });
47
+ }
48
+
49
+ export async function updateGradeCategory(
50
+ categoryId: string,
51
+ classId: string,
52
+ data: {
53
+ name?: string;
54
+ weight?: number;
55
+ dropLowest?: number;
56
+ position?: number;
57
+ }
58
+ ) {
59
+ const category = await findGradeCategory(categoryId, classId);
60
+ if (!category) {
61
+ throw new TRPCError({ code: "NOT_FOUND", message: "Category not found" });
62
+ }
63
+ return prisma.gradeCategory.update({
64
+ where: { id: categoryId },
65
+ data,
66
+ });
67
+ }
68
+
69
+ export async function deleteGradeCategory(categoryId: string, classId: string) {
70
+ const category = await findGradeCategory(categoryId, classId);
71
+ if (!category) {
72
+ throw new TRPCError({ code: "NOT_FOUND", message: "Category not found" });
73
+ }
74
+ return prisma.gradeCategory.delete({ where: { id: categoryId } });
75
+ }
76
+
77
+ export async function assignAssignmentCategory(
78
+ classId: string,
79
+ assignmentId: string,
80
+ gradeCategoryId: string | null
81
+ ) {
82
+ if (gradeCategoryId) {
83
+ const category = await findGradeCategory(gradeCategoryId, classId);
84
+ if (!category) {
85
+ throw new TRPCError({ code: "NOT_FOUND", message: "Category not found" });
86
+ }
87
+ }
88
+ return prisma.assignment.update({
89
+ where: { id: assignmentId, classId },
90
+ data: { gradeCategoryId },
91
+ });
92
+ }
93
+
94
+ // ===== Custom columns =====
95
+
96
+ export async function listCustomGradeColumns(classId: string) {
97
+ return findCustomGradeColumnsByClassId(classId);
98
+ }
99
+
100
+ export async function createCustomGradeColumn(
101
+ classId: string,
102
+ data: {
103
+ name: string;
104
+ maxPoints: number;
105
+ gradeCategoryId?: string | null;
106
+ position?: number;
107
+ }
108
+ ) {
109
+ if (data.gradeCategoryId) {
110
+ const category = await findGradeCategory(data.gradeCategoryId, classId);
111
+ if (!category) {
112
+ throw new TRPCError({ code: "NOT_FOUND", message: "Category not found" });
113
+ }
114
+ }
115
+ return prisma.customGradeColumn.create({
116
+ data: {
117
+ classId,
118
+ name: data.name,
119
+ maxPoints: data.maxPoints,
120
+ gradeCategoryId: data.gradeCategoryId ?? null,
121
+ position:
122
+ data.position ?? (await findCustomGradeColumnsByClassId(classId)).length,
123
+ },
124
+ });
125
+ }
126
+
127
+ export async function updateCustomGradeColumn(
128
+ columnId: string,
129
+ classId: string,
130
+ data: {
131
+ name?: string;
132
+ maxPoints?: number;
133
+ gradeCategoryId?: string | null;
134
+ position?: number;
135
+ }
136
+ ) {
137
+ const column = await findCustomGradeColumn(columnId, classId);
138
+ if (!column) {
139
+ throw new TRPCError({ code: "NOT_FOUND", message: "Column not found" });
140
+ }
141
+ if (data.gradeCategoryId) {
142
+ const category = await findGradeCategory(data.gradeCategoryId, classId);
143
+ if (!category) {
144
+ throw new TRPCError({ code: "NOT_FOUND", message: "Category not found" });
145
+ }
146
+ }
147
+ return prisma.customGradeColumn.update({
148
+ where: { id: columnId },
149
+ data,
150
+ });
151
+ }
152
+
153
+ export async function deleteCustomGradeColumn(
154
+ columnId: string,
155
+ classId: string
156
+ ) {
157
+ const column = await findCustomGradeColumn(columnId, classId);
158
+ if (!column) {
159
+ throw new TRPCError({ code: "NOT_FOUND", message: "Column not found" });
160
+ }
161
+ return prisma.customGradeColumn.delete({ where: { id: columnId } });
162
+ }
163
+
164
+ export async function setCustomGradeEntry(
165
+ classId: string,
166
+ columnId: string,
167
+ studentId: string,
168
+ points: number | null,
169
+ note?: string | null
170
+ ) {
171
+ const column = await findCustomGradeColumn(columnId, classId);
172
+ if (!column) {
173
+ throw new TRPCError({ code: "NOT_FOUND", message: "Column not found" });
174
+ }
175
+ if (points === null) {
176
+ await prisma.customGradeEntry.deleteMany({
177
+ where: { columnId, studentId },
178
+ });
179
+ return null;
180
+ }
181
+ return prisma.customGradeEntry.upsert({
182
+ where: { columnId_studentId: { columnId, studentId } },
183
+ create: { columnId, studentId, points, note: note ?? null },
184
+ update: { points, ...(note !== undefined ? { note } : {}) },
185
+ });
186
+ }
187
+
188
+ // ===== Policies =====
189
+
190
+ export async function getGradePolicies(classId: string) {
191
+ const policies = await findClassGradePolicies(classId);
192
+ if (!policies) {
193
+ throw new TRPCError({ code: "NOT_FOUND", message: "Class not found" });
194
+ }
195
+ return policies;
196
+ }
197
+
198
+ export async function updateGradePolicies(
199
+ classId: string,
200
+ data: {
201
+ treatMissingAsZero?: boolean;
202
+ gradeRoundingMode?: PrismaGradeRoundingMode;
203
+ }
204
+ ) {
205
+ return prisma.class.update({
206
+ where: { id: classId },
207
+ data,
208
+ select: {
209
+ id: true,
210
+ treatMissingAsZero: true,
211
+ gradeRoundingMode: true,
212
+ },
213
+ });
214
+ }
215
+
216
+ // ===== Computed gradebook =====
217
+
218
+ export interface GradebookStudentRow {
219
+ studentId: string;
220
+ studentName: string;
221
+ assignmentScores: Record<string, number | null>;
222
+ customScores: Record<string, { points: number | null; note: string | null }>;
223
+ result: FinalGradeResult;
224
+ letterGrade: string | null;
225
+ }
226
+
227
+ export async function getComputedGradebook(classId: string, userId: string) {
228
+ const [classData, categories, columns, isTeacher] = await Promise.all([
229
+ findClassForGradebook(classId),
230
+ findGradeCategoriesByClassId(classId),
231
+ findCustomGradeColumnsByClassId(classId),
232
+ isTeacherInClass(classId, userId),
233
+ ]);
234
+ if (!classData) {
235
+ throw new TRPCError({ code: "NOT_FOUND", message: "Class not found" });
236
+ }
237
+
238
+ // Students only ever see their own row.
239
+ const students = isTeacher
240
+ ? classData.students
241
+ : classData.students.filter((student) => student.id === userId);
242
+
243
+ const policies = {
244
+ treatMissingAsZero: classData.treatMissingAsZero,
245
+ roundingMode: classData.gradeRoundingMode,
246
+ };
247
+ const categoryInputs: GradeCategoryInput[] = categories.map((category) => ({
248
+ id: category.id,
249
+ name: category.name,
250
+ weight: category.weight,
251
+ dropLowest: category.dropLowest,
252
+ }));
253
+ const boundary = classData.gradingBoundaries[0] ?? null;
254
+
255
+ const rows: GradebookStudentRow[] = students.map((student) => {
256
+ const assignmentScores: Record<string, number | null> = {};
257
+ const scores: ScoreInput[] = [];
258
+
259
+ for (const assignment of classData.assignments) {
260
+ const submission = assignment.submissions.find(
261
+ (item) => item.studentId === student.id
262
+ );
263
+ const pointsEarned = submission?.gradeReceived ?? null;
264
+ assignmentScores[assignment.id] = pointsEarned;
265
+ scores.push({
266
+ categoryId: assignment.gradeCategoryId,
267
+ pointsEarned,
268
+ maxPoints: assignment.maxGrade ?? 0,
269
+ weight: assignment.weight,
270
+ });
271
+ }
272
+
273
+ const customScores: GradebookStudentRow["customScores"] = {};
274
+ for (const column of columns) {
275
+ const entry = column.entries.find(
276
+ (item) => item.studentId === student.id
277
+ );
278
+ customScores[column.id] = {
279
+ points: entry?.points ?? null,
280
+ note: entry?.note ?? null,
281
+ };
282
+ scores.push({
283
+ categoryId: column.gradeCategoryId,
284
+ pointsEarned: entry?.points ?? null,
285
+ maxPoints: column.maxPoints,
286
+ });
287
+ }
288
+
289
+ const result = computeFinalGrade(policies, categoryInputs, scores);
290
+ const letterGrade =
291
+ boundary && result.percentage !== null
292
+ ? resolveLetterGrade(boundary.structured, result.percentage)
293
+ : null;
294
+
295
+ return {
296
+ studentId: student.id,
297
+ studentName: student.profile?.displayName || student.username,
298
+ assignmentScores,
299
+ customScores,
300
+ result,
301
+ letterGrade,
302
+ };
303
+ });
304
+
305
+ return {
306
+ policies,
307
+ categories,
308
+ assignments: classData.assignments.map((assignment) => ({
309
+ id: assignment.id,
310
+ title: assignment.title,
311
+ maxGrade: assignment.maxGrade,
312
+ weight: assignment.weight,
313
+ gradeCategoryId: assignment.gradeCategoryId,
314
+ dueDate: assignment.dueDate,
315
+ })),
316
+ customColumns: columns.map((column) => ({
317
+ id: column.id,
318
+ name: column.name,
319
+ maxPoints: column.maxPoints,
320
+ gradeCategoryId: column.gradeCategoryId,
321
+ position: column.position,
322
+ })),
323
+ gradingBoundary: boundary,
324
+ students: rows,
325
+ };
326
+ }
327
+
328
+ // ===== CSV export =====
329
+
330
+ function escapeCsvCell(value: string): string {
331
+ if (/[",\n\r]/.test(value)) {
332
+ return `"${value.replace(/"/g, '""')}"`;
333
+ }
334
+ return value;
335
+ }
336
+
337
+ export async function exportGradebookCsv(classId: string, userId: string) {
338
+ const gradebook = await getComputedGradebook(classId, userId);
339
+
340
+ const header = [
341
+ "Student",
342
+ ...gradebook.assignments.map(
343
+ (assignment) => `${assignment.title} (/${assignment.maxGrade ?? 0})`
344
+ ),
345
+ ...gradebook.customColumns.map(
346
+ (column) => `${column.name} (/${column.maxPoints})`
347
+ ),
348
+ ...gradebook.categories.map((category) => `${category.name} Avg (%)`),
349
+ "Final (%)",
350
+ "Letter",
351
+ ];
352
+
353
+ const lines = [header.map(escapeCsvCell).join(",")];
354
+
355
+ for (const row of gradebook.students) {
356
+ const categoryCells = gradebook.categories.map((category) => {
357
+ const average = row.result.categories.find(
358
+ (item) => item.categoryId === category.id
359
+ )?.average;
360
+ return average === null || average === undefined
361
+ ? ""
362
+ : average.toFixed(2);
363
+ });
364
+
365
+ const cells = [
366
+ row.studentName,
367
+ ...gradebook.assignments.map((assignment) => {
368
+ const points = row.assignmentScores[assignment.id];
369
+ return points === null || points === undefined ? "" : String(points);
370
+ }),
371
+ ...gradebook.customColumns.map((column) => {
372
+ const points = row.customScores[column.id]?.points;
373
+ return points === null || points === undefined ? "" : String(points);
374
+ }),
375
+ ...categoryCells,
376
+ row.result.percentage === null ? "" : row.result.percentage.toFixed(2),
377
+ row.letterGrade ?? "",
378
+ ];
379
+ lines.push(cells.map(escapeCsvCell).join(","));
380
+ }
381
+
382
+ return { csv: lines.join("\n") };
383
+ }
@@ -0,0 +1,206 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ computeFinalGrade,
4
+ resolveLetterGrade,
5
+ roundPercentage,
6
+ type GradeCalculationPolicies,
7
+ type GradeCategoryInput,
8
+ type ScoreInput,
9
+ } from "../../../src/services/gradeCalculation.js";
10
+
11
+ const noPolicies: GradeCalculationPolicies = {
12
+ treatMissingAsZero: false,
13
+ roundingMode: "NONE",
14
+ };
15
+
16
+ const categories: GradeCategoryInput[] = [
17
+ { id: "hw", name: "Homework", weight: 40, dropLowest: 0 },
18
+ { id: "exam", name: "Exams", weight: 60, dropLowest: 0 },
19
+ ];
20
+
21
+ describe("computeFinalGrade", () => {
22
+ it("returns null percentage with no scores", () => {
23
+ const result = computeFinalGrade(noPolicies, categories, []);
24
+ expect(result.percentage).toBeNull();
25
+ expect(result.categories).toHaveLength(2);
26
+ expect(result.categories[0].average).toBeNull();
27
+ });
28
+
29
+ it("computes weighted average of category averages", () => {
30
+ const scores: ScoreInput[] = [
31
+ { categoryId: "hw", pointsEarned: 8, maxPoints: 10 }, // 80%
32
+ { categoryId: "exam", pointsEarned: 50, maxPoints: 100 }, // 50%
33
+ ];
34
+ const result = computeFinalGrade(noPolicies, categories, scores);
35
+ // 80 * 0.4 + 50 * 0.6 = 62
36
+ expect(result.percentage).toBeCloseTo(62);
37
+ });
38
+
39
+ it("puts uncategorized scores into a default category with weight 1", () => {
40
+ const scores: ScoreInput[] = [
41
+ { categoryId: "hw", pointsEarned: 10, maxPoints: 10 }, // 100%
42
+ { categoryId: null, pointsEarned: 5, maxPoints: 10 }, // 50%
43
+ ];
44
+ const result = computeFinalGrade(noPolicies, categories, scores);
45
+ // (100 * 40 + 50 * 1) / 41
46
+ expect(result.percentage).toBeCloseTo((100 * 40 + 50) / 41);
47
+ const fallback = result.categories.find((c) => c.categoryId === null);
48
+ expect(fallback?.average).toBeCloseTo(50);
49
+ expect(fallback?.weight).toBe(1);
50
+ });
51
+
52
+ it("treats unknown category ids as uncategorized", () => {
53
+ const scores: ScoreInput[] = [
54
+ { categoryId: "deleted", pointsEarned: 5, maxPoints: 10 },
55
+ ];
56
+ const result = computeFinalGrade(noPolicies, categories, scores);
57
+ expect(
58
+ result.categories.find((c) => c.categoryId === null)?.average
59
+ ).toBeCloseTo(50);
60
+ });
61
+
62
+ it("excludes missing scores by default", () => {
63
+ const scores: ScoreInput[] = [
64
+ { categoryId: "hw", pointsEarned: 8, maxPoints: 10 },
65
+ { categoryId: "hw", pointsEarned: null, maxPoints: 10 },
66
+ ];
67
+ const result = computeFinalGrade(noPolicies, categories, scores);
68
+ expect(
69
+ result.categories.find((c) => c.categoryId === "hw")?.average
70
+ ).toBeCloseTo(80);
71
+ });
72
+
73
+ it("counts missing scores as zero when treatMissingAsZero is set", () => {
74
+ const scores: ScoreInput[] = [
75
+ { categoryId: "hw", pointsEarned: 8, maxPoints: 10 },
76
+ { categoryId: "hw", pointsEarned: null, maxPoints: 10 },
77
+ ];
78
+ const result = computeFinalGrade(
79
+ { ...noPolicies, treatMissingAsZero: true },
80
+ categories,
81
+ scores
82
+ );
83
+ expect(
84
+ result.categories.find((c) => c.categoryId === "hw")?.average
85
+ ).toBeCloseTo(40);
86
+ });
87
+
88
+ it("respects per-score weights within a category", () => {
89
+ const scores: ScoreInput[] = [
90
+ { categoryId: "hw", pointsEarned: 10, maxPoints: 10, weight: 3 }, // 100%
91
+ { categoryId: "hw", pointsEarned: 0, maxPoints: 10, weight: 1 }, // 0%
92
+ ];
93
+ const result = computeFinalGrade(noPolicies, categories, scores);
94
+ expect(
95
+ result.categories.find((c) => c.categoryId === "hw")?.average
96
+ ).toBeCloseTo(75);
97
+ });
98
+
99
+ it("drops the lowest N scores in a category", () => {
100
+ const dropCategories: GradeCategoryInput[] = [
101
+ { id: "hw", name: "Homework", weight: 1, dropLowest: 1 },
102
+ ];
103
+ const scores: ScoreInput[] = [
104
+ { categoryId: "hw", pointsEarned: 10, maxPoints: 10 }, // 100%
105
+ { categoryId: "hw", pointsEarned: 2, maxPoints: 10 }, // 20% (dropped)
106
+ { categoryId: "hw", pointsEarned: 6, maxPoints: 10 }, // 60%
107
+ ];
108
+ const result = computeFinalGrade(noPolicies, dropCategories, scores);
109
+ expect(result.percentage).toBeCloseTo(80);
110
+ expect(result.categories[0].droppedScores).toBe(1);
111
+ expect(result.categories[0].countedScores).toBe(2);
112
+ });
113
+
114
+ it("never drops every score in a category", () => {
115
+ const dropCategories: GradeCategoryInput[] = [
116
+ { id: "hw", name: "Homework", weight: 1, dropLowest: 5 },
117
+ ];
118
+ const scores: ScoreInput[] = [
119
+ { categoryId: "hw", pointsEarned: 4, maxPoints: 10 },
120
+ { categoryId: "hw", pointsEarned: 8, maxPoints: 10 },
121
+ ];
122
+ const result = computeFinalGrade(noPolicies, dropCategories, scores);
123
+ expect(result.percentage).toBeCloseTo(80);
124
+ expect(result.categories[0].droppedScores).toBe(1);
125
+ });
126
+
127
+ it("ignores scores with non-positive maxPoints", () => {
128
+ const scores: ScoreInput[] = [
129
+ { categoryId: "hw", pointsEarned: 5, maxPoints: 0 },
130
+ { categoryId: "hw", pointsEarned: 9, maxPoints: 10 },
131
+ ];
132
+ const result = computeFinalGrade(noPolicies, categories, scores);
133
+ expect(
134
+ result.categories.find((c) => c.categoryId === "hw")?.average
135
+ ).toBeCloseTo(90);
136
+ });
137
+
138
+ it("excludes empty categories from the weighted average", () => {
139
+ const scores: ScoreInput[] = [
140
+ { categoryId: "hw", pointsEarned: 9, maxPoints: 10 },
141
+ ];
142
+ const result = computeFinalGrade(noPolicies, categories, scores);
143
+ expect(result.percentage).toBeCloseTo(90);
144
+ });
145
+
146
+ it("applies the rounding mode to the final percentage", () => {
147
+ const scores: ScoreInput[] = [
148
+ { categoryId: "hw", pointsEarned: 5, maxPoints: 6 }, // 83.33%
149
+ ];
150
+ expect(
151
+ computeFinalGrade(
152
+ { ...noPolicies, roundingMode: "FLOOR" },
153
+ categories,
154
+ scores
155
+ ).percentage
156
+ ).toBe(83);
157
+ expect(
158
+ computeFinalGrade(
159
+ { ...noPolicies, roundingMode: "CEILING" },
160
+ categories,
161
+ scores
162
+ ).percentage
163
+ ).toBe(84);
164
+ expect(
165
+ computeFinalGrade(
166
+ { ...noPolicies, roundingMode: "HALF_UP" },
167
+ categories,
168
+ scores
169
+ ).percentage
170
+ ).toBe(83);
171
+ });
172
+ });
173
+
174
+ describe("roundPercentage", () => {
175
+ it("rounds according to mode", () => {
176
+ expect(roundPercentage(89.5, "HALF_UP")).toBe(90);
177
+ expect(roundPercentage(89.9, "FLOOR")).toBe(89);
178
+ expect(roundPercentage(89.1, "CEILING")).toBe(90);
179
+ expect(roundPercentage(89.123, "NONE")).toBeCloseTo(89.123);
180
+ });
181
+ });
182
+
183
+ describe("resolveLetterGrade", () => {
184
+ const structured = JSON.stringify({
185
+ boundaries: [
186
+ { grade: "A", minPercentage: 90, maxPercentage: 100 },
187
+ { grade: "B", minPercentage: 80, maxPercentage: 89 },
188
+ { grade: "C", minPercentage: 70, maxPercentage: 79 },
189
+ ],
190
+ });
191
+
192
+ it("maps percentages to letters", () => {
193
+ expect(resolveLetterGrade(structured, 95)).toBe("A");
194
+ expect(resolveLetterGrade(structured, 85)).toBe("B");
195
+ expect(resolveLetterGrade(structured, 70)).toBe("C");
196
+ });
197
+
198
+ it("returns null below all boundaries", () => {
199
+ expect(resolveLetterGrade(structured, 10)).toBeNull();
200
+ });
201
+
202
+ it("returns null for malformed payloads", () => {
203
+ expect(resolveLetterGrade("not json", 90)).toBeNull();
204
+ expect(resolveLetterGrade("{}", 90)).toBeNull();
205
+ });
206
+ });