Phase 6: participation grading, gradebook passback, opt-out integrity
Ports the timing-neutral foregone-participation grading scheme from participation_grades.R: answer quality (per-course level points) minus a deduction for participation foregone through unavailability, estimated by Monte Carlo simulation of the actual weighted draw and averaged over when absences fall, plus a small form-filing incentive for drawn-while-absent-without-opt-out days. Reason-blind and luck-protected; zero-answer students floor to 0. Parameters (allowance in SD units, passing line, form penalty, simulation size/seed) are course settings. Verified against the R engine's rendered 2026q2 reports via the new import-legacy command: quality and availability match exactly, finals within Monte Carlo noise; dropped students import as inactive enrollments and are excluded identically. Grades are computed on demand into stored GradeRun snapshots and reviewed on a grades page with CSV export and per-student reports (also served to students via a publish toggle). Display scales map points to UW 4.0, a threshold table (one-click import of the Canvas course grading scheme), or raw points. Gradebook passback via AGS sits behind a settings toggle with a review-then-push flow. Opt-out withdrawals are now soft-deletes with a withdrawn_at audit trail, and close when class begins (class days gained optional start times), so availability records cannot be rewritten after the fact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
123
tests/test_grades_ui.py
Normal file
123
tests/test_grades_ui.py
Normal file
@@ -0,0 +1,123 @@
|
||||
import datetime
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import outcome_actions
|
||||
|
||||
TODAY = datetime.date.today().isoformat()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def instructor(dev_client):
|
||||
dev_client.post("/dev/launch/dev-instructor")
|
||||
# Speed: shrink the simulation for tests.
|
||||
dev_client.post(
|
||||
"/instructor/settings",
|
||||
data={"selection_mode": "weighted", "weight_factor": "2",
|
||||
"show_assessments": "on", "n_sims": "200", "sim_seed": "1"},
|
||||
)
|
||||
return dev_client
|
||||
|
||||
|
||||
def record_calls(client, n=6):
|
||||
for _ in range(n):
|
||||
client.post("/instructor/live/next", data={"date": TODAY})
|
||||
page = client.get(f"/instructor/live?date={TODAY}").get_data(as_text=True)
|
||||
call_id = re.search(r"/instructor/call/(\d+)/outcome", page).group(1)
|
||||
client.post(
|
||||
f"/instructor/call/{call_id}/outcome",
|
||||
data={"action": outcome_actions(page)["GOOD"]},
|
||||
)
|
||||
|
||||
|
||||
def test_grades_page_before_compute(instructor):
|
||||
page = instructor.get("/instructor/grades/").get_data(as_text=True)
|
||||
assert "No grades computed yet" in page
|
||||
|
||||
|
||||
def test_compute_and_view_grades(instructor):
|
||||
record_calls(instructor)
|
||||
resp = instructor.post(
|
||||
"/instructor/grades/compute", follow_redirects=True
|
||||
)
|
||||
page = resp.get_data(as_text=True)
|
||||
assert "SD of full-attendance counts" in page
|
||||
assert "4.00" in page # someone answered GOOD -> full marks
|
||||
assert "never answered" in page # not everyone got called
|
||||
|
||||
resp = instructor.get("/instructor/grades/export.csv")
|
||||
assert resp.mimetype == "text/csv"
|
||||
assert "final_points" in resp.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_student_report_and_publishing(instructor):
|
||||
record_calls(instructor)
|
||||
instructor.post("/instructor/grades/compute")
|
||||
page = instructor.get("/instructor/grades/").get_data(as_text=True)
|
||||
student_id = re.search(r"/instructor/grades/student/(\d+)", page).group(1)
|
||||
report = instructor.get(
|
||||
f"/instructor/grades/student/{student_id}"
|
||||
).get_data(as_text=True)
|
||||
assert "How your grade was computed" in report
|
||||
|
||||
# Unpublished: students see no report link and get 404.
|
||||
instructor.post("/dev/launch/dev-student-1")
|
||||
assert "participation grade report" not in instructor.get("/me").get_data(as_text=True)
|
||||
assert instructor.get("/me/report").status_code == 404
|
||||
|
||||
# Publish, and the student can read their own report.
|
||||
instructor.post("/dev/launch/dev-instructor")
|
||||
instructor.post(
|
||||
"/instructor/settings",
|
||||
data={"selection_mode": "weighted", "weight_factor": "2",
|
||||
"publish_grade_reports": "on"},
|
||||
)
|
||||
instructor.post("/dev/launch/dev-student-1")
|
||||
page = instructor.get("/me").get_data(as_text=True)
|
||||
assert "participation grade report" in page
|
||||
report = instructor.get("/me/report").get_data(as_text=True)
|
||||
assert "How your grade was computed" in report
|
||||
assert "Ada Lovelace" in report
|
||||
|
||||
|
||||
def test_push_flow_dev_mode(instructor):
|
||||
record_calls(instructor)
|
||||
instructor.post("/instructor/grades/compute")
|
||||
|
||||
# Push pages don't exist until AGS is enabled.
|
||||
assert instructor.get("/instructor/grades/push").status_code == 404
|
||||
|
||||
instructor.post(
|
||||
"/instructor/settings",
|
||||
data={"selection_mode": "weighted", "weight_factor": "2",
|
||||
"ags_enabled": "on"},
|
||||
)
|
||||
page = instructor.get("/instructor/grades/push").get_data(as_text=True)
|
||||
assert "Review before pushing" in page
|
||||
assert "Push 8 grades to Canvas" in page
|
||||
|
||||
resp = instructor.post("/instructor/grades/push")
|
||||
page = resp.get_data(as_text=True)
|
||||
assert "simulated (dev mode)" in page
|
||||
assert "8 of\n 8 pushed successfully" in page or "8 of" in page
|
||||
|
||||
|
||||
def test_grades_require_instructor(dev_client):
|
||||
dev_client.post("/dev/launch/dev-instructor")
|
||||
dev_client.post("/dev/launch/dev-student-1")
|
||||
assert dev_client.get("/instructor/grades/").status_code == 403
|
||||
|
||||
|
||||
def test_scale_settings_and_canvas_import(instructor):
|
||||
# Threshold rows via the settings form.
|
||||
instructor.post(
|
||||
"/instructor/settings",
|
||||
data={"selection_mode": "weighted", "weight_factor": "2",
|
||||
"scale_type": "table",
|
||||
"scale_rows": "93 A\n90 A-\n0 F"},
|
||||
)
|
||||
record_calls(instructor, n=3)
|
||||
instructor.post("/instructor/grades/compute")
|
||||
page = instructor.get("/instructor/grades/").get_data(as_text=True)
|
||||
assert "<strong>A</strong>" in page
|
||||
Reference in New Issue
Block a user