File size: 57,522 Bytes
00f42f8
 
 
5a3991e
00f42f8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e79873b
00f42f8
 
 
 
 
 
 
 
 
e79873b
 
00f42f8
 
 
0ba5932
 
00f42f8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6322def
 
00f42f8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6322def
 
 
 
 
 
 
 
 
 
 
 
 
00f42f8
 
 
 
6322def
00f42f8
 
 
 
 
 
 
 
 
 
 
 
 
6322def
 
 
 
 
 
 
0ba5932
6322def
 
 
 
 
 
 
5a3991e
 
 
 
 
 
 
 
 
 
 
 
e79873b
 
5a3991e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81a02cb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ef4cd18
 
 
 
 
 
 
 
 
e79873b
 
 
 
 
 
0ba5932
 
e79873b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0ba5932
 
e79873b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0ba5932
 
e79873b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0ba5932
 
e79873b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7badcff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0ba5932
 
 
 
 
7badcff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0ba5932
7badcff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8fb90d0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0ba5932
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
"""Browser checks of the night sky (static/math-sky.js): stars, gold, fading, the streak and comets.
Optional, like the other browser tests: pip install playwright && playwright install chromium."""
import json
import re
import socket
import threading
import time
from datetime import date, datetime, timedelta, timezone

import pytest
import uvicorn

from app.main import create_app

playwright = pytest.importorskip("playwright.sync_api")
KEY = "mathly.progress.v1:guest"


@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 page_with(browser, server, progress=None, no_chance=False, now=None):
    ctx = browser.new_context(viewport={"width": 390, "height": 844})
    if no_chance:   # no comet by chance: only the sure ones come
        ctx.add_init_script("Math.random=()=>0.99")
    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{}")
    if progress is not None:
        ctx.add_init_script(f"localStorage.setItem({json.dumps(KEY)},{json.dumps(json.dumps(progress))})")
    page = ctx.new_page()
    if now:   # the date the page sees; timers run as usual
        page.clock.set_fixed_time(now)
    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()
    return page, errors


def progress_of(page):
    return page.evaluate(f"JSON.parse(localStorage.getItem({json.dumps(KEY)})||'{{}}')")


def answer_skill_right(page):
    """Answers the open skill's questions right at the first try, until a celebration shows."""
    for _ in range(20):
        if page.locator(".sky-cele").count():
            return
        page.locator(".mx-choices button[data-right]").first.click()
        page.wait_for_timeout(150)
        if page.locator(".sky-cele").count():
            return
        page.locator(".mx-ok button[data-id]").first.click()
    raise AssertionError("no celebration")


def test_clean_run_turns_the_next_star_gold(browser, server):
    page, errors = page_with(browser, server)
    nxt = page.locator(".mx-star.st-next").get_attribute("data-id")
    page.locator(".mx-star.st-next").click()
    answer_skill_right(page)
    playwright.expect(page.locator(".sky-cele h2").first).to_have_text("כוכב זהב!")
    while page.locator(".sky-cele [data-sky=go]").count():
        page.locator(".sky-cele [data-sky=go]").first.click()
    p = progress_of(page)
    assert p["done"][nxt] and p["gold"][nxt]
    assert p["days"][date.today().isoformat()] >= 5
    page.locator(".mx-x").click()
    page.locator('.mx-nav [data-view="path"]').click()
    playwright.expect(page.locator(f'.mx-star[data-id="{nxt}"]')).to_have_class("mx-node mx-star st-gold")
    playwright.expect(page.locator(".sky-chip.flame b")).to_have_text("1")   # today's goal is met
    assert not errors


def test_a_faded_star_comes_first_and_one_answer_brightens_it(browser, server):
    old = (datetime.now(timezone.utc) - timedelta(days=20)).isoformat()
    page, errors = page_with(browser, server, {"done": {"t_ext": old}, "seen": {"t_ext": old}, "solved": {"t_ext": list(range(10))}})
    playwright.expect(page.locator('.mx-star[data-id="t_ext"]')).to_have_class("mx-node mx-star st-fading")
    page.locator('.mx-star[data-id="t_ext"]').click()
    page.locator(".mx-choices button[data-right]").first.click()
    playwright.expect(page.locator(".sky-toast", has_text="זורח שוב")).to_be_visible()
    page.locator(".mx-x").click()
    playwright.expect(page.locator('.mx-star[data-id="t_ext"]')).to_have_class("mx-node mx-star st-lit")
    assert not errors


def test_streak_forgives_one_missed_day_not_two(browser, server):
    day = lambda n: (date.today() - timedelta(days=n)).isoformat()
    page, _ = page_with(browser, server, {"days": {day(1): 5, day(3): 6, day(4): 5, day(7): 5}})
    playwright.expect(page.locator(".sky-chip.flame b")).to_have_text("3")   # 1, 3, 4; days 5 and 6 both missed
    page.locator(".mx-hud .sky-chip.flame").click()                     # the streak calendar
    sheet = page.locator(".mx-streak-sheet")
    playwright.expect(sheet.locator(".mx-shero b")).to_have_text("3")
    playwright.expect(sheet.locator(".mx-sstats b").first).to_have_text("3")   # the longest streak
    met = saved = 0
    for _ in range(2):   # the last week can start in the month before
        met += sheet.locator(".mx-cday.s-met").count()
        saved += sheet.locator(".mx-cday.s-saved").count()
        prev = sheet.locator("[data-act=cal-prev]")
        if prev.is_disabled():
            break
        prev.click()
    assert (met, saved) == (4, 1)   # day 2 is forgiven; days 5 and 6 are not


def test_a_caught_comet_joins_the_collection(browser, server):
    page, errors = page_with(browser, server, no_chance=True)
    page.locator(".mx-star.st-next").click()
    answer_skill_right(page)                                 # the first gold star of all brings a comet
    while page.locator(".sky-cele [data-sky=go]").count():
        page.locator(".sky-cele [data-sky=go]").first.click()
    comet = page.locator(".sky-comet")
    playwright.expect(comet).to_have_count(1, timeout=3000)
    comet.dispatch_event("click")                            # it never stands still for a normal click
    playwright.expect(page.locator(".sky-cele h2")).to_have_text("תפסתם שביט נדיר!")
    page.locator(".sky-cele [data-sky=go]").click()
    assert len(progress_of(page)["comets"]) == 1
    page.locator(".mx-x").click()
    page.locator('.mx-nav [data-view="sky"]').click()
    playwright.expect(page.locator(".mx-comet.have")).to_have_count(1)
    assert not errors


def test_home_shows_the_tally_on_top_and_opens_at_the_next_skill(browser, server):
    done = {i: "2026-01-01T00:00:00+00:00" for i in ["t_ext"]}
    page, errors = page_with(browser, server, {"done": done, "solved": {"t_ext": list(range(10))}})
    playwright.expect(page.locator(".mx-hud .sky-chip.stars b")).to_have_text("1")
    playwright.expect(page.locator(".mx-hud .sky-chip.flame")).to_be_visible()
    playwright.expect(page.locator(".mx-nav button")).to_have_count(3)   # home, path, collection
    nxt = page.locator(".mx-star.st-next")
    playwright.expect(nxt).to_be_in_viewport()
    page.evaluate("document.getElementById('mathView').scrollTo(0, 99999)")   # the tally stays on top while scrolling
    playwright.expect(page.locator(".mx-hud")).to_be_in_viewport()
    page.locator(".mx-daily").click()
    playwright.expect(page.locator(".mx-streak-sheet .mx-cday.today")).to_have_count(1)
    assert not errors


def test_a_mistake_is_quiet_hides_the_answer_and_needs_a_clean_run_to_finish(browser, server):
    page, errors = page_with(browser, server, no_chance=True)
    page.locator('.mx-star[data-id="t_ext"]').click()
    for _ in range(9):   # questions 1-9 right
        page.locator(".mx-choices button[data-right]").first.click()
        page.locator(".mx-ok button[data-id]").first.click()
    page.locator(".mx-choices button:not([data-right])").first.click()   # question 10 wrong
    playwright.expect(page.locator(".mx-bad")).to_be_visible()
    assert page.locator(".mx-choices button.right").count() == 0         # the right answer is not revealed
    assert page.locator(".mx-choices button:not([disabled])").count() == 0
    slips = progress_of(page)["slips"]
    assert [(s["id"], s["via"]) for s in slips] == [("t_ext", "practice")] and slips[0]["m"]   # the mistake is remembered
    page.locator(".mx-bad button[data-id=t_ext]").first.click()
    page.locator(".mx-choices button[data-right]").first.click()         # it comes back and is solved
    playwright.expect(page.locator(".mx-ok b")).to_have_text(re.compile("תיקנתם|הטעות מקודם"))   # fixing is praised
    playwright.expect(page.locator(".mx-ok")).to_contain_text("עוד 2 תשובות נכונות ברצף")
    assert not progress_of(page)["done"].get("t_ext")                   # all solved, but not yet a clean run
    for _ in range(2):
        page.locator(".mx-ok button[data-id]").first.click()
        page.locator(".mx-choices button[data-right]").first.click()
    playwright.expect(page.locator(".sky-cele h2").first).to_have_text("כוכב חדש!")
    p = progress_of(page)
    assert p["done"]["t_ext"] and not p["gold"].get("t_ext")
    assert p["review"]["t_ext"]["box"] == 0                              # the first review is tomorrow
    assert not errors


def test_a_due_skill_comes_back_in_a_mixed_review(browser, server):
    old = (datetime.now(timezone.utc) - timedelta(days=5)).isoformat()
    yesterday = (date.today() - timedelta(days=1)).isoformat()
    later = (date.today() + timedelta(days=3)).isoformat()
    progress = {"done": {"t_ext": old, "t_simplify": old, "k_def": old}, "seen": {"t_ext": old, "t_simplify": old, "k_def": old},
                "review": {"t_ext": {"box": 1, "due": yesterday}, "t_simplify": {"box": 0, "due": later}}}
    page, errors = page_with(browser, server, progress, no_chance=True)
    card = page.locator(".mx-review")
    playwright.expect(card).to_contain_text("2 מיומנויות")              # t_ext is due, k_def is old; t_simplify is not yet
    card.click()
    sheet = page.locator(".mx-review-sheet")
    playwright.expect(sheet.locator("h3")).to_have_text("שאלה 1 מתוך 2")
    sheet.locator(".mx-choices button[data-right]").first.click()
    playwright.expect(sheet.locator(".mx-ok")).to_contain_text("הנושא:")
    sheet.locator("[data-act=review-next]").click()
    sheet.locator(".mx-choices button:not([data-right])").first.click()
    playwright.expect(sheet.locator(".mx-bad")).to_contain_text("יחזור מחר")
    sheet.locator("[data-act=review-next]").click()
    playwright.expect(sheet.locator(".mx-ok b")).to_have_text("החזרה הסתיימה")
    p = progress_of(page)
    boxes = sorted(p["review"][i]["box"] for i in ("t_ext", "k_def"))
    assert boxes in ([0, 0], [0, 2])                                     # the right one moved on, the wrong one starts over
    tomorrow = (date.today() + timedelta(days=1)).isoformat()
    assert tomorrow in {p["review"][i]["due"] for i in ("t_ext", "k_def")}
    page.locator(".mx-x").click()
    playwright.expect(page.locator(".mx-review")).to_have_count(0)      # nothing is due any more today
    assert not errors


def test_a_round_asks_ten_of_thirty_and_a_finished_skill_gets_a_new_round(browser, server):
    page, errors = page_with(browser, server, no_chance=True)
    page.locator('.mx-star[data-id="t_ext"]').click()
    playwright.expect(page.locator(".mx-qprog span").first).to_have_text("שאלה 1 מתוך 10")
    page.locator(".mx-choices button[data-right]").first.click()
    rnd = progress_of(page)["set"]["t_ext"]
    assert len(rnd) == len(set(rnd)) == 10 and all(0 <= i < 30 for i in rnd)
    page.locator(".mx-ok button[data-id]").first.click()
    for _ in range(9):
        page.locator(".mx-choices button[data-right]").first.click()
        if page.locator(".sky-cele").count():
            break
        page.locator(".mx-ok button[data-id]").first.click()
    while page.locator(".sky-cele [data-sky=go]").count():
        page.locator(".sky-cele [data-sky=go]").first.click()
    p = progress_of(page)
    assert p["done"]["t_ext"] and sorted(p["solved"]["t_ext"]) == sorted(rnd)
    page.locator(".mx-x").click()
    page.locator('.mx-star[data-id="t_ext"]').click()   # practising it again: the questions not met yet come first
    page.locator(".mx-choices button[data-right]").first.click()
    again = progress_of(page)["set"]["t_ext"]
    assert not set(again) & set(rnd)
    assert not errors


def test_progress_from_before_rounds_stays_on_the_first_ten(browser, server):
    page, errors = page_with(browser, server, {"solved": {"t_ext": [0, 1, 2]}, "at": {"t_ext": 3}})
    page.locator('.mx-star[data-id="t_ext"]').click()
    playwright.expect(page.locator(".mx-qprog span").first).to_have_text("שאלה 4 מתוך 10")
    playwright.expect(page.locator(".mx-qdots i.ok")).to_have_count(3)
    assert not errors


def test_answers_saved_by_a_page_from_before_rounds_are_kept(browser, server):
    """A page that showed all 30 questions may have saved answers past the first ten: they join the round."""
    page, errors = page_with(browser, server, {"solved": {"t_ext": [0, 1, 14, 22]}, "at": {"t_ext": 23}})
    page.locator('.mx-star[data-id="t_ext"]').click()
    playwright.expect(page.locator(".mx-qprog span").first).to_have_text("שאלה 5 מתוך 10")
    playwright.expect(page.locator(".mx-qdots i.ok")).to_have_count(4)
    assert not errors



def test_a_practice_exam_mixes_the_taught_units_and_reports_at_the_end(browser, server):
    page, errors = page_with(browser, server, {"done": {"t_ext": "2026-10-01T00:00:00+00:00"}}, no_chance=True,
                             now=datetime(2026, 11, 3, 10))
    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
    sheet = page.locator(".mx-quiz-sheet")
    on = sheet.locator('[data-quiz-unit][aria-pressed="true"]')
    playwright.expect(on).to_have_count(4)   # before November: algebra 1-2, the kite, the isosceles triangle
    assert sorted(on.evaluate_all("els=>els.map(e=>e.dataset.quizUnit)")) == ["i", "k", "t", "t2"]
    sheet.locator('[data-quiz-unit="k"]').click()
    playwright.expect(on).to_have_count(3)
    sheet.locator('[data-quiz-unit="k"]').click()
    sheet.locator("[data-act=quiz-start]").click()
    playwright.expect(sheet.locator(".mx-quiz-dots button")).to_have_count(12)
    playwright.expect(sheet.locator("h3")).to_have_text("שאלה 1 מתוך 12")
    sheet.locator(".mx-choices button:not([data-right])").first.click()   # the first one wrong
    assert sheet.locator(".mx-ok, .mx-bad").count() == 0                # no feedback during the exam
    for _ in range(10):
        sheet.locator("[data-act=quiz-next]").click()
        sheet.locator(".mx-choices button[data-right]").first.click()
    sheet.locator("[data-act=quiz-next]").click()                       # the last one is left empty
    sheet.locator("[data-act=quiz-submit]").click()
    playwright.expect(sheet.locator(".mx-quiz-warn")).to_contain_text("שאלה אחת לא נענתה")
    sheet.locator('[data-quiz-go="0"]').click()                         # back to the first: the choice is kept
    playwright.expect(sheet.locator('.mx-choices button[aria-pressed="true"]')).to_have_count(1)
    sheet.locator('[data-quiz-go="11"]').click()
    sheet.locator("[data-act=quiz-submit]").click()                    # warned once: now it is handed in
    playwright.expect(sheet.locator("h3")).to_have_text("10 מתוך 12 נכונות")
    playwright.expect(sheet.locator(".mx-quiz-plan li")).to_have_count(1)  # the one mistake, with its skill to practise
    playwright.expect(sheet.locator(".mx-quiz-plan button[data-id]").first).to_be_visible()
    playwright.expect(sheet.locator(".mx-quiz-units-score > div")).to_have_count(4)
    p = progress_of(page)
    q = p["quizzes"][-1]
    assert (q["n"], q["right"], sorted(q["units"])) == (12, 10, ["i", "k", "t", "t2"]) and len(q["weak"]) == 2
    assert p["days"]["2026-11-03"] == 10                                  # right answers count toward today's goal
    page.locator(".mx-quiz-sheet .mx-x").click()
    assert not errors


def test_leaving_a_practice_exam_asks_first(browser, server):
    page, errors = page_with(browser, server, now=datetime(2026, 11, 3, 10))
    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()
    page.locator(".mx-choices button").first.click()
    page.keyboard.press("Escape")
    playwright.expect(page.locator(".mx-quiz-warn")).to_contain_text("לצאת?")
    playwright.expect(page.locator(".mx-quiz-sheet")).to_be_visible()
    page.locator("[data-act=quiz-quit]").click()
    playwright.expect(page.locator(".mx-quiz-sheet")).to_be_hidden()
    assert "quizzes" not in progress_of(page)
    assert not errors


# ---------- misconception patterns ----------

def slips_at(now, *rows):
    """Mistake-log entries: (days ago, skill, tag[, via])."""
    return [{"at": (now - timedelta(days=d)).isoformat(), "id": sid, "q": 0, "m": m, "via": (via or ["practice"])[0]}
            for d, sid, m, *via in rows]


def patterns_of(page, slips, mistakes=None):
    return page.evaluate("([slips,mistakes])=>window.MathlySky.patterns({slips},mistakes||{})", [slips, mistakes])


def test_old_progress_without_a_mistake_log_loads(browser, server):
    page, errors = page_with(browser, server, {"done": {}, "comets": []})
    page.locator('.mx-star[data-id="t_ext"]').click()
    page.locator(".mx-choices button:not([data-right])").first.click()
    playwright.expect(page.locator(".mx-bad")).to_be_visible()
    assert len(progress_of(page)["slips"]) == 1
    assert not errors


def test_the_mistake_log_keeps_the_newest_500(browser, server):
    page, errors = page_with(browser, server)
    log = page.evaluate("""()=>{ const P={}; for(let i=0;i<501;i++) window.MathlySky.slip(P,"t_ext",i,"sign_mid","practice");
      return P.slips.map(s=>s.q); }""")
    assert len(log) == 500 and log[0] == 1 and log[-1] == 500
    assert not errors


def test_recurring_misconceptions_need_two_recent_and_rank_by_spread(browser, server):
    page, errors = page_with(browser, server)
    now = datetime.now(timezone.utc)
    p = patterns_of(page, slips_at(now, (20, "t_ext", "sign_mid"), (3, "t_ext", "sign_mid"), (2, "t_ext", "ext_partial"),
                                   (40, "t_ext", "move_sign"), (35, "t_ext", "move_sign")))
    assert [x["m"] for x in p["recurring"]] == ["sign_mid"]              # one ext_partial is not a pattern; move_sign is old
    p = patterns_of(page, slips_at(now, *[(1, "t_ext_eq", "move_sign")] * 4,
                                   (1, "t_ext", "sign_mid"), (2, "t_simplify", "sign_mid"), (3, "t_ext_eq", "sign_mid")))
    assert [x["m"] for x in p["recurring"]] == ["sign_mid", "move_sign"]  # three skills before four times in one
    assert p["recurring"][0]["n"] == 3 and len(p["recurring"][0]["skills"]) == 3
    assert not errors


def test_the_skill_to_practise_is_the_back_skill_or_the_most_frequent(browser, server):
    page, errors = page_with(browser, server)
    now = datetime.now(timezone.utc)
    mistakes = {"ext_partial": {"back": "t_ext"}, "sign_mid": {"back": None}}
    p = patterns_of(page, slips_at(now, (1, "t_simplify", "ext_partial"), (2, "t_simplify", "ext_partial"),
                                   (1, "t_simplify", "sign_mid"), (2, "t_simplify", "sign_mid"), (3, "t_ext", "sign_mid")), mistakes)
    practise = {x["m"]: x["practise"] for x in p["recurring"]}
    assert practise == {"ext_partial": "t_ext", "sign_mid": "t_simplify"}
    assert not errors


def test_a_pattern_that_stopped_is_shown_as_stopped_until_60_days(browser, server):
    page, errors = page_with(browser, server)
    now = datetime.now(timezone.utc)
    p = patterns_of(page, slips_at(now, *[(20, "t_ext", "sign_mid")] * 3, (70, "t_ext", "move_sign"), (75, "t_ext", "move_sign"),
                                   (20, "t_ext", "ext_partial")))
    assert p["recurring"] == [] and [x["m"] for x in p["stopped"]] == ["sign_mid"]   # once is not a pattern; 70 days is gone
    assert not errors


def test_a_wrong_review_answer_is_logged(browser, server):
    old = (datetime.now(timezone.utc) - timedelta(days=5)).isoformat()
    page, errors = page_with(browser, server, {"done": {"t_ext": old}, "seen": {"t_ext": old}}, no_chance=True)
    page.locator(".mx-review").click()
    page.locator(".mx-review-sheet .mx-choices button:not([data-right])").first.click()
    playwright.expect(page.locator(".mx-review-sheet .mx-bad")).to_be_visible()
    slips = progress_of(page)["slips"]
    assert [(s["id"], s["via"]) for s in slips] == [("t_ext", "review")] and slips[0]["m"]
    assert not errors


def test_a_practice_exam_logs_only_its_final_picks_when_handed_in(browser, server):
    page, errors = page_with(browser, server, now=datetime(2026, 11, 3, 10))
    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
    sheet = page.locator(".mx-quiz-sheet")
    sheet.locator("[data-act=quiz-start]").click()
    sheet.locator(".mx-choices button:not([data-right])").first.click()   # wrong, then changed to right
    sheet.locator(".mx-choices button[data-right]").first.click()
    for i in range(1, 12):
        sheet.locator(f'[data-quiz-go="{i}"]').click()
        sheet.locator(f".mx-choices button{':not([data-right])' if i in (1, 2) else '[data-right]'}").first.click()
    assert "slips" not in progress_of(page) or not progress_of(page)["slips"]   # nothing while the exam goes on
    sheet.locator("[data-act=quiz-submit]").click()
    playwright.expect(sheet.locator("h3")).to_have_text("10 מתוך 12 נכונות")
    assert [s["via"] for s in progress_of(page)["slips"]] == ["exam", "exam"]
    assert not errors


def test_a_left_practice_exam_logs_nothing(browser, server):
    page, errors = page_with(browser, server, now=datetime(2026, 11, 3, 10))
    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()
    page.locator(".mx-choices button:not([data-right])").first.click()
    page.locator("[data-act=quiz-quit]").click()
    page.locator("[data-act=quiz-quit]").click()
    playwright.expect(page.locator(".mx-quiz-sheet")).to_be_hidden()
    assert not progress_of(page).get("slips")
    assert not errors


def test_no_patterns_card_without_a_pattern(browser, server):
    now = datetime.now(timezone.utc)
    page, errors = page_with(browser, server, {"slips": slips_at(now, (1, "t_ext", "sign_mid"))})
    playwright.expect(page.locator(".mx-patterns")).to_have_count(0)    # once is not a pattern
    assert not errors


def test_the_patterns_card_names_the_top_mistake_and_opens_what_to_practise(browser, server):
    now = datetime.now(timezone.utc)
    slips = slips_at(now, (1, "t_simplify", "sign_mid"), (2, "t_ext_eq", "sign_mid"), (3, "t_simplify", "sign_mid"),
                     (1, "t_simplify", "ext_partial"), (4, "t_simplify", "ext_partial"))
    page, errors = page_with(browser, server, {"slips": slips}, no_chance=True)
    card = page.locator(".mx-patterns")
    playwright.expect(card).to_contain_text("טעות סימן באיבר האמצעי")       # in two skills, before one skill
    playwright.expect(card).to_contain_text("ב־2 מיומנויות")
    card.click()
    sheet = page.locator(".mx-patterns-sheet")
    playwright.expect(sheet.locator(".mx-pattern-list li")).to_have_count(2)
    playwright.expect(sheet.locator(".mx-pattern-list li").first).to_contain_text("3 פעמים ב־2 מיומנויות · לאחרונה אתמול")
    assert sheet.locator(".mx-pattern-list button[data-id]").evaluate_all("els=>els.map(e=>e.dataset.id)") == [
        "t_simplify", "t_ext"]                                           # most frequent skill; then ext_partial's back-skill
    sheet.locator('.mx-pattern-list button[data-id="t_ext"]').click()
    playwright.expect(page.locator(".mx-choices")).to_be_visible()         # the practice of t_ext is open
    playwright.expect(page.locator("#mxTitle")).to_contain_text("פילוג")
    assert not errors


def test_a_stopped_pattern_is_shown_as_a_win(browser, server):
    now = datetime.now(timezone.utc)
    page, errors = page_with(browser, server, {"slips": slips_at(now, *[(20, "t_ext", "sign_mid")] * 2)}, no_chance=True)
    card = page.locator(".mx-patterns")
    playwright.expect(card).to_contain_text("כבר לא חוזר")
    card.click()
    sheet = page.locator(".mx-patterns-sheet")
    playwright.expect(sheet).to_contain_text("כרגע אין טעות שחוזרת")
    playwright.expect(sheet.locator(".mx-pattern-list.gone li")).to_contain_text("טעות סימן באיבר האמצעי")
    assert not errors


def test_the_patterns_sheet_fits_a_phone(browser, server):
    now = datetime.now(timezone.utc)
    slips = slips_at(now, *[(1, "t_simplify", "sign_mid")] * 2, *[(1, "t_ext", "ext_partial")] * 2,
                     *[(2, "t_ext_eq", "move_sign")] * 2, *[(20, "t_ext", "sign_product")] * 2)
    page, errors = page_with(browser, server, {"slips": slips}, no_chance=True)
    page.set_viewport_size({"width": 375, "height": 812})
    for target in (".mx-patterns", None):
        assert page.evaluate("document.documentElement.scrollWidth<=innerWidth")
        if target:
            page.locator(target).click()
            playwright.expect(page.locator(".mx-patterns-sheet .mx-pattern-list li")).to_have_count(4)
    assert not errors


def test_the_path_marks_the_skill_to_strengthen(browser, server):
    now = datetime.now(timezone.utc)
    progress = {"slips": slips_at(now, (1, "t_ext", "ext_partial"), (4, "t_simplify", "ext_partial")),
                "done": {"t_ext": now.isoformat()}, "seen": {"t_ext": now.isoformat()}}
    page, errors = page_with(browser, server, progress, no_chance=True)
    assert page.locator(".mx-star.fix").evaluate_all("els=>els.map(e=>e.dataset.id)") == ["t_ext"]   # ext_partial's back-skill
    playwright.expect(page.locator('.mx-star[data-id="t_ext"] + .mx-lbl em')).to_have_text("לחזק")
    assert not errors


def test_a_mistake_that_is_a_pattern_says_so_where_it_happens(browser, server):
    now = datetime.now(timezone.utc)
    page, errors = page_with(browser, server, {"slips": slips_at(now, (3, "t_ext_eq", "ext_partial"))}, no_chance=True)
    page.locator('.mx-star[data-id="t_simplify"]').click()
    page.locator(".mx-choices button[data-m=ext_partial]").first.click()   # the second time: now a pattern
    note = page.locator(".mx-bad .mx-pattern-note")
    playwright.expect(note).to_contain_text("2 פעמים ב־2 מיומנויות")
    playwright.expect(page.locator('.mx-bad button[data-id="t_ext"]')).to_have_count(1)   # offered once, not twice
    assert not errors


def test_a_first_mistake_has_no_pattern_note(browser, server):
    page, errors = page_with(browser, server, no_chance=True)
    page.locator('.mx-star[data-id="t_simplify"]').click()
    page.locator(".mx-choices button:not([data-right])").first.click()
    playwright.expect(page.locator(".mx-bad")).to_be_visible()
    playwright.expect(page.locator(".mx-pattern-note")).to_have_count(0)
    assert not errors


# ---- the class teacher's signals (MathlySky.attention): who needs attention, on Wednesday 4 November 2026
CLASS = {"skills": {"t_ext": "פילוג מורחב", "g_pyth": "פיתגורס"}, "units": [{"id": "t", "name": "טכניקה אלגברית 1"}]}


def attention(page, student, ctx=CLASS):
    return page.evaluate("([s, c]) => MathlySky.attention(s, c, new Date(2026, 10, 4, 12))", [student, ctx])


def kinds(found):
    return [x["kind"] for x in found]


def student(**progress):
    return {"signed_in": True, "progress": {"done": {}, "days": {}, "slips": [], **progress}, "quiz_sittings": []}


def slips(skill, *days_ago):
    return [{"at": (datetime(2026, 11, 4, 9) - timedelta(days=d)).isoformat() + "Z", "id": skill, "q": 0, "m": "x"}
            for d in days_ago]


def test_a_student_who_stopped_practising_needs_attention(browser, server):
    page, errors = page_with(browser, server)
    found = attention(page, student(days={"2026-10-27": 6}))
    assert [(x["kind"], x["text"]) for x in found] == [("inactive", "לא תרגל 8 ימים")]
    assert attention(page, student(days={"2026-10-28": 6})) != [] and attention(page, student(days={"2026-10-29": 6})) == []   # 7 days: yes; 6: no
    assert attention(page, student(days={"2026-11-02": 5})) == []
    assert attention(page, student(seen={"t_ext": "2026-11-03T10:00:00Z"}, days={"2026-10-01": 9})) == []   # the last practice counts
    assert not errors


def test_a_student_stuck_on_a_skill_is_named_with_it(browser, server):
    page, errors = page_with(browser, server)
    found = attention(page, student(days={"2026-11-03": 5}, slips=slips("t_ext", 1, 2, 3)))
    assert [(x["kind"], x["skills"]) for x in found] == [("stuck", ["t_ext"])] and "פילוג מורחב" in found[0]["text"]
    assert attention(page, student(days={"2026-11-03": 5}, slips=slips("t_ext", 1, 2))) == []        # two slips are not stuck
    assert attention(page, student(days={"2026-11-03": 5}, slips=slips("t_ext", 1, 20, 30))) == []   # old ones do not count
    assert attention(page, student(days={"2026-11-03": 5}, done={"t_ext": "2026-11-04"}, slips=slips("t_ext", 1, 2, 3))) == []
    assert attention(page, student(days={"2026-11-03": 5}, slips=slips("other", 1, 2, 3))) == []     # not a skill of the class
    assert not errors


def test_a_drop_in_practice_needs_enough_practice_to_drop_from(browser, server):
    page, errors = page_with(browser, server)
    week = lambda this, last: {"2026-11-02": this, "2026-10-27": last // 2, "2026-10-29": last - last // 2}
    assert page.evaluate("p => MathlySky.weekly(p, new Date(2026, 10, 4, 12))", {"days": week(14, 22)}) == {"thisWeek": 14, "lastWeek": 22}
    found = attention(page, student(days=week(6, 20)))
    assert kinds(found) == ["drop"] and "6" in found[0]["text"] and "20" in found[0]["text"]
    assert attention(page, student(days=week(2, 8))) == []      # 8 last week is too little to call a drop
    assert attention(page, student(days=week(10, 20))) == []    # exactly half is not less than half
    assert not errors


def test_a_low_server_grade_needs_attention_but_the_devices_does_not(browser, server):
    page, errors = page_with(browser, server)
    sit = lambda grade, at: {"id": at, "unit_id": "t", "grade": grade, "open": 0 if grade is not None else 1, "submitted_at": at}
    base = student(days={"2026-11-03": 5})
    found = attention(page, {**base, "quiz_sittings": [sit(40, "2026-11-03T10:00:00Z")]})
    assert [(x["kind"], x["unit"]) for x in found] == [("grade", "t")] and "40" in found[0]["text"]
    assert attention(page, {**base, "quiz_sittings": [sit(40, "2026-10-01T10:00:00Z"), sit(70, "2026-11-03T10:00:00Z")]}) == []   # the latest counts
    assert attention(page, {**base, "quiz_sittings": [sit(40, "2026-10-01T10:00:00Z"), sit(None, "2026-11-03T10:00:00Z")]}) != []   # an open one is no grade
    assert attention(page, {**base, "quiz_sittings": None}) == []
    assert attention(page, {**base, "progress": {**base["progress"], "uq": {"t": {"history": [{"score": 10}]}}}}) == []
    assert attention(page, {**base, "quiz_sittings": [sit(55, "2026-11-03T10:00:00Z")]}) == []   # 55 is not below 55
    # the official grade (the latest final sitting) counts over a later practice sitting, and is named as final
    final = lambda grade, at: {**sit(grade, at), "mode": "final"}
    found = attention(page, {**base, "quiz_sittings": [final(40, "2026-10-01T10:00:00Z"), sit(90, "2026-11-03T10:00:00Z")]})
    assert [x["kind"] for x in found] == ["grade"] and "בבוחן המסכם" in found[0]["text"] and "40" in found[0]["text"]
    assert attention(page, {**base, "quiz_sittings": [final(80, "2026-10-01T10:00:00Z"), sit(30, "2026-11-03T10:00:00Z")]}) == []
    assert not errors


def test_a_student_who_never_started_is_listed_only_once_signed_in(browser, server):
    page, errors = page_with(browser, server)
    assert [x["text"] for x in attention(page, {"signed_in": True, "progress": None, "quiz_sittings": []})] == ["עוד לא התחיל לתרגל"]
    assert attention(page, {"signed_in": False, "progress": None, "quiz_sittings": []}) == []
    assert not errors


# ---- the worked example and the help panel. The page is given an example for every skill, as the data will have.
HELP_EXAMPLE = {"question": "$(x + 5)(x − 2) = ?$",
                "steps": [{"do": "כופלים כל איבר בכל איבר: $x² − 2x + 5x − 10$", "why": "כך לא שוכחים אף מכפלה."},
                          {"do": "מחברים איברים דומים: $−2x + 5x = 3x$", "why": "רק איברים עם אותה חזקה מתחברים."},
                          {"do": "התוצאה: $x² + 3x − 10$", "why": "סידרנו מהחזקה הגבוהה לנמוכה."}],
                "answer": "$x² + 3x − 10$"}


def page_with_examples(browser, server, progress=None):
    """Like page_with, with a worked example added to every skill in the skills the server sends."""
    ctx = browser.new_context(viewport={"width": 390, "height": 844})
    ctx.add_init_script("Math.random=()=>0.99")
    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{}")
    if progress is not None:
        ctx.add_init_script(f"localStorage.setItem({json.dumps(KEY)},{json.dumps(json.dumps(progress))})")

    def skills(route):
        graph = route.fetch().json()
        for skill in graph["skills"]:
            skill["example"] = HELP_EXAMPLE
        route.fulfill(json=graph)

    ctx.route("**/api/math/skills", skills)
    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()
    return page, errors


def wrong_answer(page):
    page.locator(".mx-choices button:not([data-right])").first.click()
    playwright.expect(page.locator(".mx-bad")).to_be_visible()


def test_the_worked_example_is_offered_from_the_second_mistake_in_a_round(browser, server):
    page, errors = page_with_examples(browser, server)
    skill = page.locator(".mx-star.st-next").get_attribute("data-id")
    page.locator(".mx-star.st-next").click()
    wrong_answer(page)
    assert page.locator("[data-example]").count() == 0                       # the first mistake: only the tip
    page.locator(".mx-bad button.solid").click()                             # on to the next question
    asked = page.locator(".mx-q p").first.inner_text()
    wrong_answer(page)
    offer = page.locator(".mx-bad [data-example]")
    playwright.expect(offer).to_have_text("איך פותרים? דוגמה פתורה")
    # open it: a panel over the question, one step at a time, and the answer only at the end
    offer.click()
    help_ = page.locator(".mx-help")
    playwright.expect(help_.locator(".mx-example")).to_be_visible()
    playwright.expect(help_.locator(".mx-example-q")).to_contain_text("(x + 5)(x − 2)")
    steps = help_.locator(".mx-example-steps li")
    assert [steps.nth(i).is_visible() for i in range(3)] == [True, False, False]
    assert not help_.locator(".mx-example-answer").is_visible()
    help_.locator("[data-example-next]").click()
    assert [steps.nth(i).is_visible() for i in range(3)] == [True, True, False]
    help_.locator("[data-example-next]").click()
    playwright.expect(help_.locator("[data-example-next]")).to_have_text("הצגת התשובה")
    help_.locator("[data-example-next]").click()
    playwright.expect(help_.locator(".mx-example-answer")).to_be_visible()
    playwright.expect(help_.locator("[data-example-next]")).to_be_hidden()
    # closing returns to the same question, with the same feedback
    help_.locator("button.solid").click()
    playwright.expect(page.locator(".mx-help-wrap")).to_have_count(0)
    assert page.locator(".mx-q p").first.inner_text() == asked
    playwright.expect(page.locator(".mx-bad")).to_be_visible()
    # a third mistake offers it again; Escape closes the panel before the sheet
    page.locator(".mx-bad button.solid").click()
    wrong_answer(page)
    page.locator(".mx-bad [data-example]").click()
    playwright.expect(page.locator(".mx-help")).to_be_visible()
    page.keyboard.press("Escape")
    playwright.expect(page.locator(".mx-help-wrap")).to_have_count(0)
    playwright.expect(page.locator(".mx-sheet").first).to_be_visible()
    assert progress_of(page)["examples"][skill]["n"] == 2                    # both openings are kept
    assert not errors


def test_a_skill_without_an_example_offers_none(browser, server):
    page, errors = page_with(browser, server)               # the data has no examples yet
    page.locator(".mx-star.st-next").click()
    wrong_answer(page)
    page.locator(".mx-bad button.solid").click()
    wrong_answer(page)
    assert page.locator("[data-example]").count() == 0
    page.locator(".mx-helpbtn").click()                     # the help is there all the same
    playwright.expect(page.locator(".mx-help")).to_contain_text("לכישור הזה עוד אין דוגמה פתורה")
    assert "examples" not in progress_of(page) or progress_of(page)["examples"] == {}
    assert not errors


def test_opening_the_example_is_recorded_and_changes_neither_finishing_nor_gold(browser, server):
    page, errors = page_with_examples(browser, server)
    nxt = page.locator(".mx-star.st-next").get_attribute("data-id")
    page.locator(".mx-star.st-next").click()
    page.locator(".mx-helpbtn").click()                     # the "?" shows the example before any answer
    playwright.expect(page.locator(".mx-help .mx-example")).to_be_visible()
    from app.math_skills import algebra9
    description = next(x["description"] for x in algebra9()["skills"] if x["id"] == nxt)
    playwright.expect(page.locator(".mx-help")).to_contain_text(description.split("$")[0].strip()[:20])   # and the skill's description
    page.locator(".mx-help button.solid").click()
    p = progress_of(page)
    assert p["examples"][nxt]["n"] == 1 and p["examples"][nxt]["last"] and not p["done"].get(nxt)
    answer_skill_right(page)                                # clean first-try answers still make it gold
    playwright.expect(page.locator(".sky-cele h2").first).to_have_text("כוכב זהב!")
    while page.locator(".sky-cele [data-sky=go]").count():
        page.locator(".sky-cele [data-sky=go]").first.click()
    p = progress_of(page)
    assert p["done"][nxt] and p["gold"][nxt] and p["examples"][nxt]["n"] == 1
    assert not errors


# ---- assignments (MathlySky.assignmentState): created on 1 November 2026 (10:00 UTC), due 12 November, now Wednesday 4 November
ASSIGNMENT = {"id": "a1", "created_at": "2026-11-01T10:00:00Z", "due_on": "2026-11-12", "title": "חזרה",
              "items": [{"kind": "skill", "id": "g_pyth"}, {"kind": "unit_quiz", "unit": "g"}, {"kind": "exam", "units": ["g", "t"]}]}


def state(page, progress=None, sittings=(), now="2026-11-04T12:00:00", assignment=ASSIGNMENT):
    return page.evaluate("([a, p, s, n]) => MathlySky.assignmentState(a, p, s, new Date(n))",
                         [assignment, progress or {}, None if sittings is None else list(sittings), now])


def test_a_skill_finished_before_the_assignment_is_already_done_and_on_time(browser, server):
    page, errors = page_with(browser, server)
    got = state(page, {"done": {"g_pyth": "2026-09-20T08:00:00Z"}})
    first = got["items"][0]
    assert (first["done"], first["late"]) == (True, False) and got["doneCount"] == 1 and got["total"] == 3
    assert got["next"] == 1 and not got["finished"] and not got["overdue"]        # the next one to do is the unit quiz
    assert state(page, {})["items"][0]["done"] is False
    assert not errors


def test_a_unit_quiz_counts_only_when_handed_in_after_the_assignment_was_made(browser, server):
    page, errors = page_with(browser, server)
    before = [{"unit_id": "g", "submitted_at": "2026-10-30T09:00:00Z"}]
    after = [{"unit_id": "g", "submitted_at": "2026-11-03T09:00:00Z"}]
    other = [{"unit_id": "t", "submitted_at": "2026-11-03T09:00:00Z"}]
    assert state(page, sittings=before)["items"][1]["done"] is False
    assert state(page, sittings=other)["items"][1]["done"] is False
    assert state(page, sittings=before + after)["items"][1]["done"] is True
    assert state(page, sittings=after)["items"][1]["at"] == "2026-11-03T09:00:00Z"
    unknown = state(page, sittings=None)["items"][1]                              # the server's record could not be read
    assert (unknown["done"], unknown["known"]) == (False, False)
    assert not errors


def test_a_practice_exam_must_cover_every_unit_of_the_item_and_come_after_the_assignment(browser, server):
    page, errors = page_with(browser, server)
    exam = lambda at, *units: {"quizzes": [{"at": at, "n": 12, "right": 9, "units": list(units)}]}
    assert state(page, exam("2026-11-03T09:00:00Z", "g"))["items"][2]["done"] is False            # missing a unit
    assert state(page, exam("2026-10-30T09:00:00Z", "g", "t"))["items"][2]["done"] is False       # before the assignment
    assert state(page, exam("2026-11-03T09:00:00Z", "g", "t"))["items"][2]["done"] is True
    assert state(page, exam("2026-11-03T09:00:00Z", "t", "g", "k"))["items"][2]["done"] is True   # more units than asked is fine
    assert not errors


def test_late_means_after_the_due_day_in_jerusalem_and_overdue_means_unfinished_after_it(browser, server):
    page, errors = page_with(browser, server)
    all_done = lambda skill_at: {"done": {"g_pyth": skill_at}, "quizzes": [{"at": "2026-11-03T09:00:00Z", "units": ["g", "t"]}]}
    sit = [{"unit_id": "g", "submitted_at": "2026-11-03T09:00:00Z"}]
    on_time = state(page, all_done("2026-11-12T21:30:00Z"), sit)             # 23:30 on the 12th in Jerusalem (UTC+2)
    assert on_time["finished"] and not on_time["late"] and not on_time["overdue"]
    late = state(page, all_done("2026-11-12T22:30:00Z"), sit)                # 00:30 on the 13th
    assert late["finished"] and late["late"] and late["items"][0]["late"] and not late["items"][1]["late"]
    assert not state(page, {}, [], now="2026-11-12T20:00:00")["overdue"]      # the evening of the due day: not yet
    assert state(page, {}, [], now="2026-11-13T09:00:00")["overdue"]           # the day after, unfinished
    done_late = state(page, all_done("2026-11-02T09:00:00Z"), sit, now="2026-11-20T09:00:00")
    assert done_late["finished"] and not done_late["overdue"]                  # finished: never overdue
    assert not errors


def test_a_worksheet_is_done_when_handed_in_and_late_after_the_due_day(browser, server):
    page, errors = page_with(browser, server)
    sheet = {**ASSIGNMENT, "items": [{"kind": "worksheet", "id": "w1"}, {"kind": "skill", "id": "g_pyth"}]}
    handed = lambda at, wid="w1": {"sittings": [], "submissions": [{"worksheet_id": wid, "submitted_at": at, "score": None}]}
    on_time = page.evaluate("([a, s, n]) => MathlySky.assignmentState(a, {}, s, new Date(n))",
                            [sheet, handed("2026-11-12T20:00:00Z"), "2026-11-04T12:00:00"])
    first = on_time["items"][0]                                              # 22:00 on the due day in Jerusalem
    assert (first["done"], first["late"], first["known"]) == (True, False, True) and first["submission"]["worksheet_id"] == "w1"
    assert on_time["next"] == 1
    run = lambda server_data: page.evaluate("([a, s]) => MathlySky.assignmentState(a, {}, s, new Date('2026-11-04T12:00:00'))",
                                            [sheet, server_data])["items"][0]
    late = run(handed("2026-11-12T22:30:00Z"))                                 # 00:30 on the 13th
    assert late["done"] and late["late"]
    assert not run(handed("2026-11-03T09:00:00Z", "w2"))["done"]                # another worksheet's submission
    assert not run({"sittings": [], "submissions": []})["done"]                 # opened, not handed in
    unknown = run({"sittings": [], "submissions": None})                       # could not be read
    assert (unknown["done"], unknown["known"]) == (False, False)
    assert run([])["known"] and not run([])["done"]                            # a bare array is the sittings
    assert not errors


def test_a_returned_worksheet_is_not_done_until_handed_in_again_by_its_new_date(browser, server):
    page, errors = page_with(browser, server)
    sheet = {**ASSIGNMENT, "items": [{"kind": "worksheet", "id": "w1"}]}
    run = lambda sub, now: page.evaluate("([a, s, n]) => MathlySky.assignmentState(a, {}, s, new Date(n))",
                                         [sheet, {"sittings": [], "submissions": [sub]}, now])
    returned = {"worksheet_id": "w1", "submitted_at": "2026-11-10T09:00:00Z", "state": "returned", "return_due_on": "2026-11-16"}
    got = run(returned, "2026-11-14T12:00:00")
    assert not got["finished"] and got["items"][0]["returned"] and not got["overdue"]   # past the 12th, before the 16th
    assert run(returned, "2026-11-17T12:00:00")["overdue"]
    no_date = {**returned, "return_due_on": None}
    assert run(no_date, "2026-11-14T12:00:00")["overdue"]                     # without a new date the first one stands
    again = {"worksheet_id": "w1", "submitted_at": "2026-11-15T09:00:00Z", "state": "submitted", "version": 2,
             "earlier": [returned]}
    fixed = run(again, "2026-11-15T12:00:00")
    assert fixed["finished"] and not fixed["late"]                            # by the new date: on time
    assert not errors


def test_each_owed_item_has_one_state_in_the_tasks_order(browser, server):
    page, errors = page_with(browser, server)
    order = page.evaluate("""() => [
      {returned:true, overdue:true, waiting:true}, {overdue:true, waiting:true}, {waiting:true, checked:true},
      {checked:true, handedIn:true}, {handedIn:true, started:true}, {started:true}, {}].map(f => MathlySky.taskState(f))""")
    assert order == ["returned", "late", "waiting", "checked", "handed", "progress", "new"]
    sheet = {**ASSIGNMENT, "items": [{"kind": "worksheet", "id": "w1"}]}
    task = lambda sub, now="2026-11-04T12:00:00": page.evaluate(
        "([a, s, n]) => MathlySky.assignmentTask(a, {}, s, new Date(n))", [sheet, {"sittings": [], "submissions": [sub] if sub else []}, now])
    handed = {"worksheet_id": "w1", "submitted_at": "2026-11-03T09:00:00Z", "state": "submitted"}
    assert task(None)["state"] == "new" and task(None, "2026-11-20T12:00:00")["label"] == "באיחור"
    assert task(handed)["label"] == "ממתין לבדיקה"
    assert task({**handed, "state": "checked", "checked_at": "2026-11-05T09:00:00Z"})["label"] == "נבדק"
    assert task({**handed, "state": "returned"}, "2026-11-20T12:00:00")["label"] == "הוחזר לתיקון"   # even past the date
    skills = {**ASSIGNMENT, "items": [{"kind": "skill", "id": "g_pyth"}, {"kind": "skill", "id": "t_ext"}]}
    progress = lambda done: page.evaluate("([a, p]) => MathlySky.assignmentTask(a, p, [], new Date('2026-11-04T12:00:00')).state", [skills, {"done": done}])
    assert progress({"g_pyth": "2026-11-02T09:00:00Z"}) == "progress"
    assert progress({"g_pyth": "2026-11-02T09:00:00Z", "t_ext": "2026-11-03T09:00:00Z"}) == "handed"
    final = lambda offer: page.evaluate("o => MathlySky.finalTask(o).label", offer)
    assert final({"start": None}) == "עוד לא התחיל"
    assert final({"start": {"id": "s"}}) == "בתהליך"
    assert final({"start": {"id": "s", "sitting_id": "x"}}) == "הוגש · ממתין לתוצאות"
    assert not errors


# ---- focus areas (MathlySky.focus): where a student most needs work, with the evidence for each skill
def test_lost_quiz_points_are_split_between_the_skills_of_a_question(browser, server):
    page, errors = page_with(browser, server)
    sit = {"submitted_at": "2026-11-03T09:00:00Z", "questions": [
        {"points": 25, "earned": 10, "skills": ["t_ext", "t_ext_eq"]}, {"points": 25, "earned": None, "skills": ["t_area"]},
        {"points": 25, "earned": 25, "skills": ["t_formulas"]}]}
    lost = page.evaluate("s => MathlySky.quizLost(s, new Date('2026-11-04T12:00:00Z'))", [sit])
    assert lost == {"t_ext": 7.5, "t_ext_eq": 7.5}   # an unchecked answer adds nothing, a full one neither
    old = {**sit, "submitted_at": "2026-08-01T09:00:00Z"}
    assert page.evaluate("s => MathlySky.quizLost(s, new Date('2026-11-04T12:00:00Z'))", [old]) == {}   # over 60 days
    assert not errors


def test_the_focus_score_ranks_skills_by_their_evidence(browser, server):
    page, errors = page_with(browser, server)
    P = {"done": {"t_area": "2026-09-01T09:00:00Z"}, "seen": {"t_area": "2026-09-01T09:00:00Z", "t_simplify": "2026-11-03T09:00:00Z"},
         "slips": [{"at": "2026-11-02T09:00:00Z", "id": "t_ext", "m": "x"}] * 4, "tries": {"t_ext": 3}}
    sit = [{"submitted_at": "2026-11-03T09:00:00Z", "questions": [{"points": 25, "earned": 13, "skills": ["t_ext"]}]}]
    got = page.evaluate("([p, s]) => MathlySky.focus(p, s, ['t_ext','t_simplify','t_area','t_formulas'], new Date('2026-11-04T12:00:00Z'))", [P, sit])
    assert [x["id"] for x in got] == ["t_ext", "t_simplify", "t_area"]   # t_formulas: nothing against it
    first = got[0]
    assert (first["lost"], first["wrong"], first["unfinished"], first["fading"]) == (12, 4, True, False)
    assert got[2]["fading"] is True and got[2]["unfinished"] is False
    assert page.evaluate("() => MathlySky.focus({}, [], ['t_ext'])") == []   # no evidence, no focus area
    assert not errors


# ---------- quiz and exam names ----------
# A unit has a בוחן חיזוק (retaken) and a בוחן מסכם (once, the unit's grade); practice over several units is a מבחן
# תרגול; a מבחן is the school's exam. These earlier names are gone from every screen (a whole word: "מבחני תרגול"
# is fine, "בחני תרגול" on its own is not).
import re as _re
OLD_TERMS = ("בוחן סופי", "הבוחן הסופי", "בחנים סופיים", "בוחנים סופיים", "בוחן תרגול", "בחני תרגול", "בחני התרגול",
             "בוחן מעורב", "בחנים מעורבים", "הבוחן המעורב", "מבחני יחידה", "בוחן כמו במבחן")


def old_terms_in(text):
    return [t for t in OLD_TERMS if _re.search(rf"(?<![\u05d0-\u05ea]){t}(?![\u05d0-\u05ea])", text)]


def test_no_old_quiz_names_in_what_is_shipped():
    from pathlib import Path
    root = Path(__file__).resolve().parent.parent
    files = [*root.glob("static/*.js"), *root.glob("static/*.html"), *root.glob("app/*.py")]
    found = [f"{f.name}: {t}" for f in files for t in old_terms_in(f.read_text(encoding="utf-8"))]
    assert not found


def test_the_student_home_and_the_guide_show_no_old_quiz_names(browser, server):
    page, errors = page_with(browser, server)
    home = page.locator("#mathView").inner_text()
    page.locator("#mathView [data-guide-open]").first.click()
    playwright.expect(page.locator("#mgS")).to_be_visible()
    text = home + page.get_by_role("dialog", name="איך משתמשים באפליקציה").text_content()   # both tabs, the hidden one too
    assert not old_terms_in(text)
    assert not errors


# ---- quizzes chosen for each student (MathlySky.chance / pickQuiz)
NOW = "2026-11-04T12:00:00Z"


def chance(page, P, skill):
    return page.evaluate("([p, id, now]) => MathlySky.chance(p, id, new Date(now))", [P, skill, NOW])


def test_a_wrong_answer_is_counted_for_its_skill(browser, server):
    page, errors = page_with(browser, server)
    page.locator('.mx-star.st-next').click()
    page.locator(".mx-choices button:not([data-right])").first.click()
    assert progress_of(page)["wrongs"] == {"t_ext": 1}
    assert not errors


def test_the_chance_of_a_right_answer_follows_the_answers_and_the_stars(browser, server):
    page, errors = page_with(browser, server)
    recent, old = "2026-11-03T09:00:00Z", "2026-09-01T09:00:00Z"
    assert chance(page, {"tries": {"a": 4}}, "a") is None                                   # under 5 answers: unknown
    assert chance(page, {"tries": {"a": 8}, "wrongs": {"a": 2}}, "a") == pytest.approx(.7)   # (6 + 1) / (8 + 2)
    slips = [{"at": recent, "id": "a", "m": "x"}] * 3
    assert chance(page, {"tries": {"a": 8}, "wrongs": {"a": 1}, "slips": slips}, "a") == pytest.approx(.6)   # the slips' history counts
    weak_done = {"tries": {"a": 10}, "wrongs": {"a": 8}, "done": {"a": recent}, "seen": {"a": recent}}
    assert chance(page, weak_done, "a") == pytest.approx(.8)                                 # finished: at least 0.8
    assert chance(page, {**weak_done, "gold": {"a": recent}}, "a") == pytest.approx(.9)      # gold: at least 0.9
    assert chance(page, {**weak_done, "done": {"a": old}, "seen": {"a": old}}, "a") == pytest.approx(.7)   # fading: 0.1 less
    assert not errors


def pick(page, P, pool, **options):
    return page.evaluate("([p, pool, o, now]) => MathlySky.pickQuiz(p, pool, {...o, now: new Date(now)})", [P, pool, options, NOW])


def class_of_twelve():
    """4 started and not finished (chance 0.65), 8 finished (0.8 and more), 2 never practised."""
    recent = "2026-11-03T09:00:00Z"
    P, pool = {"tries": {}, "wrongs": {}, "done": {}, "seen": {}}, []
    for i in range(4):
        P["tries"][f"c{i}"], P["wrongs"][f"c{i}"], P["seen"][f"c{i}"] = 18, 6, recent   # (12 + 1) / 20 = 0.65
        pool.append({"id": f"c{i}", "qs": list(range(10)), "rank": i})
    for i in range(8):
        P["tries"][f"s{i}"], P["done"][f"s{i}"], P["seen"][f"s{i}"] = 12, recent, recent
        pool.append({"id": f"s{i}", "qs": list(range(10)), "rank": 10 + i})
    pool += [{"id": "n0", "qs": [0, 1], "rank": 99}, {"id": "n1", "qs": [0, 1], "rank": 98}]
    return P, pool


def test_quiz_one_aims_at_70_percent_mostly_from_near_mastered_topics(browser, server):
    page, errors = page_with(browser, server)
    P, pool = class_of_twelve()
    one = pick(page, P, pool, target=.7)
    two = pick(page, P, pool, target=.8)
    near = lambda r: sum(x["id"].startswith("c") for x in r["items"])
    assert len(one["items"]) == 12 and .65 <= one["expected"] <= .75
    assert len(two["items"]) == 12 and .75 <= two["expected"] <= .85
    assert near(one) > 6 and near(two) < near(one)                         # quiz 2 has more finished topics
    for r in (one, two):
        assert not [x for x in r["items"] if x["id"].startswith("n")]      # never practised: never asked
        per = {}
        for x in r["items"]:
            per[x["id"]] = per.get(x["id"], 0) + 1
        assert max(per.values()) <= 3                                      # at most 3 a topic
    assert not errors


def test_a_retake_draws_questions_not_seen_before(browser, server):
    page, errors = page_with(browser, server)
    P, pool = class_of_twelve()
    first = pick(page, P, pool, target=.7)
    seen = {f'{x["id"]}:{x["qi"]}': "2026-11-03T10:00:00Z" for x in first["items"]}
    second = pick(page, P, pool, target=.7, seen=seen)
    assert not {f'{x["id"]}:{x["qi"]}' for x in second["items"]} & set(seen)
    # a topic whose questions were all seen: the one seen longest ago comes first
    small = [{"id": "c0", "qs": [0, 1], "rank": 0}]
    got = pick(page, P, small, target=.7, n=1, seen={"c0:0": "2026-11-03T10:00:00Z", "c0:1": "2026-11-01T10:00:00Z"})
    assert got["items"] == [{"id": "c0", "qi": 1}]
    assert not errors


def test_a_student_with_few_answers_gets_the_most_recently_taught_topics(browser, server):
    page, errors = page_with(browser, server)
    P = {"tries": {"a": 2, "b": 2, "c": 2}, "seen": {"a": NOW, "b": NOW, "c": NOW}}
    pool = [{"id": "a", "qs": list(range(10)), "rank": 1}, {"id": "b", "qs": list(range(10)), "rank": 3},
            {"id": "c", "qs": list(range(10)), "rank": 2}, {"id": "z", "qs": list(range(10)), "rank": 9}]
    got = pick(page, P, pool, target=.7, n=6)
    assert [x["id"] for x in got["items"]][:3] == ["b", "c", "a"] and got["unknown"] == 6   # newest first, by turns
    assert "z" not in {x["id"] for x in got["items"]}
    # too few questions to fill the quiz: it is shorter
    assert len(pick(page, P, [{"id": "a", "qs": [0, 1, 2, 3], "rank": 1}], target=.7)["items"]) == 4
    assert not errors