mathly / mathly2 /tests /test_attempts.py
wzender's picture
Claude
Only the simulation is timed: lesson quizzes and exercise tests have no time limit (#3)
a1d7d52 unverified
Raw History Blame Contribute Delete
6.74 kB
"""The attempt engine (add-lesson-quizzes 1.2): grades, settling, and what a result reveals."""
import copy
import json
from datetime import datetime, timedelta, timezone
import pytest
from mathly2 import attempts
from mathly2.bank import Bank, load_bank, question_key
from mathly2.store import InMemoryStore
U = "11111111-1111-1111-1111-111111111111"
T0 = datetime(2026, 11, 10, 9, 0, tzinfo=timezone.utc)
DUE = T0 + timedelta(minutes=15)
@pytest.fixture(scope="module")
def bank():
return load_bank()
def refs(bank, n=4):
"""n questions from the first skills that have enough: [{skill_id, key}]."""
out = []
for skill in bank.skills:
for q in skill.get("questions", [])[:2]:
out.append({"skill_id": skill["id"], "key": question_key(skill["id"], q)})
return out[:n]
def choose(bank, ref, right=True):
question = bank._questions[ref["skill_id"]][ref["key"]]
picks = [i for i, c in enumerate(question["choices"]) if bool(c.get("correct")) == right]
if not right:
picks = [i for i in picks if question["choices"][i].get("mistake")] or picks
return picks[0]
def make(bank, n=4, kind="simulation"):
"""A timed attempt by default: only the simulation has a deadline."""
store = InMemoryStore()
questions = refs(bank, n)
row = store.create_attempt(U, kind, "t", T0, DUE, questions)
return store, row, questions
def test_the_grade_is_the_rounded_share_of_right_answers():
assert attempts.percent(7, 10) == 70 and attempts.percent(8, 12) == 67 and attempts.percent(0, 10) == 0 and attempts.percent(10, 10) == 100
assert attempts.percent(1, 8) == 13 and attempts.percent(1, 3) == 33 and attempts.percent(2, 3) == 67 and attempts.percent(0, 0) == 0
def test_an_unanswered_or_unknown_answer_counts_as_wrong(bank):
store, row, questions = make(bank, 4)
answers = {questions[0]["key"]: choose(bank, questions[0]), questions[1]["key"]: 99, questions[2]["key"]: "x"}
assert attempts.grade(bank, questions, answers) == (25, 1)
assert attempts.grade(bank, questions, {}) == (0, 0)
def test_settling_grades_from_the_answers_saved_and_marks_timed_out(bank):
store, row, questions = make(bank, 4)
store.save_attempt(row["id"], answers={q["key"]: choose(bank, q) for q in questions[:3]})
before = attempts.settle(store, bank, store.get_attempt(row["id"]), DUE - timedelta(seconds=1))
assert before["submitted_at"] is None and before["grade"] is None # not overdue: untouched
settled = attempts.settle(store, bank, store.get_attempt(row["id"]), DUE + timedelta(minutes=20))
assert settled["grade"] == 75 and settled["timed_out"] is True and settled["submitted_at"] == DUE
again = attempts.settle(store, bank, settled, DUE + timedelta(hours=5))
assert again == settled == store.get_attempt(row["id"]) # settling twice changes nothing
def test_submitting_before_the_deadline_grades_at_once_and_is_idempotent(bank):
store, row, questions = make(bank, 4)
store.save_attempt(row["id"], answers={q["key"]: choose(bank, q) for q in questions[:2]})
done = attempts.submit(store, bank, store.get_attempt(row["id"]), T0 + timedelta(minutes=5))
assert done["grade"] == 50 and done["timed_out"] is False and done["submitted_at"] == T0 + timedelta(minutes=5)
store.save_attempt(row["id"], answers={q["key"]: choose(bank, q) for q in questions}) # even if answers changed afterwards...
assert attempts.submit(store, bank, store.get_attempt(row["id"]), T0 + timedelta(minutes=6))["grade"] == 50 # ...the grade stands
def test_submitting_after_the_deadline_is_a_timeout(bank):
store, row, questions = make(bank, 4)
done = attempts.submit(store, bank, row, DUE + timedelta(minutes=1))
assert done["timed_out"] is True and done["grade"] == 0
@pytest.mark.parametrize("kind", ["quiz", "exercise_test"])
def test_a_quiz_or_an_exercise_test_has_no_time_limit(bank, kind):
store, row, questions = make(bank, 4, kind=kind)
store.save_attempt(row["id"], answers={q["key"]: choose(bank, q) for q in questions[:3]})
later = DUE + timedelta(days=2)
assert attempts.settle(store, bank, store.get_attempt(row["id"]), later)["submitted_at"] is None # still open long after due_at
done = attempts.submit(store, bank, store.get_attempt(row["id"]), later)
assert done["timed_out"] is False and done["grade"] == 75 and done["submitted_at"] == later
def test_the_result_lists_lost_skills_and_mistakes_but_never_the_right_answer(bank):
store, row, questions = make(bank, 4)
answers = {questions[0]["key"]: choose(bank, questions[0]), # right
questions[1]["key"]: choose(bank, questions[1], right=False), # wrong, with a named mistake
questions[2]["key"]: choose(bank, questions[2], right=False)} # wrong; the fourth is not answered
store.save_attempt(row["id"], answers=answers)
done = attempts.submit(store, bank, store.get_attempt(row["id"]), T0 + timedelta(minutes=3))
out = attempts.result(bank, done)
assert out["grade"] == 25 and out["right"] == 1 and out["total"] == 4 and len(out["missed"]) == 3
assert {s["id"] for s in out["lost_skills"]} == {q["skill_id"] for q in questions[1:]}
assert sum(s["missed"] for s in out["lost_skills"]) == 3
unanswered = out["missed"][-1]
assert unanswered["picked"] is None and unanswered["mistake"] is None
wrong = out["missed"][0]
assert wrong["picked"] and wrong["question"] and wrong["skill_name"]
blob = json.dumps(out, ensure_ascii=False)
assert '"correct"' not in blob
for ref in questions[1:]: # no right choice's text appears
question = bank._questions[ref["skill_id"]][ref["key"]]
right_text = next(c["text"] for c in question["choices"] if c.get("correct"))
picked_texts = {m["picked"] for m in out["missed"]}
assert right_text not in picked_texts or right_text == wrong["picked"]
def test_a_question_the_bank_no_longer_has_is_skipped_in_the_result(bank):
store, row, questions = make(bank, 3)
data = copy.deepcopy(bank.data)
gone = questions[0]
for s in data["skills"]:
if s["id"] == gone["skill_id"]:
s["questions"] = [q for q in s["questions"] if question_key(s["id"], q) != gone["key"]]
smaller = Bank(data)
done = attempts.submit(store, smaller, store.get_attempt(row["id"]), T0 + timedelta(minutes=1))
out = attempts.result(smaller, done)
assert out["total"] == 3 and len(out["missed"]) == 2 # the vanished question is not listed