Skip to content

Commit 3871c80

Browse files
cursoragentjon-bell
andcommitted
Re-enable gradebook what-if and add production E2E coverage
Co-authored-by: Jonathan Bell <jon@jonbell.net>
1 parent 9caaa15 commit 3871c80

File tree

3 files changed

+180
-12
lines changed

3 files changed

+180
-12
lines changed

app/course/[course_id]/gradebook/whatIf.tsx

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -179,16 +179,16 @@ function WhatIfScoreCell({
179179
);
180180
}
181181

182-
// function canEditColumn(column: GradebookColumn) {
183-
// const deps = column.dependencies;
184-
// return !(
185-
// deps &&
186-
// typeof deps === "object" &&
187-
// "gradebook_columns" in deps &&
188-
// Array.isArray((deps as { gradebook_columns?: number[] }).gradebook_columns) &&
189-
// (deps as { gradebook_columns?: number[] }).gradebook_columns!.length > 0
190-
// );
191-
// }
182+
function canEditColumn(column: GradebookColumn) {
183+
const deps = column.dependencies;
184+
return !(
185+
deps &&
186+
typeof deps === "object" &&
187+
"gradebook_columns" in deps &&
188+
Array.isArray((deps as { gradebook_columns?: number[] }).gradebook_columns) &&
189+
(deps as { gradebook_columns?: number[] }).gradebook_columns!.length > 0
190+
);
191+
}
192192

193193
function IncompleteValuesAlert({
194194
incompleteValues,
@@ -299,7 +299,7 @@ function GradebookCard({
299299
const score = studentGrade?.score_override ?? studentGrade?.score;
300300
const isShowingWhatIf =
301301
studentGrade?.score_override == null && whatIfVal?.what_if !== undefined && whatIfVal?.what_if !== score;
302-
const canEdit = false; //canEditColumn(column); TODO re-enable when fixing whatIf
302+
const canEdit = canEditColumn(column);
303303
const whatIfController = useGradebookWhatIf();
304304
const whatIfIncompleteValues = whatIfController.getIncompleteValues(column.id);
305305
const incompleteValues = whatIfIncompleteValues ?? studentGrade?.incomplete_values;

hooks/useGradebookWhatIf.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ class GradebookWhatIfController {
6767
public debugID: string = crypto.randomUUID();
6868
private _incompleteValues: GradebookWhatIfIncompleteValuesMap = {};
6969
private _subscribers: (() => void)[] = [];
70+
// Tracks columns explicitly set by user input (vs derived what-if values).
71+
private _userSetWhatIfColumns = new Set<number>();
7072
private _gradebookUnsubscribe: (() => void) | null = null;
7173
private _assignments: AssignmentForStudentDashboard[] = [];
7274
// Track shown errors to avoid spamming the user with duplicate toasts (scoped per controller instance)
@@ -240,6 +242,11 @@ class GradebookWhatIfController {
240242
what_if: value
241243
};
242244
}
245+
if (value === undefined) {
246+
this._userSetWhatIfColumns.delete(columnId);
247+
} else {
248+
this._userSetWhatIfColumns.add(columnId);
249+
}
243250
this._incompleteValues[columnId] = incompleteValues;
244251
//Find everything that depends on this column
245252
const allColumns = this.gradebookController.columns as GradebookColumnWithEntries[];
@@ -264,6 +271,7 @@ class GradebookWhatIfController {
264271
gradebook_score: existingGrade.gradebook_score
265272
};
266273
}
274+
this._userSetWhatIfColumns.delete(columnId);
267275
delete this._incompleteValues[columnId];
268276
// Recalculate dependent columns
269277
const allColumns = this.gradebookController.columns as GradebookColumnWithEntries[];
@@ -891,7 +899,7 @@ class GradebookWhatIfController {
891899

892900
// Determine if we should show a what-if value
893901
const existingWhatIf = this._grades[columnId]?.what_if;
894-
const hasUserSetWhatIf = existingWhatIf !== undefined;
902+
const hasUserSetWhatIf = this._userSetWhatIfColumns.has(columnId);
895903

896904
// Check if this column depends on other columns with user-set what-if values
897905
const hasWhatIfDependencies = this.hasWhatIfDependencies(columnId);
@@ -952,6 +960,7 @@ class GradebookWhatIfController {
952960
}
953961
// Clear error tracking Set when controller is cleaned up
954962
this._shownExpressionErrors.clear();
963+
this._userSetWhatIfColumns.clear();
955964
}
956965
}
957966

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { Course } from "@/utils/supabase/DatabaseTypes";
2+
import { test, expect } from "../global-setup";
3+
import type { Page } from "@playwright/test";
4+
import dotenv from "dotenv";
5+
import {
6+
createAssignmentsAndGradebookColumns,
7+
createClass,
8+
createUsersInClass,
9+
loginAsUser,
10+
TestingUser
11+
} from "./TestingUtils";
12+
13+
dotenv.config({ path: ".env.local" });
14+
15+
let course: Course;
16+
let student: TestingUser;
17+
18+
function normalizeText(text: string) {
19+
return text.replace(/\s+/g, " ").trim();
20+
}
21+
22+
function getGradeCard(page: Page, gradeName: string) {
23+
return page.getByRole("article", { name: `Grade for ${gradeName}` });
24+
}
25+
26+
async function gotoStudentGradebook(page: Page) {
27+
await loginAsUser(page, student, course);
28+
let lastError: unknown;
29+
for (let attempt = 0; attempt < 3; attempt++) {
30+
try {
31+
await page.goto(`/course/${course.id}/gradebook`, { waitUntil: "networkidle" });
32+
lastError = undefined;
33+
break;
34+
} catch (error) {
35+
lastError = error;
36+
await page.waitForTimeout(500);
37+
}
38+
}
39+
if (lastError) {
40+
throw lastError;
41+
}
42+
await expect(page.getByRole("region", { name: "Student Gradebook" })).toBeVisible();
43+
}
44+
45+
test.setTimeout(180_000);
46+
test.describe("Gradebook What-If", () => {
47+
test.describe.configure({ mode: "serial" });
48+
49+
test.beforeAll(async () => {
50+
course = await createClass({
51+
name: "Gradebook What If Course"
52+
});
53+
54+
const users = await createUsersInClass([
55+
{
56+
name: "WhatIf Student",
57+
role: "student",
58+
class_id: course.id,
59+
useMagicLink: true
60+
}
61+
]);
62+
63+
student = users[0];
64+
65+
// The helper sets up assignment, manual, and calculated gradebook columns used by the what-if UI.
66+
await createAssignmentsAndGradebookColumns({
67+
class_id: course.id,
68+
numAssignments: 2,
69+
numManualGradedColumns: 0,
70+
manualGradedColumnSlugs: ["participation"],
71+
groupConfig: "individual"
72+
});
73+
});
74+
75+
test("enables editing for manual what-if cards", async ({ page }) => {
76+
await gotoStudentGradebook(page);
77+
78+
const participationCard = getGradeCard(page, "Participation");
79+
await expect(participationCard).toBeVisible();
80+
81+
await participationCard.click();
82+
const whatIfInput = participationCard.locator('input[type="number"]');
83+
await expect(whatIfInput).toBeVisible();
84+
await whatIfInput.fill("100");
85+
await whatIfInput.press("Enter");
86+
87+
await expect(participationCard).toContainText("100/100");
88+
});
89+
90+
test("updates dependent final-grade simulation and restores baseline on clear", async ({ page }) => {
91+
await gotoStudentGradebook(page);
92+
93+
const participationCard = getGradeCard(page, "Participation");
94+
const finalCard = getGradeCard(page, "Final Grade");
95+
await expect(participationCard).toBeVisible();
96+
await expect(finalCard).toBeVisible();
97+
98+
const finalBefore = normalizeText(await finalCard.innerText());
99+
100+
await participationCard.click();
101+
const whatIfInput = participationCard.locator('input[type="number"]');
102+
await expect(whatIfInput).toBeVisible();
103+
await whatIfInput.fill("0");
104+
await whatIfInput.press("Enter");
105+
106+
await expect(async () => {
107+
const finalAfterSet = normalizeText(await finalCard.innerText());
108+
expect(finalAfterSet).not.toBe(finalBefore);
109+
}).toPass();
110+
111+
await participationCard.click();
112+
await expect(whatIfInput).toBeVisible();
113+
await whatIfInput.fill("");
114+
await whatIfInput.press("Enter");
115+
116+
await expect(async () => {
117+
const finalAfterClear = normalizeText(await finalCard.innerText());
118+
expect(finalAfterClear).toBe(finalBefore);
119+
}).toPass();
120+
});
121+
122+
test("keeps calculated cards read-only", async ({ page }) => {
123+
await gotoStudentGradebook(page);
124+
125+
const finalCard = getGradeCard(page, "Final Grade");
126+
const averageAssignmentsCard = getGradeCard(page, "Average Assignments");
127+
await expect(finalCard).toBeVisible();
128+
await expect(averageAssignmentsCard).toBeVisible();
129+
130+
await finalCard.click();
131+
await expect(finalCard.locator('input[type="number"]')).toHaveCount(0);
132+
133+
await averageAssignmentsCard.click();
134+
await expect(averageAssignmentsCard.locator('input[type="number"]')).toHaveCount(0);
135+
});
136+
137+
test("supports assignment-card simulation and cascades to final grade", async ({ page }) => {
138+
await gotoStudentGradebook(page);
139+
140+
const finalCard = getGradeCard(page, "Final Grade");
141+
const assignmentCard = page.getByRole("article", { name: /Grade for Test Assignment 1/ }).first();
142+
await expect(finalCard).toBeVisible();
143+
await expect(assignmentCard).toBeVisible();
144+
145+
const finalBefore = normalizeText(await finalCard.innerText());
146+
147+
await assignmentCard.click();
148+
const whatIfInput = assignmentCard.locator('input[type="number"]');
149+
await expect(whatIfInput).toBeVisible();
150+
await whatIfInput.fill("100");
151+
await whatIfInput.press("Enter");
152+
153+
await expect(assignmentCard).toContainText("100/100");
154+
await expect(async () => {
155+
const finalAfter = normalizeText(await finalCard.innerText());
156+
expect(finalAfter).not.toBe(finalBefore);
157+
}).toPass();
158+
});
159+
});

0 commit comments

Comments
 (0)