1
0
Files
coldcall_lti/tests/test_calls.py
Benjamin Mako Hill 903209d3d5 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>
2026-07-31 16:40:04 -07:00

132 lines
4.3 KiB
Python

import datetime
import random
from collections import Counter
from coldcall_lti import calls, models
DAY = datetime.date(2026, 10, 1)
def make_course(db, n=4, mode=models.SELECTION_WEIGHTED):
course = models.Course(lti_context_id="ctx-1", selection_mode=mode)
db.add(course)
students = []
for i in range(n):
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()
return course, students
def test_pick_next_honors_optouts(db_session):
course, students = make_course(db_session, n=2)
db_session.add(
models.OptOut(
course_id=course.id, student_id=students[0].id, date=DAY
)
)
db_session.flush()
rng = random.Random(1)
for _ in range(20):
assert calls.pick_next_student(db_session, course, DAY, rng).id == students[1].id
def test_pick_next_weighted_downweights(db_session):
course, students = make_course(db_session)
heavy = students[0]
for _ in range(4):
db_session.add(
models.Call(
course_id=course.id,
student_id=heavy.id,
session_date=DAY - datetime.timedelta(days=7),
status=models.STATUS_ANSWERED,
)
)
db_session.flush()
rng = random.Random(42)
picks = Counter(
calls.pick_next_student(db_session, course, DAY, rng).id
for _ in range(400)
)
assert picks[heavy.id] < min(picks[s.id] for s in students[1:]) / 2
def test_cycle_mode_calls_everyone_before_repeating(db_session):
course, students = make_course(db_session, mode=models.SELECTION_CYCLE)
rng = random.Random(7)
called = []
for _ in range(len(students)):
call = calls.create_live_call(db_session, course, DAY, rng)
call.status = models.STATUS_ANSWERED
db_session.flush()
called.append(call.student_id)
assert sorted(called) == sorted(s.id for s in students)
# The next draw starts a fresh pass rather than failing.
assert calls.create_live_call(db_session, course, DAY, rng) is not None
def test_cycle_mode_recalls_skipped_students(db_session):
course, students = make_course(db_session, n=2, mode=models.SELECTION_CYCLE)
rng = random.Random(3)
call = calls.create_live_call(db_session, course, DAY, rng)
call.status = models.STATUS_SKIPPED
db_session.flush()
# A skipped call doesn't count as having been called this pass.
pool_ids = {
calls.pick_next_student(db_session, course, DAY, rng).id
for _ in range(20)
}
assert call.student_id in pool_ids
def test_generate_day_list_cycle_covers_roster(db_session):
course, students = make_course(db_session, mode=models.SELECTION_CYCLE)
generated = calls.generate_day_list(db_session, course, DAY, rng=random.Random(1))
assert sorted(c.student_id for c in generated) == sorted(s.id for s in students)
assert all(c.status == models.STATUS_PENDING for c in generated)
def test_generate_day_list_weighted_length_and_optouts(db_session):
course, students = make_course(db_session)
db_session.add(
models.OptOut(
course_id=course.id, student_id=students[0].id, date=DAY
)
)
db_session.flush()
generated = calls.generate_day_list(
db_session, course, DAY, n=10, rng=random.Random(1)
)
assert len(generated) == 10
assert students[0].id not in {c.student_id for c in generated}
def test_clear_pending_keeps_resolved(db_session):
course, students = make_course(db_session)
generated = calls.generate_day_list(
db_session, course, DAY, n=5, rng=random.Random(1)
)
generated[0].status = models.STATUS_ANSWERED
db_session.flush()
removed = calls.clear_pending(db_session, course.id, DAY)
assert removed == 4
remaining = calls.calls_for_day(db_session, course.id, DAY)
assert len(remaining) == 1
assert remaining[0].status == models.STATUS_ANSWERED
def test_unresolved_call_is_oldest_pending(db_session):
course, students = make_course(db_session)
generated = calls.generate_day_list(
db_session, course, DAY, n=3, rng=random.Random(1)
)
assert calls.unresolved_call(db_session, course.id, DAY).id == generated[0].id