mathly / mathly2 /tests /test_home_browser.py
wzender's picture
Claude Opus 5.5
Tests on request and a readiness number that only tests raise
82cae90
Raw History Blame Contribute Delete
24.4 kB
"""The student's home, the bottom tab bar and a test's card in a real browser (add-test-readiness 3.1-3.3, 3.5)."""
import uuid
from collections import Counter
from datetime import timedelta
import pytest
playwright = pytest.importorskip("playwright.sync_api")
from mathly2 import clock
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
from mathly2.tests.serve import board_written
KID, TEACHER = "kid@school.test", "t@school.test"
KID_ID = str(uuid.uuid5(uuid.NAMESPACE_DNS, KID))
PHONE = {"width": 360, "height": 800}
PATH_TAB = ".tabs a[href='#/subject/math/path']"
@pytest.fixture(scope="module")
def bank():
return load_bank()
@pytest.fixture
def browser():
with playwright.sync_playwright() as p:
b = p.chromium.launch()
yield b
b.close()
def world(bank):
sizes = Counter(s["unit"] for s in bank.skills if bank.question_keys(s["id"]))
lessons = [l for l, c in sizes.items() if c == 4][:2]
store = InMemoryStore()
klass = store.create_class("setup", "A", lessons=lessons, teacher_email=TEACHER)
store.add_member(klass["id"], KID)
return create_app(Settings(), bank=only(bank, lessons), store=store), store, klass, lessons
def add_test(store, klass, lessons, days_ahead, set_days_ago=20, **kw):
"""A test `days_ahead` days from today that was set `set_days_ago` days ago (so the full plan applies)."""
now = clock.now()
return store.create_test(klass["id"], clock.school_day() + timedelta(days=days_ahead), lessons, TEACHER,
created_at=now - timedelta(days=set_days_ago), **kw)
def covered(bank, lessons):
return [s["id"] for s in bank.skills if s["unit"] in lessons and bank.question_keys(s["id"])]
def sign_in(browser, url, where="", viewport=PHONE):
page = offline_formulas(browser.new_page(viewport=viewport))
errors = []
page.on("pageerror", lambda e: errors.append(str(e)))
page.goto(url + where)
page.fill("#email", KID)
page.click("#dev button")
page.errors = errors
return page
def to_home(page):
page.click(".subject-card")
page.wait_for_selector(".tabs")
def no_sideways_scroll(page):
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
# --- 3.1 the card opens home and the tab bar is at the bottom ---
def test_the_card_opens_home_and_each_tab_opens_its_screen(browser, bank):
app, _, _, _ = world(bank)
with running(app) as url:
page = sign_in(browser, url)
to_home(page)
assert page.evaluate("location.hash") == "#/subject/math" and page.locator(".tabs a[aria-current='page']").inner_text() == "讘讬转"
assert page.locator(".tabs a").all_inner_texts() == ["讘讬转", "讛注诪拽讛", "讛讙砖讜转", "爪讬讜谞讬诐", "讛讗讜住祝", "讞讝专讛"]
for href, marker in (("#/subject/math/path", ".orbit"), ("#/submissions", "#submissions"), ("#/grades", "#grades"), ("#/collection", "#collection"), ("#/review", "#review"), ("#/subject/math", "#lesson-map")):
page.click(f".tabs a[href='{href}']")
page.wait_for_selector(marker)
assert page.errors == []
def test_the_tab_bar_stays_at_the_bottom_with_big_targets_and_covers_nothing(browser, bank):
app, _, _, _ = world(bank)
with running(app) as url:
page = sign_in(browser, url)
to_home(page)
page.click(PATH_TAB)
page.wait_for_selector(".orbit")
bar = page.locator(".tabs").bounding_box()
assert abs(bar["y"] + bar["height"] - PHONE["height"]) <= 1 and bar["width"] == PHONE["width"]
for box in [t.bounding_box() for t in page.locator(".tabs a").all()]:
assert box["height"] >= 44 and box["width"] >= 44
page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
last = page.evaluate("document.querySelector('.trail').lastElementChild.getBoundingClientRect().bottom")
assert last <= page.locator(".tabs").bounding_box()["y"] + 1 # the end of the page is above the bar
no_sideways_scroll(page)
def test_a_link_to_a_skill_still_opens_the_path_with_its_sheet(browser, bank):
app, _, _, lessons = world(bank)
skill = covered(bank, lessons)[0]
with running(app) as url:
page = sign_in(browser, url, f"#/skill/{skill}")
page.wait_for_selector(".mx-sheet #ex-q")
assert page.locator("#path").count() == 1 and page.locator(".tabs").count() == 1
page.click(".mx-x")
page.wait_for_selector(".orbit")
assert page.evaluate("location.hash") == "#/subject/math/path"
# --- 3.2 home ---
def test_with_no_test_home_shows_the_map_and_a_link_to_the_path(browser, bank):
app, _, _, lessons = world(bank)
with running(app) as url:
page = sign_in(browser, url)
to_home(page)
page.wait_for_selector("#lesson-map")
assert page.locator("#nearest-test").count() == 0
assert page.get_attribute("#play", "href") == f"#/round/{lessons[0]}"
no_sideways_scroll(page)
def test_home_shows_only_the_closest_test_and_the_other_tests_are_on_its_card(browser, bank):
app, store, klass, lessons = world(bank)
far = add_test(store, klass, lessons, 20)
near = add_test(store, klass, lessons, 9)
with running(app) as url:
page = sign_in(browser, url)
to_home(page)
assert page.get_attribute("#nearest-test", "data-test") == str(near["id"]) and page.get_attribute("#home-sim", "href") == f"#/sim/{near['id']}"
assert page.locator("#other-tests").count() == 0 and page.locator("#notifications").count() == 0 # only what is critical
page.click("#nearest-test .strip-main")
page.wait_for_selector("#other-tests")
assert page.get_attribute("#other-tests a", "href") == f"#/test/{far['id']}"
# --- 3.3 a test's card ---
def card_page(browser, url, test_id):
page = sign_in(browser, url, f"#/test/{test_id}")
page.wait_for_selector("#test-card")
return page
def finished(store, scope, kind, grade, questions=None, answers=None):
now = clock.now()
row = store.create_attempt(KID_ID, kind, scope, now - timedelta(days=1), now - timedelta(days=1) + timedelta(minutes=45), questions or [])
return store.save_attempt(row["id"], submitted_at=now - timedelta(days=1) + timedelta(minutes=9), grade=grade, answers=answers or {})
def test_a_test_of_another_class_or_a_missing_one_shows_an_error(browser, bank):
app, _, _, _ = world(bank)
with running(app) as url:
page = sign_in(browser, url, "#/test/999")
page.wait_for_selector("[role=alert]")
assert "诇讗 谞诪爪讗" in page.inner_text("[role=alert]")
# --- 3.4 the same sheet runs exercise tests and the simulation ---
def started_skills(store, bank, lessons, n=5):
skills = covered(bank, lessons)
for i in skills[:n]:
store.save_progress(KID_ID, i, started_at="then")
return skills
def test_an_exercise_test_runs_with_the_tests_length_and_no_timer_and_links_its_weak_skills_at_the_end(browser, bank):
app, store, klass, lessons = world(bank)
skills = started_skills(store, bank, lessons)
test = add_test(store, klass, lessons, 10, questions=6, minutes=30)
with running(app) as url:
page = sign_in(browser, url, f"#/exam/{test['id']}/1")
page.wait_for_selector("#qz-q")
assert page.evaluate("location.hash").startswith(f"#/exam/{test['id']}/1/") # the address names the attempt
assert "诪讘讞谉 转专讙讜诇 诪讗讜讝谉" in page.inner_text(".mx-sheet") and page.locator(".qz-dot").count() == 6
assert page.locator("#qz-timer").count() == 0 # only the simulation is timed
sheet = page.inner_text(".mx-sheet")
assert not any(bank.skill(i)["name"] in sheet for i in skills) # no skill names, no feedback
page.click("#qz-submit")
page.click("#qz-yes")
page.wait_for_selector("#qz-grade")
assert page.inner_text("#qz-grade b") == "0" and "转讜爪讗转 诪讘讞谉 转专讙讜诇 诪讗讜讝谉" in page.inner_text(".mx-sheet")
links = [a.get_attribute("href") for a in page.locator("#qz-lost a").all()]
assert links and all(h.startswith("#/skill/") and h[8:] in skills for h in links) # weak skills link to their stars
assert "讛诪讜讻谞讜转 谞砖讗专讛 0" in page.inner_text("#qz-ready") and page.locator("#qz-retake").count() == 0
page.click(".qz-actions [data-close]")
page.wait_for_selector("#test-card") # closing leads back to the test's card
assert page.evaluate("location.hash") == f"#/test/{test['id']}"
def test_when_the_simulations_countdown_reaches_zero_it_is_handed_in_as_it_stands(browser, bank):
from mathly2 import quiz
app, store, klass, lessons = world(bank)
started_skills(store, bank, lessons)
test = add_test(store, klass, lessons, 4, questions=6, minutes=45)
drawn = quiz.draw(bank, lessons[0])[:5]
with running(app) as url:
page = sign_in(browser, url) # signed in before the clock starts
now = clock.now()
row = store.create_attempt(KID_ID, "simulation", str(test["id"]), now - timedelta(minutes=45) + timedelta(seconds=8),
now + timedelta(seconds=8), drawn)
page.goto(url + f"#/sim/{test['id']}/{row['id']}")
page.wait_for_selector("#qz-timer")
page.wait_for_selector("#qz-grade", timeout=25000)
assert page.inner_text("#qz-grade b") == "0" and "讛讝诪谉 谞讙诪专" in page.inner_text(".qz-result")
def test_after_the_simulation_the_wrong_questions_are_answered_again_in_a_round(browser, bank):
app, store, klass, lessons = world(bank)
started_skills(store, bank, lessons)
test = add_test(store, klass, lessons, 4, questions=6, minutes=45)
with running(app) as url:
page = sign_in(browser, url, f"#/sim/{test['id']}")
page.wait_for_selector("#qz-q")
page.click("#qz-submit") # nothing answered: six mistakes
page.click("#qz-yes")
page.wait_for_selector("#qz-retry")
assert "(6)" in page.inner_text("#qz-retry")
page.click("#qz-retry")
page.wait_for_selector(".round-layer #ex-q")
assert "讞讝专讛 注诇 讛讟注讜讬讜转" in page.inner_text(".round-layer .ex-chip") and "诪转讜讱 6" in page.inner_text("#rd-count")
context = page.inner_text("#rd-context")
assert "诪讘讞谉 住讬诪讜诇爪讬讛" in context and "砖讗诇讛 1 诪转讜讱 6" in context and "诇讗 注谞讬转诐" in context # where the question came from
for n in range(6):
page.locator(".round-layer .ex-choice").first.click()
page.wait_for_selector(".round-layer .ex-ok, .round-layer .ex-bad")
page.click("#rd-next")
if n < 5:
page.wait_for_function(f"document.querySelector('#rd-count').textContent.includes('{n + 2} 诪转讜讱')")
page.wait_for_selector("#rd-result[data-kind='retry']")
assert "讛爪讬讜谉 讘诪讘讞谉 诇讗 诪砖转谞讛" in page.inner_text("#rd-result") and page.locator(".rd-bigstars span").count() == 0
assert store.attempts_of(KID_ID, "simulation")[0]["grade"] == 0
page.click("#rd-result [data-close]")
page.wait_for_selector("#test-card")
assert page.locator(".round-layer").count() == 0 and page.errors == []
page.check("#practice input[value='focus']") # the hard skills now have their own test
assert page.locator("#focus-skills .ex-chip").count() > 0
page.click("#practice-start")
page.wait_for_selector("#qz-q")
assert "诪讘讞谉 转专讙讜诇 诪诪讜拽讚" in page.inner_text(".mx-sheet h3") and 0 < page.locator(".qz-dot").count() <= 15
focus = store.attempts_of(KID_ID, "exercise_test")[0]
lost = {q["skill_id"] for q in store.attempts_of(KID_ID, "simulation")[0]["questions"]} | set(covered(bank, lessons)[:5]) # or still learning
assert focus["scope"].endswith(":3") and {q["skill_id"] for q in focus["questions"]} <= lost and page.errors == []
# --- 3.5 the grades screen has exercise tests and simulations ---
def test_exercise_tests_and_the_simulation_appear_on_the_grades_screen(browser, bank):
app, store, klass, lessons = world(bank)
test = add_test(store, klass, lessons, 6)
removed = add_test(store, klass, lessons, 12)
finished(store, f"{test['id']}:1", "exercise_test", 55)
finished(store, f"{test['id']}:1", "exercise_test", 80)
finished(store, f"{test['id']}:2", "exercise_test", 70)
finished(store, str(test["id"]), "simulation", 65)
finished(store, f"{removed['id']}:1", "exercise_test", 90)
store.remove_test(removed["id"])
with running(app) as url:
page = sign_in(browser, url, "#/grades")
page.wait_for_selector("#exercise-grades")
one = page.locator(f"#exercise-grades li[data-test='{test['id']}'][data-number='1']")
assert one.locator(".gr-num").inner_text() == "80" and "3 谞讬住讬讜谞讜转" in one.inner_text() and "诪讘讞谉 转专讙讜诇 诪讗讜讝谉" in one.inner_text() # 1 and the old 2: balanced
assert page.locator(f"#simulation-grades li[data-test='{test['id']}'] .gr-num").inner_text() == "65"
gone = page.locator(f"#exercise-grades li[data-test='{removed['id']}']")
assert gone.locator(".gr-num").inner_text() == "90" and "讛讜住专" in gone.inner_text() # a removed test keeps its grade and name
assert page.locator("#no-grades").count() == 0 and page.locator("#quiz-grades").count() == 0
no_sideways_scroll(page)
page.click(f"#simulation-grades li[data-test='{test['id']}'] a")
page.wait_for_selector("#test-card")
# --- 4.1 the teacher's tests tab ---
DESKTOP = {"width": 1280, "height": 800}
def teacher_page(browser, url, viewport=PHONE):
page = offline_formulas(browser.new_page(viewport=viewport))
errors = []
page.on("pageerror", lambda e: errors.append(str(e)))
page.goto(url)
page.fill("#email", TEACHER)
page.click("#dev button")
page.wait_for_selector("#teaching")
page.click("#teacher-tabs [data-tab='tests']")
page.wait_for_selector("#tests")
page.errors = errors
return page
def iso(days):
return (clock.school_day() + timedelta(days=days)).isoformat()
@pytest.mark.parametrize("viewport", [PHONE, DESKTOP])
def test_a_teacher_sets_a_test_with_the_defaults_moves_its_date_and_removes_it(browser, bank, viewport):
app, store, klass, lessons = world(bank)
with running(app) as url:
page = teacher_page(browser, url, viewport)
assert "注讜讚 诇讗 谞拽讘注 诪讘讞谉" in page.inner_text(".class-tests")
page.click(".set-test summary")
assert page.input_value("[name=questions]") == "12" and page.input_value("[name=minutes]") == "45" # the defaults
page.fill("[name=date]", iso(10))
page.fill("[name=note]", "驻专拽 3")
page.click(".set-test button[type=submit]")
page.wait_for_selector(".test-row")
row = page.locator(".test-row")
assert "12 砖讗诇讜转" in row.inner_text() and "45 讚拽讜转" in row.inner_text() and "讘注讜讚 10 讬诪讬诐" in row.inner_text() and "驻专拽 3" in row.inner_text()
assert [t["questions"] for t in store.tests_of_classes([klass["id"]])] == [12]
page.click("[data-edit]") # move the date two days later
page.fill(".edit-slot [name=date]", iso(12))
page.click(".edit-slot button[type=submit]")
page.wait_for_function("document.querySelector('.test-row h4').innerText.includes('讘注讜讚 12 讬诪讬诐')")
page.click("[data-remove-test]") # removing takes two steps
page.click("[data-confirm-remove]")
page.wait_for_selector(".test-row", state="detached")
assert store.tests_of_classes([klass["id"]]) == [] and len(store.tests_of_classes([klass["id"]], include_removed=True)) == 1
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
assert page.errors == []
def test_the_tab_shows_who_has_not_taken_the_simulation_and_each_students_readiness(browser, bank):
app, store, klass, lessons = world(bank)
others = ["a@school.test", "b@school.test"]
for email in others:
store.add_member(klass["id"], email)
test = add_test(store, klass, lessons, 5)
now = clock.now()
row = store.create_attempt(KID_ID, "simulation", str(test["id"]), now - timedelta(days=1), now - timedelta(days=1) + timedelta(minutes=45), [])
store.save_attempt(row["id"], submitted_at=now - timedelta(days=1) + timedelta(minutes=5), grade=75)
store.link_user(KID, KID_ID)
with running(app) as url:
page = teacher_page(browser, url)
assert "1 诪转讜讱 3 注砖讜 住讬诪讜诇爪讬讛" in page.inner_text(".sim-count")
missing = page.inner_text(".sim-missing")
assert "a@school.test" in missing and "b@school.test" in missing and KID not in missing
assert page.locator(".readiness-table tbody tr").count() == 3
mine = page.locator(f".readiness-table tr:has-text('{KID}')")
assert "45 路 " in mine.inner_text() and "1 路 讛讟讜讘 75" in mine.inner_text() # one simulation of 75: 60% of the way
def test_a_refused_test_shows_why_and_keeps_what_was_typed(browser, bank):
app, store, klass, lessons = world(bank)
with running(app) as url:
page = teacher_page(browser, url)
page.click(".set-test summary")
page.fill("[name=date]", iso(10))
page.fill("[name=note]", "诇讗 诇讗讘讚")
for box in page.locator(".set-test [name=lessons]").all():
box.uncheck() # no lesson: the server refuses
page.click(".set-test button[type=submit]")
page.wait_for_selector(".set-test .form-error:not([hidden])")
assert page.input_value(".set-test [name=note]") == "诇讗 诇讗讘讚" and page.input_value(".set-test [name=date]") == iso(10)
assert store.tests_of_classes([klass["id"]]) == []
# --- 5.1 the whole countdown ---
def answer_in_browser(page, bank, store, kind, scope, rights):
"""Answer the open attempt on screen right or wrong, one flag per question, moving with "next"; then submit. The board counts as written on."""
board_written(page)
page.wait_for_selector("#qz-q")
refs = store.attempts_of(KID_ID, kind, scope)[-1]["questions"]
for n, ok in enumerate(rights):
key = page.get_attribute("#qz-q", "data-key")
question = bank._questions[key.split(":")[0]][key]
picks = [i for i, c in enumerate(question["choices"]) if bool(c.get("correct")) == ok]
page.evaluate("window.MathlyBoard && (window.MathlyBoard.hasWork = () => true)") # as if the way were on the board
page.click(f"#qz-choices .qz-choice[data-i='{picks[0]}']")
if n < len(rights) - 1:
page.click(".qz-nav button >> nth=1")
page.wait_for_function("(k) => document.querySelector('#qz-q').dataset.key !== k", arg=key)
page.click("#qz-submit")
page.wait_for_selector("#qz-grade")
return refs
# --- readiness-tooltip: the number explains itself ---
# --- readiness: the home's block, the test's page, practice tests by mode and simulations on request ---
def test_home_shows_the_readiness_and_two_clear_buttons_that_raise_it(browser, bank):
app, store, klass, lessons = world(bank)
test = add_test(store, klass, lessons, 2)
with running(app) as url:
page = sign_in(browser, url)
to_home(page)
strip = page.locator("#nearest-test")
assert "诪讘讞谉 讘注讜讚 2 讬诪讬诐" in strip.inner_text() and "诪讜讻谞讜转 诇诪讘讞谉" in strip.inner_text()
assert strip.locator(".rd-num").inner_text() == "0" and "诪转讞讬诇讬诐" in strip.inner_text() and '注讜讚 40 诇专诪讛 "讘讚专讱"' in strip.inner_text()
assert page.get_attribute("#nearest-test .strip-main", "href") == f"#/test/{test['id']}"
assert "诪讘讞谉 转专讙讜诇" in page.inner_text("#home-practice") and "+30" in page.inner_text("#home-practice") and "+60" in page.inner_text("#home-sim")
assert page.get_attribute("#home-practice", "href") == f"#/test/{test['id']}/practice"
page.click("#home-practice") # straight to choosing a practice test
page.wait_for_selector("#practice .modes")
assert page.evaluate("document.getElementById('practice').getBoundingClientRect().top") < 200
no_sideways_scroll(page)
def test_a_simulation_on_request_raises_readiness_with_a_level_up_and_another_can_follow(browser, bank):
app, store, klass, lessons = world(bank)
test = add_test(store, klass, lessons, 30, questions=4, minutes=20) # a month ahead: no window
with running(app) as url:
page = sign_in(browser, url)
to_home(page)
page.click("#home-sim")
page.wait_for_selector("#qz-timer")
assert "诪讘讞谉 住讬诪讜诇爪讬讛" in page.inner_text(".mx-sheet") and page.locator(".qz-dot").count() == 4
answer_in_browser(page, bank, store, "simulation", str(test["id"]), [True] * 4)
page.wait_for_selector("#qz-ready.up")
assert page.inner_text(".qz-gain") == "+60" and "注诇转讛 诇志60" in page.inner_text("#qz-ready") and "专诪讛 讞讚砖讛: 讘讚专讱" in page.inner_text("#qz-ready")
page.wait_for_function("document.querySelector('#qz-ready .rd-bar i').style.width === '60%'") # the bar grows to the new number
page.click("#qz-more-sim") # another one, on request
page.wait_for_selector("#qz-timer")
assert len(store.attempts_of(KID_ID, "simulation")) == 2 and page.errors == []
def test_the_tests_page_has_three_practice_modes_and_the_trail_of_gains(browser, bank):
app, store, klass, lessons = world(bank)
skills = covered(bank, lessons)
test = add_test(store, klass, lessons, 10, questions=4)
finished(store, f"{test['id']}:1", "exercise_test", 50)
with running(app) as url:
page = card_page(browser, url, test["id"])
page.wait_for_function("document.querySelector('#test-card .rd-num').textContent === '15'")
assert [m.get_attribute("data-mode") for m in page.locator("#practice .mode").all()] == ["balanced", "focus", "manual"]
assert page.locator("#practice input[value='focus']").is_disabled() # nothing hard yet (no questions in that one)
assert "+15" in page.inner_text("#trail") and "诪讘讞谉 转专讙讜诇 诪讗讜讝谉" in page.inner_text("#trail")
page.check("#practice input[value='manual']")
assert page.locator("#manual-skills").is_visible() and page.locator("#manual-skills input").count() == len(skills)
page.click("#practice-start")
assert "讘讞专讜 诇驻讞讜转" in page.inner_text("#practice-error")
page.check(f"#manual-skills input[value='{skills[0]}']")
page.click("#practice-start")
page.wait_for_selector("#qz-q")
row = store.attempts_of(KID_ID, "exercise_test", f"{test['id']}:4")[0]
assert "诪讘讞谉 转专讙讜诇 讘讘讞讬专讛" in page.inner_text(".mx-sheet h3") and {q["skill_id"] for q in row["questions"]} == {skills[0]}
page.click(".mx-x")
page.wait_for_selector("#practice")
page.click("#rd-how summary")
assert "住讬诪讜诇爪讬讛 住讜讙专 60%" in page.inner_text("#rd-how") and page.errors == []
no_sideways_scroll(page)