File size: 35,388 Bytes
e2dec0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0ba5932
e2dec0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
af5d4de
 
 
 
 
 
 
 
e2dec0b
af5d4de
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d822734
 
 
 
 
 
 
 
 
 
 
 
 
 
 
af5d4de
 
 
 
 
 
 
 
e2dec0b
d822734
 
8c4e47d
d822734
 
8c4e47d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d822734
e2dec0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
af5d4de
e2dec0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0ba5932
 
e2dec0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79c65cf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b4b6397
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7badcff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8fb90d0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8c4e47d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c218e3a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
006e4fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
"""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 == []