Phase 1: scaffold, data models, selection logic, tests
Flask application skeleton with SQLAlchemy models for courses (including per-course settings), students, enrollments, opt-outs, and calls; the weighted and cycle selection logic ported from the manual coldcall scripts; alembic migrations; and a pytest suite covering selection behavior and the model query helpers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
15
tests/conftest.py
Normal file
15
tests/conftest.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from coldcall_lti.db import Base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_session():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
session = sessionmaker(bind=engine)()
|
||||
yield session
|
||||
session.close()
|
||||
engine.dispose()
|
||||
87
tests/test_models.py
Normal file
87
tests/test_models.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import datetime
|
||||
|
||||
from coldcall_lti import models
|
||||
|
||||
|
||||
def make_course_with_students(session, n=3):
|
||||
course = models.Course(lti_context_id="ctx-1", title="Test Course")
|
||||
session.add(course)
|
||||
students = []
|
||||
for i in range(n):
|
||||
s = models.Student(canvas_user_id=f"user-{i}", name=f"Student {i}")
|
||||
session.add(s)
|
||||
students.append(s)
|
||||
session.flush()
|
||||
for s in students:
|
||||
session.add(models.Enrollment(course_id=course.id, student_id=s.id))
|
||||
session.flush()
|
||||
return course, students
|
||||
|
||||
|
||||
def test_answered_call_counts_ignores_skipped_and_pending(db_session):
|
||||
course, students = make_course_with_students(db_session)
|
||||
day = datetime.date(2026, 10, 1)
|
||||
a, b, c = students
|
||||
for status, student in [
|
||||
(models.STATUS_ANSWERED, a),
|
||||
(models.STATUS_ANSWERED, a),
|
||||
(models.STATUS_SKIPPED, a),
|
||||
(models.STATUS_ANSWERED, b),
|
||||
(models.STATUS_MISSING, b),
|
||||
(models.STATUS_PENDING, c),
|
||||
]:
|
||||
db_session.add(
|
||||
models.Call(
|
||||
course_id=course.id,
|
||||
student_id=student.id,
|
||||
session_date=day,
|
||||
status=status,
|
||||
)
|
||||
)
|
||||
db_session.flush()
|
||||
|
||||
counts = models.answered_call_counts(db_session, course.id)
|
||||
assert counts == {a.id: 2, b.id: 1}
|
||||
|
||||
|
||||
def test_students_present_excludes_optouts_and_inactive(db_session):
|
||||
course, students = make_course_with_students(db_session)
|
||||
day = datetime.date(2026, 10, 1)
|
||||
a, b, c = students
|
||||
|
||||
db_session.add(
|
||||
models.OptOut(course_id=course.id, student_id=a.id, date=day)
|
||||
)
|
||||
enrollment_b = (
|
||||
db_session.query(models.Enrollment)
|
||||
.filter_by(course_id=course.id, student_id=b.id)
|
||||
.one()
|
||||
)
|
||||
enrollment_b.active = False
|
||||
db_session.flush()
|
||||
|
||||
present = models.students_present(db_session, course.id, day)
|
||||
assert [s.id for s in present] == [c.id]
|
||||
|
||||
# The opt-out only applies on its own date.
|
||||
other_day = datetime.date(2026, 10, 2)
|
||||
present = models.students_present(db_session, course.id, other_day)
|
||||
assert {s.id for s in present} == {a.id, c.id}
|
||||
|
||||
|
||||
def test_students_present_excludes_instructor_role(db_session):
|
||||
course, students = make_course_with_students(db_session)
|
||||
instructor = models.Student(canvas_user_id="teacher-1", name="Teacher")
|
||||
db_session.add(instructor)
|
||||
db_session.flush()
|
||||
db_session.add(
|
||||
models.Enrollment(
|
||||
course_id=course.id, student_id=instructor.id, role="instructor"
|
||||
)
|
||||
)
|
||||
db_session.flush()
|
||||
|
||||
present = models.students_present(
|
||||
db_session, course.id, datetime.date(2026, 10, 1)
|
||||
)
|
||||
assert instructor.id not in {s.id for s in present}
|
||||
45
tests/test_selection.py
Normal file
45
tests/test_selection.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import random
|
||||
from collections import Counter
|
||||
|
||||
from coldcall_lti import selection
|
||||
|
||||
|
||||
STUDENTS = ["a", "b", "c", "d"]
|
||||
|
||||
|
||||
def test_weights_halve_per_answered_call():
|
||||
weights = selection.compute_weights(STUDENTS, {"a": 0, "b": 1, "c": 3}, 2.0)
|
||||
assert weights == {"a": 1.0, "b": 0.5, "c": 0.125, "d": 1.0}
|
||||
|
||||
|
||||
def test_weight_factor_one_is_uniform():
|
||||
weights = selection.compute_weights(STUDENTS, {"a": 5, "b": 2}, 1.0)
|
||||
assert set(weights.values()) == {1.0}
|
||||
|
||||
|
||||
def test_heavily_called_student_selected_less():
|
||||
rng = random.Random(42)
|
||||
counts = Counter(
|
||||
selection.select_student(STUDENTS, {"a": 4}, 2.0, rng)
|
||||
for _ in range(4000)
|
||||
)
|
||||
# "a" has weight 1/16 against 1 for the others, so it should get
|
||||
# roughly 1/49th of the picks; the others roughly a third each.
|
||||
assert counts["a"] < 250
|
||||
for s in ("b", "c", "d"):
|
||||
assert 1000 < counts[s] < 1700
|
||||
|
||||
|
||||
def test_generate_call_list_downweights_within_list():
|
||||
rng = random.Random(7)
|
||||
picks = selection.generate_call_list(STUDENTS, {}, 200, 2.0, rng)
|
||||
assert len(picks) == 200
|
||||
counts = Counter(picks)
|
||||
# Sequential downweighting keeps the distribution close to even.
|
||||
assert all(35 < counts[s] < 65 for s in STUDENTS)
|
||||
|
||||
|
||||
def test_generate_cycle_list_covers_everyone_once():
|
||||
rng = random.Random(1)
|
||||
picks = selection.generate_cycle_list(STUDENTS, rng)
|
||||
assert sorted(picks) == sorted(STUDENTS)
|
||||
Reference in New Issue
Block a user