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