Download mathly2/tests/test_attempts.py from wzender/mathly: direct link, hf CLI and curl.
- Browser
- Download file 6.74 kB
-
https://huggingface.co/spaces/wzender/mathly/resolve/main/mathly2/tests/test_attempts.py
- Command line
-
hf download hf://spaces/wzender/mathly/mathly2/tests/test_attempts.py
-
curl -L -o test_attempts.py https://huggingface.co/spaces/wzender/mathly/resolve/main/mathly2/tests/test_attempts.py
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) | |
| 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 | |
| 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 | |