Download mathly2/tests/test_path_browser.py from wzender/mathly: direct link, hf CLI and curl.
- Browser
- Download file 18.9 kB
-
https://huggingface.co/spaces/wzender/mathly/resolve/main/mathly2/tests/test_path_browser.py
- Command line
-
hf download hf://spaces/wzender/mathly/mathly2/tests/test_path_browser.py
-
curl -L -o test_path_browser.py https://huggingface.co/spaces/wzender/mathly/resolve/main/mathly2/tests/test_path_browser.py
18.9 kB
| """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) | |
| 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 | |
| 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 --- | |
| 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']}" | |
| 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" | |