Tests follow board work; home spec validates again (#11)
Browse filesThe tests expect board work's "work" fields and board functions; tests about other things count the board as written on. The home spec's "Nothing critical" scenario is back.
- mathly2/tests/serve.py +17 -0
- mathly2/tests/test_assignments_api.py +6 -6
- mathly2/tests/test_board_browser.py +1 -1
- mathly2/tests/test_home_browser.py +3 -1
- mathly2/tests/test_quiz_browser.py +2 -2
- mathly2/tests/test_written_browser.py +2 -2
- mathly2/tests/test_written_store.py +6 -5
- openspec/changes/game-home-lesson-rounds/specs/student-home/spec.md +4 -0
mathly2/tests/serve.py
CHANGED
|
@@ -39,3 +39,20 @@ def only(bank, lessons):
|
|
| 39 |
from mathly2.bank import Bank
|
| 40 |
keep = set(lessons)
|
| 41 |
return Bank({**bank.data, "units": [u for u in bank.lessons if u["id"] in keep], "skills": [s for s in bank.skills if s["unit"] in keep]})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
from mathly2.bank import Bank
|
| 40 |
keep = set(lessons)
|
| 41 |
return Bank({**bank.data, "units": [u for u in bank.lessons if u["id"] in keep], "skills": [s for s in bank.skills if s["unit"] in keep]})
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# The board as if the student wrote the way on it (board-work): for tests about something else, a quiz or a test then takes a choice
|
| 45 |
+
# without drawing first. No picture is sent (pictureOf gives nothing). The board-work tests draw on the real board instead.
|
| 46 |
+
_BOARD_WRITTEN = """(() => {
|
| 47 |
+
const patch = b => { b.hasWork = () => true; b.workSig = () => "test"; b.pictureOf = async () => null; return b; };
|
| 48 |
+
if (window.MathlyBoard) { patch(window.MathlyBoard); return; }
|
| 49 |
+
let board;
|
| 50 |
+
Object.defineProperty(window, "MathlyBoard", { configurable: true, get: () => board, set: b => { board = patch(b); } });
|
| 51 |
+
})()"""
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def board_written(page):
|
| 55 |
+
"""From now on (and after a reload) every board on the page counts as written on."""
|
| 56 |
+
page.add_init_script(_BOARD_WRITTEN)
|
| 57 |
+
page.evaluate(_BOARD_WRITTEN)
|
| 58 |
+
return page
|
mathly2/tests/test_assignments_api.py
CHANGED
|
@@ -467,7 +467,7 @@ def counts(client, who=TEACHER):
|
|
| 467 |
|
| 468 |
|
| 469 |
def test_the_counts_are_classes_tests_assignments_and_submissions_waiting(world):
|
| 470 |
-
assert counts(world) == {"classes": 1, "tests": 0, "assignments": 0, "inbox": 0, "progress": 2, "ai": 0}
|
| 471 |
for _ in range(3):
|
| 472 |
world.store.create_test(world.a["id"], clock.school_day() + 10 * DAY, [world.quiz_lesson], TEACHER, created_at=clock.now())
|
| 473 |
a = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
|
|
@@ -478,7 +478,7 @@ def test_the_counts_are_classes_tests_assignments_and_submissions_waiting(world)
|
|
| 478 |
submit(world, a["id"], KID)
|
| 479 |
submit(world, a["id"], KID2)
|
| 480 |
submit(world, b["id"], KID)
|
| 481 |
-
assert counts(world) == {"classes": 1, "tests": 4, "assignments": 4, "inbox": 3, "progress": 2, "ai": 0}
|
| 482 |
|
| 483 |
|
| 484 |
def test_checking_a_submission_lowers_the_inbox_and_removed_items_are_not_counted(world):
|
|
@@ -490,13 +490,13 @@ def test_checking_a_submission_lowers_the_inbox_and_removed_items_are_not_counte
|
|
| 490 |
assert counts(world)["inbox"] == 0
|
| 491 |
world.store.remove_test(test["id"])
|
| 492 |
world.delete(f"/api/assignments/{a['id']}", headers=auth(TEACHER))
|
| 493 |
-
assert counts(world) == {"classes": 1, "tests": 0, "assignments": 0, "inbox": 0, "progress": 2, "ai": 0}
|
| 494 |
|
| 495 |
|
| 496 |
def test_another_teachers_items_are_not_counted_and_a_developer_counts_everything(world):
|
| 497 |
give(world, kind="quiz", quiz_lesson=world.quiz_lesson)
|
| 498 |
world.store.create_test(world.b["id"], clock.school_day() + 10 * DAY, [world.other_quiz], OTHER_TEACHER, created_at=clock.now())
|
| 499 |
-
assert counts(world, OTHER_TEACHER) == {"classes": 1, "tests": 1, "assignments": 0, "inbox": 0, "progress": 1, "ai": 0}
|
| 500 |
-
assert counts(world) == {"classes": 1, "tests": 0, "assignments": 1, "inbox": 0, "progress": 2, "ai": 0}
|
| 501 |
-
assert counts(world, DEV) == {"classes": 2, "tests": 1, "assignments": 1, "inbox": 0, "progress": 3, "ai": 0}
|
| 502 |
assert world.get("/api/teaching/counts", headers=auth(KID)).status_code == 403
|
|
|
|
| 467 |
|
| 468 |
|
| 469 |
def test_the_counts_are_classes_tests_assignments_and_submissions_waiting(world):
|
| 470 |
+
assert counts(world) == {"classes": 1, "tests": 0, "assignments": 0, "inbox": 0, "progress": 2, "ai": 0, "work": 0}
|
| 471 |
for _ in range(3):
|
| 472 |
world.store.create_test(world.a["id"], clock.school_day() + 10 * DAY, [world.quiz_lesson], TEACHER, created_at=clock.now())
|
| 473 |
a = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
|
|
|
|
| 478 |
submit(world, a["id"], KID)
|
| 479 |
submit(world, a["id"], KID2)
|
| 480 |
submit(world, b["id"], KID)
|
| 481 |
+
assert counts(world) == {"classes": 1, "tests": 4, "assignments": 4, "inbox": 3, "progress": 2, "ai": 0, "work": 0}
|
| 482 |
|
| 483 |
|
| 484 |
def test_checking_a_submission_lowers_the_inbox_and_removed_items_are_not_counted(world):
|
|
|
|
| 490 |
assert counts(world)["inbox"] == 0
|
| 491 |
world.store.remove_test(test["id"])
|
| 492 |
world.delete(f"/api/assignments/{a['id']}", headers=auth(TEACHER))
|
| 493 |
+
assert counts(world) == {"classes": 1, "tests": 0, "assignments": 0, "inbox": 0, "progress": 2, "ai": 0, "work": 0}
|
| 494 |
|
| 495 |
|
| 496 |
def test_another_teachers_items_are_not_counted_and_a_developer_counts_everything(world):
|
| 497 |
give(world, kind="quiz", quiz_lesson=world.quiz_lesson)
|
| 498 |
world.store.create_test(world.b["id"], clock.school_day() + 10 * DAY, [world.other_quiz], OTHER_TEACHER, created_at=clock.now())
|
| 499 |
+
assert counts(world, OTHER_TEACHER) == {"classes": 1, "tests": 1, "assignments": 0, "inbox": 0, "progress": 1, "ai": 0, "work": 0}
|
| 500 |
+
assert counts(world) == {"classes": 1, "tests": 0, "assignments": 1, "inbox": 0, "progress": 2, "ai": 0, "work": 0}
|
| 501 |
+
assert counts(world, DEV) == {"classes": 2, "tests": 1, "assignments": 1, "inbox": 0, "progress": 3, "ai": 0, "work": 0}
|
| 502 |
assert world.get("/api/teaching/counts", headers=auth(KID)).status_code == 403
|
mathly2/tests/test_board_browser.py
CHANGED
|
@@ -79,7 +79,7 @@ def test_the_math_keyboard_file_is_the_unedited_copy_from_mathly1():
|
|
| 79 |
def test_the_board_loads_and_opens_in_light_and_dark_with_no_error(browser, scheme):
|
| 80 |
with running(create_app()) as url:
|
| 81 |
page = board_page(browser, url, DESKTOP, scheme)
|
| 82 |
-
assert page.evaluate("Object.keys(window.MathlyBoard).sort()") == ["button", "closeFull", "desk", "isOn", "mount", "openFull", "show", "toggle"]
|
| 83 |
page.click("[data-board-toggle]")
|
| 84 |
page.wait_for_selector(".mx-board")
|
| 85 |
assert page.is_visible(".mx-board") and page.is_visible(".mx-board-bar")
|
|
|
|
| 79 |
def test_the_board_loads_and_opens_in_light_and_dark_with_no_error(browser, scheme):
|
| 80 |
with running(create_app()) as url:
|
| 81 |
page = board_page(browser, url, DESKTOP, scheme)
|
| 82 |
+
assert page.evaluate("Object.keys(window.MathlyBoard).sort()") == ["button", "closeFull", "desk", "hasWork", "isOn", "mount", "openFull", "pictureOf", "show", "toggle", "workSig"]
|
| 83 |
page.click("[data-board-toggle]")
|
| 84 |
page.wait_for_selector(".mx-board")
|
| 85 |
assert page.is_visible(".mx-board") and page.is_visible(".mx-board-bar")
|
mathly2/tests/test_home_browser.py
CHANGED
|
@@ -13,6 +13,7 @@ from mathly2.config import Settings
|
|
| 13 |
from mathly2.main import create_app
|
| 14 |
from mathly2.store import InMemoryStore
|
| 15 |
from mathly2.tests.serve import offline_formulas, only, running
|
|
|
|
| 16 |
|
| 17 |
KID, TEACHER = "kid@school.test", "t@school.test"
|
| 18 |
KID_ID = str(uuid.uuid5(uuid.NAMESPACE_DNS, KID))
|
|
@@ -475,7 +476,8 @@ def test_a_refused_test_shows_why_and_keeps_what_was_typed(browser, bank):
|
|
| 475 |
# --- 5.1 the whole countdown ---
|
| 476 |
|
| 477 |
def answer_in_browser(page, bank, store, kind, scope, rights):
|
| 478 |
-
"""Answer the open attempt on screen right or wrong, one flag per question, moving with "next"; then submit."""
|
|
|
|
| 479 |
page.wait_for_selector("#qz-q")
|
| 480 |
refs = store.attempts_of(KID_ID, kind, scope)[-1]["questions"]
|
| 481 |
for n, ok in enumerate(rights):
|
|
|
|
| 13 |
from mathly2.main import create_app
|
| 14 |
from mathly2.store import InMemoryStore
|
| 15 |
from mathly2.tests.serve import offline_formulas, only, running
|
| 16 |
+
from mathly2.tests.serve import board_written
|
| 17 |
|
| 18 |
KID, TEACHER = "kid@school.test", "t@school.test"
|
| 19 |
KID_ID = str(uuid.uuid5(uuid.NAMESPACE_DNS, KID))
|
|
|
|
| 476 |
# --- 5.1 the whole countdown ---
|
| 477 |
|
| 478 |
def answer_in_browser(page, bank, store, kind, scope, rights):
|
| 479 |
+
"""Answer the open attempt on screen right or wrong, one flag per question, moving with "next"; then submit. The board counts as written on."""
|
| 480 |
+
board_written(page)
|
| 481 |
page.wait_for_selector("#qz-q")
|
| 482 |
refs = store.attempts_of(KID_ID, kind, scope)[-1]["questions"]
|
| 483 |
for n, ok in enumerate(rights):
|
mathly2/tests/test_quiz_browser.py
CHANGED
|
@@ -13,7 +13,7 @@ from mathly2.bank import load_bank
|
|
| 13 |
from mathly2.config import Settings
|
| 14 |
from mathly2.main import create_app
|
| 15 |
from mathly2.store import InMemoryStore
|
| 16 |
-
from mathly2.tests.serve import offline_formulas, only, running
|
| 17 |
|
| 18 |
KID = "kid@school.test"
|
| 19 |
KID_ID = str(uuid.uuid5(uuid.NAMESPACE_DNS, KID))
|
|
@@ -61,7 +61,7 @@ def seed_attempt(store, bank, lesson, grade=None, minutes_left=None, answers=Non
|
|
| 61 |
|
| 62 |
|
| 63 |
def path_page(browser, url, where="", viewport=PHONE):
|
| 64 |
-
page = offline_formulas(browser.new_page(viewport=viewport))
|
| 65 |
errors = []
|
| 66 |
page.on("pageerror", lambda e: errors.append(str(e)))
|
| 67 |
page.goto(url + where)
|
|
|
|
| 13 |
from mathly2.config import Settings
|
| 14 |
from mathly2.main import create_app
|
| 15 |
from mathly2.store import InMemoryStore
|
| 16 |
+
from mathly2.tests.serve import board_written, offline_formulas, only, running
|
| 17 |
|
| 18 |
KID = "kid@school.test"
|
| 19 |
KID_ID = str(uuid.uuid5(uuid.NAMESPACE_DNS, KID))
|
|
|
|
| 61 |
|
| 62 |
|
| 63 |
def path_page(browser, url, where="", viewport=PHONE):
|
| 64 |
+
page = board_written(offline_formulas(browser.new_page(viewport=viewport))) # these tests are not about the work on the board
|
| 65 |
errors = []
|
| 66 |
page.on("pageerror", lambda e: errors.append(str(e)))
|
| 67 |
page.goto(url + where)
|
mathly2/tests/test_written_browser.py
CHANGED
|
@@ -837,11 +837,11 @@ def test_every_teacher_tab_shows_its_number_and_the_numbers_change_after_grading
|
|
| 837 |
page.wait_for_selector("#teaching")
|
| 838 |
page.wait_for_function("document.querySelector('[data-count=inbox]')?.innerText !== ''")
|
| 839 |
numbers = lambda: {el.get_attribute("data-count"): el.inner_text() for el in page.locator("#teacher-tabs [data-count]").all()}
|
| 840 |
-
assert numbers() == {"classes": "1", "tests": "3", "assignments": "2", "inbox": "2", "progress": "2", "ai": "0"}
|
| 841 |
for tab in ("tests", "assignments", "inbox"): # on every tab
|
| 842 |
page.click(f"#teacher-tabs [data-tab='{tab}']")
|
| 843 |
page.wait_for_function("(t) => document.querySelector(`#teacher-tabs [data-tab='${t}'][aria-pressed='true']`) && [...document.querySelectorAll('#teacher-tabs [data-count]')].every(e => e.innerText !== '')", arg=tab)
|
| 844 |
-
assert numbers() == {"classes": "1", "tests": "3", "assignments": "2", "inbox": "2", "progress": "2", "ai": "0"}, tab
|
| 845 |
page.click(f"[data-open='{monday['id']}']")
|
| 846 |
page.fill("[name=grade]", "80")
|
| 847 |
page.click("#give-grade")
|
|
|
|
| 837 |
page.wait_for_selector("#teaching")
|
| 838 |
page.wait_for_function("document.querySelector('[data-count=inbox]')?.innerText !== ''")
|
| 839 |
numbers = lambda: {el.get_attribute("data-count"): el.inner_text() for el in page.locator("#teacher-tabs [data-count]").all()}
|
| 840 |
+
assert numbers() == {"classes": "1", "tests": "3", "assignments": "2", "inbox": "2", "progress": "2", "ai": "0", "work": "0"}
|
| 841 |
for tab in ("tests", "assignments", "inbox"): # on every tab
|
| 842 |
page.click(f"#teacher-tabs [data-tab='{tab}']")
|
| 843 |
page.wait_for_function("(t) => document.querySelector(`#teacher-tabs [data-tab='${t}'][aria-pressed='true']`) && [...document.querySelectorAll('#teacher-tabs [data-count]')].every(e => e.innerText !== '')", arg=tab)
|
| 844 |
+
assert numbers() == {"classes": "1", "tests": "3", "assignments": "2", "inbox": "2", "progress": "2", "ai": "0", "work": "0"}, tab
|
| 845 |
page.click(f"[data-open='{monday['id']}']")
|
| 846 |
page.fill("[name=grade]", "80")
|
| 847 |
page.click("#give-grade")
|
mathly2/tests/test_written_store.py
CHANGED
|
@@ -45,9 +45,9 @@ def test_picture_refs_find_assignments_submissions_and_requests(store):
|
|
| 45 |
s = store.save_submission(a["id"], U, pictures=[lone["id"]])
|
| 46 |
r = store.create_photo_request(U, a["id"], "d1", "1234", NOW, NOW + timedelta(minutes=10))
|
| 47 |
store.save_photo_request(r["id"], state="received", picture_id=lone["id"])
|
| 48 |
-
assert store.picture_refs(pic["id"]) == {"assignments": [a["id"]], "submissions": [], "requests": [], "ai_requests": []}
|
| 49 |
-
assert store.picture_refs(lone["id"]) == {"assignments": [], "submissions": [s["id"]], "requests": [r["id"]], "ai_requests": []}
|
| 50 |
-
assert store.picture_refs(picture(store)["id"]) == {"assignments": [], "submissions": [], "requests": [], "ai_requests": []}
|
| 51 |
|
| 52 |
|
| 53 |
# --- 1.1 assignments ---
|
|
@@ -172,8 +172,9 @@ def test_supabase_requests_for_pictures_and_assignments():
|
|
| 172 |
assert s.calls[4][3]["due_date"] == "2026-11-20" and s.calls[4][3]["pages"] == ["p1"] and made_a["due_date"] == DUE
|
| 173 |
assert s.get_assignment(3)["pages"] == ["p1"] and [a["id"] for a in s.assignments_of_classes([2, 1])] == [3]
|
| 174 |
assert s.calls[6][2] == {"class_id": "in.(1,2)", "select": "*", "order": "due_date,id", "removed_at": "is.null"}
|
| 175 |
-
s2 = Recorder([[{"id": 3}], [{"id": 9}], [{"id": 4}], [{"id": "a1"}]])
|
| 176 |
-
assert s2.picture_refs("p1") == {"assignments": [3], "submissions": [9], "requests": [4], "ai_requests": ["a1"]}
|
|
|
|
| 177 |
assert s2.calls[0][2] == {"pages": "cs.{p1}", "select": "id"} and s2.calls[1][2] == {"pictures": "cs.{p1}", "select": "id"}
|
| 178 |
|
| 179 |
|
|
|
|
| 45 |
s = store.save_submission(a["id"], U, pictures=[lone["id"]])
|
| 46 |
r = store.create_photo_request(U, a["id"], "d1", "1234", NOW, NOW + timedelta(minutes=10))
|
| 47 |
store.save_photo_request(r["id"], state="received", picture_id=lone["id"])
|
| 48 |
+
assert store.picture_refs(pic["id"]) == {"assignments": [a["id"]], "submissions": [], "requests": [], "ai_requests": [], "work": []}
|
| 49 |
+
assert store.picture_refs(lone["id"]) == {"assignments": [], "submissions": [s["id"]], "requests": [r["id"]], "ai_requests": [], "work": []}
|
| 50 |
+
assert store.picture_refs(picture(store)["id"]) == {"assignments": [], "submissions": [], "requests": [], "ai_requests": [], "work": []}
|
| 51 |
|
| 52 |
|
| 53 |
# --- 1.1 assignments ---
|
|
|
|
| 172 |
assert s.calls[4][3]["due_date"] == "2026-11-20" and s.calls[4][3]["pages"] == ["p1"] and made_a["due_date"] == DUE
|
| 173 |
assert s.get_assignment(3)["pages"] == ["p1"] and [a["id"] for a in s.assignments_of_classes([2, 1])] == [3]
|
| 174 |
assert s.calls[6][2] == {"class_id": "in.(1,2)", "select": "*", "order": "due_date,id", "removed_at": "is.null"}
|
| 175 |
+
s2 = Recorder([[{"id": 3}], [{"id": 9}], [{"id": 4}], [{"id": "a1"}], [{"ref": "12", "question_key": "q1"}]])
|
| 176 |
+
assert s2.picture_refs("p1") == {"assignments": [3], "submissions": [9], "requests": [4], "ai_requests": ["a1"], "work": ["12:q1"]}
|
| 177 |
+
assert s2.calls[4][1] == "m2_work" and s2.calls[4][2] == {"picture_id": "eq.p1", "select": "ref,question_key"}
|
| 178 |
assert s2.calls[0][2] == {"pages": "cs.{p1}", "select": "id"} and s2.calls[1][2] == {"pictures": "cs.{p1}", "select": "id"}
|
| 179 |
|
| 180 |
|
openspec/changes/game-home-lesson-rounds/specs/student-home/spec.md
CHANGED
|
@@ -47,6 +47,10 @@ The path SHALL be reached from its tab, "העמקה"; the home has no other link
|
|
| 47 |
### Requirement: Home shows only what is critical
|
| 48 |
The home SHALL show only: the stars and goal, the play button, the lessons board, the nearest test's strip, a required simulation, the number of new assignments (as one button to "הגשות") and photo help. Other tests, the readiness bar, the practice tests, assignments that were handed in or checked, and settings SHALL NOT be on home; they are on the test's card and in their tabs.
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
#### Scenario: Two tests
|
| 51 |
- **WHEN** a student has two upcoming tests
|
| 52 |
- **THEN** home shows the nearest one, and the other is listed on that test's card
|
|
|
|
| 47 |
### Requirement: Home shows only what is critical
|
| 48 |
The home SHALL show only: the stars and goal, the play button, the lessons board, the nearest test's strip, a required simulation, the number of new assignments (as one button to "הגשות") and photo help. Other tests, the readiness bar, the practice tests, assignments that were handed in or checked, and settings SHALL NOT be on home; they are on the test's card and in their tabs.
|
| 49 |
|
| 50 |
+
#### Scenario: Nothing critical
|
| 51 |
+
- **WHEN** a student has no upcoming test, no new assignment and no lesson behind
|
| 52 |
+
- **THEN** home shows the stars and goal, the play button and the lessons, and no test strip or buttons under it
|
| 53 |
+
|
| 54 |
#### Scenario: Two tests
|
| 55 |
- **WHEN** a student has two upcoming tests
|
| 56 |
- **THEN** home shows the nearest one, and the other is listed on that test's card
|