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)