mathly / tests /test_board_browser.py
3v324v23's picture
feat: update project documentation and improve board interaction handling
006e4fe
Raw History Blame Contribute Delete
35.4 kB
"""Browser checks of the sketch board beside a skill's exercise (static/math-board.js).
Optional, like the other browser tests: pip install playwright && playwright install chromium."""
import re
import socket
import threading
import time
import pytest
import uvicorn
from app.main import create_app
playwright = pytest.importorskip("playwright.sync_api")
@pytest.fixture(scope="module")
def server():
app = create_app()
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
srv = uvicorn.Server(uvicorn.Config(app, log_level="error"))
thread = threading.Thread(target=srv.run, kwargs={"sockets": [sock]}, daemon=True)
thread.start()
try:
deadline = time.monotonic() + 5
while not srv.started:
assert thread.is_alive() and time.monotonic() < deadline
time.sleep(.01)
yield f"http://127.0.0.1:{sock.getsockname()[1]}"
finally:
srv.should_exit = True
thread.join(timeout=5)
@pytest.fixture(scope="module")
def browser():
with playwright.sync_playwright() as p:
b = p.chromium.launch()
yield b
b.close()
def open_skill(browser, server, width, height):
ctx = browser.new_context(viewport={"width": width, "height": height})
ctx.route("https://cdn.jsdelivr.net/**", lambda route: route.abort())
ctx.route("https://fonts.googleapis.com/**", lambda route: route.abort())
ctx.add_init_script("try{localStorage.setItem('mathly.sound','off')}catch{}")
page = ctx.new_page()
errors = []
page.on("pageerror", lambda e: errors.append(str(e)))
page.goto(server)
page.locator('.mx-nav [data-view="path"]').click() # the app opens on home; the trail is on the path tab
playwright.expect(page.locator(".mx-trail")).to_be_visible()
page.locator(".mx-star.st-next").click()
playwright.expect(page.locator(".mx-sheet")).to_be_visible()
return page, errors
def ink_pixels(page):
return page.evaluate("""()=>{ const c=document.querySelector('.mx-board canvas'), d=c.getContext('2d').getImageData(0,0,c.width,c.height).data;
let n=0; for(let i=3;i<d.length;i+=4) if(d[i]) n++; return n; }""")
def draw_line(page):
box = page.locator(".mx-board canvas").bounding_box()
x, y = box["x"] + 60, box["y"] + 60
page.mouse.move(x, y)
page.mouse.down()
for k in range(1, 12):
page.mouse.move(x + k * 15, y + k * 6)
page.mouse.up()
def touch(page, events):
"""Sends pointer events of a finger or a pen to the board: [(type, id, x, y, kind)], x and y from its corner."""
page.evaluate("""(events)=>{ const c=document.querySelector('.mx-board canvas'), r=c.getBoundingClientRect();
for(const [type,id,x,y,kind] of events) c.dispatchEvent(new PointerEvent(type,{pointerId:id,pointerType:kind||'touch',
clientX:r.left+x,clientY:r.top+y,button:type==='pointermove'?-1:0,buttons:type==='pointerup'?0:1,pressure:.5,bubbles:true,isPrimary:id===1})); }""", events)
def test_on_a_phone_the_board_opens_full_screen_over_the_exercise(browser, server):
page, errors = open_skill(browser, server, 390, 844)
assert page.locator(".mx-board").count() == 0 # nothing until asked
page.locator("[data-board-toggle]").click()
board = page.locator(".mx-board.full")
playwright.expect(board).to_be_visible()
box = board.bounding_box()
assert box["width"] == 390 and box["height"] == 844
touch(page, [("pointerdown", 1, 60, 200), *[("pointermove", 1, 60 + k * 20, 200) for k in range(1, 10)], ("pointerup", 1, 240, 200)])
assert alpha_near(page, 150, 200) > 0 # one finger draws
# two fingers scroll the paper and draw nothing
touch(page, [("pointerdown", 2, 100, 500), ("pointerdown", 3, 200, 500),
*[e for k in range(1, 11) for e in (("pointermove", 2, 100, 500 - k * 30), ("pointermove", 3, 200, 500 - k * 30))],
("pointerup", 2, 100, 200), ("pointerup", 3, 200, 200)])
assert alpha_near(page, 150, 200) == 0 and alpha_near(page, 150, 0) == 0 # the line went up out of sight
playwright.expect(page.locator(".mx-board-top")).to_be_visible()
page.locator(".mx-board-top").click()
assert alpha_near(page, 150, 200) > 0 and alpha_near(page, 150, 500) == 0
# once a pen has written, a resting hand does not draw
touch(page, [("pointerdown", 5, 60, 600, "pen"), ("pointermove", 5, 200, 600, "pen"), ("pointerup", 5, 200, 600, "pen")])
touch(page, [("pointerdown", 6, 60, 700), ("pointermove", 6, 200, 700), ("pointerup", 6, 200, 700)])
assert alpha_near(page, 130, 600) > 0 and alpha_near(page, 130, 700) == 0
# a palm (two contacts) lands while the pen writes: the stroke stays and the paper does not scroll
touch(page, [("pointerdown", 7, 60, 400, "pen"), ("pointermove", 7, 120, 400, "pen"),
("pointerdown", 8, 150, 600), ("pointerdown", 9, 200, 620),
("pointermove", 8, 150, 450), ("pointermove", 9, 200, 470),
("pointermove", 7, 200, 400, "pen"), ("pointerup", 7, 200, 400, "pen"),
("pointerup", 8, 150, 450), ("pointerup", 9, 200, 470)])
assert alpha_near(page, 160, 400) > 0 and alpha_near(page, 130, 600) > 0
# the hand rests first, then the pen comes down: the hand's mark is taken back, the pen writes
page.wait_for_timeout(600)
touch(page, [("pointerdown", 10, 60, 300), ("pointermove", 10, 120, 300),
("pointerdown", 11, 60, 250, "pen"), ("pointermove", 11, 200, 250, "pen"), ("pointerup", 11, 200, 250, "pen"),
("pointermove", 10, 200, 300), ("pointerup", 10, 200, 300)])
assert alpha_near(page, 130, 250) > 0 and alpha_near(page, 90, 300) == 0
page.locator(".mx-board-back").click() # back to the question; the sketch waits
playwright.expect(page.locator(".mx-board")).to_have_count(0)
playwright.expect(page.locator(".mx-sheet")).to_be_visible()
page.locator("[data-board-toggle]").click()
assert alpha_near(page, 150, 200) > 0
page.keyboard.press("Escape") # closes the board, not the exercise
playwright.expect(page.locator(".mx-board")).to_have_count(0)
playwright.expect(page.locator(".mx-sheet")).to_be_visible()
assert errors == []
def test_the_board_slides_over_the_exercise_like_a_closet_door(browser, server):
page, errors = open_skill(browser, server, 1440, 900)
page.locator("[data-board-toggle]").click()
board, sheet = page.locator(".mx-board"), page.locator(".mx-sheet")
door = page.locator(".mx-board-door")
playwright.expect(door).to_be_visible()
playwright.expect(door).to_have_attribute("aria-expanded", "false")
shut = board.bounding_box()
sheet_box = sheet.bounding_box()
assert shut["x"] + shut["width"] < sheet_box["x"] # beside the exercise
door.click() # a tap: all the way open, over the exercise, and never full screen
playwright.expect(door).to_have_attribute("aria-expanded", "true")
page.wait_for_timeout(400)
wide = board.bounding_box()
assert wide["x"] == shut["x"] and abs(wide["x"] + wide["width"] - (1440 - 16)) < 2
assert page.evaluate("!document.fullscreenElement")
assert page.evaluate("document.elementFromPoint(1300, 450).closest('.mx-board') !== null")
drag(page, (900, 300), (1200, 300)) # it draws on the part that slid open
assert alpha_near(page, 1050, 300) > 0
door.click() # a tap again: back beside the exercise
playwright.expect(door).to_have_attribute("aria-expanded", "false")
page.wait_for_timeout(400)
assert abs(board.bounding_box()["width"] - shut["width"]) < 2
# dragged by its handle, it stops where it is let go
d = door.bounding_box()
x, y = d["x"] + d["width"] / 2, d["y"] + d["height"] / 2
page.mouse.move(x, y)
page.mouse.down()
page.mouse.move(x + 250, y, steps=8)
page.mouse.up()
playwright.expect(door).to_have_attribute("aria-expanded", "true")
assert abs(board.bounding_box()["width"] - shut["width"] - 250) < 3
assert errors == []
def test_board_turns_on_draws_undoes_and_is_remembered(browser, server):
page, errors = open_skill(browser, server, 1440, 900)
toggle = page.locator("[data-board-toggle]")
playwright.expect(toggle).to_be_visible()
playwright.expect(toggle).to_have_attribute("aria-pressed", "false")
toggle.click()
playwright.expect(page.locator(".mx-board")).to_be_visible()
playwright.expect(page.locator(".mx-sheet")).to_have_class(re.compile(r"\bwith-board\b"))
# the board and the exercise sit side by side, not on top of each other
board, sheet = page.locator(".mx-board").bounding_box(), page.locator(".mx-sheet").bounding_box()
assert board["x"] + board["width"] <= sheet["x"]
draw_line(page)
assert ink_pixels(page) > 50
page.keyboard.press("Control+z")
assert ink_pixels(page) == 0
draw_line(page)
page.locator(".mx-board [data-do=clear]").click()
assert ink_pixels(page) == 0
page.locator(".mx-board [data-do=undo]").click() # a clear can be undone
assert ink_pixels(page) > 50
# closed and opened again: the board is still on and the same question keeps its sketch
page.locator(".mx-x").click()
page.locator(".mx-star.st-next").click()
playwright.expect(page.locator(".mx-board")).to_be_visible()
assert ink_pixels(page) > 50
page.locator(".mx-board-bar [data-do=hide]").click()
playwright.expect(page.locator(".mx-board")).to_have_count(0)
playwright.expect(page.locator("[data-board-toggle]")).to_have_attribute("aria-pressed", "false")
assert page.evaluate("localStorage.getItem('mathly.board')") == "off"
assert errors == []
def alpha_near(page, x, y):
"""The most ink in the 3x3 board pixels around (x, y), in CSS pixels from the board's corner."""
return page.evaluate("""([x,y])=>{ const c=document.querySelector('.mx-board canvas'), d=devicePixelRatio||1;
const px=c.getContext('2d').getImageData(Math.round(x*d)-1,Math.round(y*d)-1,3,3).data; let m=0;
for(let i=3;i<px.length;i+=4) m=Math.max(m,px[i]); return m; }""", [x, y])
def drag(page, a, b, steps=10):
box = page.locator(".mx-board canvas").bounding_box()
page.mouse.move(box["x"] + a[0], box["y"] + a[1])
page.mouse.down()
for k in range(1, steps + 1):
page.mouse.move(box["x"] + a[0] + (b[0] - a[0]) * k / steps, box["y"] + a[1] + (b[1] - a[1]) * k / steps)
page.mouse.up()
def board_on(page):
page.locator("[data-board-toggle]").click()
playwright.expect(page.locator(".mx-board")).to_be_visible()
def test_lines_snap_circles_and_a_bigger_eraser(browser, server):
page, errors = open_skill(browser, server, 1440, 900)
board_on(page)
page.locator(".mx-board [data-tool=line]").click()
drag(page, (100, 100), (300, 100))
assert alpha_near(page, 200, 100) > 0
drag(page, (305, 108), (305, 300)) # starts near the first line's end: it starts exactly there
assert alpha_near(page, 300, 120) > 0 and alpha_near(page, 306, 120) == 0
page.locator(".mx-board [data-tool=circle]").click()
drag(page, (450, 300), (530, 300)) # from the center out to the radius
assert alpha_near(page, 450, 380) > 0 and alpha_near(page, 450, 300) == 0
# the eraser's sizes show only with the eraser; the big one wipes what the small one misses
playwright.expect(page.locator(".mx-board-sizes")).to_be_hidden()
page.locator(".mx-board [data-tool=eraser]").click()
playwright.expect(page.locator(".mx-board-sizes")).to_be_visible()
page.locator(".mx-board [data-eraser='16']").click()
drag(page, (150, 125), (250, 125))
assert alpha_near(page, 200, 100) > 0
page.locator(".mx-board [data-eraser='90']").click()
drag(page, (150, 125), (250, 125))
assert alpha_near(page, 200, 100) == 0
assert errors == []
def test_the_practice_exam_has_a_board_with_a_sketch_per_question(browser, server):
page, errors = open_skill(browser, server, 1440, 900)
page.locator(".mx-x").click()
page.evaluate("""() => { const b = document.createElement('button'); b.dataset.act = 'quiz';
document.getElementById('mathView').append(b); b.click(); b.remove(); }""") # the mixed quiz, as an assignment opens it
page.locator("[data-act=quiz-start]").click()
playwright.expect(page.locator(".mx-quiz-sheet")).to_be_visible()
board_on(page)
draw_line(page)
assert ink_pixels(page) > 50
page.locator("[data-act=quiz-next]").click()
playwright.expect(page.locator(".mx-board")).to_be_visible() # still on for the next question, on a clean page
assert ink_pixels(page) == 0
page.locator("[data-act=quiz-prev]").click()
assert ink_pixels(page) > 50
assert errors == []
def test_elements_move_together_when_joined_and_can_be_deleted(browser, server):
page, errors = open_skill(browser, server, 1440, 900)
board_on(page)
page.locator(".mx-board [data-tool=line]").click()
drag(page, (100, 300), (300, 300))
drag(page, (302, 302), (200, 150)) # snaps to the first line's end
drag(page, (198, 148), (102, 298)) # and closes the triangle
page.locator(".mx-board [data-tool=circle]").click()
drag(page, (500, 250), (550, 250))
page.locator(".mx-board [data-tool=move]").click()
drag(page, (200, 300), (200, 400)) # grab the base: the whole triangle moves
assert alpha_near(page, 200, 300) == 0 and alpha_near(page, 200, 400) > 0
assert alpha_near(page, 200, 250) > 0 # the top corner came along (was at 150)
assert alpha_near(page, 550, 250) > 0 # the circle stayed
drag(page, (550, 250), (550, 450)) # the circle alone
assert alpha_near(page, 550, 250) == 0 and alpha_near(page, 550, 450) > 0
playwright.expect(page.locator(".mx-board-del")).to_be_visible()
page.keyboard.press("Delete")
assert alpha_near(page, 550, 450) == 0
page.keyboard.press("Control+z") # a delete can be undone, and so can a move
assert alpha_near(page, 550, 450) > 0
page.keyboard.press("Control+z")
assert alpha_near(page, 550, 250) > 0
assert errors == []
def test_the_eraser_cuts_a_pen_stroke_and_a_moved_shape_comes_whole(browser, server):
page, errors = open_skill(browser, server, 1440, 900)
board_on(page)
drag(page, (100, 200), (500, 200), steps=30) # a pen stroke
page.locator(".mx-board [data-tool=eraser]").click()
page.locator(".mx-board [data-eraser='40']").click()
drag(page, (300, 150), (300, 250)) # cut through the middle
assert alpha_near(page, 300, 200) == 0
assert alpha_near(page, 150, 200) > 0 and alpha_near(page, 450, 200) > 0
page.locator(".mx-board [data-tool=move]").click()
drag(page, (150, 200), (150, 400)) # the left piece is an element of its own
assert alpha_near(page, 150, 400) > 0 and alpha_near(page, 450, 200) > 0
assert errors == []
def test_the_question_is_copied_to_the_top_of_the_board(browser, server):
page, errors = open_skill(browser, server, 1440, 900)
board_on(page)
width = page.locator(".mx-board canvas").bounding_box()["width"]
assert alpha_near(page, width - 40, 30) == 0
page.locator(".mx-board [data-do=question]").click()
assert alpha_near(page, width - 40, 30) > 0 # a card at the top
playwright.expect(page.locator(".mx-board [data-tool=move]")).to_have_attribute("aria-pressed", "true")
drag(page, (width - 40, 30), (width - 40, 430)) # it moves like anything else
assert alpha_near(page, width - 40, 30) == 0 and alpha_near(page, width - 40, 430) > 0
page.locator(".mx-board-del").click()
assert ink_pixels(page) == 0
assert errors == []
def test_the_wheel_scrolls_an_endless_paper(browser, server):
page, errors = open_skill(browser, server, 1440, 900)
board_on(page)
drag(page, (100, 100), (300, 100))
box = page.locator(".mx-board canvas").bounding_box()
page.mouse.move(box["x"] + 300, box["y"] + 300)
page.mouse.wheel(0, -500) # never above the top
page.wait_for_timeout(100)
assert alpha_near(page, 200, 100) > 0
page.mouse.wheel(0, 2000)
page.wait_for_timeout(100)
assert alpha_near(page, 200, 100) == 0 # scrolled out of sight
playwright.expect(page.locator(".mx-board-top")).to_be_visible()
drag(page, (100, 300), (300, 300)) # drawn far down the paper
page.locator(".mx-board-top").click()
assert alpha_near(page, 200, 100) > 0 and alpha_near(page, 200, 300) == 0
page.mouse.move(box["x"] + 300, box["y"] + 300)
page.mouse.wheel(0, 2000)
page.wait_for_timeout(100)
assert alpha_near(page, 200, 300) > 0
playwright.expect(page.locator(".mx-board-send")).to_be_hidden() # handing in is the unit quiz's
assert errors == []
def type_math(page, at, latex):
"""Taps the board with the math tool at `at`, types `latex` into the math field and adds it."""
box = page.locator(".mx-board canvas").bounding_box()
page.mouse.click(box["x"] + at[0], box["y"] + at[1])
field = page.locator(".mx-board-mathedit math-field")
playwright.expect(field).to_be_visible(timeout=10000)
page.evaluate("v=>{document.querySelector('.mx-board-mathedit math-field').value=v}", latex)
page.locator(".mx-board-mathedit [data-math=ok]").dispatch_event("pointerdown")
playwright.expect(page.locator(".mx-board-mathedit")).to_have_count(0)
def test_typed_math_is_an_element_like_any_other(browser, server):
page, errors = open_skill(browser, server, 1440, 900)
board_on(page)
page.locator(".mx-board [data-tool=math]").click()
box = page.locator(".mx-board canvas").bounding_box()
page.mouse.click(box["x"] + 100, box["y"] + 200)
playwright.expect(page.locator(".mx-board-mathedit math-field")).to_be_visible(timeout=10000)
assert page.evaluate("window.mathVirtualKeyboard.visible") # the math keyboard opens with it
page.keyboard.press("Escape") # cancels the formula, not the board
playwright.expect(page.locator(".mx-board-mathedit")).to_have_count(0)
playwright.expect(page.locator(".mx-board")).to_be_visible()
assert ink_pixels(page) == 0
type_math(page, (100, 200), r"\frac{x^2-9}{x+3}=x-3")
assert ink_pixels(page) > 100
assert alpha_near(page, 112, 200) > 0 or alpha_near(page, 130, 200) > 0
type_math(page, (100, 200), r"x^2") # tapping it with the math tool edits it, in place
assert ink_pixels(page) > 0
page.locator(".mx-board [data-do=undo]").click() # back to the fraction
page.locator(".mx-board [data-tool=move]").click()
drag(page, (120, 200), (120, 450))
assert alpha_near(page, 112, 200) + alpha_near(page, 130, 200) == 0
assert alpha_near(page, 112, 450) + alpha_near(page, 130, 450) > 0 # it went with the drag, whole
page.locator(".mx-board [data-tool=eraser]").click()
drag(page, (100, 440), (260, 460))
assert ink_pixels(page) == 0 # the eraser takes the whole formula
assert errors == []
def test_the_math_renderer_draws_what_the_keyboard_makes(browser, server):
page, errors = open_skill(browser, server, 1440, 900)
sizes = page.evaluate("""()=>[
'x^2-5x+6=0', 'x_{1,2}=\\\\frac{-b\\\\pm\\\\sqrt{b^2-4ac}}{2a}', '\\\\sqrt[3]{8}', '\\\\left(\\\\frac{a}{b}\\\\right)^{3}',
'\\\\left|x-2\\\\right|\\\\le 5', '\\\\angle ABC=30^{\\\\circ}', '\\\\triangle ABC\\\\cong\\\\triangle DEF', 'a\\\\perp b\\\\parallel c',
'\\\\text{שטח}=\\\\pi r^2', '\\\\placeholder{}+1', '\\\\unknowncommand{x}', ''
].map(l=>{ const L=window.MathlyBoardMath.layout(l,26); return [L.w,L.a,L.d]; })""")
assert all(w >= 0 and a >= 0 and d >= 0 for w, a, d in sizes)
assert all(w > 10 for w, _, _ in sizes[:-1])
frac, plain = sizes[1], sizes[0]
assert frac[0] > plain[0] and frac[1] > plain[1] # a fraction is wider and taller than a line of text
assert errors == []
def test_the_move_tool_scrolls_when_it_drags_empty_paper(browser, server):
page, errors = open_skill(browser, server, 390, 844)
page.locator("[data-board-toggle]").click()
touch(page, [("pointerdown", 1, 60, 300), *[("pointermove", 1, 60 + k * 20, 300) for k in range(1, 10)], ("pointerup", 1, 240, 300)])
page.locator(".mx-board [data-tool=move]").click()
# one finger on empty paper: the paper scrolls, the line goes up with it
touch(page, [("pointerdown", 2, 150, 600), *[("pointermove", 2, 150, 600 - k * 20) for k in range(1, 11)], ("pointerup", 2, 150, 400)])
assert alpha_near(page, 150, 300) == 0 and alpha_near(page, 150, 100) > 0
playwright.expect(page.locator(".mx-board-top")).to_be_visible()
# one finger on the line: the line moves, the paper stays
touch(page, [("pointerdown", 3, 150, 100), *[("pointermove", 3, 150, 100 + k * 20) for k in range(1, 11)], ("pointerup", 3, 150, 300)])
assert alpha_near(page, 150, 100) == 0 and alpha_near(page, 150, 300) > 0
playwright.expect(page.locator(".mx-board-del")).to_be_visible() # it is selected, not the paper scrolled
# never above the top
touch(page, [("pointerdown", 4, 300, 200), *[("pointermove", 4, 300, 200 + k * 60) for k in range(1, 11)], ("pointerup", 4, 300, 800)])
playwright.expect(page.locator(".mx-board-top")).to_be_hidden()
assert errors == []
def test_an_angle_gets_an_arc_and_its_words_at_a_triangles_corner(browser, server):
page, errors = open_skill(browser, server, 1440, 900)
board_on(page)
page.locator(".mx-board [data-tool=line]").click()
drag(page, (100, 300), (300, 300))
drag(page, (302, 302), (200, 150))
drag(page, (198, 148), (102, 298))
page.locator(".mx-board [data-tool=angle]").click()
box = page.locator(".mx-board canvas").bounding_box()
at = lambda x, y: (box["x"] + x, box["y"] + y)
# the bottom-left corner: its edges go right (0°) and up to the top (-56°), so the arc is at radius 30, around -28°
arc, words = (126, 286), (138, 280)
ink_by = lambda x, y: max(alpha_near(page, x + dx, y + dy) for dx in (-6, -3, 0, 3, 6) for dy in (-4, 0, 4))
assert alpha_near(page, *arc) == 0
page.mouse.move(*at(112, 294)) # pointing into the corner: its arc shows, dashed, before anything is added
assert alpha_near(page, *arc) > 0
page.mouse.move(*at(400, 500)) # away from every corner: nothing
assert alpha_near(page, *arc) == 0
page.mouse.click(*at(112, 294))
field = page.locator(".mx-board-anglein")
playwright.expect(field).to_be_focused()
field.fill("60")
field.press("Enter")
playwright.expect(field).to_have_count(0)
assert alpha_near(page, *arc) > 0 and ink_by(*words) > 0
# the words open again for a change; a number is shown without its degree sign, which comes back by itself
page.mouse.click(*at(*words))
playwright.expect(field).to_have_value("60")
field.press("Escape") # leaves it as it was, and the board open
playwright.expect(field).to_have_count(0)
playwright.expect(page.locator(".mx-board")).to_be_visible()
assert ink_by(*words) > 0
# the angle belongs to its corner: it moves with the triangle and goes with an undo
page.locator(".mx-board [data-tool=move]").click()
drag(page, (200, 300), (200, 400))
assert alpha_near(page, *arc) == 0 and alpha_near(page, arc[0], arc[1] + 100) > 0
page.keyboard.press("Control+z") # the move
page.keyboard.press("Control+z") # the angle
assert alpha_near(page, *arc) == 0 and alpha_near(page, 200, 300) > 0
# a corner where nothing meets is no angle; Escape on a new one takes it back
page.locator(".mx-board [data-tool=angle]").click()
page.mouse.click(*at(500, 500))
playwright.expect(field).to_have_count(0)
page.mouse.click(*at(112, 294))
field.press("Escape")
assert alpha_near(page, *arc) == 0
# α, β and γ are a tap away, typed where the cursor is; a number added to them is in degrees too
page.mouse.click(*at(112, 294))
page.locator(".mx-board-angleedit button", has_text="β").click()
playwright.expect(field).to_have_value("β")
playwright.expect(field).to_be_focused()
page.keyboard.type("+90")
page.keyboard.press("Enter")
playwright.expect(field).to_have_count(0)
assert alpha_near(page, *arc) > 0 and ink_by(*words) > 0
page.mouse.click(*at(*words))
playwright.expect(field).to_have_value("β+90") # its degree sign comes back by itself
field.fill("180-alpha") # the letters' names become the letters
field.press("Enter")
page.mouse.click(*at(*words))
playwright.expect(field).to_have_value("180-α")
# a double arc (equal angles are marked alike): a second arc 4 px outside the first, kept, and undone like the rest
outer = (100 + 34 * 0.883, 300 - 34 * 0.469)
assert alpha_near(page, *outer) == 0
page.locator(".mx-board-arcs [data-arcs='2']").click()
assert alpha_near(page, *outer) > 0 # shown at once
field.press("Enter")
assert alpha_near(page, *arc) > 0 and alpha_near(page, *outer) > 0
page.mouse.click(*at(112, 294))
playwright.expect(page.locator(".mx-board-arcs [data-arcs='2']")).to_have_attribute("aria-pressed", "true")
field.press("Escape")
page.keyboard.press("Control+z")
assert alpha_near(page, *arc) > 0 and alpha_near(page, *outer) == 0
# its colour, arcs and words together, picked in the same box
red = lambda: page.evaluate("""([x,y])=>{ const c=document.querySelector('.mx-board canvas'), d=devicePixelRatio||1;
const px=c.getContext('2d').getImageData(Math.round(x*d)-1,Math.round(y*d)-1,3,3).data; let n=0;
for(let i=0;i<px.length;i+=4) if(px[i+3]>128&&px[i]>150&&px[i+2]<130) n++; return n; }""", list(arc))
assert red() == 0
page.mouse.click(*at(112, 294))
page.locator(".mx-board-colors [data-color='#D3455B']").click()
assert red() > 0 # shown at once
field.press("Enter")
assert red() > 0
page.keyboard.press("Control+z")
assert red() == 0
assert errors == []
def test_angles_where_a_line_meets_a_side_or_crosses_another(browser, server):
page, errors = open_skill(browser, server, 1440, 900)
board_on(page)
page.locator(".mx-board [data-tool=line]").click()
drag(page, (90, 438), (420, 438))
drag(page, (92, 436), (200, 80))
drag(page, (202, 82), (418, 436))
drag(page, (200, 82), (262, 433)) # from the top down to the base, ending a little short: it snaps onto the base
drag(page, (120, 300), (380, 300)) # across the triangle
page.locator(".mx-board [data-tool=angle]").click()
box = page.locator(".mx-board canvas").bounding_box()
field = page.locator(".mx-board-anglein")
def add(x, y, text):
page.mouse.click(box["x"] + x, box["y"] + y)
playwright.expect(field).to_be_focused()
field.fill(text)
field.press("Enter")
foot = 262 + (438 - 433) * 62 / 353 # where the middle line meets the base
assert alpha_near(page, foot, 437) > 0 and alpha_near(page, foot, 441) == 0 # it reached the base, not past it
add(250, 430, "α") # either side of the line drawn from the base
add(275, 430, "β")
assert alpha_near(page, foot - 30, 438 - 3) > 0 and alpha_near(page, foot + 30, 438 - 3) > 0
add(191, 106, "x") # the two halves of the top corner
add(211, 104, "y")
add(205, 105, "2α+β") # pointing along the middle line: the whole top angle, a wider arc
assert page.locator(".mx-board-anglein").count() == 0
# a narrow half's words sit right by its arc (radius 30), not far down between the lines; the whole angle's arc
# goes around them
x_words = (200 + 45 * -0.07, 80 + 45 * 1.0)
assert max(alpha_near(page, x_words[0] + dx, x_words[1] + dy) for dx in (-4, 0, 4) for dy in (-4, 0, 4)) > 0
add(250, 306, "60") # where two lines cross
# it all moves as one: the line from the base and the crossing line come with the triangle, their angles too
page.locator(".mx-board [data-tool=move]").click()
drag(page, (150, 438), (150, 538))
assert alpha_near(page, foot - 30, 535) > 0 and alpha_near(page, foot - 30, 435) == 0
assert alpha_near(page, 300, 400) > 0 # the crossing line, 100 lower
assert errors == []
def test_a_right_angle_is_marked_with_a_square(browser, server):
page, errors = open_skill(browser, server, 1440, 900)
board_on(page)
page.locator(".mx-board [data-tool=line]").click()
drag(page, (100, 400), (340, 400))
drag(page, (102, 398), (100, 160))
drag(page, (100, 162), (338, 398))
page.locator(".mx-board [data-tool=angle]").click()
box = page.locator(".mx-board canvas").bounding_box()
page.mouse.click(box["x"] + 112, box["y"] + 388)
page.locator(".mx-board-arcs [data-arcs='right']").click()
page.locator(".mx-board-anglein").press("Enter")
# a square of side 14 in the corner: its far corner at (114, 386), and no arc at radius 30
assert alpha_near(page, 114, 386) > 0 and alpha_near(page, 107, 386) > 0 and alpha_near(page, 114, 393) > 0
assert alpha_near(page, 100 + 30 * 0.707, 400 - 30 * 0.707) == 0
page.mouse.click(box["x"] + 112, box["y"] + 388) # the square opens it again, as an arc does
playwright.expect(page.locator(".mx-board-arcs [data-arcs='right']")).to_have_attribute("aria-pressed", "true")
page.locator(".mx-board-arcs [data-arcs='1']").click()
page.locator(".mx-board-anglein").press("Enter")
assert alpha_near(page, 114, 386) == 0 and alpha_near(page, 100 + 30 * 0.707, 400 - 30 * 0.707) > 0
assert errors == []
def test_the_board_has_a_scientific_calculator(browser, server):
page, errors = open_skill(browser, server, 1400, 900)
page.locator("[data-board-toggle]").click()
page.locator('.mx-board [data-do="calc"]').click()
calc = page.locator(".mx-calc")
playwright.expect(calc).to_be_visible()
def tap(*labels):
for label in labels:
calc.locator("button", has=page.locator(f"b:text-is('{label}')")).first.click()
tap("sin", "3", "0", ")", "=")
playwright.expect(calc.locator("output")).to_have_text("0.5")
tap("S⇔D")
playwright.expect(calc.locator("output")).to_have_text("1/2")
tap("×", "4", "=") # carries on from the answer
playwright.expect(calc.locator("output")).to_have_text("2")
tap("AC", "SHIFT", "sin", "1", ")", "=") # sin⁻¹(1) in degrees
playwright.expect(calc.locator("output")).to_have_text("90")
tap("AC", "2", "÷", "0", "=")
playwright.expect(calc.locator("output")).to_have_text("שגיאה")
assert not errors
def test_the_calculator_is_dragged_by_its_bar(browser, server):
page, errors = open_skill(browser, server, 1400, 900)
page.locator("[data-board-toggle]").click()
page.locator('.mx-board [data-do="calc"]').click()
calc = page.locator(".mx-calc")
playwright.expect(calc).to_be_visible()
stage = page.locator(".mx-board-stage").bounding_box()
before = calc.bounding_box()
grip = page.locator(".mx-calc-grip").bounding_box()
gx, gy = grip["x"] + grip["width"] / 2, grip["y"] + grip["height"] / 2
page.mouse.move(gx, gy)
page.mouse.down()
for k in range(1, 11):
page.mouse.move(gx + k * 20, gy - k * 15)
page.mouse.up()
after = calc.bounding_box()
assert abs(after["x"] - before["x"] - 200) < 2 and abs(after["y"] - before["y"] + 150) < 2
# dragged far past the edge: it stays whole inside the board
grip = page.locator(".mx-calc-grip").bounding_box()
gx, gy = grip["x"] + grip["width"] / 2, grip["y"] + grip["height"] / 2
page.mouse.move(gx, gy)
page.mouse.down()
page.mouse.move(gx - 2000, gy - 2000, steps=5)
page.mouse.up()
box = calc.bounding_box()
assert abs(box["x"] - stage["x"]) < 2 and abs(box["y"] - stage["y"]) < 2
# closed and opened again: it comes back where it was left
page.locator('.mx-board [data-do="calc"]').click()
page.locator('.mx-board [data-do="calc"]').click()
again = calc.bounding_box()
assert abs(again["x"] - box["x"]) < 2 and abs(again["y"] - box["y"]) < 2
assert errors == []
def test_the_paper_scrolls_sideways_to_what_was_written_on_the_wide_board(browser, server):
page, errors = open_skill(browser, server, 1440, 900)
page.locator("[data-board-toggle]").click()
door = page.locator(".mx-board-door")
door.click()
page.wait_for_timeout(400)
drag(page, (1100, 300), (1300, 300)) # written on the part that slid open
assert alpha_near(page, 1200, 300) > 0
door.click() # slid back: that writing is past the board's right edge
page.wait_for_timeout(400)
width = page.locator(".mx-board canvas").bounding_box()["width"]
assert width < 1100
# two fingers moving left bring it into sight
touch(page, [("pointerdown", 1, 500, 400), ("pointerdown", 2, 500, 500),
*[e for k in range(1, 11) for e in (("pointermove", 1, 500 - k * 60, 400), ("pointermove", 2, 500 - k * 60, 500))],
("pointerup", 1, -100, 400), ("pointerup", 2, -100, 500)])
assert alpha_near(page, 600, 300) > 0
playwright.expect(page.locator(".mx-board-top")).to_be_visible()
# Shift and the wheel scroll sideways too, never past the paper's left edge
box = page.locator(".mx-board canvas").bounding_box()
page.mouse.move(box["x"] + 200, box["y"] + 200)
page.keyboard.down("Shift")
page.mouse.wheel(0, -2000)
page.keyboard.up("Shift")
page.wait_for_timeout(100)
assert alpha_near(page, 600, 300) == 0 # back at the left edge: the writing is out of sight again
# the move tool drags the paper sideways
page.locator('.mx-board [data-tool="move"]').click()
drag(page, (700, 600), (100, 600))
assert alpha_near(page, 600, 300) > 0
page.locator(".mx-board-top").click() # back to the paper's corner
assert alpha_near(page, 600, 300) == 0
playwright.expect(page.locator(".mx-board-top")).to_be_hidden()
assert errors == []
def test_a_held_pen_does_not_select_the_page(browser, server):
page, errors = open_skill(browser, server, 390, 844)
page.locator("[data-board-toggle]").click()
playwright.expect(page.locator(".mx-board.full")).to_be_visible()
style = page.evaluate("""()=>{ const s=getComputedStyle(document.querySelector('.mx-board'));
return [s.userSelect||s.webkitUserSelect, s.webkitTouchCallout||'none']; }""")
assert style[0] == "none"
# nothing on the board starts a selection, and the paper's touches are its own
blocked = page.evaluate("""()=>{ const b=document.querySelector('.mx-board');
const sel=new Event('selectstart',{bubbles:true,cancelable:true}); b.querySelector('[data-title]').dispatchEvent(sel);
const t=new Event('touchstart',{bubbles:true,cancelable:true}); b.querySelector('canvas').dispatchEvent(t);
return [sel.defaultPrevented, t.defaultPrevented]; }""")
assert blocked == [True, True]
# a field to type in still selects
page.evaluate("""()=>{ const i=document.createElement('input'); i.id='probe'; document.querySelector('.mx-board-stage').append(i); }""")
assert not page.evaluate("""()=>{ const e=new Event('selectstart',{bubbles:true,cancelable:true});
document.getElementById('probe').dispatchEvent(e); return e.defaultPrevented; }""")
assert errors == []