1
0
Files
coldcall_lti/tests/test_instructor_ui.py
Benjamin Mako Hill 4e29baddf8 Setup banner, assessment timestamps, and installation docs
Instructor home shows a setup banner while a course has no class
schedule, since that is the one gap that degrades the student
experience (free-date opt-outs, no withdrawal locking); it points at
the prefilled schedule form and settings and disappears once any
class day exists.

Calls gain assessment_entered_at, stamped when an outcome is recorded
or actually changed (bulk saves that change nothing don't restamp),
null for pending calls and legacy imports, and included in the calls
export — distinguishing assessed-in-class from assessed-later.

The README setup section now gives both install routes: pure
pip/venv, and Debian system packages with the apt line. Fixing this
uncovered that numpy was missing from the project dependencies (the
grading engine needs it; the system-site-packages venv had masked
the omission).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 13:35:25 -07:00

313 lines
11 KiB
Python

import datetime
import re
import pytest
from conftest import outcome_actions
TODAY_DATE = datetime.date.today()
TODAY = TODAY_DATE.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": outcome_actions(page)["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_and_labels_change(instructor):
page = instructor.get("/instructor/").get_data(as_text=True)
assert "Generate list for" in page
instructor.post(f"/instructor/day/{TODAY}/generate", data={"n": "12"})
page = instructor.get("/instructor/").get_data(as_text=True)
assert f"View the list for {TODAY}" in page
assert "Regenerate list" in page
# Regenerating replaces the unused list outright.
resp = instructor.post(
f"/instructor/day/{TODAY}/generate",
data={"n": "5"},
follow_redirects=True,
)
assert resp.get_data(as_text=True).count("<tr>") == 6
# No recorded outcomes yet: no confirmation dialog wired up.
page = instructor.get(f"/instructor/day/{TODAY}/print").get_data(as_text=True)
assert "onsubmit" not in page
# Record one outcome; the regenerate form now carries a warning.
edit = instructor.get(f"/instructor/day/{TODAY}").get_data(as_text=True)
call_id = re.findall(r'name="status-(\d+)"', edit)[0]
instructor.post(
f"/instructor/day/{TODAY}",
data={f"status-{call_id}": "answered"},
)
page = instructor.get(f"/instructor/day/{TODAY}/print").get_data(as_text=True)
assert "already have recorded outcomes" in page
def test_assessment_entered_at_stamping(instructor):
from coldcall_lti.models import Call
# Live flow: pending call has no stamp; recording an outcome sets it.
instructor.post("/instructor/live/next", data={"date": TODAY})
page = instructor.get(f"/instructor/live?date={TODAY}").get_data(as_text=True)
call_id = int(re.search(r"/instructor/call/(\d+)/outcome", page).group(1))
app = instructor.application
def entered_at(cid):
with app.app_context():
db = app.extensions["db_session_factory"]()
value = db.get(Call, cid).assessment_entered_at
db.close()
return value
assert entered_at(call_id) is None
instructor.post(
f"/instructor/call/{call_id}/outcome",
data={"action": outcome_actions(page)["GOOD"]},
)
first_stamp = entered_at(call_id)
assert first_stamp is not None
# A day-editor save that changes nothing does not restamp; a real
# change does.
edit = instructor.get(f"/instructor/day/{TODAY}").get_data(as_text=True)
good_id = re.search(
r'value="(\d+)" selected>GOOD</option>', edit
).group(1)
instructor.post(
f"/instructor/day/{TODAY}",
data={f"status-{call_id}": "answered",
f"assessment-{call_id}": good_id},
)
assert entered_at(call_id) == first_stamp
instructor.post(
f"/instructor/day/{TODAY}",
data={f"status-{call_id}": "missing"},
)
assert entered_at(call_id) != first_stamp
# The export carries the column.
csv_text = instructor.get("/instructor/export/calls.csv").get_data(as_text=True)
assert "assessment_entered_at" in csv_text.splitlines()[0]
def test_setup_banner_until_schedule_exists(instructor):
# The dev course seeds a schedule, so no banner by default.
page = instructor.get("/instructor/").get_data(as_text=True)
assert "No class schedule yet" not in page
# Strip the schedule; the banner appears.
from coldcall_lti.models import ClassDay
app = instructor.application
with app.app_context():
db = app.extensions["db_session_factory"]()
db.query(ClassDay).delete()
db.commit()
db.close()
page = instructor.get("/instructor/").get_data(as_text=True)
assert "No class schedule yet" in page
# Adding a single class day dismisses it.
future = (TODAY_DATE + datetime.timedelta(days=30)).isoformat()
instructor.post("/instructor/schedule/add", data={"date": future})
page = instructor.get("/instructor/").get_data(as_text=True)
assert "No class schedule yet" not in page
def test_home_working_date_override(instructor):
tomorrow = (
datetime.date.today() + datetime.timedelta(days=1)
).isoformat()
page = instructor.get(f"/instructor/?date={tomorrow}").get_data(as_text=True)
assert f"available on {tomorrow}" in page
assert f"Generate list for {tomorrow}" in page
assert "back to today" in page
# Generate tomorrow's list a day in advance; today stays untouched.
resp = instructor.post(
f"/instructor/day/{tomorrow}/generate",
data={"n": "5"},
follow_redirects=True,
)
assert resp.get_data(as_text=True).count("<tr>") == 6
page = instructor.get(f"/instructor/day/{TODAY}/print").get_data(as_text=True)
assert "No call list has been generated" in page
def test_add_call_after_the_fact(instructor):
page = instructor.get(f"/instructor/day/{TODAY}").get_data(as_text=True)
student_id = re.search(r'<option value="(\d+)">', page).group(1)
good_id = re.search(r'value="(\d+)">GOOD</option>', page).group(1)
resp = instructor.post(
f"/instructor/day/{TODAY}/add",
data={
"student_id": student_id,
"status": "answered",
"assessment": good_id,
"note": "volunteered",
},
follow_redirects=True,
)
page = resp.get_data(as_text=True)
assert len(re.findall(r'name="status-(\d+)"', page)) == 1
assert 'value="volunteered"' in page
# Junk student ids are rejected.
resp = instructor.post(
f"/instructor/day/{TODAY}/add",
data={"student_id": "99999", "status": "answered"},
)
assert resp.status_code == 400
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
poor_id = re.search(r'value="(\d+)"[^>]*>POOR</option>', page).group(1)
resp = instructor.post(
f"/instructor/day/{TODAY}",
data={
f"status-{ids[0]}": "answered",
f"assessment-{ids[0]}": poor_id,
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": "missing"}
)
assert resp.status_code == 404
def test_assessment_scale_editing(instructor):
page = instructor.get("/instructor/settings").get_data(as_text=True)
ids = {
label: lid
for lid, label in re.findall(
r'name="label-(\d+)" value="([^"]+)"', page
)
}
assert set(ids) == {"GOOD", "SATISFACTORY", "POOR", "NO MEANINGFUL ANSWER"}
# Record a call assessed GOOD so that level is in use.
record = instructor.post(
"/instructor/live/next", data={"date": TODAY}, follow_redirects=True
).get_data(as_text=True)
call_id = re.search(r"/instructor/call/(\d+)/outcome", record).group(1)
instructor.post(
f"/instructor/call/{call_id}/outcome",
data={"action": f"level-{ids['GOOD']}"},
)
# Rename and re-point the in-use level; delete an unused one; add one.
form = {"selection_mode": "weighted", "weight_factor": "2",
"show_assessments": "on",
"new_label": "HEROIC", "new_points": "110"}
for label, lid in ids.items():
form[f"label-{lid}"] = "EXCELLENT" if label == "GOOD" else label
form[f"points-{lid}"] = "90" if label == "GOOD" else "50"
form[f"position-{lid}"] = "0"
form[f"delete-{ids['POOR']}"] = "on"
form[f"delete-{ids['GOOD']}"] = "on" # in use: must survive
page = instructor.post(
"/instructor/settings", data=form, follow_redirects=True
).get_data(as_text=True)
assert 'value="EXCELLENT"' in page # renamed
assert 'value="90.0"' in page # re-pointed
assert "POOR" not in page # unused level deleted
assert re.search(r"in use \(1\s+call\)", page) # used level kept
assert "HEROIC" not in page # >100 points rejected
# The rename follows through to recorded calls.
day_page = instructor.get(f"/instructor/day/{TODAY}").get_data(as_text=True)
assert "EXCELLENT" in day_page
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