File size: 8,325 Bytes
a7ffb22 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 | 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"
@pytest.fixture
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
|