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>
67 lines
2.1 KiB
Python
67 lines
2.1 KiB
Python
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_batch_covers_everyone_once():
|
|
rng = random.Random(1)
|
|
picks = selection.generate_cycle_batch(STUDENTS, {}, None, rng)
|
|
assert sorted(picks) == sorted(STUDENTS)
|
|
|
|
|
|
def test_generate_cycle_batches_walk_the_pass():
|
|
rng = random.Random(1)
|
|
counts = {}
|
|
first = selection.generate_cycle_batch(STUDENTS, counts, 3, rng)
|
|
assert len(first) == 3
|
|
for s in first:
|
|
counts[s] = counts.get(s, 0) + 1
|
|
|
|
# The remainder batch is short, not wrapped into the next pass.
|
|
second = selection.generate_cycle_batch(STUDENTS, counts, 3, rng)
|
|
assert len(second) == 1
|
|
assert sorted(first + second) == sorted(STUDENTS)
|
|
for s in second:
|
|
counts[s] = counts.get(s, 0) + 1
|
|
|
|
# Everyone called once: a new pass opens with the full roster.
|
|
third = selection.generate_cycle_batch(STUDENTS, counts, 3, rng)
|
|
assert len(third) == 3
|
|
assert set(third) <= set(STUDENTS)
|