Generating a call list is non-destructive by intent: the button reads Regenerate when an unused list exists and replaces it freely, with a confirmation only when the day already has recorded outcomes (which are always kept). The print view shows resolved calls' outcomes, offers generate/regenerate in place, and the home page links to the existing list. The day editor now supports adding calls after the fact (any roster student, with status/assessment/note), completing full editability: add, edit, delete. Cycle mode is now a true rolling rotation driven by non-skipped call counts: batches of n take the next students of the current pass, remainder batches come up short rather than wrapping, a new pass starts when everyone has been called, and the cycle continues across lists and class days. Live mode uses the same rule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
150 lines
5.1 KiB
Python
150 lines
5.1 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_cycle_batches_continue_across_days(db_session):
|
|
course, students = make_course(db_session, mode=models.SELECTION_CYCLE)
|
|
rng = random.Random(1)
|
|
day2 = DAY + datetime.timedelta(days=2)
|
|
|
|
first = calls.generate_day_list(db_session, course, DAY, n=3, rng=rng)
|
|
assert len(first) == 3
|
|
# Next day's batch continues the pass: the remaining student only.
|
|
second = calls.generate_day_list(db_session, course, day2, n=3, rng=rng)
|
|
assert len(second) == 1
|
|
assert sorted(c.student_id for c in first + second) == sorted(
|
|
s.id for s in students
|
|
)
|
|
# Pass complete: the next batch starts a fresh pass.
|
|
third = calls.generate_day_list(db_session, course, day2, n=3, rng=rng)
|
|
assert len(third) == 3
|
|
|
|
|
|
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
|