import datetime import pytest from coldcall_lti import grading, models, scales D0 = datetime.date(2026, 10, 1) def setup_course(db, n_students=6, n_days=10, calls_per_day=3, n_sims=400, **course_kw): course = models.Course( lti_context_id="ctx-1", n_sims=n_sims, sim_seed=1, **course_kw ) db.add(course) db.flush() models.ensure_default_levels(db, course) levels = { lvl.label: lvl for lvl in models.assessment_levels(db, course.id) } students = [] for i in range(n_students): s = models.Student(canvas_user_id=f"u{i}", name=f"Student {i}") db.add(s) students.append(s) db.flush() for s in students: db.add(models.Enrollment(course_id=course.id, student_id=s.id)) db.flush() # Round-robin answered calls, all GOOD, over n_days class days. days = [D0 + datetime.timedelta(days=i) for i in range(n_days)] idx = 0 for day in days: for _ in range(calls_per_day): db.add( models.Call( course_id=course.id, student_id=students[idx % n_students].id, session_date=day, status=models.STATUS_ANSWERED, assessment_id=levels["GOOD"].id, ) ) idx += 1 db.flush() return course, students, days, levels def test_all_present_all_good_grades_full(db_session): course, students, days, levels = setup_course(db_session) results = grading.compute(db_session, course) assert results["curve"][0] == 0.0 for row in results["students"]: assert row["q_points"] == 100.0 assert row["days_missed"] == 0 assert row["final_points"] == 100.0 def test_foregone_curve_is_nonnegative_and_grows(db_session): course, *_ = setup_course(db_session) results = grading.compute(db_session, course) curve = results["curve"] assert all(v >= 0 for v in curve) # Missing most days must cost more than missing one. assert curve[8] > curve[1] def test_zero_answer_student_floors_to_zero(db_session): course, students, days, levels = setup_course(db_session) extra = models.Student(canvas_user_id="u-silent", name="Silent Student") db_session.add(extra) db_session.flush() db_session.add( models.Enrollment(course_id=course.id, student_id=extra.id) ) db_session.flush() results = grading.compute(db_session, course) row = next( r for r in results["students"] if r["student_id"] == extra.id ) assert row["q_points"] is None assert row["final_points"] == 0.0 def test_luck_protection_present_but_rarely_called(db_session): """A student who is always available but was called only once is not penalized: quality is their grade.""" course, students, days, levels = setup_course(db_session) lucky = models.Student(canvas_user_id="u-lucky", name="Lucky Student") db_session.add(lucky) db_session.flush() db_session.add( models.Enrollment(course_id=course.id, student_id=lucky.id) ) db_session.add( models.Call( course_id=course.id, student_id=lucky.id, session_date=days[0], status=models.STATUS_ANSWERED, assessment_id=levels["POOR"].id, ) ) db_session.flush() results = grading.compute(db_session, course) row = next( r for r in results["students"] if r["student_id"] == lucky.id ) assert row["days_missed"] == 0 assert row["final_points"] == pytest.approx(69.7, abs=0.01) def test_absences_within_allowance_are_free(db_session): course, students, days, levels = setup_course( db_session, allowance_sd_units=5.0 ) # One student opts out of two days. for day in days[:2]: db_session.add( models.OptOut( course_id=course.id, student_id=students[0].id, date=day ) ) db_session.flush() results = grading.compute(db_session, course) row = next( r for r in results["students"] if r["student_id"] == students[0].id ) assert row["days_missed"] == 2 # Huge allowance: no deduction despite the absences. assert row["final_points"] == row["q_points"] def test_heavy_absence_is_deducted(db_session): course, students, days, levels = setup_course( db_session, allowance_sd_units=0.25 ) for day in days[:8]: db_session.add( models.OptOut( course_id=course.id, student_id=students[0].id, date=day ) ) db_session.flush() results = grading.compute(db_session, course) row = next( r for r in results["students"] if r["student_id"] == students[0].id ) assert row["final_points"] < row["q_points"] def test_form_penalty_only_for_uncovered_missing_days(db_session): course, students, days, levels = setup_course(db_session) caught_s, covered_s = students[0], students[1] # Both drawn-but-missing on day 3; only covered_s filed the form. for s in (caught_s, covered_s): db_session.add( models.Call( course_id=course.id, student_id=s.id, session_date=days[3], status=models.STATUS_MISSING, ) ) db_session.add( models.OptOut( course_id=course.id, student_id=covered_s.id, date=days[3] ) ) db_session.flush() results = grading.compute(db_session, course) rows = {r["student_id"]: r for r in results["students"]} assert rows[caught_s.id]["caught"] == 1 assert rows[covered_s.id]["caught"] == 0 # Both have the same quality and same availability; the caught # student ends exactly one form penalty lower. diff = (rows[covered_s.id]["final_points"] - rows[caught_s.id]["final_points"]) assert diff == pytest.approx(course.form_penalty_points, abs=0.01) def test_dropped_students_are_excluded_everywhere(db_session): course, students, days, levels = setup_course(db_session) baseline = grading.compute(db_session, course) dropped = models.Student(canvas_user_id="u-dropped", name="Dropped") db_session.add(dropped) db_session.flush() db_session.add( models.Enrollment( course_id=course.id, student_id=dropped.id, active=False ) ) for day in days[:3]: db_session.add( models.Call( course_id=course.id, student_id=dropped.id, session_date=day, status=models.STATUS_ANSWERED, assessment_id=levels["GOOD"].id, ) ) db_session.flush() results = grading.compute(db_session, course) assert results["excluded_students"] == 1 assert results["excluded_calls"] == 3 assert results["n_students"] == baseline["n_students"] assert dropped.id not in {r["student_id"] for r in results["students"]} # Same seed, same effective inputs: identical simulation results. assert results["sd_full"] == baseline["sd_full"] assert results["curve"] == baseline["curve"] def test_scales(): course = models.Course(lti_context_id="x", scale_type="uw4") assert scales.display(course, 100.0) == "4.00" assert scales.display(course, 65.15) == "1.70" assert scales.display(course, 84.85) == "3.00" assert scales.display(course, 0.0) == "0.00" course.scale_type = "table" course.scale_config = '[[93, "A"], [90, "A-"], [0, "F"]]' assert scales.display(course, 95) == "A" assert scales.display(course, 91.2) == "A-" assert scales.display(course, 10) == "F" course.scale_type = "none" assert scales.display(course, 87.5) == "87.5" course.scale_type = "uw4" assert scales.ags_score(course, 84.85) == (3.0, 4.0) course.scale_type = "table" assert scales.ags_score(course, 84.85) == (84.85, 100.0)