Phase 3: instructor in-class UI
Live mode with resolve-before-next call flow and one-tap outcomes (assessments, missing, skip); printable numbered call lists with pictures and a blank notes column; a day editor for after-class outcome entry, replacing hand-editing of call_list TSVs; and a per-course settings page (selection mode, weight factor, assessment visibility). Selection honors opt-outs and the course's mode: weighted draws use full-course answered-call history, cycle mode calls everyone once per day before starting over and treats skips as never called. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
121
tests/test_instructor_ui.py
Normal file
121
tests/test_instructor_ui.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import datetime
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
TODAY = datetime.date.today().isoformat()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def instructor(dev_client):
|
||||
dev_client.post("/dev/launch/dev-instructor")
|
||||
return dev_client
|
||||
|
||||
|
||||
def test_home_page(instructor):
|
||||
page = instructor.get("/instructor/").get_data(as_text=True)
|
||||
assert "8 active students" in page
|
||||
assert "Live cold call" in page
|
||||
|
||||
|
||||
def test_live_flow_records_outcome(instructor):
|
||||
resp = instructor.post(
|
||||
"/instructor/live/next", data={"date": TODAY}, follow_redirects=True
|
||||
)
|
||||
page = resp.get_data(as_text=True)
|
||||
match = re.search(r'/instructor/call/(\d+)/outcome', page)
|
||||
assert match, "live page should show outcome buttons for the call"
|
||||
assert "GOOD" in page
|
||||
|
||||
call_id = match.group(1)
|
||||
resp = instructor.post(
|
||||
f"/instructor/call/{call_id}/outcome",
|
||||
data={"action": "GOOD"},
|
||||
follow_redirects=True,
|
||||
)
|
||||
page = resp.get_data(as_text=True)
|
||||
assert "Calls so far today" in page
|
||||
assert "GOOD" in page
|
||||
# The call is resolved, so the next-student button is back.
|
||||
assert "Call next student" in page
|
||||
|
||||
|
||||
def test_live_requires_resolution_before_next(instructor):
|
||||
instructor.post("/instructor/live/next", data={"date": TODAY})
|
||||
page = instructor.get(f"/instructor/live?date={TODAY}").get_data(as_text=True)
|
||||
first_call = re.search(r'/instructor/call/(\d+)/outcome', page).group(1)
|
||||
|
||||
# Pressing next again does not create a second pending call.
|
||||
instructor.post("/instructor/live/next", data={"date": TODAY})
|
||||
page = instructor.get(f"/instructor/live?date={TODAY}").get_data(as_text=True)
|
||||
assert re.findall(r'/instructor/call/(\d+)/outcome', page) == [first_call]
|
||||
|
||||
|
||||
def test_generate_and_print(instructor):
|
||||
resp = instructor.post(
|
||||
f"/instructor/day/{TODAY}/generate",
|
||||
data={"n": "12"},
|
||||
follow_redirects=True,
|
||||
)
|
||||
page = resp.get_data(as_text=True)
|
||||
assert page.count("<tr>") == 13 # header + 12 rows
|
||||
|
||||
|
||||
def test_regenerate_replaces_pending(instructor):
|
||||
instructor.post(f"/instructor/day/{TODAY}/generate", data={"n": "12"})
|
||||
resp = instructor.post(
|
||||
f"/instructor/day/{TODAY}/generate",
|
||||
data={"n": "5"},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert resp.get_data(as_text=True).count("<tr>") == 6
|
||||
|
||||
|
||||
def test_day_edit_saves_outcomes(instructor):
|
||||
instructor.post(f"/instructor/day/{TODAY}/generate", data={"n": "2"})
|
||||
page = instructor.get(f"/instructor/day/{TODAY}").get_data(as_text=True)
|
||||
ids = re.findall(r'name="status-(\d+)"', page)
|
||||
assert len(ids) == 2
|
||||
|
||||
resp = instructor.post(
|
||||
f"/instructor/day/{TODAY}",
|
||||
data={
|
||||
f"status-{ids[0]}": "answered",
|
||||
f"assessment-{ids[0]}": "POOR",
|
||||
f"note-{ids[0]}": "rough day",
|
||||
f"status-{ids[1]}": "pending",
|
||||
f"delete-{ids[1]}": "on",
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
page = resp.get_data(as_text=True)
|
||||
assert 'value="rough day"' in page
|
||||
assert len(re.findall(r'name="status-(\d+)"', page)) == 1
|
||||
|
||||
|
||||
def test_outcome_rejects_bad_action_and_foreign_call(instructor):
|
||||
instructor.post("/instructor/live/next", data={"date": TODAY})
|
||||
page = instructor.get(f"/instructor/live?date={TODAY}").get_data(as_text=True)
|
||||
call_id = re.search(r'/instructor/call/(\d+)/outcome', page).group(1)
|
||||
|
||||
resp = instructor.post(
|
||||
f"/instructor/call/{call_id}/outcome", data={"action": "nonsense"}
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
resp = instructor.post(
|
||||
"/instructor/call/99999/outcome", data={"action": "GOOD"}
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_settings_roundtrip(instructor):
|
||||
resp = instructor.post(
|
||||
"/instructor/settings",
|
||||
data={"selection_mode": "cycle", "weight_factor": "3.0"},
|
||||
follow_redirects=True,
|
||||
)
|
||||
page = resp.get_data(as_text=True)
|
||||
assert '<option value="cycle" selected>' in page
|
||||
assert 'value="3.0"' in page
|
||||
# Checkbox left unchecked turns assessment visibility off.
|
||||
assert "checked" not in page
|
||||
Reference in New Issue
Block a user