mathly / tests /test_phone_browser.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
84.5 kB
"""Optional browser regression checks: pip install playwright && playwright install chromium.""" import socket import threading import time import pytest import uvicorn from app.main import create_app playwright = pytest.importorskip("playwright.sync_api") @pytest.fixture def phone_server(monkeypatch): monkeypatch.setenv("ANTHROPIC_API_KEY", "browser-test") app = create_app() class Solver: def solve(self, image, media_type): assert image and media_type == "image/jpeg" return {"readable": True, "problem": "Triangle", "steps": [{"hint": "Hint", "work": "Work"}], "answer": "10 cm", "skill": "", "note": ""} app.state.photo, app.state.photo_key = Solver(), "browser-test" with socket.socket() as sock: sock.bind(("127.0.0.1", 0)) server = uvicorn.Server(uvicorn.Config(app, log_level="error")) thread = threading.Thread(target=server.run, kwargs={"sockets": [sock]}, daemon=True) thread.start() try: deadline = time.monotonic() + 5 while not server.started: assert thread.is_alive() and time.monotonic() < deadline time.sleep(.01) yield f"http://127.0.0.1:{sock.getsockname()[1]}" finally: server.should_exit = True thread.join(timeout=5) @pytest.mark.parametrize("manager", [True, False]) def test_class_management_geometry_and_permissions(phone_server, manager): import json row = {"id": 7, "name": "כיתת תרגול", "lessons": ["t"], "emails": ["student@example.com", "teacher@example.com"], "teachers": ["teacher@example.com"], "exam_title": "לוח מבחנים – כיתה ט׳11, תשפ״ז", "exam_note": "ייתכנו שינויים.", "exams": [{"date": "2026-11-16", "subject": "מתמטיקה"}]} writes = [] with playwright.sync_playwright() as p: browser = p.chromium.launch() try: page = browser.new_page() page.route("https://cdn.jsdelivr.net/**", lambda route: route.abort()) page.add_init_script('''window.supabase={createClient:()=>({auth:{ getSession:async()=>({data:{session:{access_token:"test",user:{id:"test"}}}}), onAuthStateChange:()=>{},signOut:async()=>{} }})};''') def index(route): response = route.fetch() route.fulfill(response=response, body=response.text().replace( 'supabaseUrl:""', 'supabaseUrl:"https://auth.invalid"')) page.route(phone_server + "/", index) page.route("**/api/me", lambda route: route.fulfill(json={ "email": "student@example.com", "user_id": "test", "answer_scans": False, "permissions": ["management.access", "classes.manage"] if manager else ["path.use"], "classes": [row]})) page.route("**/api/classes", lambda route: route.fulfill(json={"classes": [row]})) def save(route): body = json.loads(route.request.post_data) writes.append(body) if len(writes) == 1: route.fulfill(status=503, json={"detail": "temporary failure"}) else: row.update(body) route.fulfill(json={"ok": True}) page.route("**/api/classes/7", save) page.goto(phone_server) playwright.expect(page.get_by_role("heading", name="המקצועות שלי")).to_be_visible() page.locator('[data-subject="מתמטיקה"]').click() button = page.locator('[data-act="manage"]') if not manager: playwright.expect(page.locator('.mx-top')).to_be_visible() playwright.expect(button).to_have_count(0) return playwright.expect(page.locator(".mx-exam-countdown")).to_be_visible() playwright.expect(page.locator(".mx-exam-countdown time")).to_contain_text("16 בנובמבר 2026") button.click() playwright.expect(page.locator(".mx-exam-board")).to_contain_text("מתמטיקה") page.get_by_role("button", name="+ כיתה חדשה").click() subject = page.locator('input[name="subject"][value="מתמטיקה"]') units = page.locator('input[name="unit"][data-subject="מתמטיקה"]') playwright.expect(subject).not_to_be_checked() playwright.expect(page.locator('[data-subject-units="מתמטיקה"]')).to_be_hidden() subject.check() playwright.expect(page.locator('[data-subject-units="מתמטיקה"]')).to_be_visible() assert units.count() > 1 and all(units.nth(i).is_checked() for i in range(units.count())) units.first.uncheck() assert subject.evaluate("input => input.checked && input.indeterminate") subject.uncheck() assert not any(units.nth(i).is_checked() for i in range(units.count())) page.get_by_role("button", name="ביטול").click() page.get_by_role("button", name="עריכה").click() playwright.expect(page.locator('input[name="subject"][value="מתמטיקה"]')).to_be_checked() geometry = page.locator('input[name="unit"][value="a"]') geometry.check() page.get_by_role("button", name="שמירת השינויים").click() playwright.expect(page.get_by_role("status")).to_contain_text("לא הצלחנו לשמור") playwright.expect(geometry).to_be_checked() page.get_by_role("button", name="שמירת השינויים").click() playwright.expect(page.get_by_role("status")).to_contain_text("השינויים נשמרו") assert writes[-1] == {"name": row["name"], "emails": row["emails"], "teachers": row["teachers"], "lessons": ["t", "a"]} page.locator('.mx-x').click() page.locator('.mx-nav [data-view="path"]').click() playwright.expect(page.locator('[data-id="a_30_side"]')).to_be_visible() page.reload() page.locator('[data-subject="מתמטיקה"]').click() page.locator('.mx-nav [data-view="path"]').click() playwright.expect(page.locator('[data-id="a_30_side"]')).to_be_visible() finally: browser.close() def test_class_teacher_report(phone_server): """A student chosen as the class's teacher opens the class report: progress, difficulties and both kinds of grades.""" from datetime import datetime, timedelta, timezone now = datetime.now(timezone.utc) slips = [{"at": (now - timedelta(days=d)).isoformat(), "id": "t_ext", "q": 0, "m": "ext_partial"} for d in (1, 3)] row = {"id": 4, "name": "ט׳2", "lessons": ["t"], "teacher": True, "exams": []} report = {"class": {"id": 4, "name": "ט׳2", "lessons": ["t"]}, "students": [ {"email": "dana@example.com", "signed_in": True, "saved_at": None, "progress": { "done": {"t_ext": "2026-11-01"}, "gold": {}, "days": {}, "missed": {}, "slips": slips, "updated": 1, "quizzes": [{"at": "2026-11-02T10:00:00Z", "n": 12, "right": 9, "ms": 600000, "units": ["t"]}], # the device claims 85; the server's own record of the sitting says 62 "uq": {"t": {"history": [{"at": "2026-11-03T10:00:00Z", "score": 85, "sid": "s1"}], "going": False}}}, "quiz_sittings": [{"id": "s1", "unit_id": "t", "submitted_at": "2026-11-04T10:00:00Z", "grade": 62, "open": 0}]}, {"email": "omer@example.com", "signed_in": False, "saved_at": None, "progress": None, "quiz_sittings": []}, {"email": "noa@example.com", "signed_in": True, "saved_at": None, "progress": { # handed in, a check still waits "done": {}, "gold": {}, "days": {}, "missed": {}, "slips": [], "updated": 1, "quizzes": [], "uq": {"t": {"history": [{"at": "2026-11-04T09:00:00Z", "sid": "s2"}], "going": False}}}, "quiz_sittings": [{"id": "s2", "unit_id": "t", "submitted_at": "2026-11-04T09:00:00Z", "grade": None, "open": 1}]}, {"email": "ben@example.com", "signed_in": True, "saved_at": None, "progress": { # from before the server kept sittings "done": {}, "gold": {}, "days": {}, "missed": {}, "slips": [], "updated": 1, "quizzes": [], "uq": {"t": {"history": [{"at": "2026-10-20T09:00:00Z", "score": 100}], "going": False}}}, "quiz_sittings": []}]} with playwright.sync_playwright() as p: browser = p.chromium.launch() try: page = browser.new_page() page.route("https://cdn.jsdelivr.net/**", lambda route: route.abort()) page.add_init_script('''window.supabase={createClient:()=>({auth:{ getSession:async()=>({data:{session:{access_token:"test",user:{id:"test"}}}}), onAuthStateChange:()=>{},signOut:async()=>{} }})};''') def index(route): response = route.fetch() route.fulfill(response=response, body=response.text().replace( 'supabaseUrl:""', 'supabaseUrl:"https://auth.invalid"')) page.route(phone_server + "/", index) page.route("**/api/me", lambda route: route.fulfill(json={ "email": "teacher@example.com", "user_id": "test", "answer_scans": False, "permissions": ["path.use"], "classes": [row]})) page.route("**/api/math/progress", lambda route: route.fulfill(json={"progress": {}})) page.route("**/api/classes/4/report", lambda route: route.fulfill(json=report)) page.goto(phone_server) page.locator('[data-act="class-report"]').click() sheet = page.locator(".mx-report-sheet") playwright.expect(sheet.locator("#mxTitle")).to_have_text("הכיתה שלי: ט׳2") playwright.expect(sheet.locator(".mx-report-common")).to_contain_text("1 תלמידים: dana") dana, omer = sheet.locator(".mx-report-student").nth(0), sheet.locator(".mx-report-student").nth(1) playwright.expect(dana.locator("summary")).to_contain_text("בוחן 62") playwright.expect(dana.locator("summary")).to_contain_text("תרגול 9/12") playwright.expect(omer).to_contain_text("עוד לא נכנס לחשבון") dana.locator("summary").click() quiz = dana.locator("section", has_text="בוחן מסכם").locator(".mx-report-table") playwright.expect(quiz).to_contain_text("62/100") playwright.expect(quiz).not_to_contain_text("85") # the device's number is never shown playwright.expect(dana.locator(".mx-report-table").nth(1)).to_contain_text("75") # the practice data says it comes from the device; the quiz grades do not playwright.expect(dana.locator("section h5", has_text="התקדמות ביחידות")).to_contain_text("נתוני תרגול מהמכשיר") playwright.expect(dana.locator("section h5", has_text="מבחני תרגול")).to_contain_text("נתוני תרגול מהמכשיר") playwright.expect(dana.locator("section h5", has_text="בחני היחידה")).not_to_contain_text("מהמכשיר") noa, ben = sheet.locator(".mx-report-student").nth(2), sheet.locator(".mx-report-student").nth(3) noa.locator("summary").click(); ben.locator("summary").click() # columns: the official (final) grade, then the latest practice sitting playwright.expect(noa.locator(".mx-report-grade")).to_have_text(["–", "ממתין לבדיקה"]) playwright.expect(ben.locator(".mx-report-grade")).to_have_text(["–", "לא מאומת"]) # not "100" playwright.expect(ben.locator(".mx-report-student, summary").first).not_to_contain_text("בוחן 100") page.locator(".mx-report-sheet .mx-x").click() playwright.expect(sheet).to_be_hidden() finally: browser.close() def open_report(p, server, students, now=None, lessons=("t",), me=None, routes=None, assignments=None): """The class teacher's report for class 4 (lessons: unit t) with these students; returns (browser, page, sheet).""" from datetime import datetime row = {"id": 4, "name": "ט׳2", "lessons": list(lessons), "teacher": True, "exams": []} report = {"class": {"id": 4, "name": "ט׳2", "lessons": list(lessons)}, "students": students, "assignments": assignments} browser = p.chromium.launch() page = browser.new_page() page.clock.set_fixed_time(now or datetime(2026, 11, 4, 12)) # a Wednesday: this week began on Sunday 1 November page.route("https://cdn.jsdelivr.net/**", lambda route: route.abort()) page.add_init_script('''window.supabase={createClient:()=>({auth:{ getSession:async()=>({data:{session:{access_token:"test",user:{id:"test"}}}}), onAuthStateChange:()=>{},signOut:async()=>{} }})};''') def index(route): response = route.fetch() route.fulfill(response=response, body=response.text().replace('supabaseUrl:""', 'supabaseUrl:"https://auth.invalid"')) page.route(server + "/", index) page.route("**/api/me", lambda route: route.fulfill(json={ "email": "teacher@example.com", "user_id": "test", "answer_scans": False, "permissions": ["path.use"], "classes": [row], **(me or {})})) page.route("**/api/math/progress", lambda route: route.fulfill(json={"progress": {}})) page.route("**/api/classes/4/report", lambda route: route.fulfill(json=report)) if routes: routes(page) page.goto(server) page.locator('[data-act="class-report"]').click() return browser, page, page.locator(".mx-report-sheet") def pupil(email, days=None, slips=(), signed_in=True, progress=True, **extra): base = {"done": {}, "gold": {}, "days": days or {}, "missed": {}, "slips": list(slips), "updated": 1, "quizzes": [], "uq": {}} return {"email": email, "signed_in": signed_in, "saved_at": None, "quiz_sittings": [], "progress": {**base, **extra} if progress else None} def test_a_class_teacher_sees_only_their_classes_until_they_view_as_a_student(phone_server): with playwright.sync_playwright() as p: browser, page, sheet = open_report(p, phone_server, [pupil("dana@example.com")]) try: sheet.locator('[data-act="preview-on"]').click() playwright.expect(page.locator(".mx-preview")).to_contain_text("מצב תצוגה כתלמיד") page.locator('.mx-nav [data-view="path"]').click() playwright.expect(page.locator(".mx-pghead")).to_contain_text("המסלול שלי") # the trail, as a student has it playwright.expect(page.locator('[data-act="class-report"]')).to_have_count(0) page.locator('[data-act="preview-off"]').click() playwright.expect(page.locator(".mx-preview")).to_have_count(0) playwright.expect(page.locator(".mx-pghead")).to_have_count(0) playwright.expect(page.locator('[data-act="class-report"]')).to_have_count(1) finally: browser.close() def test_a_students_card_lists_the_skills_where_they_opened_the_worked_example(phone_server): students = [pupil("dana@example.com", {"2026-11-03": 5}, examples={ "t_ext": {"n": 3, "last": "2026-11-02T10:00:00Z"}, # this week "t_simplify": {"n": 1, "last": "2026-10-25T10:00:00Z"}, # within two weeks "t_area": {"n": 4, "last": "2026-10-01T10:00:00Z"}, # too long ago "g_pyth": {"n": 2, "last": "2026-11-03T10:00:00Z"}}), # not a skill of this class pupil("omer@example.com", {"2026-11-03": 5})] with playwright.sync_playwright() as p: browser, page, sheet = open_report(p, phone_server, students) try: dana = sheet.locator("#rs-0") dana.locator("summary").click() helped = dana.locator("section", has_text="דוגמאות פתורות") playwright.expect(helped.locator("li")).to_have_count(2) playwright.expect(helped.locator("li").nth(0)).to_contain_text("3 פעמים") # the newest first playwright.expect(helped.locator("li").nth(1)).to_contain_text("פעם אחת") playwright.expect(helped.locator("h5")).to_contain_text("נתוני תרגול מהמכשיר") omer = sheet.locator("#rs-1") omer.locator("summary").click() assert omer.locator("section", has_text="דוגמאות פתורות").count() == 0 finally: browser.close() def test_the_report_opens_with_the_students_who_need_attention(phone_server): stuck = [{"at": f"2026-11-0{d}T09:00:00Z", "id": "t_ext", "q": 0, "m": "ext_partial"} for d in (1, 2, 3)] students = [pupil("ben@example.com", {"2026-11-03": 5, "2026-10-27": 7}), pupil("dana@example.com", {"2026-10-27": 6}), pupil("eli@example.com", {"2026-11-03": 5}, slips=stuck), pupil("noa@example.com", progress=False), pupil("omer@example.com", signed_in=False, progress=False)] with playwright.sync_playwright() as p: browser, page, sheet = open_report(p, phone_server, students) try: attn = sheet.locator(".mx-report-attn") playwright.expect(attn.locator("h4")).to_have_text("דורשים תשומת לב") items = attn.locator("li") playwright.expect(items).to_have_count(3) # ben is fine; omer has not signed in yet playwright.expect(items.nth(0)).to_contain_text("dana") playwright.expect(items.nth(0)).to_contain_text("לא תרגל 8 ימים") playwright.expect(items.nth(1)).to_contain_text("eli") playwright.expect(items.nth(1)).to_contain_text("תקוע במיומנות") playwright.expect(items.nth(2)).to_contain_text("noa") playwright.expect(items.nth(2)).to_contain_text("עוד לא התחיל לתרגל") assert "ben" not in attn.inner_text() # a name in the list opens that student's card eli = sheet.locator("#rs-2") # the card's id is the student's place in the class list assert eli.get_attribute("open") is None items.nth(1).locator("button").click() assert eli.get_attribute("open") is not None # this week and last week on a card ben = sheet.locator("#rs-0") ben.locator("summary").click() playwright.expect(ben.locator(".mx-report-week")).to_have_text("5 תשובות נכונות השבוע · 7 בשבוע שעבר") finally: browser.close() def test_the_report_says_when_nobody_needs_attention(phone_server): with playwright.sync_playwright() as p: browser, page, sheet = open_report(p, phone_server, [pupil("ben@example.com", {"2026-11-03": 5})]) try: playwright.expect(sheet.locator(".mx-report-attn")).to_contain_text("אף תלמיד לא דורש תשומת לב כרגע") playwright.expect(sheet.locator(".mx-report-attn li")).to_have_count(0) finally: browser.close() def test_the_class_map_colours_each_students_units_and_opens_a_unit_by_skill(phone_server): from app.math_skills import algebra9 skills = [x["id"] for x in algebra9()["skills"] if x["unit"] == "t"] first, second = skills[0], skills[1] fresh = "2026-10-30T10:00:00Z" students = [ pupil("ana@example.com", {"2026-11-03": 5}, done={first: fresh}, gold={first: fresh}, seen={first: fresh}, tries={first: 6, second: 3}, missed={second: ["m"]}), pupil("cy@example.com", {"2026-10-20": 5}, tries={second: 2}, missed={second: ["m"]}), {**pupil("eve@example.com", progress=False), "name": "נועה ל."}] with playwright.sync_playwright() as p: browser, page, sheet = open_report(p, phone_server, students, lessons=[u["id"] for u in algebra9()["units"][:6]]) try: row = lambda who: sheet.locator("tbody tr", has=page.locator(f"th:text-is('{who}')")) # a choice saved by the topic version of the map (other column names) does not keep November closed page.evaluate("localStorage.setItem('mathly.report.mapOpen', '|אלגברה וחזקות'); localStorage.setItem('mathly.report.unit', 't')") # and a unit saved by the old table: the map still opens on its months sheet.locator("[data-report-tab=map]").click() sheet.locator("[data-report-sort]").select_option("name") playwright.expect(sheet.locator("tbody th")).to_have_text(["נועה ל.", "ana", "cy"]) # first level: a column per month of the syllabus, with the students' progress over all its units; the current month (November) is open months = sheet.locator("thead [data-heat-group]") playwright.expect(months).to_have_count(3) # September, October, November: the first six units playwright.expect(months.first).to_contain_text("ספטמבר") playwright.expect(sheet.locator(".mx-heat-unit")).to_have_count(2) # November's units (q, q2) are open playwright.expect(sheet.locator("[data-heat-group=נובמבר]")).to_have_attribute("aria-expanded", "true") assert "stuck" in row("ana").locator(".mx-heat-cell").first.get_attribute("class") months.first.click() # opens into its two units (t and k) playwright.expect(sheet.locator(".mx-heat-unit")).to_have_count(4) playwright.expect(months.first).to_have_attribute("aria-expanded", "true") ana, cy, eve = (row(w).locator(".mx-heat-cell").first for w in ("ana", "cy", "נועה ל.")) playwright.expect(ana).to_have_text(f"{round(100 / len(skills))}%") # one skill finished assert "stuck" in ana.get_attribute("class") # and the second is still open assert "h0" in cy.get_attribute("class") and "stuck" in cy.get_attribute("class") # practised, finished nothing playwright.expect(eve).to_have_attribute("aria-label", "עוד לא התחיל לתרגל") # a unit's name opens that unit by skill, and the choice shows in the unit list sheet.locator("[data-heat-all=close]").click() playwright.expect(sheet.locator(".mx-heat-unit")).to_have_count(0) sheet.locator("[data-heat-all=open]").click() playwright.expect(sheet.locator(".mx-heat-unit")).to_have_count(6) sheet.locator("[data-heat-unit=t]").click() playwright.expect(sheet.locator("[data-report-unit]")).to_have_value("t") playwright.expect(sheet.locator("thead th")).to_have_count(1 + len(skills)) playwright.expect(row("ana").locator(".mx-cell").first).to_have_attribute("aria-label", "כוכב זהב") sheet.locator("[data-report-unit]").select_option("overview") playwright.expect(sheet.locator(".mx-heat")).to_be_visible() # many units are wider than a phone: the map scrolls inside its own box, never the page or the sheet page.set_viewport_size({"width": 375, "height": 700}) assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") assert sheet.evaluate("el => el.scrollWidth <= el.clientWidth") finally: browser.close() def test_a_students_card_says_where_they_stand_and_lists_only_the_units_they_started(phone_server): from app.math_skills import algebra9 skills = [x["id"] for x in algebra9()["skills"] if x["unit"] == "t"] students = [pupil("ana@example.com", {"2026-11-03": 5}, missed={skills[1]: ["m"]}), pupil("bo@example.com", {"2026-11-03": 5}, done={skills[0]: "2026-11-03T10:00:00Z"}), pupil("cy@example.com", {"2026-10-01": 5}, done={skills[0]: "2026-10-01T10:00:00Z"})] with playwright.sync_playwright() as p: browser, page, sheet = open_report(p, phone_server, students, lessons=[u["id"] for u in algebra9()["units"][:3]]) try: card = lambda who: sheet.locator(".mx-report-student", has=page.locator(f"b:text-is('{who}')")) playwright.expect(card("ana").locator("summary .mx-report-chip").first).to_have_text("תקוע") playwright.expect(card("bo").locator("summary .mx-report-chip").first).to_have_text("פעיל") playwright.expect(card("cy").locator("summary .mx-report-chip").first).to_have_text("לא פעיל") # a month ago playwright.expect(card("bo").locator(".mx-spark")).to_have_attribute("aria-label", "5 תשובות נכונות ב־14 הימים האחרונים") card("bo").locator("summary").click() playwright.expect(card("bo").locator(".mx-quiz-units-score > div")).to_have_count(1) # the unit they started playwright.expect(card("bo")).to_contain_text("עוד 2 יחידות בלי פעילות") finally: browser.close() def test_the_reports_order_and_view_are_kept_and_names_come_from_the_class(phone_server): students = [pupil("cy@example.com", {"2026-10-31": 5}), # all practised within the week: nobody has a signal {**pupil("ana@example.com", {"2026-11-03": 5}), "name": "אנה כהן"}, pupil("bo@example.com", {"2026-10-29": 5}), pupil("dov@example.com", {"2026-11-02": 5})] with playwright.sync_playwright() as p: browser, page, sheet = open_report(p, phone_server, students) try: order = lambda: sheet.locator(".mx-report-student > summary > b").all_inner_texts() sort = sheet.locator("[data-report-sort]") assert order() == ["cy", "אנה כהן", "bo", "dov"] # nobody has a signal: the class's order sort.select_option("name") assert order() == ["אנה כהן", "bo", "cy", "dov"] sort.select_option("activity") assert order() == ["bo", "cy", "dov", "אנה כהן"] and order() != ["cy", "אנה כהן", "bo", "dov"] # longest ago first sheet.locator("#rs-1 summary").click() playwright.expect(sheet.locator("#rs-1")).to_contain_text("ana@example.com") # the full email stays in the card sort.select_option("progress") sheet.locator("[data-report-tab=subs]").click() # the choices stay when the report is opened again sheet.locator(".mx-x").click() page.locator('[data-act="class-report"]').click() playwright.expect(sheet.locator("[data-report-sort]")).to_have_value("progress") playwright.expect(sheet.locator("[data-report-tab=subs]")).to_have_attribute("aria-pressed", "true") finally: browser.close() def sitting_of(*attempts, unit="t", grade=None): return {"id": "sit-1", "unit_id": unit, "submitted_at": "2026-11-04T09:00:00Z", "grade": grade, "open": 0, "attempts": list(attempts), "questions": [{"points": 25, "earned": 25, "state": "checked"} for _ in attempts]} def answer_of(attempt_id, review=None, request=None): return {"attempt": {"id": attempt_id, "question_id": "quiz:t:1", "snapshot": {"name": "בוחן מסכם: שאלה 2", "question": "פתרו: $x + 1 = 3$"}}, "attachment": {"id": "att"}, "student": {"email": "dana@example.com"}, "grading": {"state": "completed", "result": {"verdict": "partial", "score": 40, "feedback": "חסר פירוט", "transcription": "x = 2", "solution": "$x = 2$"}}, "review": review, "request": request, "effective_score": 40} PNG = bytes.fromhex("89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360000002000001e221bc330000000049454e44ae426082") def test_the_teacher_opens_a_quiz_answer_and_saves_a_score_and_a_comment(phone_server): import json students = [{**pupil("dana@example.com"), "quiz_sittings": [sitting_of("a1", "a2", "a3", "a4", grade=100)]}] puts, state = [], {"review": None} def routes(page): def answer(route): route.fulfill(json=answer_of("a2", state["review"], {"id": "r1", "state": "open", "message": "כתבתי x=2", "created_at": "2026-11-04T10:00:00Z"})) def save(route): puts.append(json.loads(route.request.post_data)) state["review"] = {"id": "v1", "score": puts[-1].get("score"), "comment": puts[-1].get("comment", ""), "created_at": "2026-11-04T11:00:00Z", "reviewer_email": "t@x"} route.fulfill(json={"review": state["review"], "request": {"id": "r1", "state": "closed", "message": "כתבתי x=2"}}) page.route("**/api/teacher/answers/a2", lambda route: save(route) if route.request.method == "GET" and False else answer(route)) page.route("**/api/teacher/answers/a2/review", save) page.route("**/api/teacher/answers/a2/image", lambda route: route.fulfill(body=PNG, content_type="image/jpeg")) with playwright.sync_playwright() as p: browser, page, sheet = open_report(p, phone_server, students, routes=routes) try: sheet.locator("#rs-0 summary").click() sheet.locator("[data-answers]").click() # the answers of the latest sitting sheet = page.locator(".mx-tr-sheet") playwright.expect(sheet.locator("#mxTitle")).to_contain_text("תשובות בבוחן") playwright.expect(sheet.locator("[data-review-answer]")).to_have_count(4) sheet.locator("[data-review-answer]").nth(1).click() playwright.expect(sheet.locator(".mx-tr-request")).to_contain_text("כתבתי x=2") # the student's request playwright.expect(sheet.locator(".mx-tr-ai")).to_contain_text("40/100") playwright.expect(sheet.locator(".mx-tr-ai")).to_contain_text("חסר פירוט") playwright.expect(sheet.locator(".mx-tr-img")).to_be_visible() form = sheet.locator("[data-review-form]") form.locator("[name=score]").fill("150") # out of range: the page's own limit assert form.locator("[name=score]").evaluate("el => el.validity.rangeOverflow") form.locator("[name=score]").fill("90") form.locator("[name=comment]").fill("הדרך נכונה, טעות חישוב") form.locator("[type=submit]").click() playwright.expect(form.locator("[role=status]")).to_contain_text("נשמר") assert puts == [{"score": 90, "comment": "הדרך נכונה, טעות חישוב"}] playwright.expect(sheet.locator("section", has_text="הבדיקה שלכם")).to_contain_text("90/100") playwright.expect(sheet.locator(".mx-tr-request")).to_have_count(0) # answered: the request is closed sheet.locator("[data-review-back]").click() # back to the sitting's answers playwright.expect(sheet.locator("#mxTitle")).to_contain_text("תשובות בבוחן") sheet.locator("[data-review-back]").click() # and to the report playwright.expect(page.locator(".mx-report-sheet #mxTitle")).to_contain_text("הכיתה שלי") finally: browser.close() def test_a_comment_alone_is_saved_without_a_score_and_a_teacher_without_access_is_told(phone_server): import json students = [{**pupil("dana@example.com"), "quiz_sittings": [sitting_of("a1", "a2", "a3", "a4", grade=100)]}] puts = [] def routes(page): page.route("**/api/teacher/answers/a1", lambda route: route.fulfill(json=answer_of("a1"))) page.route("**/api/teacher/answers/a1/image", lambda route: route.fulfill(body=PNG, content_type="image/jpeg")) page.route("**/api/teacher/answers/a1/review", lambda route: (puts.append(json.loads(route.request.post_data)), route.fulfill(json={"review": {"score": None, "comment": "יפה", "created_at": "2026-11-04T11:00:00Z"}, "request": None}))[1]) page.route("**/api/teacher/answers/a3", lambda route: route.fulfill(status=403, json={"detail": "forbidden"})) page.route("**/api/teacher/answers/a3/image", lambda route: route.fulfill(status=403, json={"detail": "forbidden"})) with playwright.sync_playwright() as p: browser, page, sheet = open_report(p, phone_server, students, routes=routes) try: sheet.locator("#rs-0 summary").click() sheet.locator("[data-answers]").click() sheet = page.locator(".mx-tr-sheet") sheet.locator("[data-review-answer]").nth(2).click() playwright.expect(sheet.locator(".mx-manage-state")).to_have_text("אין לכם הרשאה לתשובה הזו.") sheet.locator("[data-review-back]").click() sheet.locator("[data-review-answer]").nth(0).click() form = sheet.locator("[data-review-form]") form.locator("[name=comment]").fill("יפה") form.locator("[type=submit]").click() playwright.expect(form.locator("[role=status]")).to_contain_text("נשמר") assert puts == [{"comment": "יפה"}] # no score key: the AI's score stays finally: browser.close() def test_a_handled_request_in_the_queue_opens_the_answer(phone_server): rows = [{"id": "q1", "state": "done", "kind": "grading", "email": "dana@example.com", "attempt_id": "a2", "label": "בוחן: שאלה 2", "question": "פתרו", "created_at": "2026-11-04T08:00:00Z", "result": {"verdict": "partial", "score": 40}}, {"id": "q2", "state": "done", "kind": "photo", "email": "dana@example.com", "attempt_id": None, "label": "תרגיל", "created_at": "2026-11-04T08:00:00Z", "result": {"readable": True, "answer": "3"}}] def routes(page): page.route("**/api/ai-queue", lambda route: route.fulfill(json={"requests": rows, "all": False})) page.route("**/api/teacher/answers/a2", lambda route: route.fulfill(json=answer_of("a2"))) page.route("**/api/teacher/answers/a2/image", lambda route: route.fulfill(body=PNG, content_type="image/jpeg")) with playwright.sync_playwright() as p: browser, page, sheet = open_report(p, phone_server, [pupil("dana@example.com")], me={"ai_review": True}, routes=routes) try: sheet.locator(".mx-x").click() page.locator('[data-act="ai-queue"]').first.click() queue = page.locator(".mx-aq-sheet") playwright.expect(queue.locator("[data-review-answer]")).to_have_count(1) # only an answer, not a photographed problem queue.locator("[data-review-answer]").click() playwright.expect(page.locator(".mx-tr-sheet .mx-tr-ai")).to_contain_text("40/100") page.locator("[data-review-back]").click() playwright.expect(page.locator(".mx-aq-sheet")).to_be_visible() finally: browser.close() def test_requests_to_look_again_have_their_own_tab_and_count_in_the_queue_button(phone_server): asking = [{"id": "r1", "attempt_id": "a2", "student": "dana@example.com", "message": "כתבתי x=2", "question": "בוחן: שאלה 2", "created_at": "2026-11-04T10:00:00Z"}] waiting = [{"id": "q1", "state": "pending", "kind": "grading", "email": "omer@example.com", "attempt_id": "a9", "label": "תרגיל", "question": "פתרו", "created_at": "2026-11-04T08:00:00Z"}] students = [{**pupil("dana@example.com"), "review_requests": [ {"attempt_id": "a2", "message": "כתבתי x=2", "question": "בוחן: שאלה 2", "created_at": "2026-11-04T10:00:00Z"}]}] def routes(page): page.route("**/api/ai-queue", lambda route: route.fulfill(json={"requests": waiting, "all": False})) page.route("**/api/ai-queue/q1/image", lambda route: route.fulfill(body=PNG, content_type="image/jpeg")) page.route("**/api/teacher/review-requests", lambda route: route.fulfill(json={"requests": asking})) page.route("**/api/teacher/answers/a2", lambda route: route.fulfill(json=answer_of( "a2", None, {"id": "r1", "state": "open", "message": "כתבתי x=2", "created_at": "2026-11-04T10:00:00Z"}))) page.route("**/api/teacher/answers/a2/image", lambda route: route.fulfill(body=PNG, content_type="image/jpeg")) with playwright.sync_playwright() as p: browser, page, sheet = open_report(p, phone_server, students, me={"ai_review": True}, routes=routes) try: # the report names the student who asked, and opens the answer; back leads to the report playwright.expect(sheet.locator("#rs-0 summary")).to_contain_text("ביקש בדיקה חוזרת") sheet.locator("#rs-0 summary").click() sheet.locator("#rs-0 [data-review-answer]").click() playwright.expect(page.locator(".mx-tr-request")).to_contain_text("כתבתי x=2") page.locator("[data-review-back]").click() playwright.expect(page.locator(".mx-report-sheet #mxTitle")).to_contain_text("הכיתה שלי") page.locator(".mx-report-sheet .mx-x").click() # one waiting request and one student asking to look again: the button says 2 button = page.locator('[data-act="ai-queue"]').first playwright.expect(button).to_contain_text("2") button.click() queue = page.locator(".mx-aq-sheet") playwright.expect(queue.locator("[data-aq-tab=queue]")).to_have_attribute("aria-pressed", "true") playwright.expect(queue.locator(".mx-aq-card")).to_have_count(1) # the waiting request queue.locator("[data-aq-tab=requests]").click() playwright.expect(queue.locator("[data-aq-tab=requests]")).to_contain_text("1") card = queue.locator(".mx-aq-card") playwright.expect(card).to_have_count(1) playwright.expect(card).to_contain_text("dana") playwright.expect(card).to_contain_text("כתבתי x=2") card.locator("[data-review-answer]").click() playwright.expect(page.locator(".mx-tr-sheet .mx-tr-ai")).to_contain_text("40/100") page.locator("[data-review-back]").click() playwright.expect(page.locator(".mx-aq-sheet [data-aq-tab=requests]")).to_have_attribute("aria-pressed", "true") # the tab is kept finally: browser.close() def open_student_app(p, server, assignments, sittings=(), progress=None, now=None): """A student of class 4 (units t and k) on the path, with these assignments from the server; returns (browser, page).""" from datetime import datetime row = {"id": 4, "name": "ט׳2", "lessons": ["t", "k"], "teacher": False, "exams": []} browser = p.chromium.launch() page = browser.new_page() page.clock.set_fixed_time(now or datetime(2026, 11, 4, 12)) page.route("https://cdn.jsdelivr.net/**", lambda route: route.abort()) page.add_init_script('''window.supabase={createClient:()=>({auth:{ getSession:async()=>({data:{session:{access_token:"test",user:{id:"test"}}}}), onAuthStateChange:()=>{},signOut:async()=>{} }})};''') def index(route): response = route.fetch() route.fulfill(response=response, body=response.text().replace('supabaseUrl:""', 'supabaseUrl:"https://auth.invalid"')) page.route(server + "/", index) page.route("**/api/me", lambda route: route.fulfill(json={ "email": "student@example.com", "user_id": "test", "answer_scans": False, "permissions": ["path.use"], "classes": [row]})) page.route("**/api/math/progress", lambda route: route.fulfill(json={"progress": progress or {}})) page.route("**/api/math/assignments", lambda route: route.fulfill(json={ "assignments": assignments, "sittings": list(sittings), "today": "2026-11-04"})) page.goto(server) page.locator('.mx-nav [data-view="home"]').click() # the assignments are on home return browser, page def given(title, due, items, id=None, created="2026-11-01T10:00:00Z"): return {"id": id or title, "class_id": 4, "class_name": "ט׳2", "title": title, "note": "", "due_on": due, "items": items, "created_at": created, "updated_at": created} SKILL, QUIZ, EXAM = {"kind": "skill", "id": "t_ext"}, {"kind": "unit_quiz", "unit": "k"}, {"kind": "exam", "units": ["k", "t"]} def test_the_home_screen_lists_the_open_assignments_late_ones_and_the_recently_done(phone_server): assignments = [ given("חזרה לפני המבחן", "2026-11-12", [SKILL, QUIZ, EXAM], "open"), given("משימה שאיחרה", "2026-11-02", [{"kind": "skill", "id": "t_simplify"}], "late"), given("משימה שהסתיימה", "2026-11-01", [SKILL], "done"), given("משימה ישנה שהסתיימה", "2026-10-01", [SKILL], "old")] progress = {"done": {"t_ext": "2026-09-01T10:00:00Z"}} with playwright.sync_playwright() as p: browser, page = open_student_app(p, phone_server, assignments, progress=progress) try: cards = page.locator(".mx-asgs .mx-asg") playwright.expect(cards).to_have_count(2) # the finished ones are not cards playwright.expect(cards.nth(0)).to_contain_text("משימה שאיחרה") # the earliest due date first playwright.expect(cards.nth(0)).to_contain_text("באיחור") assert "late" in cards.nth(0).get_attribute("class") open_ = cards.nth(1) playwright.expect(open_).to_contain_text("חזרה לפני המבחן") playwright.expect(open_).to_contain_text("1 מתוך 3 הושלמו") items = open_.locator(".mx-asg-items li") assert [items.nth(i).get_attribute("class") for i in range(3)] == ["done", "", ""] playwright.expect(items.nth(1)).to_contain_text("בוחן חיזוק") playwright.expect(items.nth(2)).to_contain_text("מבחן תרגול") playwright.expect(open_.locator("[data-asg-go]")).to_contain_text("להמשיך: בוחן חיזוק") # the next one to do done = page.locator(".mx-asg-done") playwright.expect(done.locator("summary")).to_have_text("הושלמו (1)") done.locator("summary").click() playwright.expect(done).to_contain_text("משימה שהסתיימה") assert "משימה ישנה שהסתיימה" not in page.locator(".mx-asgs").inner_text() finally: browser.close() def test_continue_opens_the_first_item_that_is_not_done(phone_server): first = [given("ראשון", "2026-11-12", [SKILL, QUIZ, EXAM], "a")] with playwright.sync_playwright() as p: browser, page = open_student_app(p, phone_server, first) # nothing done: the skill try: playwright.expect(page.locator("[data-asg-go]")).to_contain_text("להתחיל") page.locator("[data-asg-go]").click() playwright.expect(page.locator(".mx-sheet h3").first).to_contain_text("חוק הפילוג המורחב") finally: browser.close() with playwright.sync_playwright() as p: browser, page = open_student_app(p, phone_server, first, progress={"done": {"t_ext": "2026-09-01T10:00:00Z"}}) try: # the skill done: the unit quiz page.locator("[data-asg-go]").click() playwright.expect(page.locator(".mx-uq-sheet")).to_be_visible() finally: browser.close() sitting = [{"unit_id": "k", "submitted_at": "2026-11-03T09:00:00Z"}] with playwright.sync_playwright() as p: browser, page = open_student_app(p, phone_server, first, sitting, {"done": {"t_ext": "2026-09-01T10:00:00Z"}}) try: # then the practice exam, with its units chosen playwright.expect(page.locator("[data-asg-go]")).to_contain_text("להמשיך: מבחן תרגול") page.locator("[data-asg-go]").click() units = page.locator(".mx-quiz-sheet [data-quiz-unit]") playwright.expect(page.locator(".mx-quiz-sheet")).to_contain_text("המורה ביקש מבחן תרגול") assert units.count() >= 2 and all(units.nth(i).get_attribute("aria-pressed") == "true" for i in range(units.count())) finally: browser.close() def test_a_finished_assignment_leaves_the_card_and_the_skills_of_open_ones_wear_a_marker(phone_server): assignments = [given("חזרה", "2026-11-12", [SKILL, {"kind": "skill", "id": "t_simplify"}], "a"), given("עוד אחת", "2026-11-12", [SKILL], "b")] with playwright.sync_playwright() as p: browser, page = open_student_app(p, phone_server, assignments) try: page.locator('.mx-nav [data-view="path"]').click() # the markers are on the trail star = page.locator('.mx-star[data-id="t_ext"]') playwright.expect(star.locator(".mx-asg-pin")).to_have_count(1) label = star.get_attribute("aria-label") assert "משימה מהמורה: חזרה, עוד אחת" in label # both assignments name it playwright.expect(page.locator('.mx-star[data-id="t_simplify"] .mx-asg-pin')).to_have_count(1) assert "משימה מהמורה: חזרה" in page.locator('.mx-star[data-id="t_simplify"]').get_attribute("aria-label") assert page.locator('.mx-star[data-id="t_ext_eq"] .mx-asg-pin').count() == 0 # not assigned: no marker assert "משימה" not in page.locator('.mx-star[data-id="t_ext_eq"]').get_attribute("aria-label") finally: browser.close() done = {"done": {"t_ext": "2026-09-01T10:00:00Z", "t_simplify": "2026-09-02T10:00:00Z"}} with playwright.sync_playwright() as p: browser, page = open_student_app(p, phone_server, assignments, progress=done) try: playwright.expect(page.locator(".mx-asgs")).to_contain_text("הושלמו (2)") assert page.locator(".mx-asg-pin").count() == 0 and page.locator(".mx-asg").count() == 0 finally: browser.close() def test_each_assignment_says_how_much_time_is_left_to_hand_in(phone_server): """Noon UTC on Wednesday 4 November is 14:00 in Jerusalem: the due day itself has 10 hours left.""" assignments = [given(title, due, [SKILL], title) for title, due in ( ("אתמול", "2026-11-03"), ("לפני שלושה ימים", "2026-11-01"), ("היום", "2026-11-04"), ("מחר", "2026-11-05"), ("בעוד שלושה ימים", "2026-11-07"))] with playwright.sync_playwright() as p: from datetime import datetime, timezone browser, page = open_student_app(p, phone_server, assignments, now=datetime(2026, 11, 4, 12, tzinfo=timezone.utc)) try: due = lambda title: page.locator(".mx-asg", has_text=title).locator(".mx-asg-due") playwright.expect(due("לפני שלושה ימים")).to_contain_text("באיחור של 3 ימים") playwright.expect(due("אתמול")).to_contain_text("באיחור של יום") playwright.expect(due("היום")).to_contain_text("היום, נשארו כ־10 שעות") playwright.expect(due("מחר")).to_contain_text("מחר, נשאר יום אחד") playwright.expect(due("בעוד שלושה ימים")).to_contain_text("נשארו 3 ימים") assert "soon" in due("היום").get_attribute("class") and "soon" in due("מחר").get_attribute("class") assert "soon" not in due("בעוד שלושה ימים").get_attribute("class") finally: browser.close() def test_the_home_screen_is_quiet_without_assignments(phone_server): with playwright.sync_playwright() as p: browser, page = open_student_app(p, phone_server, []) try: playwright.expect(page.locator(".mx-continue")).to_be_visible() assert page.locator(".mx-asgs").count() == 0 page.locator('.mx-nav [data-view="path"]').click() playwright.expect(page.locator(".mx-star").first).to_be_visible() assert page.locator(".mx-asg-pin").count() == 0 finally: browser.close() def taught(title, due, items, id=None, created="2026-11-01T10:00:00Z"): return {"id": id or title, "class_id": 4, "title": title, "note": "", "due_on": due, "items": items, "created_by_email": "teacher@example.com", "created_at": created, "updated_at": created} ASG_STUDENTS = [ # all three items done: the skill long ago, the unit quiz and the practice exam after the assignment was made {**pupil("dana@example.com", {"2026-11-03": 5}, done={"t_ext": "2026-09-01T10:00:00Z"}, quizzes=[{"at": "2026-11-03T09:00:00Z", "n": 12, "right": 9, "units": ["k", "t"]}]), "quiz_sittings": [{"id": "s1", "unit_id": "k", "submitted_at": "2026-11-03T09:00:00Z", "grade": 90, "open": 0}]}, {**pupil("noa@example.com", {"2026-11-03": 5}, done={"t_ext": "2026-09-01T10:00:00Z"}), "quiz_sittings": []}, {**pupil("omer@example.com", {"2026-11-03": 5}), "quiz_sittings": []}] def test_the_assignments_tab_shows_how_many_finished_and_who_did_what(phone_server): assignments = [taught("חזרה לפני המבחן", "2026-11-12", [SKILL, QUIZ, EXAM], "a"), taught("משימה ישנה", "2026-11-02", [SKILL], "b")] with playwright.sync_playwright() as p: browser, page, sheet = open_report(p, phone_server, ASG_STUDENTS, lessons=["t", "k"], assignments=assignments) try: sheet.locator("[data-report-tab=tasks]").click() cards = sheet.locator(".mx-asg-t") playwright.expect(cards).to_have_count(2) playwright.expect(cards.nth(0)).to_contain_text("חזרה לפני המבחן") # the latest due date first playwright.expect(cards.nth(0)).to_contain_text("1 מתוך 3 תלמידים סיימו") playwright.expect(cards.nth(1)).to_contain_text("המועד עבר") playwright.expect(cards.nth(1)).to_contain_text("2 מתוך 3 תלמידים סיימו") # the skill alone: dana and noa first = cards.nth(0) first.locator("summary").click() row = lambda who: first.locator("tbody tr", has=page.locator(f"th:text-is('{who}')")).locator("td.mx-asg-cell") playwright.expect(row("dana")).to_have_text(["✓", "✓", "✓"]) playwright.expect(row("noa")).to_have_text(["✓", "–", "–"]) playwright.expect(row("omer")).to_have_text(["–", "–", "–"]) state = lambda who: first.locator("tbody tr", has=page.locator(f"th:text-is('{who}')")).locator("td.mx-task-state") playwright.expect(state("dana")).to_have_text("הוגש") # the same state the student sees playwright.expect(state("noa")).to_have_text("בתהליך") playwright.expect(state("omer")).to_have_text("עוד לא התחיל") finally: browser.close() def test_a_student_who_is_late_with_an_assignment_needs_attention(phone_server): assignments = [taught("משימה ישנה", "2026-11-02", [SKILL], "b")] with playwright.sync_playwright() as p: browser, page, sheet = open_report(p, phone_server, ASG_STUDENTS, assignments=assignments) try: attn = sheet.locator(".mx-report-attn li") playwright.expect(attn).to_have_count(1) # omer: dana and noa finished it playwright.expect(attn).to_contain_text("omer") playwright.expect(attn).to_contain_text("משימה באיחור: משימה ישנה") finally: browser.close() def test_the_assignments_tab_says_when_assignments_cannot_be_read(phone_server): with playwright.sync_playwright() as p: browser, page, sheet = open_report(p, phone_server, ASG_STUDENTS, assignments=None) try: sheet.locator("[data-report-tab=tasks]").click() playwright.expect(sheet).to_contain_text("המשימות אינן זמינות כרגע") assert sheet.locator("[data-asg-new]").count() == 0 finally: browser.close() MANAGES = {"permissions": ["path.use", "management.access"]} # skills, quizzes and exams in an assignment: developers only def asg_routes(sent, saved): """POST/PUT/DELETE of class 4's assignments, kept in `sent`; `saved(body, id)` makes the answer.""" import json def handle(route): request = route.request body = json.loads(request.post_data) if request.post_data else None sent.append((request.method, request.url.rsplit("/assignments", 1)[1], body)) if request.method == "DELETE": return route.fulfill(json={"ok": True}) route.fulfill(status=201 if request.method == "POST" else 200, json=saved(body, request.url.rsplit("/", 1)[1])) return lambda page: page.route("**/api/classes/4/assignments**", handle) def test_the_teacher_makes_an_assignment_with_the_form(phone_server): sent = [] made = lambda body, _id: {**taught(body["title"], body["due_on"], body["items"], "new"), "note": body["note"]} with playwright.sync_playwright() as p: browser, page, sheet = open_report(p, phone_server, ASG_STUDENTS, lessons=["t", "k"], assignments=[], routes=asg_routes(sent, made), me=MANAGES) try: sheet.locator("[data-report-tab=tasks]").click() playwright.expect(sheet).to_contain_text("עוד לא נתתם משימות") sheet.locator("[data-asg-new]").click() form = sheet.locator("[data-asg-form]") status = form.locator("[role=status]") form.locator("button[type=submit]").click() playwright.expect(status).to_have_text("כתבו כותרת למשימה.") form.locator("[name=title]").fill("חזרה לפני המבחן") form.locator("button[type=submit]").click() playwright.expect(status).to_contain_text("בחרו לפחות פריט אחד") form.locator("[name=note]").fill("בהצלחה") form.locator("[name=due_on]").fill("2026-11-14") form.locator("details", has_text="טכניקה אלגברית 1").first.locator("summary").click() form.locator("[name=skill][value=t_ext]").check() form.locator("[name=skill][value=t_simplify]").check() form.locator("[name=quiz][value=k]").check() form.locator("[name=exam][value=t]").check() form.locator("[name=exam][value=k]").check() form.locator("button[type=submit]").click() playwright.expect(sheet.locator(".mx-asg-t")).to_have_count(1) playwright.expect(sheet.locator(".mx-asg-t")).to_contain_text("חזרה לפני המבחן") playwright.expect(sheet).to_contain_text("המשימה נשמרה") assert sent == [("POST", "", {"title": "חזרה לפני המבחן", "note": "בהצלחה", "due_on": "2026-11-14", "items": [ {"kind": "skill", "id": "t_ext"}, {"kind": "skill", "id": "t_simplify"}, {"kind": "unit_quiz", "unit": "k"}, {"kind": "exam", "units": ["t", "k"]}]})] assert sheet.locator("[data-asg-form]").count() == 0 finally: browser.close() def test_the_teacher_edits_an_assignment_and_a_refusal_is_explained(phone_server): sent = [] changed = lambda body, id_: {**taught(body["title"], body["due_on"], body["items"], id_), "note": body["note"]} assignments = [taught("חזרה", "2026-11-12", [SKILL, QUIZ, EXAM], "a")] with playwright.sync_playwright() as p: browser, page, sheet = open_report(p, phone_server, ASG_STUDENTS, lessons=["t", "k"], assignments=assignments, routes=asg_routes(sent, changed), me=MANAGES) try: sheet.locator("[data-report-tab=tasks]").click() sheet.locator("[data-asg-edit=a]").click() form = sheet.locator("[data-asg-form]") playwright.expect(form.locator("[name=title]")).to_have_value("חזרה") playwright.expect(form.locator("[name=due_on]")).to_have_value("2026-11-12") playwright.expect(form.locator("[name=skill][value=t_ext]")).to_be_checked() playwright.expect(form.locator("[name=quiz][value=k]")).to_be_checked() assert form.locator("[name=exam]:checked").count() == 2 form.locator("[name=title]").fill("חזרה מורחבת") form.locator("[name=due_on]").fill("2026-11-15") form.locator("button[type=submit]").click() playwright.expect(sheet.locator(".mx-asg-t")).to_contain_text("חזרה מורחבת") assert sent[0][0] == "PUT" and sent[0][1] == "/a" and sent[0][2]["due_on"] == "2026-11-15" assert sent[0][2]["items"] == [SKILL, QUIZ, {"kind": "exam", "units": ["t", "k"]}] # the form's items, in unit order sheet.locator("[data-asg-edit=a]").click() sheet.locator("[data-asg-cancel]").click() # cancelling changes nothing assert len(sent) == 1 and sheet.locator("[data-asg-form]").count() == 0 finally: browser.close() with playwright.sync_playwright() as p: browser, page, sheet = open_report(p, phone_server, ASG_STUDENTS, assignments=[], routes=lambda pg: pg.route( "**/api/classes/4/assignments**", lambda route: route.fulfill(status=403, json={"detail": "x"})), me=MANAGES) try: sheet.locator("[data-report-tab=tasks]").click() sheet.locator("[data-asg-new]").click() form = sheet.locator("[data-asg-form]") form.locator("[name=title]").fill("x") form.locator("details").first.locator("summary").click() form.locator("[name=skill]").first.check() form.locator("button[type=submit]").click() playwright.expect(form.locator("[role=status]")).to_have_text("רק מורה הכיתה יכול לתת לה משימות.") playwright.expect(form.locator("button[type=submit]")).to_be_enabled() finally: browser.close() def test_deleting_an_assignment_asks_first_and_says_how_many_already_did_part_of_it(phone_server): sent, messages = [], [] assignments = [taught("חזרה", "2026-11-12", [SKILL, QUIZ], "a")] with playwright.sync_playwright() as p: browser, page, sheet = open_report(p, phone_server, ASG_STUDENTS, lessons=["t", "k"], assignments=assignments, routes=asg_routes(sent, lambda body, id_: body)) try: def answer(accept): def on_dialog(dialog): messages.append(dialog.message) dialog.accept() if accept else dialog.dismiss() page.once("dialog", on_dialog) sheet.locator("[data-report-tab=tasks]").click() answer(False) sheet.locator("[data-asg-del=a]").click() page.wait_for_timeout(200) assert sent == [] and sheet.locator(".mx-asg-t").count() == 1 # dismissed: nothing is deleted answer(True) sheet.locator("[data-asg-del=a]").click() playwright.expect(sheet.locator(".mx-asg-t")).to_have_count(0) assert sent == [("DELETE", "/a", None)] assert 'למחוק את המשימה "חזרה"' in messages[0] and "2 תלמידים כבר השלימו" in messages[0] # dana and noa did the skill assert messages[1] == messages[0] finally: browser.close() def test_class_editor_takes_the_teacher_as_free_text(phone_server): import json row = {"id": 7, "name": "ט׳3", "lessons": ["t"], "emails": ["a@example.com", "b@example.com"], "teachers": ["b@example.com"], "exams": []} writes = [] with playwright.sync_playwright() as p: browser = p.chromium.launch() try: page = browser.new_page() page.route("https://cdn.jsdelivr.net/**", lambda route: route.abort()) page.add_init_script('''window.supabase={createClient:()=>({auth:{ getSession:async()=>({data:{session:{access_token:"test",user:{id:"test"}}}}), onAuthStateChange:()=>{},signOut:async()=>{} }})};''') def index(route): response = route.fetch() route.fulfill(response=response, body=response.text().replace( 'supabaseUrl:""', 'supabaseUrl:"https://auth.invalid"')) def save(route): writes.append(json.loads(route.request.post_data)) route.fulfill(json={"ok": True}) page.route(phone_server + "/", index) page.route("**/api/me", lambda route: route.fulfill(json={ "email": "boss@example.com", "user_id": "test", "answer_scans": False, "permissions": ["management.access", "classes.manage"], "classes": []})) page.route("**/api/math/progress", lambda route: route.fulfill(json={"progress": {}})) page.route("**/api/classes", lambda route: route.fulfill(json={"classes": [row]})) page.route("**/api/classes/7", save) page.goto(phone_server) page.locator('[data-act="manage"]').click() playwright.expect(page.locator(".mx-class-card")).to_contain_text("b@example.com") page.get_by_role("button", name="עריכה").click() teacher = page.locator('input[name="teacher"]') playwright.expect(teacher).to_have_value("b@example.com") teacher.fill("t@example.com") page.locator('textarea[name="emails"]').fill("a@example.com\nt@example.com\nc@example.com") page.get_by_role("button", name="שמירת השינויים").click() playwright.expect(page.locator(".mx-class-form [role=status]")).to_contain_text("מורה לא יכול להיות גם תלמיד") page.locator('textarea[name="emails"]').fill("a@example.com\nb@example.com\nc@example.com") page.get_by_role("button", name="שמירת השינויים").click() playwright.expect(page.locator(".mx-manage-state")).not_to_have_text("שומרים…") assert writes[-1]["teachers"] == ["t@example.com"] assert writes[-1]["emails"] == ["a@example.com", "b@example.com", "c@example.com"] finally: browser.close() # ---- focus areas: where each student, and the class, most need work (Sky.focus), and giving practice from there def lost_on(skill, earned, at="2026-11-03T09:00:00Z"): return {"id": "sit-" + skill, "unit_id": "t", "submitted_at": at, "grade": 50, "open": 0, "mode": "final", "attempts": [], "questions": [{"points": 25, "earned": earned, "state": "checked", "skills": [skill]}]} def test_a_students_card_says_what_to_work_on_with_its_evidence(phone_server): slips = [{"at": "2026-11-02T09:00:00Z", "id": "t_ext_eq", "q": 0, "m": "sign_mid"}] * 4 dana = {**pupil("dana@example.com", slips=slips, tries={"t_ext_eq": 3}), "quiz_sittings": [lost_on("t_ext_eq", 13)]} with playwright.sync_playwright() as p: browser, page, sheet = open_report(p, phone_server, [dana, pupil("noa@example.com")]) try: card = sheet.locator(".mx-report-student").filter(has_text="dana") card.locator("summary").first.click() focus = card.locator(".mx-focus") first = focus.locator(".mx-focus-list li").first playwright.expect(first).to_contain_text("איבד 12 נק׳ בבחנים") # the server's evidence, no device tag playwright.expect(first).to_contain_text("4 טעויות ב־14 ימים · לא הושלם") playwright.expect(first.locator(".mx-device")).to_have_text("· מהמכשיר") # the device's evidence is labelled first.locator("[data-focus-give]").click() form = sheet.locator("[data-focus-form]") playwright.expect(form.locator('[name=skill][value="t_ext_eq"]')).to_be_checked() playwright.expect(form.locator('[name=student][value="dana@example.com"]')).to_be_checked() playwright.expect(form.locator('[name=student][value="noa@example.com"]')).not_to_be_checked() sheet.locator("[data-report-tab=list]").click() noa = sheet.locator(".mx-report-student").filter(has_text="noa") noa.locator("summary").first.click() playwright.expect(noa.locator(".mx-focus")).to_contain_text("אין כרגע מיומנות שצריך לחזק") finally: browser.close() def test_the_class_view_lists_the_shared_weak_skills_and_gives_practice_to_those_students(phone_server): weak = lambda email: {**pupil(email, tries={"t_area": 2}), "quiz_sittings": [lost_on("t_area", 5)]} students = [weak("dana@example.com"), weak("noa@example.com"), pupil("omer@example.com")] with playwright.sync_playwright() as p: browser, page, sheet = open_report(p, phone_server, students) try: sheet.locator("[data-report-tab=focus]").click() item = sheet.locator(".mx-focus-list li").first playwright.expect(item).to_contain_text("2 תלמידים") item.locator("details summary").click() playwright.expect(item.locator("details")).to_contain_text("dana · noa") item.locator("[data-focus-give]").click() form = sheet.locator("[data-focus-form]") playwright.expect(form.locator('[name=skill][value="t_area"]')).to_be_checked() playwright.expect(form.locator("[name=student]:checked")).to_have_count(2) playwright.expect(form.locator('[name=student][value="omer@example.com"]')).not_to_be_checked() finally: browser.close() # ---- the teacher's inbox: one list of what waits, opened one after another; and releasing a final's results def test_the_inbox_opens_each_waiting_item_in_turn(phone_server): import json sub = {"id": "s1", "worksheet_id": "w1", "pages": 1, "submitted_at": "2026-11-03T09:00:00Z", "checked_at": None, "score": None, "comment": "", "version": 1, "state": "submitted", "note": "שאלה 3?"} dana = {**pupil("dana@example.com"), "worksheet_submissions": [sub]} entries = [{"kind": "quiz", "class_id": 4, "student_email": "dana@example.com", "title": "מבחן: טכניקה אלגברית 1 · שאלה 2", "attempt_id": "a2", "unit_id": "t", "window_id": "win", "submitted_at": "2026-11-02T09:00:00Z", "state": "unreadable", "late": False}, {"kind": "worksheet", "class_id": 4, "student_email": "dana@example.com", "title": "דף פילוג", "assignment": "חזרה", "assignment_id": "asg", "worksheet_id": "w1", "submission_id": "s1", "version": 1, "note": "שאלה 3?", "submitted_at": "2026-11-03T09:00:00Z", "state": "submitted", "late": False}] saved = [] def routes(page): page.route("**/api/teacher/inbox**", lambda route: route.fulfill(json={"entries": entries, "count": 2, "unavailable": []})) page.route("**/api/teacher/answers/a2", lambda route: route.fulfill(json=answer_of("a2"))) page.route("**/api/teacher/answers/a2/image", lambda route: route.fulfill(body=PNG, content_type="image/jpeg")) page.route("**/api/teacher/answers/a2/review", lambda route: (saved.append(("a2", json.loads(route.request.post_data))), route.fulfill(json={"review": {"score": 70, "comment": "", "created_at": "2026-11-04T10:00:00Z"}, "request": None}))[1]) page.route("**/api/classes/4/worksheets/w1/submissions/s1/pages/1", lambda route: route.fulfill(body=PNG, content_type="image/jpeg")) page.route("**/api/classes/4/worksheets/w1/submissions/s1", lambda route: (saved.append(("s1", json.loads(route.request.post_data))), route.fulfill(json={**sub, "checked_at": "2026-11-04T10:00:00Z", "score": 90, "state": "checked"}))[1]) with playwright.sync_playwright() as p: browser, page, sheet = open_report(p, phone_server, [dana], routes=routes, assignments=[]) try: sheet.locator("[data-report-tab=inbox]").click() rows = sheet.locator(".mx-inbox-row") playwright.expect(rows).to_have_count(2) playwright.expect(rows.nth(0)).to_contain_text("ה־AI לא הצליח לקרוא") # oldest first playwright.expect(rows.nth(1)).to_contain_text('"שאלה 3?"') sheet.locator('[data-inbox-filter="kind"]').select_option("worksheet") playwright.expect(rows).to_have_count(1) sheet.locator('[data-inbox-filter="kind"]').select_option("") rows.nth(0).locator("[data-inbox-open]").click() review = page.locator(".mx-tr-sheet") playwright.expect(review.locator(".mx-tr-ai")).to_contain_text("40/100") review.locator("[name=score]").fill("70") review.locator("[data-review-form] [type=submit]").click() # saved: the next one, Dana's worksheet, opens by itself ws = page.locator(".mx-report-sheet .mx-ws-review") playwright.expect(ws.locator(".mx-ws-note-shown")).to_contain_text("שאלה 3?") ws.locator("[data-ws-check] [name=score]").fill("90") ws.locator("[data-ws-check] [type=submit]").click() playwright.expect(page.locator(".mx-report-sheet .mx-report")).to_contain_text("סיימתם את כל מה שממתין") assert saved == [("a2", {"comment": "", "score": 70}), ("s1", {"score": 90, "comment": ""})] finally: browser.close() def test_releasing_a_finals_results_with_open_checks_asks_first(phone_server): released = [] late = {**pupil("noa@example.com"), "quiz_sittings": [{"id": "f2", "unit_id": "t", "mode": "final", "window_id": "win", "grade": None, "open": 1, "submitted_at": "2026-11-03T09:00:00Z", "attempts": [], "questions": []}]} done = {**pupil("dana@example.com"), "quiz_sittings": [{"id": "f1", "unit_id": "t", "mode": "final", "window_id": "win", "grade": 80, "open": 0, "submitted_at": "2026-11-03T09:00:00Z", "attempts": [], "questions": []}]} windows = {"windows": [{"id": "win", "class_id": 4, "unit_id": "t", "opens_at": "2026-11-03T08:00:00+00:00", "closes_at": "2026-11-03T10:00:00+00:00", "released_at": None}], "grants": [], "starts": [], "now": "2026-11-04T12:00:00+00:00"} def routes(page): page.route("**/api/classes/4/quiz-windows", lambda route: route.fulfill(json=windows)) page.route("**/api/classes/4/quiz-windows/win/release", lambda route: (released.append(1), route.fulfill(json={**windows["windows"][0], "released_at": "x"}))[1]) with playwright.sync_playwright() as p: browser, page, sheet = open_report(p, phone_server, [done, late], routes=routes, assignments=[]) try: sheet.locator("[data-report-tab=finals]").click() card = sheet.locator(".mx-final-t") playwright.expect(card).to_contain_text("הגישו 2 מתוך 2 · נבדקו במלואם 1") asked = [] page.on("dialog", lambda d: (asked.append(d.message), d.accept())) card.locator("[data-final-release]").click() page.wait_for_timeout(300) assert asked and "בוחן אחד עוד לא נבדק" in asked[0] and released == [1] finally: browser.close() def test_the_teacher_opens_a_final_quiz_and_gives_one_student_another_sitting(phone_server): import json posted = [] windows = {"windows": [], "grants": [], "starts": [], "now": "2026-11-04T12:00:00+00:00"} def windows_route(route): if route.request.method == "POST": body = json.loads(route.request.post_data) posted.append(("open", body)) windows["windows"].append({"id": "win", "class_id": 4, "unit_id": body["unit_id"], "opens_at": body["opens_at"], "closes_at": body["closes_at"], "released_at": None}) return route.fulfill(status=201, json=windows["windows"][-1]) route.fulfill(json=windows) def grant_route(route): posted.append(("grant", json.loads(route.request.post_data))) route.fulfill(status=201, json={"id": "g", "window_id": "win"}) def routes(page): page.route("**/api/classes/4/quiz-windows", windows_route) page.route("**/api/classes/4/quiz-windows/win/grants", grant_route) with playwright.sync_playwright() as p: browser, page, sheet = open_report(p, phone_server, [pupil("dana@example.com")], routes=routes, assignments=[]) try: sheet.locator("[data-report-tab=finals]").click() form = sheet.locator("[data-final-open]") form.locator("button[type=submit]").click() card = sheet.locator(".mx-final-t") playwright.expect(card).to_contain_text("טכניקה אלגברית 1") kind, body = posted[0] assert kind == "open" and body["unit_id"] == "t" and body["opens_at"] < body["closes_at"] card.locator("details summary").click() card.locator("[data-final-grant] [name=reason]").fill("נעדרה") card.locator("[data-final-grant] button[type=submit]").click() page.wait_for_timeout(300) assert posted[1][0] == "grant" and posted[1][1]["student_email"] == "dana@example.com" and posted[1][1]["reason"] == "נעדרה" finally: browser.close() def test_prompter_from_management(phone_server): import json with playwright.sync_playwright() as p: browser = p.chromium.launch() try: page = browser.new_page() page.route("https://cdn.jsdelivr.net/**", lambda route: route.abort()) page.add_init_script('''window.supabase={createClient:()=>({auth:{ getSession:async()=>({data:{session:{access_token:"test",user:{id:"test"}}}}), onAuthStateChange:()=>{},signOut:async()=>{} }})};''') def index(route): response = route.fetch() route.fulfill(response=response, body=response.text().replace( 'supabaseUrl:""', 'supabaseUrl:"https://auth.invalid"')) page.route(phone_server + "/", index) page.route("**/api/me", lambda route: route.fulfill(json={ "email": "dev@example.com", "user_id": "test", "answer_scans": False, "permissions": ["management.access"], "classes": []})) page.route("**/api/math/progress", lambda route: route.fulfill(json={"progress": {}})) page.route("**/api/classes", lambda route: route.fulfill(json={"classes": []})) saved = [] def texts(route): # app/prompter.py, in memory req = route.request if req.method == "GET": return route.fulfill(json={"texts": saved}) body = json.loads(req.post_data) row = {"id": "aaaaaaaa-aaaa-4aaa-8aaa-%012d" % len(saved), "title": body["title"] or body["body"].split("\n")[0], "body": body["body"], "updated_at": "2026-11-06T10:00:00Z"} saved.insert(0, row) route.fulfill(json={"text": row}) page.route("**/api/prompter/texts", texts) page.goto(phone_server) page.locator('[data-act="manage"]').click() page.locator('[data-act="manage-prompter"]').click() text = "שלום לכולם\nזה טקסט לפרומפטר" + "\nעוד שורה" * 60 # long enough to still be scrolling page.locator("#mxPrompterText").fill(text) page.locator("#mxPrompterTitle").fill("פתיחה") page.locator('[data-act="prompter-save"]').click() playwright.expect(page.locator(".mx-prompter-state")).to_have_text("נשמר: „פתיחה”.") assert saved[0]["title"] == "פתיחה" and saved[0]["body"] == text playwright.expect(page.locator(".mx-prompter-list article")).to_contain_text("פתיחה") page.locator("[data-prompter-play]").click() # a saved text opens straight in the prompter prompter = page.locator(".mxp") page.wait_for_function("document.fullscreenElement?.classList.contains('mxp')") # it opens full screen playwright.expect(prompter.locator(".mxp-text")).to_have_text(text) page.keyboard.press("m") playwright.expect(prompter).to_have_class("mxp mirror") page.keyboard.press(" ") playwright.expect(prompter).to_have_class("mxp mirror playing", timeout=5000) speed = page.locator(".mxp-speed-v").inner_text() page.locator(".mxp-view").hover() page.mouse.wheel(0, 120) # while playing, the wheel is the speed playwright.expect(page.locator(".mxp-speed-v")).not_to_have_text(speed) hint = prompter.locator(".mxp-hint") # the effect is shown at the bottom, then fades playwright.expect(hint).to_have_class("mxp-hint on") playwright.expect(hint).to_contain_text("מהירות 45") playwright.expect(hint).to_have_class("mxp-hint", timeout=3000) page.locator(".mxp-view").click() # a click pauses playwright.expect(prompter).to_have_class("mxp mirror") page.keyboard.press("PageDown") # a presentation clicker plays playwright.expect(prompter).to_have_class("mxp mirror playing", timeout=500) # resumes with no 3-2-1 playwright.expect(page.locator(".mxp-count")).to_have_count(0) page.locator(".mxp-view").click() top = page.locator(".mxp-scroll").evaluate("e=>e.scrollTop") page.locator(".mxp-view").click(button="right") # right-click goes back a few lines assert page.locator(".mxp-scroll").evaluate("e=>e.scrollTop") <= top page.keyboard.press("f") # F leaves full screen and keeps the prompter page.wait_for_function("!document.fullscreenElement") playwright.expect(prompter).to_have_count(1) page.keyboard.press("f") page.wait_for_function("!!document.fullscreenElement") page.evaluate("document.exitFullscreen()") # what the browser does with Esc in full screen playwright.expect(prompter).to_have_count(0) playwright.expect(page.locator(".mx-manage-sheet")).to_be_visible() page.locator('[data-act="prompter-go"]').click() page.keyboard.press("Escape") playwright.expect(prompter).to_have_count(0) playwright.expect(page.locator(".mx-manage-sheet")).to_be_visible() # Esc closes only the prompter finally: browser.close() def test_prompter_outline_from_management(phone_server): """ראשי פרקים: Claude writes a saved text's outline once (mocked here), then it opens at once and can be edited.""" import json with playwright.sync_playwright() as p: browser = p.chromium.launch() try: page = browser.new_page() page.route("https://cdn.jsdelivr.net/**", lambda route: route.abort()) page.add_init_script('''window.supabase={createClient:()=>({auth:{ getSession:async()=>({data:{session:{access_token:"test",user:{id:"test"}}}}), onAuthStateChange:()=>{},signOut:async()=>{} }})};''') def index(route): response = route.fetch() route.fulfill(response=response, body=response.text().replace( 'supabaseUrl:""', 'supabaseUrl:"https://auth.invalid"')) page.route(phone_server + "/", index) page.route("**/api/me", lambda route: route.fulfill(json={ "email": "dev@example.com", "user_id": "test", "answer_scans": False, "permissions": ["management.access"], "classes": []})) page.route("**/api/math/progress", lambda route: route.fulfill(json={"progress": {}})) page.route("**/api/classes", lambda route: route.fulfill(json={"classes": []})) row = {"id": "aaaaaaaa-aaaa-4aaa-8aaa-000000000001", "title": "Zohar's lecture", "outline": None, "body": "Slide 1 — Hello\n35 seconds\nGood morning.\nSlide 2 — Bye\nThank you.", "updated_at": "2026-11-07T10:00:00Z"} calls = [] outline = "Slide 1 — Hello\n35 seconds\n• Good morning\n\nSlide 2 — Bye\n• Thanks" def make(route): calls.append(route.request.method) row["outline"] = outline route.fulfill(json={"text": row}) def save(route): row.update(json.loads(route.request.post_data)) route.fulfill(json={"text": row}) page.route("**/api/prompter/texts", lambda route: route.fulfill(json={"texts": [row]})) page.route("**/api/prompter/texts/*/outline", make) page.route("**/api/prompter/texts/aaaaaaaa-aaaa-4aaa-8aaa-000000000001", save) page.goto(phone_server) page.locator('[data-act="manage"]').click() page.locator('[data-act="manage-prompter"]').click() page.locator("[data-prompter-outline]").click() # no outline yet: Claude writes it playwright.expect(page.locator(".mxp")).to_have_count(1) playwright.expect(page.locator(".mxp-sbody p.pt")).to_have_text(["Good morning"]) playwright.expect(page.locator(".mx-prompter-state")).to_contain_text("מוכנים ונשמרו") page.keyboard.press("Escape") page.locator("[data-prompter-outline]").click() # now it opens with no second call playwright.expect(page.locator(".mxp")).to_have_count(1) assert calls == ["POST"] page.keyboard.press("Escape") page.locator("[data-prompter-open]").click() # editing shows the outline and saves it area = page.locator("#mxPrompterOutline") playwright.expect(area).to_have_value(outline) area.fill(outline.replace("Thanks", "Thank you all")) page.locator('[data-act="prompter-save"]').click() playwright.expect(page.locator(".mx-prompter-state")).to_contain_text("נשמר") assert "Thank you all" in row["outline"] finally: browser.close()
"""Optional browser regression checks: pip install playwright && playwright install chromium."""
import socket
import threading
import time
import pytest
import uvicorn
from app.main import create_app
playwright = pytest.importorskip("playwright.sync_api")
@pytest.fixture
def phone_server(monkeypatch):
monkeypatch.setenv("ANTHROPIC_API_KEY", "browser-test")
app = create_app()
class Solver:
def solve(self, image, media_type):
assert image and media_type == "image/jpeg"
return {"readable": True, "problem": "Triangle", "steps": [{"hint": "Hint", "work": "Work"}],
"answer": "10 cm", "skill": "", "note": ""}
app.state.photo, app.state.photo_key = Solver(), "browser-test"
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
server = uvicorn.Server(uvicorn.Config(app, log_level="error"))
thread = threading.Thread(target=server.run, kwargs={"sockets": [sock]}, daemon=True)
thread.start()
try:
deadline = time.monotonic() + 5
while not server.started:
assert thread.is_alive() and time.monotonic() < deadline
time.sleep(.01)
yield f"http://127.0.0.1:{sock.getsockname()[1]}"
finally:
server.should_exit = True
thread.join(timeout=5)
@pytest.mark.parametrize("manager", [True, False])
def test_class_management_geometry_and_permissions(phone_server, manager):
import json
row = {"id": 7, "name": "כיתת תרגול", "lessons": ["t"],
"emails": ["student@example.com", "teacher@example.com"], "teachers": ["teacher@example.com"],
"exam_title": "לוח מבחנים – כיתה ט׳11, תשפ״ז",
"exam_note": "ייתכנו שינויים.",
"exams": [{"date": "2026-11-16", "subject": "מתמטיקה"}]}
writes = []
with playwright.sync_playwright() as p:
browser = p.chromium.launch()
try:
page = browser.new_page()
page.route("https://cdn.jsdelivr.net/**", lambda route: route.abort())
page.add_init_script('''window.supabase={createClient:()=>({auth:{
getSession:async()=>({data:{session:{access_token:"test",user:{id:"test"}}}}),
onAuthStateChange:()=>{},signOut:async()=>{}
}})};''')
def index(route):
response = route.fetch()
route.fulfill(response=response, body=response.text().replace(
'supabaseUrl:""', 'supabaseUrl:"https://auth.invalid"'))
page.route(phone_server + "/", index)
page.route("**/api/me", lambda route: route.fulfill(json={
"email": "student@example.com", "user_id": "test", "answer_scans": False,
"permissions": ["management.access", "classes.manage"] if manager else ["path.use"],
"classes": [row]}))
page.route("**/api/classes", lambda route: route.fulfill(json={"classes": [row]}))
def save(route):
body = json.loads(route.request.post_data)
writes.append(body)
if len(writes) == 1:
route.fulfill(status=503, json={"detail": "temporary failure"})
else:
row.update(body)
route.fulfill(json={"ok": True})
page.route("**/api/classes/7", save)
page.goto(phone_server)
playwright.expect(page.get_by_role("heading", name="המקצועות שלי")).to_be_visible()
page.locator('[data-subject="מתמטיקה"]').click()
button = page.locator('[data-act="manage"]')
if not manager:
playwright.expect(page.locator('.mx-top')).to_be_visible()
playwright.expect(button).to_have_count(0)
return
playwright.expect(page.locator(".mx-exam-countdown")).to_be_visible()
playwright.expect(page.locator(".mx-exam-countdown time")).to_contain_text("16 בנובמבר 2026")
button.click()
playwright.expect(page.locator(".mx-exam-board")).to_contain_text("מתמטיקה")
page.get_by_role("button", name="+ כיתה חדשה").click()
subject = page.locator('input[name="subject"][value="מתמטיקה"]')
units = page.locator('input[name="unit"][data-subject="מתמטיקה"]')
playwright.expect(subject).not_to_be_checked()
playwright.expect(page.locator('[data-subject-units="מתמטיקה"]')).to_be_hidden()