1
0

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:
2026-07-31 16:26:53 -07:00
commit 8d8a463f81
16 changed files with 795 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.venv/
__pycache__/
*.pyc
.pytest_cache/
instance/
*.egg-info/
.claude/

65
README.md Normal file
View File

@@ -0,0 +1,65 @@
# coldcall-lti
A Canvas external tool (LTI 1.3) for managing cold calls in case-based
classes: it selects students to call using weighted randomness, records
what happened with each call, lets students report planned absences, and
reports participation data back to both instructor and students. It
replaces a manual workflow built on exported rosters, Google Forms, and
local scripts.
## How it works
The tool is a Flask application that Canvas launches over LTI 1.3. A
single URL serves everyone: Canvas identifies the person and course on
each launch, so instructors get the call-list and reporting views while
students get the absence form and their own history. The roster comes
from Canvas through the Names and Role Provisioning Service, which means
adds and drops are picked up automatically rather than reconciled by
hand.
Selection uses the same weighting as the manual system it replaces: each
answered call divides a student's weight by the course's weight factor
(default 2), so students who have answered more questions become
progressively less likely to be called. Each course can instead use
"cycle" mode, which shuffles the roster and calls everyone exactly once.
These, along with whether students can see their own assessments, are
per-course settings.
## Layout
- `coldcall_lti/models.py` — SQLAlchemy models: courses (with their
settings), students, enrollments, opt-outs, and calls, plus the query
helpers that feed selection.
- `coldcall_lti/selection.py` — the weighted and cycle selection logic.
Kept free of database and web dependencies so it can be tested and
reasoned about on its own.
- `coldcall_lti/__init__.py` — the Flask application factory.
- `migrations/` — alembic migrations. The schema avoids
database-specific types so the same migrations run on SQLite (the
default) and MariaDB/MySQL; switching is a matter of changing
`COLDCALL_DATABASE_URL`.
- `tests/` — pytest suite covering selection behavior and the model
helpers.
## Setup
Development uses a virtualenv that shares the system's Debian-packaged
libraries (Flask, SQLAlchemy, alembic, pytest) and adds the one
PyPI-only dependency, the maintained `pylti1p3next` fork of PyLTI1p3:
```
python3 -m venv --system-site-packages .venv
.venv/bin/pip install pylti1p3next
.venv/bin/pip install -e .
```
Create the database and run the tests:
```
.venv/bin/python -m alembic upgrade head
.venv/bin/python -m pytest tests/
```
Configuration is by environment variable: `COLDCALL_DATABASE_URL` (any
SQLAlchemy URL; defaults to an SQLite file under `instance/`) and
`COLDCALL_SECRET_KEY` for Flask sessions.

116
alembic.ini Normal file
View File

@@ -0,0 +1,116 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
# Use forward slashes (/) also on windows to provide an os agnostic path
script_location = migrations
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = sqlite:///instance/coldcall.sqlite3
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

22
coldcall_lti/__init__.py Normal file
View File

@@ -0,0 +1,22 @@
import os
from flask import Flask
from .config import Config
from .db import make_engine, make_session_factory
def create_app(config=Config):
app = Flask(__name__, instance_relative_config=True)
app.config.from_object(config)
os.makedirs(app.instance_path, exist_ok=True)
engine = make_engine(app.config["DATABASE_URL"])
app.extensions["db_engine"] = engine
app.extensions["db_session_factory"] = make_session_factory(engine)
@app.get("/healthz")
def healthz():
return {"status": "ok"}
return app

8
coldcall_lti/config.py Normal file
View File

@@ -0,0 +1,8 @@
import os
class Config:
SECRET_KEY = os.environ.get("COLDCALL_SECRET_KEY", "dev-only-not-secret")
DATABASE_URL = os.environ.get(
"COLDCALL_DATABASE_URL", "sqlite:///instance/coldcall.sqlite3"
)

14
coldcall_lti/db.py Normal file
View File

@@ -0,0 +1,14 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
class Base(DeclarativeBase):
pass
def make_engine(url, echo=False):
return create_engine(url, echo=echo)
def make_session_factory(engine):
return sessionmaker(bind=engine, expire_on_commit=False)

150
coldcall_lti/models.py Normal file
View File

@@ -0,0 +1,150 @@
import datetime
from sqlalchemy import (
Boolean,
Date,
DateTime,
Float,
ForeignKey,
String,
Text,
UniqueConstraint,
func,
select,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .db import Base
def utcnow():
return datetime.datetime.now(datetime.UTC)
# Call outcomes. ANSWERED is the only status that counts toward a
# student's selection weight; SKIPPED calls are treated as if they never
# happened; MISSING means the student was called but absent without an
# opt-out. PENDING is used for pre-generated (printable) lists whose
# outcomes are recorded after class.
STATUS_ANSWERED = "answered"
STATUS_MISSING = "missing"
STATUS_SKIPPED = "skipped"
STATUS_PENDING = "pending"
CALL_STATUSES = (STATUS_ANSWERED, STATUS_MISSING, STATUS_SKIPPED, STATUS_PENDING)
# Default assessment vocabulary, carried over from the manual system.
ASSESSMENTS = ("GOOD", "SATISFACTORY", "POOR", "NO MEANINGFUL ANSWER")
SELECTION_WEIGHTED = "weighted"
SELECTION_CYCLE = "cycle"
class Course(Base):
__tablename__ = "courses"
id: Mapped[int] = mapped_column(primary_key=True)
lti_context_id: Mapped[str] = mapped_column(String(255), unique=True)
lti_deployment_id: Mapped[str | None] = mapped_column(String(255))
title: Mapped[str | None] = mapped_column(String(255))
# Per-course settings.
weight_factor: Mapped[float] = mapped_column(Float, default=2.0)
selection_mode: Mapped[str] = mapped_column(
String(16), default=SELECTION_WEIGHTED
)
show_assessments: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime, default=utcnow
)
enrollments: Mapped[list["Enrollment"]] = relationship(back_populates="course")
class Student(Base):
__tablename__ = "students"
id: Mapped[int] = mapped_column(primary_key=True)
# The LTI "sub" claim: stable, opaque, unique per Canvas user.
canvas_user_id: Mapped[str] = mapped_column(String(255), unique=True)
name: Mapped[str | None] = mapped_column(String(255))
sortable_name: Mapped[str | None] = mapped_column(String(255))
email: Mapped[str | None] = mapped_column(String(255))
avatar_url: Mapped[str | None] = mapped_column(String(1024))
enrollments: Mapped[list["Enrollment"]] = relationship(back_populates="student")
class Enrollment(Base):
__tablename__ = "enrollments"
__table_args__ = (UniqueConstraint("course_id", "student_id"),)
id: Mapped[int] = mapped_column(primary_key=True)
course_id: Mapped[int] = mapped_column(ForeignKey("courses.id"))
student_id: Mapped[int] = mapped_column(ForeignKey("students.id"))
role: Mapped[str] = mapped_column(String(32), default="student")
# Set False when a student disappears from the NRPS roster (dropped).
active: Mapped[bool] = mapped_column(Boolean, default=True)
course: Mapped[Course] = relationship(back_populates="enrollments")
student: Mapped[Student] = relationship(back_populates="enrollments")
class OptOut(Base):
__tablename__ = "optouts"
__table_args__ = (UniqueConstraint("course_id", "student_id", "date"),)
id: Mapped[int] = mapped_column(primary_key=True)
course_id: Mapped[int] = mapped_column(ForeignKey("courses.id"))
student_id: Mapped[int] = mapped_column(ForeignKey("students.id"))
date: Mapped[datetime.date] = mapped_column(Date)
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime, default=utcnow
)
class Call(Base):
__tablename__ = "calls"
id: Mapped[int] = mapped_column(primary_key=True)
course_id: Mapped[int] = mapped_column(ForeignKey("courses.id"))
student_id: Mapped[int] = mapped_column(ForeignKey("students.id"))
session_date: Mapped[datetime.date] = mapped_column(Date)
status: Mapped[str] = mapped_column(String(16), default=STATUS_PENDING)
assessment: Mapped[str | None] = mapped_column(String(32))
note: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime, default=utcnow
)
student: Mapped[Student] = relationship()
def answered_call_counts(session, course_id):
"""Number of answered calls per student, the input to selection
weighting. Skipped, pending, and missing calls do not count, matching
the old system where only answered calls reduced a student's odds."""
rows = session.execute(
select(Call.student_id, func.count())
.where(Call.course_id == course_id, Call.status == STATUS_ANSWERED)
.group_by(Call.student_id)
).all()
return dict(rows)
def students_present(session, course_id, on_date):
"""Active student-role enrollees minus those opted out for the date."""
opted_out = select(OptOut.student_id).where(
OptOut.course_id == course_id, OptOut.date == on_date
)
rows = session.execute(
select(Student)
.join(Enrollment, Enrollment.student_id == Student.id)
.where(
Enrollment.course_id == course_id,
Enrollment.role == "student",
Enrollment.active.is_(True),
Student.id.not_in(opted_out),
)
).scalars()
return list(rows)

42
coldcall_lti/selection.py Normal file
View File

@@ -0,0 +1,42 @@
"""Cold-call selection logic.
Ported from the manual system's coldcall.py: each answered call a
student has already received divides their selection weight by the
course's weight factor, so with the default factor of 2 a student who
has answered n questions is 2^n times less likely to be picked than one
who has answered none. A factor of 1 makes selection uniform.
The alternative "cycle" mode shuffles the present students and calls
each exactly once, the equivalent of the old --shuffle flag.
"""
import random
def compute_weights(student_ids, prev_counts, weight_factor=2.0):
return {
s: weight_factor ** -prev_counts.get(s, 0) for s in student_ids
}
def select_student(student_ids, prev_counts, weight_factor=2.0, rng=random):
weights = compute_weights(student_ids, prev_counts, weight_factor)
return rng.choices(list(weights), weights=list(weights.values()), k=1)[0]
def generate_call_list(student_ids, prev_counts, n, weight_factor=2.0, rng=random):
"""A list of n weighted picks. Counts update as the list is built,
so a student picked early is downweighted for later slots, matching
the old manual script's behavior of recording each call before
drawing the next."""
counts = dict(prev_counts)
picks = []
for _ in range(n):
s = select_student(student_ids, counts, weight_factor, rng)
counts[s] = counts.get(s, 0) + 1
picks.append(s)
return picks
def generate_cycle_list(student_ids, rng=random):
return rng.sample(list(student_ids), len(student_ids))

1
migrations/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

83
migrations/env.py Normal file
View File

@@ -0,0 +1,83 @@
import os
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from coldcall_lti.db import Base
from coldcall_lti import models # noqa: F401 (populates Base.metadata)
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# The database URL comes from the same environment variable the app
# uses, falling back to the value in alembic.ini.
if os.environ.get("COLDCALL_DATABASE_URL"):
config.set_main_option("sqlalchemy.url", os.environ["COLDCALL_DATABASE_URL"])
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

26
migrations/script.py.mako Normal file
View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,90 @@
"""initial schema
Revision ID: ad7e3f5ea2f3
Revises:
Create Date: 2026-07-31 16:23:01.164289
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ad7e3f5ea2f3'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('courses',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('lti_context_id', sa.String(length=255), nullable=False),
sa.Column('lti_deployment_id', sa.String(length=255), nullable=True),
sa.Column('title', sa.String(length=255), nullable=True),
sa.Column('weight_factor', sa.Float(), nullable=False),
sa.Column('selection_mode', sa.String(length=16), nullable=False),
sa.Column('show_assessments', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('lti_context_id')
)
op.create_table('students',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('canvas_user_id', sa.String(length=255), nullable=False),
sa.Column('name', sa.String(length=255), nullable=True),
sa.Column('sortable_name', sa.String(length=255), nullable=True),
sa.Column('email', sa.String(length=255), nullable=True),
sa.Column('avatar_url', sa.String(length=1024), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('canvas_user_id')
)
op.create_table('calls',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('course_id', sa.Integer(), nullable=False),
sa.Column('student_id', sa.Integer(), nullable=False),
sa.Column('session_date', sa.Date(), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('assessment', sa.String(length=32), nullable=True),
sa.Column('note', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['course_id'], ['courses.id'], ),
sa.ForeignKeyConstraint(['student_id'], ['students.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('enrollments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('course_id', sa.Integer(), nullable=False),
sa.Column('student_id', sa.Integer(), nullable=False),
sa.Column('role', sa.String(length=32), nullable=False),
sa.Column('active', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['course_id'], ['courses.id'], ),
sa.ForeignKeyConstraint(['student_id'], ['students.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('course_id', 'student_id')
)
op.create_table('optouts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('course_id', sa.Integer(), nullable=False),
sa.Column('student_id', sa.Integer(), nullable=False),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['course_id'], ['courses.id'], ),
sa.ForeignKeyConstraint(['student_id'], ['students.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('course_id', 'student_id', 'date')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('optouts')
op.drop_table('enrollments')
op.drop_table('calls')
op.drop_table('students')
op.drop_table('courses')
# ### end Alembic commands ###

24
pyproject.toml Normal file
View File

@@ -0,0 +1,24 @@
[build-system]
requires = ["setuptools>=64"]
build-backend = "setuptools.build_meta"
[project]
name = "coldcall-lti"
version = "0.1.0"
description = "Canvas LTI 1.3 tool for managing in-class cold calls"
requires-python = ">=3.11"
# Known-good versions: flask 3.1.1, SQLAlchemy 2.0.40, alembic 1.13.2,
# pylti1p3next 2.0.2 (development uses Debian system packages for all
# but pylti1p3next; these floors record what the code was written against).
dependencies = [
"flask>=3.1",
"pylti1p3next>=2.0",
"SQLAlchemy>=2.0",
"alembic>=1.13",
]
[project.optional-dependencies]
test = ["pytest>=8"]
[tool.setuptools]
packages = ["coldcall_lti"]

15
tests/conftest.py Normal file
View 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
View 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
View 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)