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("") == 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("") == 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', 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("") == 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'', 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', 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 '