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