Phase 3: instructor in-class UI
Live mode with resolve-before-next call flow and one-tap outcomes (assessments, missing, skip); printable numbered call lists with pictures and a blank notes column; a day editor for after-class outcome entry, replacing hand-editing of call_list TSVs; and a per-course settings page (selection mode, weight factor, assessment visibility). Selection honors opt-outs and the course's mode: weighted draws use full-course answered-call history, cycle mode calls everyone once per day before starting over and treats skips as never called. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,8 @@ import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from coldcall_lti import create_app
|
||||
from coldcall_lti.config import Config
|
||||
from coldcall_lti.db import Base
|
||||
|
||||
|
||||
@@ -13,3 +15,20 @@ def db_session():
|
||||
yield session
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def make_app(tmp_path, dev_mode=True):
|
||||
class TestConfig(Config):
|
||||
TESTING = True
|
||||
DATABASE_URL = f"sqlite:///{tmp_path}/test.sqlite3"
|
||||
DEV_MODE = dev_mode
|
||||
SECRET_KEY = "test"
|
||||
|
||||
app = create_app(TestConfig)
|
||||
Base.metadata.create_all(app.extensions["db_engine"])
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dev_client(tmp_path):
|
||||
return make_app(tmp_path, dev_mode=True).test_client()
|
||||
|
||||
@@ -1,25 +1,4 @@
|
||||
import pytest
|
||||
|
||||
from coldcall_lti import create_app
|
||||
from coldcall_lti.config import Config
|
||||
from coldcall_lti.db import Base
|
||||
|
||||
|
||||
def make_app(tmp_path, dev_mode):
|
||||
class TestConfig(Config):
|
||||
TESTING = True
|
||||
DATABASE_URL = f"sqlite:///{tmp_path}/test.sqlite3"
|
||||
DEV_MODE = dev_mode
|
||||
SECRET_KEY = "test"
|
||||
|
||||
app = create_app(TestConfig)
|
||||
Base.metadata.create_all(app.extensions["db_engine"])
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dev_client(tmp_path):
|
||||
return make_app(tmp_path, dev_mode=True).test_client()
|
||||
from conftest import make_app
|
||||
|
||||
|
||||
def test_healthz(tmp_path):
|
||||
@@ -33,7 +12,7 @@ def test_dev_routes_absent_outside_dev_mode(tmp_path):
|
||||
|
||||
|
||||
def test_views_require_launch(dev_client):
|
||||
assert dev_client.get("/instructor").status_code == 403
|
||||
assert dev_client.get("/instructor/").status_code == 403
|
||||
assert dev_client.get("/me").status_code == 403
|
||||
|
||||
|
||||
@@ -45,7 +24,6 @@ def test_dev_instructor_launch_shows_roster(dev_client):
|
||||
assert resp.status_code == 200
|
||||
page = resp.get_data(as_text=True)
|
||||
assert "8 active students" in page
|
||||
assert "Ada Lovelace" in page
|
||||
|
||||
|
||||
def test_dev_student_launch(dev_client):
|
||||
@@ -54,5 +32,6 @@ def test_dev_student_launch(dev_client):
|
||||
)
|
||||
page = resp.get_data(as_text=True)
|
||||
assert "Ada Lovelace" in page
|
||||
# A student cannot reach the instructor view.
|
||||
assert dev_client.get("/instructor").status_code == 403
|
||||
# A student cannot reach the instructor views.
|
||||
assert dev_client.get("/instructor/").status_code == 403
|
||||
assert dev_client.get("/instructor/live").status_code == 403
|
||||
|
||||
131
tests/test_calls.py
Normal file
131
tests/test_calls.py
Normal file
@@ -0,0 +1,131 @@
|
||||
import datetime
|
||||
import random
|
||||
from collections import Counter
|
||||
|
||||
from coldcall_lti import calls, models
|
||||
|
||||
DAY = datetime.date(2026, 10, 1)
|
||||
|
||||
|
||||
def make_course(db, n=4, mode=models.SELECTION_WEIGHTED):
|
||||
course = models.Course(lti_context_id="ctx-1", selection_mode=mode)
|
||||
db.add(course)
|
||||
students = []
|
||||
for i in range(n):
|
||||
s = models.Student(canvas_user_id=f"u{i}", name=f"Student {i}")
|
||||
db.add(s)
|
||||
students.append(s)
|
||||
db.flush()
|
||||
for s in students:
|
||||
db.add(models.Enrollment(course_id=course.id, student_id=s.id))
|
||||
db.flush()
|
||||
return course, students
|
||||
|
||||
|
||||
def test_pick_next_honors_optouts(db_session):
|
||||
course, students = make_course(db_session, n=2)
|
||||
db_session.add(
|
||||
models.OptOut(
|
||||
course_id=course.id, student_id=students[0].id, date=DAY
|
||||
)
|
||||
)
|
||||
db_session.flush()
|
||||
rng = random.Random(1)
|
||||
for _ in range(20):
|
||||
assert calls.pick_next_student(db_session, course, DAY, rng).id == students[1].id
|
||||
|
||||
|
||||
def test_pick_next_weighted_downweights(db_session):
|
||||
course, students = make_course(db_session)
|
||||
heavy = students[0]
|
||||
for _ in range(4):
|
||||
db_session.add(
|
||||
models.Call(
|
||||
course_id=course.id,
|
||||
student_id=heavy.id,
|
||||
session_date=DAY - datetime.timedelta(days=7),
|
||||
status=models.STATUS_ANSWERED,
|
||||
)
|
||||
)
|
||||
db_session.flush()
|
||||
|
||||
rng = random.Random(42)
|
||||
picks = Counter(
|
||||
calls.pick_next_student(db_session, course, DAY, rng).id
|
||||
for _ in range(400)
|
||||
)
|
||||
assert picks[heavy.id] < min(picks[s.id] for s in students[1:]) / 2
|
||||
|
||||
|
||||
def test_cycle_mode_calls_everyone_before_repeating(db_session):
|
||||
course, students = make_course(db_session, mode=models.SELECTION_CYCLE)
|
||||
rng = random.Random(7)
|
||||
called = []
|
||||
for _ in range(len(students)):
|
||||
call = calls.create_live_call(db_session, course, DAY, rng)
|
||||
call.status = models.STATUS_ANSWERED
|
||||
db_session.flush()
|
||||
called.append(call.student_id)
|
||||
assert sorted(called) == sorted(s.id for s in students)
|
||||
|
||||
# The next draw starts a fresh pass rather than failing.
|
||||
assert calls.create_live_call(db_session, course, DAY, rng) is not None
|
||||
|
||||
|
||||
def test_cycle_mode_recalls_skipped_students(db_session):
|
||||
course, students = make_course(db_session, n=2, mode=models.SELECTION_CYCLE)
|
||||
rng = random.Random(3)
|
||||
call = calls.create_live_call(db_session, course, DAY, rng)
|
||||
call.status = models.STATUS_SKIPPED
|
||||
db_session.flush()
|
||||
# A skipped call doesn't count as having been called this pass.
|
||||
pool_ids = {
|
||||
calls.pick_next_student(db_session, course, DAY, rng).id
|
||||
for _ in range(20)
|
||||
}
|
||||
assert call.student_id in pool_ids
|
||||
|
||||
|
||||
def test_generate_day_list_cycle_covers_roster(db_session):
|
||||
course, students = make_course(db_session, mode=models.SELECTION_CYCLE)
|
||||
generated = calls.generate_day_list(db_session, course, DAY, rng=random.Random(1))
|
||||
assert sorted(c.student_id for c in generated) == sorted(s.id for s in students)
|
||||
assert all(c.status == models.STATUS_PENDING for c in generated)
|
||||
|
||||
|
||||
def test_generate_day_list_weighted_length_and_optouts(db_session):
|
||||
course, students = make_course(db_session)
|
||||
db_session.add(
|
||||
models.OptOut(
|
||||
course_id=course.id, student_id=students[0].id, date=DAY
|
||||
)
|
||||
)
|
||||
db_session.flush()
|
||||
generated = calls.generate_day_list(
|
||||
db_session, course, DAY, n=10, rng=random.Random(1)
|
||||
)
|
||||
assert len(generated) == 10
|
||||
assert students[0].id not in {c.student_id for c in generated}
|
||||
|
||||
|
||||
def test_clear_pending_keeps_resolved(db_session):
|
||||
course, students = make_course(db_session)
|
||||
generated = calls.generate_day_list(
|
||||
db_session, course, DAY, n=5, rng=random.Random(1)
|
||||
)
|
||||
generated[0].status = models.STATUS_ANSWERED
|
||||
db_session.flush()
|
||||
|
||||
removed = calls.clear_pending(db_session, course.id, DAY)
|
||||
assert removed == 4
|
||||
remaining = calls.calls_for_day(db_session, course.id, DAY)
|
||||
assert len(remaining) == 1
|
||||
assert remaining[0].status == models.STATUS_ANSWERED
|
||||
|
||||
|
||||
def test_unresolved_call_is_oldest_pending(db_session):
|
||||
course, students = make_course(db_session)
|
||||
generated = calls.generate_day_list(
|
||||
db_session, course, DAY, n=3, rng=random.Random(1)
|
||||
)
|
||||
assert calls.unresolved_call(db_session, course.id, DAY).id == generated[0].id
|
||||
121
tests/test_instructor_ui.py
Normal file
121
tests/test_instructor_ui.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import datetime
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
TODAY = datetime.date.today().isoformat()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def instructor(dev_client):
|
||||
dev_client.post("/dev/launch/dev-instructor")
|
||||
return dev_client
|
||||
|
||||
|
||||
def test_home_page(instructor):
|
||||
page = instructor.get("/instructor/").get_data(as_text=True)
|
||||
assert "8 active students" in page
|
||||
assert "Live cold call" in page
|
||||
|
||||
|
||||
def test_live_flow_records_outcome(instructor):
|
||||
resp = instructor.post(
|
||||
"/instructor/live/next", data={"date": TODAY}, follow_redirects=True
|
||||
)
|
||||
page = resp.get_data(as_text=True)
|
||||
match = re.search(r'/instructor/call/(\d+)/outcome', page)
|
||||
assert match, "live page should show outcome buttons for the call"
|
||||
assert "GOOD" in page
|
||||
|
||||
call_id = match.group(1)
|
||||
resp = instructor.post(
|
||||
f"/instructor/call/{call_id}/outcome",
|
||||
data={"action": "GOOD"},
|
||||
follow_redirects=True,
|
||||
)
|
||||
page = resp.get_data(as_text=True)
|
||||
assert "Calls so far today" in page
|
||||
assert "GOOD" in page
|
||||
# The call is resolved, so the next-student button is back.
|
||||
assert "Call next student" in page
|
||||
|
||||
|
||||
def test_live_requires_resolution_before_next(instructor):
|
||||
instructor.post("/instructor/live/next", data={"date": TODAY})
|
||||
page = instructor.get(f"/instructor/live?date={TODAY}").get_data(as_text=True)
|
||||
first_call = re.search(r'/instructor/call/(\d+)/outcome', page).group(1)
|
||||
|
||||
# Pressing next again does not create a second pending call.
|
||||
instructor.post("/instructor/live/next", data={"date": TODAY})
|
||||
page = instructor.get(f"/instructor/live?date={TODAY}").get_data(as_text=True)
|
||||
assert re.findall(r'/instructor/call/(\d+)/outcome', page) == [first_call]
|
||||
|
||||
|
||||
def test_generate_and_print(instructor):
|
||||
resp = instructor.post(
|
||||
f"/instructor/day/{TODAY}/generate",
|
||||
data={"n": "12"},
|
||||
follow_redirects=True,
|
||||
)
|
||||
page = resp.get_data(as_text=True)
|
||||
assert page.count("<tr>") == 13 # header + 12 rows
|
||||
|
||||
|
||||
def test_regenerate_replaces_pending(instructor):
|
||||
instructor.post(f"/instructor/day/{TODAY}/generate", data={"n": "12"})
|
||||
resp = instructor.post(
|
||||
f"/instructor/day/{TODAY}/generate",
|
||||
data={"n": "5"},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert resp.get_data(as_text=True).count("<tr>") == 6
|
||||
|
||||
|
||||
def test_day_edit_saves_outcomes(instructor):
|
||||
instructor.post(f"/instructor/day/{TODAY}/generate", data={"n": "2"})
|
||||
page = instructor.get(f"/instructor/day/{TODAY}").get_data(as_text=True)
|
||||
ids = re.findall(r'name="status-(\d+)"', page)
|
||||
assert len(ids) == 2
|
||||
|
||||
resp = instructor.post(
|
||||
f"/instructor/day/{TODAY}",
|
||||
data={
|
||||
f"status-{ids[0]}": "answered",
|
||||
f"assessment-{ids[0]}": "POOR",
|
||||
f"note-{ids[0]}": "rough day",
|
||||
f"status-{ids[1]}": "pending",
|
||||
f"delete-{ids[1]}": "on",
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
page = resp.get_data(as_text=True)
|
||||
assert 'value="rough day"' in page
|
||||
assert len(re.findall(r'name="status-(\d+)"', page)) == 1
|
||||
|
||||
|
||||
def test_outcome_rejects_bad_action_and_foreign_call(instructor):
|
||||
instructor.post("/instructor/live/next", data={"date": TODAY})
|
||||
page = instructor.get(f"/instructor/live?date={TODAY}").get_data(as_text=True)
|
||||
call_id = re.search(r'/instructor/call/(\d+)/outcome', page).group(1)
|
||||
|
||||
resp = instructor.post(
|
||||
f"/instructor/call/{call_id}/outcome", data={"action": "nonsense"}
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
resp = instructor.post(
|
||||
"/instructor/call/99999/outcome", data={"action": "GOOD"}
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_settings_roundtrip(instructor):
|
||||
resp = instructor.post(
|
||||
"/instructor/settings",
|
||||
data={"selection_mode": "cycle", "weight_factor": "3.0"},
|
||||
follow_redirects=True,
|
||||
)
|
||||
page = resp.get_data(as_text=True)
|
||||
assert '<option value="cycle" selected>' in page
|
||||
assert 'value="3.0"' in page
|
||||
# Checkbox left unchecked turns assessment visibility off.
|
||||
assert "checked" not in page
|
||||
Reference in New Issue
Block a user