mathly / mathly2 /tests /test_learning.py
3v324v23's picture
Add specifications for exercise tests, grades, learning path, lesson quizzes, readiness meter, real tests, rewards, simulation tests, skill mastery, smart board, student home, and test countdown
a7ffb22
Raw History Blame Contribute Delete
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"
@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