1
0
Files
coldcall_lti/tests/test_student_ui.py
Benjamin Mako Hill 7ec1be5dc6 Phase 5: reporting, exports, pronouns, and roster freshness
Instructor participation report: per-student histograms, outcome mix
by class day, and a sortable table including the fairness ratio
(answered calls over questions present for, with opt-out days out of
the denominator), plus CSV exports of students, calls, and opt-outs.

Assessment scales are now per-course data: ordered levels with labels
and points out of 100 (defaults carry the old R grading values), with
calls referencing levels by id so renames follow through to history.
Renaming, re-pointing, reordering, and adding levels are always
allowed; deleting a level in use by recorded calls is blocked.

Pronouns and course term dates come from Canvas custom variable
substitutions, at launch and roster-wide via rlid-scoped NRPS; the
student page notes that names/pronouns are Canvas-sourced. Rosters
can also be refreshed outside launches: a "Sync roster now" button
and a sync-rosters CLI command for an hourly cron job, skipping ended
courses. Alembic now runs SQLite-compatible batch migrations with a
constraint naming convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:05:34 -07:00

122 lines
4.1 KiB
Python

import datetime
import re
import pytest
from conftest import outcome_actions
TODAY = datetime.date.today()
@pytest.fixture
def student(dev_client):
# Instructor launch first so the dev course and roster exist.
dev_client.post("/dev/launch/dev-instructor")
dev_client.post("/dev/launch/dev-student-1")
return dev_client
def next_class_day_iso(page):
"""First offered opt-out date from the student page's select."""
match = re.search(r'<option value="(\d{4}-\d{2}-\d{2})"', page)
return match.group(1)
def test_student_home_shows_standing_and_histogram(student):
page = student.get("/me").get_data(as_text=True)
assert "answered <strong>0</strong>" in page
assert "class median is 0" in page
assert 'class="hist"' in page
assert "classmate" in page
def test_optout_roundtrip(student):
page = student.get("/me").get_data(as_text=True)
day = next_class_day_iso(page)
resp = student.post("/me/optout", data={"date": day}, follow_redirects=True)
page = resp.get_data(as_text=True)
assert f"<td>{day}</td>" in page
assert "Withdraw" in page
# Duplicate submissions are ignored, not errors.
student.post("/me/optout", data={"date": day})
page = student.get("/me").get_data(as_text=True)
assert page.count(f"<td>{day}</td>") == 1
optout_id = re.search(r"/me/optout/(\d+)/delete", page).group(1)
resp = student.post(f"/me/optout/{optout_id}/delete", follow_redirects=True)
assert f"<td>{day}</td>" not in resp.get_data(as_text=True)
def test_optout_rejects_non_class_day(student):
# Dev course meets Tue/Thu; find an upcoming Monday.
day = TODAY + datetime.timedelta(days=1)
while day.weekday() != 0:
day += datetime.timedelta(days=1)
resp = student.post("/me/optout", data={"date": day.isoformat()})
assert resp.status_code == 400
def test_optout_rejects_past_date(student):
resp = student.post(
"/me/optout",
data={"date": (TODAY - datetime.timedelta(days=7)).isoformat()},
)
assert resp.status_code == 400
def test_optout_affects_instructor_present_count(student):
page = student.get("/me").get_data(as_text=True)
day = next_class_day_iso(page)
student.post("/me/optout", data={"date": day})
student.post("/dev/launch/dev-instructor")
page = student.get(f"/instructor/live?date={day}").get_data(as_text=True)
assert "7 of 8 students available" in page
assert "(1 opted out)" in page
def test_students_cannot_touch_others_optouts(student):
page = student.get("/me").get_data(as_text=True)
day = next_class_day_iso(page)
student.post("/me/optout", data={"date": day})
page = student.get("/me").get_data(as_text=True)
optout_id = re.search(r"/me/optout/(\d+)/delete", page).group(1)
student.post("/dev/launch/dev-student-2")
assert student.post(f"/me/optout/{optout_id}/delete").status_code == 404
def test_assessment_visibility_setting(student):
# Record an answered call for dev-student-1 as the instructor.
student.post("/dev/launch/dev-instructor")
day = TODAY.isoformat()
# Draw live calls until dev-student-1 comes up, resolving each.
for _ in range(200):
student.post("/instructor/live/next", data={"date": day})
page = student.get(f"/instructor/live?date={day}").get_data(as_text=True)
call_id = re.search(r"/instructor/call/(\d+)/outcome", page).group(1)
target = "Ada Lovelace" in page
student.post(
f"/instructor/call/{call_id}/outcome",
data={"action": outcome_actions(page)["POOR"]},
)
if target:
break
student.post("/dev/launch/dev-student-1")
page = student.get("/me").get_data(as_text=True)
assert "POOR" in page
# Turn visibility off; the outcome shows as just "answered".
student.post("/dev/launch/dev-instructor")
student.post(
"/instructor/settings",
data={"selection_mode": "weighted", "weight_factor": "2"},
)
student.post("/dev/launch/dev-student-1")
page = student.get("/me").get_data(as_text=True)
assert "POOR" not in page
assert "answered" in page