Download mathly2/tests/test_learning.py from wzender/mathly: direct link, hf CLI and curl.
- Browser
- Download file 8.33 kB
-
https://huggingface.co/spaces/wzender/mathly/resolve/main/mathly2/tests/test_learning.py
- Command line
-
hf download hf://spaces/wzender/mathly/mathly2/tests/test_learning.py
-
curl -L -o test_learning.py https://huggingface.co/spaces/wzender/mathly/resolve/main/mathly2/tests/test_learning.py
8.33 kB
| import random | |
| from datetime import date, timedelta | |
| import pytest | |
| from mathly2.learning import (LEARNING, MASTERED, NOT_STARTED, current_round, is_mastered, mark_started, next_in_round, next_skill, | |
| record_practice_answer, skill_state, streak, today_progress) | |
| from mathly2.store import InMemoryStore | |
| U, S = "u-1", "skill-1" | |
| MON, TUE = date(2026, 11, 2), date(2026, 11, 3) | |
| NOW = "2026-11-02T10:00:00+00:00" | |
| def store(): | |
| return InMemoryStore() | |
| def answer(store, n, correct, day, counted=True): | |
| return record_practice_answer(store, U, S, f"{S}:q{n}", correct, counted, None if correct else "m1", day, NOW) | |
| def state(store): | |
| return skill_state(store.get_progress(U, S)) | |
| # --- the mastery rule (spec: skill-mastery) --- | |
| def test_mastered_over_two_days(store): | |
| answer(store, 1, True, MON) | |
| answer(store, 2, True, MON) | |
| assert state(store) == LEARNING | |
| answer(store, 3, True, TUE) | |
| assert state(store) == MASTERED | |
| def test_three_in_a_row_on_one_day_is_not_yet_mastered(store): | |
| for n in (1, 2, 3): | |
| answer(store, n, True, MON) | |
| assert state(store) == LEARNING | |
| answer(store, 4, True, TUE) # the last 3 are now Mon, Mon, Tue | |
| assert state(store) == MASTERED | |
| def test_help_opened_skips_the_answer_without_breaking_the_run(store): | |
| answer(store, 1, True, MON) | |
| answer(store, 2, True, MON) | |
| answer(store, 3, True, MON, counted=False) # helped: ignored for mastery | |
| assert state(store) == LEARNING # a helped answer cannot be the third | |
| answer(store, 4, True, TUE) | |
| assert state(store) == MASTERED # Mon, Mon, Tue: the run survived the helped answer | |
| def test_a_wrong_answer_restarts_the_run(store): | |
| answer(store, 1, True, MON) | |
| answer(store, 2, True, MON) | |
| answer(store, 3, False, MON) | |
| answer(store, 4, True, TUE) | |
| assert state(store) == LEARNING # the last 3 are correct, wrong, correct | |
| answer(store, 5, True, TUE) | |
| answer(store, 6, True, TUE + timedelta(days=1)) | |
| assert state(store) == MASTERED | |
| def test_a_wrong_answer_months_later_does_not_undo_mastery(store): | |
| answer(store, 1, True, MON) | |
| answer(store, 2, True, MON) | |
| answer(store, 3, True, TUE) | |
| first = store.get_progress(U, S)["mastered_at"] | |
| answer(store, 4, False, date(2027, 3, 8)) | |
| assert state(store) == MASTERED | |
| assert store.get_progress(U, S)["mastered_at"] == first | |
| def test_mastered_at_is_set_once(store): | |
| answer(store, 1, True, MON) | |
| answer(store, 2, True, MON) | |
| record_practice_answer(store, U, S, "k3", True, True, None, TUE, "2026-11-03T08:00:00+00:00") | |
| record_practice_answer(store, U, S, "k4", True, True, None, TUE, "2026-11-09T08:00:00+00:00") | |
| assert store.get_progress(U, S)["mastered_at"] == "2026-11-03T08:00:00+00:00" | |
| def test_is_mastered_needs_exactly_the_last_three(): | |
| a = lambda correct, day: {"correct": correct, "day": day} | |
| assert not is_mastered([]) | |
| assert not is_mastered([a(True, MON), a(True, TUE)]) | |
| assert is_mastered([a(False, MON), a(True, MON), a(True, MON), a(True, TUE)]) | |
| # --- skill states --- | |
| def test_skill_states(store): | |
| assert skill_state(None) == NOT_STARTED | |
| assert state(store) == NOT_STARTED | |
| store.save_progress(U, S, current_key="k") # a row without started_at is still not started | |
| assert state(store) == NOT_STARTED | |
| def test_a_why_answer_moves_a_skill_to_learning_but_cannot_master_it(store): | |
| mark_started(store, U, S, NOW) | |
| assert state(store) == LEARNING | |
| for _ in range(5): | |
| mark_started(store, U, S, NOW) | |
| assert state(store) == LEARNING # only practice answers can master | |
| assert store.get_progress(U, S)["started_at"] == NOW | |
| def test_first_practice_answer_moves_not_started_to_learning(store): | |
| answer(store, 1, False, MON) | |
| assert state(store) == LEARNING | |
| # --- the round: each question of a skill once --- | |
| KEYS = [f"k{i}" for i in range(10)] | |
| def rows(*pairs): | |
| return [{"question_key": k, "correct": ok, "counted": True, "day": MON, "mistake_id": None} for k, ok in pairs] | |
| def test_a_round_asks_every_question_once_and_ends_when_all_are_answered(): | |
| rng, answered, last, shown = random.Random(1), [], None, [] | |
| left = list(KEYS) | |
| while True: | |
| nxt = next_in_round(left, last, rng) | |
| if nxt is None: | |
| break | |
| shown.append(nxt) | |
| answered.append((nxt, len(shown) % 3 != 0)) # every third answer wrong | |
| last = nxt | |
| left = current_round(KEYS, rows(*answered), 0)["left"] | |
| assert sorted(shown) == sorted(KEYS) and len(set(shown)) == 10 # each once, a wrong one does not come back | |
| assert current_round(KEYS, rows(*answered), 0)["done"] is True | |
| def test_the_round_is_the_answers_since_its_start_and_no_start_means_a_new_round_begins(): | |
| old = rows(("k0", True), ("k1", False), ("k2", True)) | |
| assert current_round(KEYS, old, None) == {"answers": [], "left": KEYS, "done": False} # an old student: everything so far is past | |
| now = old + rows(("k5", True), ("k6", False)) | |
| got = current_round(KEYS, now, 3) | |
| assert [a["question_key"] for a in got["answers"]] == ["k5", "k6"] and got["left"] == [k for k in KEYS if k not in ("k5", "k6")] and not got["done"] | |
| def test_a_question_not_in_the_round_or_repeated_is_ignored_and_a_skill_with_no_questions_is_never_done(): | |
| got = current_round(["a", "b"], rows(("a", True), ("a", False), ("zzz", True)), 0) | |
| assert [a["question_key"] for a in got["answers"]] == ["a"] and got["left"] == ["b"] and not got["done"] | |
| assert current_round([], [], 0)["done"] is False | |
| def test_next_in_round_is_never_the_one_just_answered_and_none_when_nothing_is_left(): | |
| rng = random.Random(2) | |
| for _ in range(50): | |
| assert next_in_round(["k1", "k2", "k3"], "k2", rng) != "k2" | |
| assert next_in_round([]) is None and next_in_round(["k1"], "k1") is None | |
| assert next_in_round(["only"]) == "only" # a skill with one question: a round of one | |
| # --- daily goal and streak --- | |
| TODAY = date(2026, 11, 10) | |
| def test_goal_missed_and_reached(): | |
| assert today_progress({TODAY: 3}, TODAY) == {"done": 3, "goal": 5, "reached": False} | |
| assert today_progress({TODAY: 5}, TODAY)["reached"] is True | |
| assert today_progress({}, TODAY)["done"] == 0 | |
| def test_streak_counts_today_once_it_reaches_five(): | |
| assert streak({TODAY: 5, TODAY - timedelta(days=1): 6}, TODAY) == 2 | |
| def test_streak_is_kept_before_todays_five(): | |
| daily = {TODAY: 3, TODAY - timedelta(days=1): 5, TODAY - timedelta(days=2): 7} | |
| assert streak(daily, TODAY) == 2 | |
| def test_streak_breaks_after_a_missed_day(): | |
| daily = {TODAY: 5, TODAY - timedelta(days=1): 4, TODAY - timedelta(days=2): 9} | |
| assert streak(daily, TODAY) == 1 | |
| assert streak({TODAY - timedelta(days=2): 9}, TODAY) == 0 | |
| assert streak({}, TODAY) == 0 | |
| # --- the next skill (add-learning-core 1.4; spec: Next skill) --- | |
| ORDER = ["a", "b", "c", "d"] | |
| def test_a_new_student_gets_the_first_skill(): | |
| assert next_skill(ORDER, {}, []) == "a" | |
| def test_the_marker_stays_on_the_skill_answered_last_while_it_is_not_mastered(): | |
| states = {"a": MASTERED, "b": LEARNING, "c": LEARNING} | |
| assert next_skill(ORDER, states, ["c", "b", "a"]) == "c" # c was answered most recently | |
| def test_when_the_last_answer_mastered_a_skill_it_goes_to_the_most_recent_unmastered_one(): | |
| states = {"a": MASTERED, "b": LEARNING, "c": MASTERED} | |
| assert next_skill(ORDER, states, ["c", "b", "a"]) == "b" | |
| def test_when_every_recent_skill_is_mastered_it_goes_to_the_first_unmastered_in_order(): | |
| states = {"c": MASTERED, "d": MASTERED} | |
| assert next_skill(ORDER, states, ["d", "c"]) == "a" | |
| def test_when_everything_is_mastered_nothing_is_marked(): | |
| assert next_skill(ORDER, {k: MASTERED for k in ORDER}, ["a", "b"]) is None | |
| def test_skills_outside_the_students_lessons_never_carry_the_marker(): | |
| assert next_skill(ORDER, {}, ["elsewhere", "b"]) == "b" | |
| assert next_skill([], {}, ["a"]) is None | |
| def test_the_marker_never_changes_the_states_or_the_order(): | |
| states, order = {"a": LEARNING}, list(ORDER) | |
| next_skill(order, states, ["a"]) | |
| assert states == {"a": LEARNING} and order == ORDER | |