List lifecycle polish and cycle batches, from live walkthrough feedback
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>
This commit is contained in:
@@ -93,6 +93,24 @@ def test_generate_day_list_cycle_covers_roster(db_session):
|
||||
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(
|
||||
|
||||
@@ -63,8 +63,16 @@ def test_generate_and_print(instructor):
|
||||
assert page.count("<tr>") == 13 # header + 12 rows
|
||||
|
||||
|
||||
def test_regenerate_replaces_pending(instructor):
|
||||
def test_regenerate_replaces_pending_and_labels_change(instructor):
|
||||
page = instructor.get("/instructor/").get_data(as_text=True)
|
||||
assert "Generate list for" in page
|
||||
|
||||
instructor.post(f"/instructor/day/{TODAY}/generate", data={"n": "12"})
|
||||
page = instructor.get("/instructor/").get_data(as_text=True)
|
||||
assert "View today's list" in page
|
||||
assert "Regenerate list" in page
|
||||
|
||||
# Regenerating replaces the unused list outright.
|
||||
resp = instructor.post(
|
||||
f"/instructor/day/{TODAY}/generate",
|
||||
data={"n": "5"},
|
||||
@@ -72,6 +80,47 @@ def test_regenerate_replaces_pending(instructor):
|
||||
)
|
||||
assert resp.get_data(as_text=True).count("<tr>") == 6
|
||||
|
||||
# No recorded outcomes yet: no confirmation dialog wired up.
|
||||
page = instructor.get(f"/instructor/day/{TODAY}/print").get_data(as_text=True)
|
||||
assert "onsubmit" not in page
|
||||
|
||||
# Record one outcome; the regenerate form now carries a warning.
|
||||
edit = instructor.get(f"/instructor/day/{TODAY}").get_data(as_text=True)
|
||||
call_id = re.findall(r'name="status-(\d+)"', edit)[0]
|
||||
instructor.post(
|
||||
f"/instructor/day/{TODAY}",
|
||||
data={f"status-{call_id}": "answered"},
|
||||
)
|
||||
page = instructor.get(f"/instructor/day/{TODAY}/print").get_data(as_text=True)
|
||||
assert "already have recorded outcomes" in page
|
||||
|
||||
|
||||
def test_add_call_after_the_fact(instructor):
|
||||
page = instructor.get(f"/instructor/day/{TODAY}").get_data(as_text=True)
|
||||
student_id = re.search(r'<option value="(\d+)">', page).group(1)
|
||||
good_id = re.search(r'value="(\d+)">GOOD</option>', page).group(1)
|
||||
|
||||
resp = instructor.post(
|
||||
f"/instructor/day/{TODAY}/add",
|
||||
data={
|
||||
"student_id": student_id,
|
||||
"status": "answered",
|
||||
"assessment": good_id,
|
||||
"note": "volunteered",
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
page = resp.get_data(as_text=True)
|
||||
assert len(re.findall(r'name="status-(\d+)"', page)) == 1
|
||||
assert 'value="volunteered"' in page
|
||||
|
||||
# Junk student ids are rejected.
|
||||
resp = instructor.post(
|
||||
f"/instructor/day/{TODAY}/add",
|
||||
data={"student_id": "99999", "status": "answered"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_day_edit_saves_outcomes(instructor):
|
||||
instructor.post(f"/instructor/day/{TODAY}/generate", data={"n": "2"})
|
||||
|
||||
@@ -39,7 +39,28 @@ def test_generate_call_list_downweights_within_list():
|
||||
assert all(35 < counts[s] < 65 for s in STUDENTS)
|
||||
|
||||
|
||||
def test_generate_cycle_list_covers_everyone_once():
|
||||
def test_generate_cycle_batch_covers_everyone_once():
|
||||
rng = random.Random(1)
|
||||
picks = selection.generate_cycle_list(STUDENTS, rng)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user