File size: 18,902 Bytes
a7ffb22
 
 
 
 
 
 
 
 
 
 
 
0815e01
a7ffb22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0815e01
a7ffb22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0815e01
a7ffb22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6f5bc4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
"""The path in a real browser at phone width: orbits of every size, the states in words, the owl, keyboard use, reduced motion."""
import uuid
from collections import Counter

import pytest

playwright = pytest.importorskip("playwright.sync_api")

from mathly2.bank import load_bank
from mathly2.config import Settings
from mathly2.main import create_app
from mathly2.store import InMemoryStore
from mathly2.tests.serve import offline_formulas, only, running

KID = "kid@school.test"
KID_ID = str(uuid.uuid5(uuid.NAMESPACE_DNS, KID))
NOW = "2026-11-02T10:00:00+00:00"
PHONE = {"width": 360, "height": 800}
WORDS = {"not_started": "ืœื ื”ืชื—ืœืช", "learning": "ื‘ืœืžื™ื“ื”", "mastered": "ื ืฉืœื˜ื”"}


def lessons_by_size(bank):
    sizes = Counter(s["unit"] for s in bank.skills)
    pick = lambda n: next(lesson for lesson, count in sizes.items() if count == n)
    return pick(2), pick(3), pick(6)


@pytest.fixture(scope="module")
def bank():
    return load_bank()


def world(bank, lessons):
    store = InMemoryStore()
    store.add_member(store.create_class("setup", "A", lessons=list(lessons))["id"], KID)
    return create_app(Settings(), bank=only(bank, lessons), store=store), store


@pytest.fixture
def browser():
    with playwright.sync_playwright() as p:
        b = p.chromium.launch()
        yield b
        b.close()


def path_page(browser, url, scheme="light", **options):
    page = offline_formulas(browser.new_page(viewport=PHONE, color_scheme=scheme, **options))
    page.goto(url)
    page.fill("#email", KID)
    page.click("#dev button")
    page.click(".subject-card")
    page.click(".tabs a[href='#/subject/math/path']")
    page.wait_for_selector(".orbit")
    return page


# --- 4.1 the rings ---

def test_rings_of_2_3_and_6_skills_fit_a_360px_screen_and_keep_clear_of_the_hub(browser, bank):
    two, three, six = lessons_by_size(bank)
    app, _ = world(bank, [two, three, six])
    with running(app) as url:
        page = path_page(browser, url)
        assert page.locator(".orbit").count() == 3
        for lesson, n in ((two, 2), (three, 3), (six, 6)):
            orbit = page.locator(f"#lesson-{lesson}")
            hub = orbit.locator(".hub").bounding_box()
            stars = orbit.locator(".star .node")
            assert stars.count() == n
            for i in range(n):
                node = stars.nth(i).bounding_box()
                assert node["x"] >= 0 and node["x"] + node["width"] <= PHONE["width"], f"star {i + 1} of {lesson} leaves the screen"
                apart = (node["x"] + node["width"] <= hub["x"] or hub["x"] + hub["width"] <= node["x"]
                         or node["y"] + node["height"] <= hub["y"] or hub["y"] + hub["height"] <= node["y"])
                assert apart, f"star {i + 1} of {lesson} is over the hub"
            labels = orbit.locator(".star .lbl")
            for i in range(n):
                box = labels.nth(i).bounding_box()
                assert box["x"] >= 0 and box["x"] + box["width"] <= PHONE["width"]
            assert orbit.locator(".ring path").count() == (n - 1) + (1 if n > 2 else 0)        # the arcs between neighbours, and the faint closing one
        assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")


def test_stars_are_numbered_clockwise_from_the_top(browser, bank):
    _, _, six = lessons_by_size(bank)
    app, _ = world(bank, [six])
    with running(app) as url:
        page = path_page(browser, url)
        hub = page.locator(".hub").bounding_box()
        centre = (hub["x"] + hub["width"] / 2, hub["y"] + hub["height"] / 2)
        found = []
        for i in range(6):
            node = page.locator(".star .node").nth(i).bounding_box()
            found.append((node["x"] + node["width"] / 2 - centre[0], node["y"] + node["height"] / 2 - centre[1]))
        assert found[0][1] < 0 and abs(found[0][0]) < 5                       # 1 is at the top
        assert found[1][0] > 0 and found[2][0] > 0 and found[3][1] > 0        # then right, down, and round to the left
        assert found[4][0] < 0 and found[5][0] < 0


# --- 4.2 the states, the owl, the lessons shown ---

@pytest.mark.parametrize("scheme", ["light", "dark"])
def test_each_state_says_its_word_the_mastered_star_differs_and_the_owl_is_on_the_next_one(browser, bank, scheme):
    two, three, _ = lessons_by_size(bank)
    app, store = world(bank, [two, three])
    first, second = (s["id"] for s in bank.skills if s["unit"] == two)
    store.save_progress(KID_ID, first, started_at=NOW, mastered_at=NOW)            # mastered
    store.save_progress(KID_ID, second, started_at=NOW)                            # learning
    store.add_answer(KID_ID, second, "k", False, True, None, __import__("datetime").date(2026, 11, 2))   # answered last: the owl goes here
    with running(app) as url:
        page = path_page(browser, url, scheme)
        assert page.locator(".orbit").count() == 2 and page.locator(f"#lesson-{two}, #lesson-{three}").count() == 2
        for skill, state in ((first, "mastered"), (second, "learning")):
            text = page.inner_text(f".star[data-skill='{skill}'] .lbl")
            assert WORDS[state] in text and {"mastered": "โœ“", "learning": "โ—"}[state] in text
        third = next(s["id"] for s in bank.skills if s["unit"] == three)
        assert WORDS["not_started"] in page.inner_text(f".star[data-skill='{third}'] .lbl") and "โ—‹" in page.inner_text(f".star[data-skill='{third}'] .lbl")
        look = lambda skill: page.evaluate("(id) => { const n = document.querySelector(`.star[data-skill='${id}'] .node`); const s = getComputedStyle(n);"
                                           " return [s.borderTopColor, s.backgroundImage + s.backgroundColor]; }", skill)
        assert look(first) != look(second) != look(third) and look(first) != look(third)      # three different looks
        assert page.locator(".star.next").count() == 1 and page.get_attribute(".star.next", "data-skill") == second
        assert page.locator(".star.next .perch .sky-owl").count() == 1
        assert "ืžืžืฉื™ื›ื™ื ื›ืืŸ" in page.inner_text(".star.next .lbl")
        assert "1 ืžืชื•ืš 2 ื ืฉืœื˜ื•" in page.inner_text(f"#lesson-{two} .hub")
        assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")


def test_a_new_student_gets_the_owl_on_the_first_skill_and_everything_stays_open(browser, bank):
    two, three, _ = lessons_by_size(bank)
    app, _ = world(bank, [two, three])
    with running(app) as url:
        page = path_page(browser, url)
        first = page.locator(".star").first.get_attribute("data-skill")
        assert page.get_attribute(".star.next", "data-skill") == first
        assert page.locator(".star[href]").count() == page.locator(".star").count()      # every star is a link: nothing is locked


def test_a_student_sees_only_the_lessons_of_their_class(browser, bank):
    two, _, _ = lessons_by_size(bank)
    app, _ = world(bank, [two])
    with running(app) as url:
        page = path_page(browser, url)
        assert page.locator(".orbit").count() == 1 and page.locator(f"#lesson-{two}").count() == 1


# --- 4.3 access ---

def test_every_star_is_reached_with_tab_and_opened_with_enter_and_names_its_number_name_and_state(browser, bank):
    two, _, _ = lessons_by_size(bank)
    app, _ = world(bank, [two])
    with running(app) as url:
        page = path_page(browser, url)
        skills = [s for s in bank.skills if s["unit"] == two]
        for i, skill in enumerate(skills, 1):
            assert page.get_attribute(f".star[data-skill='{skill['id']}']", "aria-label").startswith(f"{i}. {skill['name']}: ")
        page.focus("#out")                                                           # start before the path, then Tab onwards
        reached = []
        for _ in range(40):
            page.keyboard.press("Tab")
            on = page.evaluate("document.activeElement.classList.contains('star') ? document.activeElement.dataset.skill : null")
            if on and on not in reached:
                reached.append(on)
            if len(reached) == len(skills):
                break
        assert reached == [s["id"] for s in skills]                                  # in order, by keyboard
        page.keyboard.press("Enter")
        assert page.evaluate("location.hash") == f"#/skill/{skills[-1]['id']}"


def test_nothing_on_the_path_animates_when_the_device_asks_for_less_motion(browser, bank):
    two, _, _ = lessons_by_size(bank)
    app, _ = world(bank, [two])
    with running(app) as url:
        page = path_page(browser, url)
        assert page.evaluate("document.getAnimations().length") > 0                    # the owl blinks and the next star pulses
        page.emulate_media(reduced_motion="reduce")
        assert page.evaluate("document.getAnimations().length") == 0


# --- 7.3 choosing a subject ---

def test_after_sign_in_the_student_sees_the_math_card_then_the_path_and_can_go_back(browser, bank):
    two, three, _ = lessons_by_size(bank)
    app, store = world(bank, [two, three])
    first = next(s["id"] for s in bank.skills if s["unit"] == two)
    store.save_progress(KID_ID, first, started_at=NOW, mastered_at=NOW)
    total = sum(1 for s in bank.skills if s["unit"] in (two, three))
    with running(app) as url:
        page = offline_formulas(browser.new_page(viewport=PHONE))
        page.goto(url)
        page.fill("#email", KID)
        page.click("#dev button")
        page.wait_for_selector(".subject-card")
        assert page.locator(".orbit").count() == 0 and page.locator(".subject-card").count() == 1      # the subjects first, no path yet
        assert "ืžืชืžื˜ื™ืงื” ื›ื™ืชื” ื˜ - ืžืฆื•ื™ื ื•ืช" in page.inner_text(".subject-card") and f"1 ืžืชื•ืš {total} ืžื™ื•ืžื ื•ื™ื•ืช" in page.inner_text(".subject-card")
        assert page.get_attribute(".subject-card [role=progressbar]", "aria-valuenow") == "1"
        assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
        page.click(".subject-card")
        page.wait_for_selector(".tabs")                                                              # the card opens the subject's home
        assert page.evaluate("location.hash") == "#/subject/math" and page.locator(".orbit").count() == 0
        page.click(".tabs a[href='#/subject/math/path']")
        page.wait_for_selector(".orbit")
        assert page.evaluate("location.hash") == "#/subject/math/path"
        page.click("#to-subjects")                                                                    # a way back to the subjects
        page.wait_for_selector(".subject-card")
        assert page.locator(".orbit").count() == 0


def test_a_link_to_a_skill_opens_the_math_path_with_its_sheet(browser, bank):
    two, _, _ = lessons_by_size(bank)
    app, _ = world(bank, [two])
    skill = next(s["id"] for s in bank.skills if s["unit"] == two)
    with running(app) as url:
        page = offline_formulas(browser.new_page(viewport=PHONE))
        page.goto(f"{url}#/skill/{skill}")
        page.fill("#email", KID)
        page.click("#dev button")
        page.wait_for_selector(".mx-sheet #ex-q")
        assert page.locator("#path").count() == 1 and page.locator(".subject-card").count() == 0


# --- add-rewards 1.1: the goal and the streak are on the path ---

def test_the_path_shows_todays_goal_and_the_streak(browser, bank):
    two, _, _ = lessons_by_size(bank)
    app, store = world(bank, [two])
    skill = next(s["id"] for s in bank.skills if s["unit"] == two)
    from mathly2 import clock
    for i in range(3):
        store.add_answer(KID_ID, skill, f"k{i}", True, True, None, clock.school_day())
    with running(app) as url:
        page = path_page(browser, url)
        assert "ื™ืขื“ ื™ื•ืžื™: 3 ืžืชื•ืš 5" in page.inner_text("#goal-text") and "ืจืฆืฃ: 0" in page.inner_text("#streak")
        assert page.get_attribute("#goal [role=progressbar]", "aria-valuenow") == "3"


# --- add-rewards 2.3: a faded star ---

def test_a_faded_star_is_dimmed_says_fading_and_still_counts_as_mastered(browser, bank):
    from datetime import timedelta
    from mathly2 import clock
    two, _, _ = lessons_by_size(bank)
    app, store = world(bank, [two])
    first, second = [s["id"] for s in bank.skills if s["unit"] == two][:2]
    for skill in (first, second):
        store.save_progress(KID_ID, skill, started_at=NOW, mastered_at=NOW)
    store.add_answer(KID_ID, first, "k", True, True, None, clock.school_day() - timedelta(days=20))      # faded
    store.add_answer(KID_ID, second, "k", True, True, None, clock.school_day() - timedelta(days=1))      # bright
    with running(app) as url:
        page = path_page(browser, url)
        faded, bright = f".star[data-skill='{first}']", f".star[data-skill='{second}']"
        assert "ื“ื•ืขืš" in page.inner_text(faded + " .lbl") and "โ—Œ" in page.inner_text(faded + " .lbl")
        assert "ื“ื•ืขืš" in page.get_attribute(faded, "aria-label") and "ื ืฉืœื˜ื”" in page.get_attribute(faded, "aria-label")
        assert "ื“ื•ืขืš" not in page.inner_text(bright + " .lbl")
        look = lambda sel: page.evaluate("(s) => getComputedStyle(document.querySelector(s + ' .node')).opacity", sel)
        assert look(faded) != look(bright)
        assert "2 ืžืชื•ืš" in page.inner_text(f"#lesson-{two} .hub")              # fading does not change what counts
        # one answer on it relights the star
        store.add_answer(KID_ID, first, "k2", False, True, None, clock.school_day())
        page.reload()
        page.wait_for_selector(".orbit")
        assert "ื“ื•ืขืš" not in page.inner_text(faded + " .lbl")


# --- after Google sign-in the address holds the token: the student still lands on the subjects ---

def test_an_oauth_fragment_in_the_address_does_not_skip_the_subject_cards(browser, bank):
    two, _, _ = lessons_by_size(bank)
    app, _ = world(bank, [two])
    with running(app) as url:
        page = offline_formulas(browser.new_page(viewport=PHONE))
        page.goto(url + "#access_token=abc.def.ghi&expires_in=3600&token_type=bearer")        # what the sign-in service sends the browser back with
        page.fill("#email", KID)
        page.click("#dev button")
        page.wait_for_selector(".subject-card")
        assert page.locator(".orbit").count() == 0


# --- add-subjects-and-worksheets 4.1: a card per subject, and each subject's own path ---

def lesson_ids(page):
    return {o.get_attribute("id")[len("lesson-"):] for o in page.locator("section.orbit").all()}


def two_subjects(bank):
    """A math lesson from the bank and a published science lesson; the student's two classes study one subject each."""
    from mathly2 import content
    from mathly2.lessons import assign_ids
    math_lesson = lessons_by_size(bank)[0]
    store = InMemoryStore()
    store.create_subject("science", "ืžื“ืขื™ื", "setup", icon="โš—")
    row = store.create_lesson("science", "setup", {})
    doc = assign_ids({"name": "ืชืื™ื", "mistakes": {"m": {"name": "ื‘ืœื‘ื•ืœ", "tip": "ืงืจืื• ืฉื•ื‘"}}, "skills": [
        {"name": f"ืžื™ื•ืžื ื•ืช {k}", "questions": [{"question": f"ืฉืืœื” {k}.{i}", "choices": [{"text": "ื›ืŸ", "correct": True}, {"text": "ืœื", "mistake": "m"}]}
                                              for i in range(5)]} for k in (1, 2)]}, row["id"])
    store.save_lesson(row["id"], draft=doc, published=doc)
    store.add_member(store.create_class("setup", "A")["id"], KID)
    store.add_member(store.create_class("setup", "B", subjects=["science"])["id"], KID)
    app = create_app(Settings(), bank=only(bank, [math_lesson]), store=store)
    content.rebuild(app)
    return app, store, math_lesson, f"w{row['id']}"


@pytest.mark.parametrize("viewport", [PHONE, {"width": 1280, "height": 800}])
def test_each_subject_has_a_card_its_home_and_its_own_path(browser, bank, viewport):
    app, store, math_lesson, unit = two_subjects(bank)
    with running(app) as url:
        page = offline_formulas(browser.new_page(viewport=viewport))
        page.goto(url)
        page.fill("#email", KID)
        page.click("#dev button")
        page.wait_for_selector(".subject-card")
        cards = page.locator(".subject-card")
        assert cards.count() == 2
        assert "ืžืชืžื˜ื™ืงื” ื›ื™ืชื” ื˜ - ืžืฆื•ื™ื ื•ืช" in cards.nth(0).inner_text() and "ืžื“ืขื™ื" in cards.nth(1).inner_text()
        assert "0 ืžืชื•ืš 2 ืžื™ื•ืžื ื•ื™ื•ืช" in cards.nth(1).inner_text()
        cards.nth(1).click()
        page.wait_for_selector(".tabs")
        assert page.evaluate("location.hash") == "#/subject/science"
        assert page.locator("#to-subjects").count() == 1                                        # a way back to the subjects
        page.click(".tabs a[href='#/subject/science/path']")
        page.wait_for_selector(".orbit")
        assert lesson_ids(page) == {unit}                    # only science's lesson
        assert page.locator(".star.next").get_attribute("data-skill") == f"{unit}_1"
        page.click(".tabs a[href='#/subject/science']")
        page.click("#to-subjects")
        page.wait_for_selector(".subject-card")
        page.locator(".subject-card").nth(0).click()
        page.click(".tabs a[href='#/subject/math/path']")
        page.wait_for_selector(".orbit")
        assert unit not in lesson_ids(page) and math_lesson in lesson_ids(page)
        assert page.evaluate("document.documentElement.scrollWidth <= innerWidth")


def test_a_link_to_a_skill_opens_its_own_subjects_path(browser, bank):
    app, store, math_lesson, unit = two_subjects(bank)
    with running(app) as url:
        page = offline_formulas(browser.new_page(viewport=PHONE))
        page.goto(url)
        page.fill("#email", KID)
        page.click("#dev button")
        page.wait_for_selector(".subject-card")
        page.goto(f"{url}#/skill/{unit}_2")
        page.reload()
        page.wait_for_selector(".mx-sheet")
        assert lesson_ids(page) == {unit}
        page.click(".mx-x")
        page.wait_for_function("location.hash === '#/subject/science/path'")


def test_a_wrong_answer_in_a_second_subject_names_its_mistake_on_the_exercise_screen(browser, bank):
    app, store, math_lesson, unit = two_subjects(bank)
    with running(app) as url:
        page = offline_formulas(browser.new_page(viewport=PHONE))
        page.goto(url)
        page.fill("#email", KID)
        page.click("#dev button")
        page.wait_for_selector(".subject-card")
        page.goto(f"{url}#/skill/{unit}_1")
        page.wait_for_selector("#ex-choices .ex-choice")
        page.click("#ex-choices .ex-choice[data-i='1']")                                      # choice 1 is always the wrong one here
        page.wait_for_selector("#ex-fb .ex-bad")
        assert "ื‘ืœื‘ื•ืœ" in page.inner_text("#ex-fb") and "ืงืจืื• ืฉื•ื‘" in page.inner_text("#ex-fb")
        assert store.review_of(KID_ID)[0]["skill_id"] == f"{unit}_1"