Phase 4: student views, opt-outs, and class schedule
Student page with a standing summary, a class-comparison histogram (zero-dependency HTML/CSS) with a plain-language fewer/same/more sentence, opt-out management with withdrawal of future dates, and a call history that respects the per-course assessment-visibility setting and never shows pending or skipped calls. Opt-outs validate against a new per-course class-day schedule (range generator plus individual add/remove for holidays), since Canvas has no structured meeting-day data; courses without a schedule fall back to a free date picker. Dev mode seeds a Tue/Thu pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
78
tests/test_schedule_ui.py
Normal file
78
tests/test_schedule_ui.py
Normal file
@@ -0,0 +1,78 @@
|
||||
import datetime
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def instructor(dev_client):
|
||||
dev_client.post("/dev/launch/dev-instructor")
|
||||
return dev_client
|
||||
|
||||
|
||||
def test_generate_range_creates_weekday_dates(instructor):
|
||||
# A fixed two-week window well in the future, Mon/Wed.
|
||||
resp = instructor.post(
|
||||
"/instructor/schedule/generate",
|
||||
data={
|
||||
"start": "2027-01-04",
|
||||
"end": "2027-01-15",
|
||||
"weekday": ["0", "2"],
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
page = resp.get_data(as_text=True)
|
||||
for expected in ["Jan 4", "Jan 6", "Jan 11", "Jan 13"]:
|
||||
assert expected in page
|
||||
assert "Jan 5" not in page
|
||||
|
||||
# Re-generating the same range adds nothing new.
|
||||
resp = instructor.post(
|
||||
"/instructor/schedule/generate",
|
||||
data={"start": "2027-01-04", "end": "2027-01-15", "weekday": ["0"]},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert resp.get_data(as_text=True).count("Jan 4") == 1
|
||||
|
||||
|
||||
def test_generate_rejects_bad_input(instructor):
|
||||
resp = instructor.post(
|
||||
"/instructor/schedule/generate",
|
||||
data={"start": "2027-01-15", "end": "2027-01-04", "weekday": ["0"]},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
resp = instructor.post(
|
||||
"/instructor/schedule/generate",
|
||||
data={"start": "2027-01-04", "end": "2027-01-15"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_add_and_delete_single_day(instructor):
|
||||
resp = instructor.post(
|
||||
"/instructor/schedule/add",
|
||||
data={"date": "2027-03-01"},
|
||||
follow_redirects=True,
|
||||
)
|
||||
page = resp.get_data(as_text=True)
|
||||
assert "Mar 1, 2027" in page
|
||||
|
||||
# Find the delete form id adjacent to the date row.
|
||||
rows = re.findall(
|
||||
r"<td[^>]*>\s*(\w{3} \w{3} \d+, \d{4})\s*</td>.*?/instructor/schedule/(\d+)/delete",
|
||||
page,
|
||||
re.S,
|
||||
)
|
||||
ids = {label: did for label, did in rows}
|
||||
day_id = ids["Mon Mar 1, 2027"]
|
||||
|
||||
resp = instructor.post(
|
||||
f"/instructor/schedule/{day_id}/delete", follow_redirects=True
|
||||
)
|
||||
assert "Mar 1, 2027" not in resp.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_schedule_requires_instructor(dev_client):
|
||||
dev_client.post("/dev/launch/dev-instructor") # create course
|
||||
dev_client.post("/dev/launch/dev-student-1")
|
||||
assert dev_client.get("/instructor/schedule").status_code == 403
|
||||
118
tests/test_student_ui.py
Normal file
118
tests/test_student_ui.py
Normal file
@@ -0,0 +1,118 @@
|
||||
import datetime
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
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": "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
|
||||
Reference in New Issue
Block a user