File size: 150,027 Bytes
c869096
 
0242444
c869096
f98e127
c869096
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1713922
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c869096
 
 
 
 
 
 
 
 
baad55f
c869096
 
 
3ed1d24
 
 
 
 
 
 
 
 
ac07113
3ed1d24
 
a8fd466
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c869096
 
 
 
 
 
0af696f
 
c869096
 
0af696f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c869096
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b9cb1c2
 
f57196b
0e25dfa
c869096
 
 
 
de5c6dc
 
 
 
 
 
 
 
 
 
4621f3a
 
 
 
 
 
 
 
7b3e3e7
 
77ea3c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1c2ce5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e5fc76a
 
 
 
 
 
 
 
 
 
 
7b3e3e7
 
 
 
 
 
ccffcbd
 
1d1f2d7
3f25257
ccffcbd
 
 
 
7b3e3e7
 
 
 
ccffcbd
b265e1b
 
 
 
ccffcbd
7b3e3e7
0af696f
 
43b9af7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
085ce5d
 
 
 
b9cb1c2
 
085ce5d
 
 
 
 
 
 
 
 
 
455c931
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69dc89e
 
 
 
455c931
 
6de0ea6
 
 
 
 
 
 
 
 
61f5be2
6c1de89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94873fd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6c1de89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ccffcbd
 
6c1de89
ccffcbd
6c1de89
 
8eeed09
 
a8fd466
 
 
8eeed09
f57196b
8eeed09
f57196b
a8fd466
f57196b
 
 
d8b47d7
8eeed09
7e9916e
f57196b
 
7e9916e
 
 
 
 
f57196b
7e9916e
eb6c566
 
 
7e9916e
 
8eeed09
 
 
 
731718b
 
a8fd466
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c8045f2
 
 
 
 
 
 
f57196b
 
 
c8045f2
 
 
 
 
731718b
 
 
 
a8fd466
9f3ca5d
d8b47d7
731718b
 
 
 
 
 
35394c4
 
 
 
731718b
 
 
 
 
 
16dae6c
 
 
55164e5
731718b
 
 
 
4884712
 
 
0508bdf
08aea0e
 
9f3ca5d
 
 
 
a8fd466
9f3ca5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8fd466
9f3ca5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8fd466
9f3ca5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8fd466
9f3ca5d
 
 
 
bb8c49a
9f3ca5d
 
 
 
 
 
 
 
 
 
 
 
 
 
bb8c49a
 
 
 
 
 
9f3ca5d
 
 
 
 
 
 
 
 
a8fd466
9f3ca5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8fd466
9f3ca5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8fd466
9f3ca5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8fd466
9f3ca5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f57196b
 
 
 
 
a8fd466
f57196b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8fd466
f57196b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8fd466
f57196b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8fd466
f57196b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8fd466
f57196b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8fd466
f57196b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8fd466
f57196b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8fd466
f57196b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8fd466
f57196b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
08aea0e
 
 
 
 
b9cb1c2
 
08aea0e
0242444
08aea0e
 
66d03bb
0242444
d8b47d7
 
 
 
 
 
 
 
 
a5c9173
 
 
 
0242444
 
 
 
 
 
 
 
 
f98e127
66d03bb
f98e127
a5c9173
c8045f2
a5c9173
c8045f2
a5c9173
 
 
 
 
08aea0e
1a26e87
 
 
66d03bb
 
 
 
 
 
 
 
 
5d06ea2
 
 
 
 
 
1a26e87
 
 
3b6eb00
 
 
 
 
 
 
 
 
 
 
 
1a26e87
 
 
 
08aea0e
 
 
 
 
 
b9cb1c2
 
 
 
 
a9f22dc
 
 
 
 
 
 
3ed1d24
 
 
 
 
 
 
08aea0e
 
 
 
 
 
 
 
0b390f8
c8045f2
66d03bb
0b390f8
d8b47d7
 
 
 
 
 
 
c8045f2
 
a9f22dc
c8045f2
 
 
 
08aea0e
c8045f2
 
0b390f8
 
 
a6c789c
66d03bb
0b390f8
 
 
c8045f2
 
 
0b390f8
c8045f2
 
 
a5c9173
c8045f2
 
0242444
3ed1d24
 
 
 
 
1a26e87
 
 
 
 
 
 
 
 
 
94af308
 
1a26e87
 
 
 
 
 
 
7bfd86c
 
94af308
1a26e87
94af308
 
 
 
 
 
 
1a26e87
 
 
 
3b6eb00
1a26e87
 
 
 
3b6eb00
1a26e87
 
 
3b6eb00
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ccffcbd
 
3b6eb00
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1a26e87
 
 
d5462e6
1a26e87
 
3b6eb00
 
1a26e87
 
 
66d03bb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d06ea2
 
 
 
 
 
 
 
66d03bb
 
 
5d06ea2
66d03bb
 
c8045f2
0b390f8
 
a5c9173
c8045f2
a6c789c
c8045f2
 
 
 
0b390f8
 
 
c8045f2
 
a5c9173
 
c8045f2
 
 
 
 
 
 
a5c9173
c8045f2
0b390f8
 
 
a6c789c
 
 
 
 
 
 
 
 
c8045f2
a9f22dc
 
 
d8b47d7
 
c8045f2
 
01f0f9e
 
 
 
 
 
 
 
6953a11
01f0f9e
 
 
6953a11
01f0f9e
 
 
 
c8045f2
 
a5c9173
c8045f2
 
a5c9173
 
 
08aea0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b9cb1c2
f98e127
 
b9cb1c2
f98e127
 
b9cb1c2
f98e127
 
08aea0e
 
2e629ed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b390f8
 
 
0242444
 
 
 
 
 
 
 
3ed1d24
0242444
 
 
 
 
3ed1d24
269a3c8
0242444
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c15166b
0242444
 
c15166b
 
 
 
0242444
3ed1d24
 
 
4f6b38e
3ed1d24
 
 
 
 
c15166b
3ed1d24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269a3c8
 
 
 
 
 
 
 
 
 
3ed1d24
 
 
 
 
0242444
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4f6b38e
c15166b
0242444
 
89d97df
 
0242444
 
 
3ed1d24
 
 
 
c15166b
3ed1d24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269a3c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0242444
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0af696f
 
269a3c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0af696f
 
 
 
 
 
 
ac07113
 
0af696f
269a3c8
 
0af696f
 
0cfc3a7
 
 
 
 
 
 
 
 
 
 
 
 
f98e127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1c2ce5a
 
 
 
 
 
 
 
 
f90708b
1c2ce5a
 
 
 
 
69eaa4c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
"""Tests with synthetic CTC emissions: no model download needed."""
import io
import re
import wave
from dataclasses import replace

import numpy as np
import pytest

from app.align import ctc_viterbi, greedy_decode
from app.phrases import PHRASES
from app.scorer import Scorer, Vocab, tokenize

SYMS = ["<pad>", "w", "eː", "n", "i", "l", "ħ", "a", "m", "aː", "χ", "h", "k", "ʔ", "d", "ʃ", "s", "ʕ", "q", "e"]
VOCAB = {s: i for i, s in enumerate(SYMS)}


def emissions(phones, frames=3, gap=2, peak=0.9):
    """Log-posteriors where each phone peaks for `frames` frames, separated by blanks."""
    V = len(SYMS)
    rows = []

    def row(sym, p):
        r = np.full(V, (1 - p) / (V - 1))
        r[VOCAB[sym]] = p
        return r

    for _ in range(gap):
        rows.append(row("<pad>", 0.95))
    for ph in phones:
        for _ in range(frames):
            rows.append(row(ph, peak))
        for _ in range(gap):
            rows.append(row("<pad>", 0.95))
    return np.log(np.array(rows))


def find(he):
    return next(p for p in PHRASES if p["he"] == he)


def scorer_for(logp):
    return Scorer(lambda wav: logp, Vocab(VOCAB))


TOILET = find("איפה השירותים?")          # وين الحمام  w eː n | i l ħ a m m aː m
TIME = find("מה השעה?")                   # قديش الساعة


def test_viterbi_spans_cover_every_token():
    toks = [VOCAB[s] for s in ["w", "eː", "n"]]
    logp = emissions(["w", "eː", "n"])
    al = ctc_viterbi(logp, toks, 0)
    assert al is not None
    assert all(0 <= a < b for a, b in al.spans)
    assert [a for a, _ in al.spans] == sorted(a for a, _ in al.spans)
    assert greedy_decode(logp, 0) == toks


def test_too_short_returns_none():
    logp = emissions(["w"], frames=1, gap=0)
    assert ctc_viterbi(logp, [VOCAB["w"], VOCAB["eː"], VOCAB["n"]], 0) is None


def test_geminates_collapse():
    toks = tokenize("i l ħ a m m aː m", Vocab(VOCAB))
    assert [t.phoneme for t in toks] == ["i", "l", "ħ", "a", "m", "aː", "m"]


def test_correct_pronunciation_scores_high():
    logp = emissions(["w", "eː", "n", "i", "l", "ħ", "a", "m", "aː", "m"])
    r = scorer_for(logp).score(None, TOILET)
    assert r["score"] >= 90
    assert r["tips"] == []
    assert not r["unrelated"]


def test_teacher_diagnostics_include_compact_phoneme_heatmap():
    logp = emissions(["w", "eː", "n", "i", "l", "χ", "a", "m", "aː", "m"])
    scorer = scorer_for(logp)
    assert "phoneme_heatmap" not in scorer.score(None, TOILET)
    heatmap = scorer.score_logp(logp, TOILET, diagnostics=True, duration_s=2.5)["phoneme_heatmap"]
    assert heatmap["duration_ms"] == 2500
    assert heatmap["frame_bins"] == len(logp)
    assert {"ħ", "χ", "blank"} <= {row["phoneme"] for row in heatmap["rows"]}
    assert all(len(row["values"]) == heatmap["frame_bins"] for row in heatmap["rows"])
    assert [p["phoneme"] for p in heatmap["tracks"]["expected"]] == [
        "w", "eː", "n", "i", "l", "ħ", "a", "m", "aː", "m"]
    assert [p["phoneme"] for p in heatmap["tracks"]["heard"]] == [
        "w", "e", "n", "i", "l", "χ", "a", "m", "a", "m"]


def test_hebrew_chet_flagged_on_hammam():
    # ħ said as the Hebrew uvular χ
    logp = emissions(["w", "eː", "n", "i", "l", "χ", "a", "m", "aː", "m"])
    r = scorer_for(logp).score(None, TOILET)
    word = r["words"][1]
    h = next(p for p in word["phonemes"] if p["ipa"] == "ħ")
    assert h["flagged"] and h["heard"] == "χ"
    assert r["words"][0]["score"] > 90          # وين is fine
    assert r["tips"][0]["key"] == "ħ>x"
    assert r["tips"][0]["word"] == "الحمّام"
    assert 50 <= r["score"] < 85              # one wrong consonant: "almost", not "excellent"


def test_applied_phrase_calibration_raises_only_the_matching_confusion():
    logp = emissions(["w", "eː", "n", "i", "l", "χ", "a", "m", "aː", "m"])
    scorer = scorer_for(logp)
    key = TOILET.get("key", TOILET["he"])
    scorer.set_calibrations([{"expected": "ħ", "heard": "χ", "floor": 70,
                              "scope": "phrase", "phrase": key}])
    r = scorer.score(None, TOILET)
    h = next(p for p in r["words"][1]["phonemes"] if p["ipa"] == "ħ")
    assert h["score"] == 70 and not h["flagged"]
    assert r["calibrated"] == [{"expected": "ħ", "heard": "χ", "from": 2, "to": 70}]


def test_deleted_ayin_gets_conservative_builtin_calibration():
    phrase = {"variants": [("مع", "m a ʕ")]}
    scorer = scorer_for(emissions(["m", "a"]))
    r = scorer.score(None, phrase, diagnostics=True)
    ayin = next(p for p in r["words"][0]["phonemes"] if p["ipa"] == "ʕ")
    trace = next(p for p in r["trace"]["tokens"] if p["ipa"] == "ʕ")
    calibration = next(s for s in trace["stages"] if s["stage"] == "calibration")
    assert ayin["score"] == 60 and not ayin["flagged"]
    assert calibration == {"stage": "calibration", "score": 60, "floor": 60, "applied": True}
    applied = r["calibrated"][0]
    assert applied["expected"] == "ʕ" and applied["heard"] == "_"
    assert applied["from"] < 50 and applied["to"] == 60
    scorer.set_calibrations([{"expected": "ʕ", "heard": "_", "floor": 75, "scope": "global"}])
    overridden = scorer.score(None, phrase)
    assert next(p for p in overridden["words"][0]["phonemes"] if p["ipa"] == "ʕ")["score"] == 75


def test_h_instead_of_pharyngeal():
    logp = emissions(["w", "eː", "n", "i", "l", "h", "a", "m", "aː", "m"])
    r = scorer_for(logp).score(None, TOILET)
    assert r["tips"][0]["key"] == "ħ>h"


def test_k_for_qaf_is_rural_and_correct():
    # learner says "kaddeesh" for قديش: rural Palestinian, so ق is accepted as k; the answer stays the urban one
    logp = emissions(["k", "a", "d", "eː", "ʃ", "i", "s", "aː", "ʕ", "a"])
    r = scorer_for(logp).score(None, TIME)
    q = r["words"][0]["phonemes"][0]
    assert r["variant"]["ipa"].startswith("ʔ") and q["sound"] == "Q" and q["realised"] == "k"
    assert q["score"] >= 90 and not q["flagged"] and not r["tips"] and r["score"] >= 90
    assert r["words"][0]["heard_tr"].startswith("כּ")      # written as said, not coloured as a mistake


# ---------- dialects and Hebrew-speaker rules (docs/scoring-dialects-plan.md) ----------

def custom_scorer(extra, phones, frames=3, gap=2, peak=0.9):
    """A scorer over SYMS plus `extra`, hearing `phones` cleanly."""
    syms = SYMS + [s for s in extra if s not in SYMS]
    vocab = {s: i for i, s in enumerate(syms)}

    def row(sym, p):
        r = np.full(len(syms), (1 - p) / (len(syms) - 1))
        r[vocab[sym]] = p
        return r

    rows = [row("<pad>", 0.95)] * gap
    for ph in phones:
        rows += [row(ph, peak)] * frames + [row("<pad>", 0.95)] * gap
    return Scorer(lambda wav: np.log(np.array(rows)), Vocab(vocab))


COFFEE = {"variants": [("قهوة", "ʔ a h w e")]}


def test_q_for_qaf_is_accepted():
    r = scorer_for(emissions(["q", "a", "d", "eː", "ʃ", "i", "s", "aː", "ʕ", "a"])).score(None, TIME)
    assert r["words"][0]["phonemes"][0]["realised"] == "q" and not r["tips"] and r["score"] >= 90


def test_dropped_qaf_between_vowels_is_not_an_error():
    r = scorer_for(emissions(["a", "h", "w", "e"])).score(None, COFFEE)
    assert r["score"] >= 90 and not r["tips"] and not any(i["missing"] for i in r["issues"])


def test_hamza_is_not_a_qaf():
    # اهلا is written with ء: kahla is not a dialect form, it is a mistake
    phrase = {"variants": [("اهلا", "ʔ a h l a")]}
    r = scorer_for(emissions(["k", "a", "h", "l", "a"])).score(None, phrase)
    p = r["words"][0]["phonemes"][0]
    assert "sound" not in p and p["flagged"] and p["heard"] == "k"
    assert [t["key"] for t in r["tips"]] == ["ʔ>k"]


def test_reference_is_matched_by_letter_not_by_realisation():
    ref = scorer_for(emissions(["q", "a", "h", "w", "e"])).reference(None, COFFEE)   # the voice says ق as q
    assert ref.sounds[0] == (0, "Q")
    r = scorer_for(emissions(["ʔ", "a", "h", "w", "e"])).score(None, COFFEE, [ref])
    assert r["score"] >= 95 and not r["tips"] and r["references"] == 1


def test_mixed_dialect_is_noted_but_not_marked_down():
    phrase = {"variants": [("قديش قهوة", "ʔ a d d eː ʃ | ʔ a h w e")]}
    mixed = scorer_for(emissions(["ʔ", "a", "d", "eː", "ʃ", "k", "a", "h", "w", "e"])).score(None, phrase)
    assert mixed["mixed_dialect"] and mixed["score"] >= 90 and not mixed["tips"]
    rural = scorer_for(emissions(["k", "a", "d", "eː", "ʃ", "k", "a", "h", "w", "e"])).score(None, phrase)
    assert rural["dialect"] == "rural" and "mixed_dialect" not in rural


def test_rural_kaf_and_tha():
    phrase = {"variants": [("كثير", "k t iː r")]}
    r = custom_scorer(["tʃ", "θ", "r", "iː", "t"], ["tʃ", "θ", "iː", "r"]).score(None, phrase)
    assert [p.get("realised") for p in r["words"][0]["phonemes"][:2]] == ["tʃ", "θ"]
    assert r["score"] >= 90 and not r["tips"] and r["dialect"] == "rural"
    r = custom_scorer(["tʃ", "θ", "r", "iː", "t"], ["tʃ", "t", "iː", "r"]).score(None, phrase)
    assert r["mixed_dialect"] and r["score"] >= 90          # rural ك with urban ث


def test_final_ta_marbuta_vowel_may_be_a_e_or_i():
    for vowel in ("a", "e", "i"):
        r = scorer_for(emissions(["ʔ", "a", "h", "w", vowel])).score(None, COFFEE)
        assert r["words"][0]["phonemes"][-1]["score"] >= 95, vowel


def test_diphthong_for_long_vowel():
    # بيت said as bayt (rural / older speech) for beet
    phrase = {"variants": [("بيت", "b eː t")]}
    r = custom_scorer(["b", "t", "j"], ["b", "a", "j", "t"]).score(None, phrase)
    assert r["score"] >= 90 and not r["tips"] and r["variant"]["ipa"] == "b a j t"


def test_archify_places_each_letter():
    from app.archi import archify, unresolved
    assert archify("قديش الساعة", "ʔ a d d eː ʃ | i s s aː ʕ a") == "Q:ʔ a d d eː ʃ | i s s aː ʕ E:a"
    assert archify("انا من القدس", "ʔ a n a | m i n | i l q u d s") == "ʔ a n a | m i n | i l Q:q u d s"
    assert archify("شكرا كثير", "ʃ u k r a n | k t iː r") == "ʃ u K:k r a n | K:k TH:t iː r"
    assert archify("قهوة", "Q:k a h w e") == "Q:k a h w e"                  # written by hand: left alone
    assert unresolved("بعثت", "b a ʕ a t t") == [("بعثت", "ث")]             # ث and ت said as one geminate


def test_every_qaf_in_the_bank_is_placed():
    from app.archi import unresolved
    bad = {(w, ipa) for p in PHRASES for ar, ipa in p["variants"] for w, letter in unresolved(ar, ipa) if letter == "ق"}
    assert not bad, f"mark these ق by hand in the phrase (Q:ʔ): {sorted(bad)[:10]}"


def test_emphatic_heard_plain_is_a_mistake_with_the_rules_feedback():
    from app.rules import load
    phrase = {"variants": [("ظهر", "ðˤ u h r")]}
    r = custom_scorer(["ðˤ", "dˤ", "zˤ", "z", "u", "r"], ["z", "u", "h", "r"]).score(None, phrase)
    assert [t["key"] for t in r["tips"]] == ["ZH>z"]
    assert r["tips"][0]["text"] == load().check("ZH", "z").feedback and r["tips"][0]["severity"] == 2


def margin_scorer(competitor_ratio):
    """طين where tˤ is weak (p 0.07) and plain t is `competitor_ratio` times likelier in the same frames."""
    syms = SYMS + ["tˤ", "t"]
    vocab = {s: i for i, s in enumerate(syms)}
    V = len(syms)

    def row(probs):
        r = np.full(V, 1e-4)
        for sym, p in probs.items():
            r[vocab[sym]] = p
        return r / r.sum()

    rows = [row({"<pad>": 0.95})] * 2
    rows += [row({"tˤ": 0.07, "t": 0.07 * competitor_ratio, "<pad>": 0.8})] * 3 + [row({"<pad>": 0.95})] * 2
    for ph in ("i", "n"):
        rows += [row({ph: 0.9})] * 3 + [row({"<pad>": 0.95})] * 2
    return Scorer(lambda wav: np.log(np.array(rows)), Vocab(vocab))


def test_emphatic_needs_a_clear_win_before_it_is_flagged():
    phrase = {"variants": [("طين", "tˤ i n")]}
    under = margin_scorer(1.8).score(None, phrase)        # plain t under twice as likely: the model's doubt
    p = under["words"][0]["phonemes"][0]
    assert p["score"] < 50 and not p["flagged"] and not under["tips"]
    over = margin_scorer(2.5).score(None, phrase)
    assert over["words"][0]["phonemes"][0]["flagged"] and [t["key"] for t in over["tips"]] == ["tˤ>t"]


@pytest.mark.parametrize("ar,ipa,extra,said,key,heard_letter", [
    ("فنجان", "f i n dʒ aː n", ["f", "p", "dʒ"], ["p", "i", "n", "dʒ", "aː", "n"], "f>p", "פּ"),
    ("وين", "w eː n", ["v"], ["v", "eː", "n"], "w>v", "ב"),
    ("بس", "b a s", ["b", "v"], ["v", "a", "s"], "b>v", "ב"),
    ("صباح", "sˤ a b aː ħ", ["sˤ", "ts", "b"], ["ts", "a", "b", "aː", "ħ"], "sˤ>ts", "טס"),
    ("مرة", "m a r r a", ["r", "ʁ"], ["m", "a", "ʁ", "a"], "r>ʁ", None),
])
def test_hebrew_speaker_substitutions(ar, ipa, extra, said, key, heard_letter):
    from app.rules import load
    r = custom_scorer(extra, said).score(None, {"variants": [(ar, ipa)]})
    tip = next(t for t in r["tips"] if t["key"] == key)
    expected, heard = key.split(">")
    assert tip["text"] == load().check(expected, heard).feedback
    if heard_letter:                    # a sound Arabic does not have is still written in the heard line
        assert heard_letter in r["heard_tr"].replace("\u05b0", "").replace("\u05b7", "").replace("\u05b4", "")


def test_a_sound_outside_arabic_and_hebrew_is_not_named():
    # the multilingual model offers a click for ħ: reported as unclear, not as "heard as ǀ"
    r = custom_scorer(["ǀ"], ["w", "eː", "n", "i", "l", "ǀ", "a", "m", "aː", "m"]).score(None, TOILET)
    h = next(p for p in r["words"][1]["phonemes"] if p["ipa"] == "ħ")
    assert h["flagged"] and h["heard"] is None


def test_meaning_changing_mistakes_lead_the_tips():
    # w said as v (severity 2) and ħ as Hebrew ח (severity 3): ħ leads even though v scored lower
    r = custom_scorer(["v"], ["v", "eː", "n", "i", "l", "χ", "a", "m", "aː", "m"]).score(None, TOILET)
    assert [t["key"] for t in r["tips"]][:2] == ["ħ>x", "w>v"]


def test_unrelated_sentence():
    logp = emissions(["ʃ", "a", "l", "a", "m"])
    r = scorer_for(logp).score(None, TOILET)
    assert r["unrelated"] and r["score"] <= 30


@pytest.mark.parametrize("p", PHRASES, ids=lambda p: p["he"])
def test_phrase_bank_word_counts_match(p):
    for ar, ipa in p["variants"]:
        assert len(ar.split()) == len(ipa.split("|")), (ar, ipa)


def test_api_end_to_end_with_stub_model():
    from fastapi.testclient import TestClient
    from app.main import create_app

    logp = emissions(["w", "eː", "n", "i", "l", "χ", "a", "m", "aː", "m"])
    app = create_app(scorer_for(logp))
    # 1 s of a 220 Hz tone as a WAV upload, so ffmpeg decoding is exercised
    t = np.arange(16000) / 16000
    pcm = (0.3 * np.sin(2 * np.pi * 220 * t) * 32767).astype("<i2").tobytes()
    buf = io.BytesIO()
    with wave.open(buf, "wb") as w:
        w.setnchannels(1); w.setsampwidth(2); w.setframerate(16000); w.writeframes(pcm)

    with TestClient(app) as c:
        assert c.get("/").status_code == 200
        ph = c.get("/api/phrases").json()
        shown = {c["id"] for c in ph["categories"]}   # the phrases of no lesson are in the bank, not on the page
        assert len(ph["phrases"]) == sum(p["cat"] in shown for p in PHRASES) and "ipa" not in ph["phrases"][0]
        assert TOILET["cat"] not in shown and [l["id"] for l in ph["lessons"] if not l.get("soon")] == ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6", "2.7", "2.8", "2.9"]
        assert len(ph["lessons"]) == 20 and [v["name"] for v in ph["levels"]] == ["שלב א", "שלב ב"]
        pid = PHRASES.index(TOILET)
        r = c.post("/api/score", data={"phrase_id": pid}, files={"audio": ("a.wav", buf.getvalue(), "audio/wav")})
        assert r.status_code == 200, r.text
        assert r.json()["tips"][0]["key"] == "ħ>x"


def test_anything_below_100_says_what_was_wrong():
    for phones in (["w", "eː", "n", "i", "l", "ħ", "a", "m", "aː", "m"],      # clean
                   ["w", "eː", "n", "i", "l", "χ", "a", "m", "aː", "m"],      # wrong consonant
                   ["w", "e", "n", "i", "l", "ħ", "a", "m", "a", "m"]):       # short vowels
        r = scorer_for(emissions(phones)).score(None, TOILET)
        assert r["score"] == 100 or r["unrelated"] or r["tips"] or r["issues"], phones
        for i in r["issues"]:
            assert i["word_tr"] and i["sound"] and not i["sound"].isascii()


def test_dropped_glottal_stop_is_not_an_error():
    # اهلا said without the initial ʔ, as natives do
    phrase = {"variants": [("اهلا", "ʔ a h l a")]}
    r = scorer_for(emissions(["a", "h", "l", "a"])).score(None, phrase)
    assert r["words"][0]["score"] >= 90 and r["score"] >= 90
    assert not r["tips"] and not any("עצירה" in i["sound"] for i in r["issues"])


@pytest.mark.parametrize("competitor", ["f", "s", "χ"])
def test_one_frame_onset_spike_does_not_become_a_glottal_substitution(competitor):
    """A generic microphone/breath transient must not be reported as a consonant the learner produced."""
    syms = SYMS + ([competitor] if competitor not in SYMS else [])
    vocab = {s: i for i, s in enumerate(syms)}
    V = len(syms)

    def row(sym, peak):
        p = np.full(V, (1 - peak) / (V - 1))
        p[vocab[sym]] = peak
        return p

    # No glottal stop: one weak arbitrary-phone spike, then the clean vowel-initial word.
    rows = [row("<pad>", .95), row(competitor, .23)]
    for ph in ["a", "h", "l", "a"]:
        rows += [row(ph, .9)] * 3 + [row("<pad>", .95)] * 2
    logp = np.log(np.array(rows))
    phrase = {"variants": [("اهلا", "ʔ a h l a")]}
    r = Scorer(lambda wav: logp, Vocab(vocab)).score_logp(logp, phrase, diagnostics=True)
    onset = r["trace"]["tokens"][0]
    assert onset["competitor_frames"] == 1
    assert onset["stages"][1]["applied"] == "glottal_dropped"
    assert r["words"][0]["score"] >= 90 and not r["tips"]


def test_sustained_initial_consonant_is_still_a_glottal_substitution():
    phrase = {"variants": [("اهلا", "ʔ a h l a")]}
    r = scorer_for(emissions(["s", "a", "h", "l", "a"])).score(None, phrase, diagnostics=True)
    onset = r["trace"]["tokens"][0]
    assert onset["competitor_frames"] >= 2
    assert onset["stages"][1]["applied"] is None
    assert r["words"][0]["phonemes"][0]["heard"] == "s"
    assert r["tips"][0]["key"] == "ʔ>s"


TAWLITNA = {"variants": [("طاولتنا", "tˤ aː w l i t n a")]}


def onset_logp(lead, competitor_frames=1, target_peak=.10, competitor="j"):
    """طاولتنا whose first consonant (after the `lead` phones) peaks weakly, behind `competitor_frames` frames of a
    competitor at 23%: the shape of a breath or microphone onset in front of a word-initial stop."""
    syms = ["<pad>", "tˤ", "aː", "w", "l", "i", "t", "n", "a", "j", "o"]
    vocab = {s: i for i, s in enumerate(syms)}

    def row(sym, peak):
        p = np.full(len(syms), (1 - peak) / (len(syms) - 1))
        p[vocab[sym]] = peak
        return p

    rows = [row("<pad>", .95)]
    for ph in lead:
        rows += [row(ph, .9)] * 3 + [row("<pad>", .95)] * 2
    rows += [row(competitor, .23)] * competitor_frames + [row("tˤ", target_peak), row("<pad>", .95)]
    for ph in ["aː", "w", "l", "i", "t", "n", "a"]:
        rows += [row(ph, .9)] * 3 + [row("<pad>", .95)] * 2
    logp = np.log(np.array(rows))
    return Scorer(lambda wav: logp, Vocab(vocab)), logp


def test_one_frame_spike_before_the_first_consonant_is_not_a_substitution():
    """Rule 31: tˤ said but weak at onset, outscored by one j frame (the reviewed طاولتنا recording)."""
    scorer, logp = onset_logp([])
    r = scorer.score_logp(logp, TAWLITNA, diagnostics=True)
    onset = r["trace"]["tokens"][0]
    assert onset["competitor_frames"] == 1 and onset["gop"] < 0
    assert onset["stages"][1]["applied"] == "onset_transient"
    assert onset["final"] >= 90 and not onset["flagged"]
    assert not r["tips"] and all(i["heard"] is None for i in r["issues"])   # listed as the weakest, j not named


def test_two_frame_competitor_before_the_first_consonant_is_still_a_substitution():
    scorer, logp = onset_logp([], competitor_frames=2)
    r = scorer.score_logp(logp, TAWLITNA, diagnostics=True)
    onset = r["trace"]["tokens"][0]
    assert onset["competitor_frames"] >= 2
    assert onset["stages"][1]["applied"] is None
    assert onset["flagged"] and r["tips"][0]["key"] == "tˤ>j"


def test_one_frame_spike_inside_the_phrase_is_not_an_onset_transient():
    scorer, logp = onset_logp(["a"])
    phrase = {"variants": [("اطاولتنا", "a tˤ aː w l i t n a")]}
    r = scorer.score_logp(logp, phrase, diagnostics=True)
    tok = next(t for t in r["trace"]["tokens"] if t["ipa"] == "tˤ")
    assert tok["i"] > 0 and tok["stages"][1]["applied"] != "onset_transient"
    assert tok["flagged"]


def test_onset_transient_toggle_switches_the_exemption_off():
    scorer, logp = onset_logp([])
    scorer.disabled = {"onset_transient"}
    r = scorer.score_logp(logp, TAWLITNA, diagnostics=True)
    assert r["trace"]["tokens"][0]["stages"][1]["applied"] is None
    assert r["trace"]["tokens"][0]["flagged"]


def test_ana_explicitly_accepts_the_natural_vowell_initial_form():
    from app.grammar import PRONOUNS

    ana = next(p for p in PRONOUNS if p[0] == "ana")
    assert ("انا", "a n a") in ana[3]
    phrase = {"variants": ana[3]}
    r = scorer_for(emissions(["a", "n", "a"])).score(None, phrase)
    assert r["variant"]["ipa"] == "a n a" and r["score"] >= 90
    assert not r["tips"] and not r["issues"]


def test_tts_endpoint_serves_phrase_audio_and_503_without_key():
    from fastapi.testclient import TestClient

    from app.main import create_app

    class FakeTTS:
        from app.tts import RAEF_VOICE_ID
        voice_id, voice_name = RAEF_VOICE_ID, "רַאאִף"

        def wav(self, text, voice=""):
            from app.arabic_diacritics import point_arabic
            assert text == point_arabic(*PHRASES[0]["variants"][0])
            assert voice == self.voice_id
            return b"RIFFfake", self.voice_id

    with TestClient(create_app(scorer_for(emissions(["w"])), FakeTTS())) as c:
        r = c.get("/api/tts/0")
        assert r.status_code == 200 and r.headers["content-type"] == "audio/wav" and r.content == b"RIFFfake"
        assert r.headers["x-tts-provider"] == "sawtakarabi.ai" and r.headers["x-tts-voice-id"] == FakeTTS.voice_id

        from app.tts import VOICES
        voices = c.get("/api/phrases").json()["voices"]
        assert [v["name"] for v in voices] == list(VOICES)
        assert voices == [{"id": FakeTTS.voice_id, "name": "רַאאִף"}]
        assert c.get("/api/tts/9999").status_code == 404
        health = c.get("/api/health").json()
        assert health["tts"] is True and health["rules"]["pharyngeal_h"]["detectable"]


def test_spelling_variants_of_a_sound_are_not_errors():
    # the multilingual vocab spells one sound several ways; the model saying t̪ / ɾ / æ for t / r / a is not a mistake
    syms = SYMS + ["t", "t̪", "tː", "r", "ɾ", "æ", "b"]
    vocab = {s: i for i, s in enumerate(syms)}
    V = len(syms)

    def row(sym, p):
        r = np.full(V, (1 - p) / (V - 1))
        r[vocab[sym]] = p
        return r

    rows = [row("<pad>", 0.95)] * 2
    for ph in ["m", "æ", "ɾ", "ħ", "æ", "b", "æ"]:   # مرحبا as the model may spell it
        rows += [row(ph, 0.9)] * 3 + [row("<pad>", 0.95)] * 2
    phrase = {"variants": [("مرحبا", "m a r ħ a b a")]}
    r = Scorer(lambda wav: np.log(np.array(rows)), Vocab(vocab)).score(None, phrase)
    assert r["score"] >= 95 and not r["unrelated"] and not r["tips"]
    import unicodedata
    assert unicodedata.normalize("NFC", r["heard_tr"]) == unicodedata.normalize("NFC", "מַרְחַבַּא")
    assert r["words"][0]["heard_tr"] == r["heard_tr"]


def test_heard_is_reported_per_word_in_hebrew():
    # ħ said as χ: the second word shows the ח' the model heard
    r = scorer_for(emissions(["w", "eː", "n", "i", "l", "χ", "a", "m", "aː", "m"])).score(None, TOILET)
    from app.translit import to_hebrew
    assert r["words"][0]["heard_tr"] == r["words"][0]["tr"]
    assert [w["heard_tr"] for w in r["words"]] == to_hebrew("w eː n | i l x a m m aː m")
    assert r["words"][1]["tr"] != r["words"][1]["heard_tr"]


def test_day2_grammar_reaches_every_pronoun_and_negation():
    from app.grammar import PRONOUNS, SENTENCES
    # day 2 has no tab on the page, so the bank is read directly, in the page's shape
    day2 = [{**p, "id": i, "ar": p["variants"][0][0]} for i, p in enumerate(PHRASES) if p["cat"] == "day2"]
    listed = [p for p in day2 if p["listed"]]
    assert len(listed) == len(SENTENCES) and not any(p["neg"] for p in listed)
    for p in listed:  # each taught sentence has its negation and the other seven subjects
        same = [q for q in day2 if q["grp"] == p["grp"]]
        assert len(same) == 2 * len(PRONOUNS)
        neg = next(q for q in same if q["pron"] == p["pron"] and q["neg"])
        assert " לא " in neg["he"] and " ما " in neg["ar"] and " מַא " in neg["tr"]
    she = next(p for p in day2 if p["he"] == "היא לא פתחה את הדלת ולא אכלה עם אחמד")
    assert she["ar"] == "هيّ ما فتحت الباب وما اكلت مع احمد"
    assert PHRASES[she["id"]]["variants"][0][1] == "h i j j e | m a | f a t ħ a t | i l b aː b | u m a | ʔ a k l a t | m a ʕ | ʔ a ħ m a d"


def test_hebrew_prompt_audio_uses_requested_engine_and_falls_back(tmp_path, monkeypatch):
    from fastapi.testclient import TestClient

    from app import prompt_tts
    from app.main import create_app

    said = []

    def broken(text):
        raise OSError("blocked")

    monkeypatch.setenv("TTS_CACHE_DIR", str(tmp_path))
    monkeypatch.setitem(prompt_tts._SYNTH, "edge", lambda text: said.append(("edge", text)) or b"EDGE")
    monkeypatch.setitem(prompt_tts._SYNTH, "gtts", broken)
    pid = PHRASES.index(find("מה שלומך? (לגבר)"))
    with TestClient(create_app(scorer_for(emissions(["w"])))) as c:
        r = c.get(f"/api/prompt-audio/{pid}?engine=edge")
        assert r.status_code == 200 and r.content == b"EDGE" and r.headers["x-tts-engine"] == "edge"
        assert said == [("edge", "מה שלומך?")]                       # the bracketed note is not read out
        r = c.get(f"/api/prompt-audio/{pid}?engine=gtts")             # gTTS down: served by the other engine
        assert r.status_code == 200 and r.headers["x-tts-engine"] == "edge" and len(said) == 1  # ... from cache
        assert c.get(f"/api/prompt-audio/{pid}?engine=other").status_code == 400
        r = c.get("/api/say", params={"text": "ציון 82. כמעט.", "engine": "edge"})   # driving mode's spoken assessment
        assert r.status_code == 200 and said[-1] == ("edge", "ציון 82. כמעט.") and not list(tmp_path.rglob("*82*"))
        assert len(list((tmp_path / "he").glob("*.mp3"))) == 1                         # ... is not cached
        assert c.get("/api/say", params={"text": "hello"}).status_code == 400
        monkeypatch.setitem(prompt_tts._SYNTH, "edge", broken)
        assert c.get("/api/prompt-audio/0?engine=edge").status_code == 503


def test_tips_come_most_crucial_first():
    # ħ badly wrong (χ), ʕ only swallowed into the vowel... both flagged: the worse sound leads
    phrase = {"variants": [("ساعة الحمام", "s aː ʕ a | i l ħ a m aː m")]}
    r = scorer_for(emissions(["s", "aː", "ʔ", "a", "i", "l", "χ", "a", "m", "aː", "m"])).score(None, phrase)
    assert len(r["tips"]) == 2
    assert [t["score"] for t in r["tips"]] == sorted(t["score"] for t in r["tips"])
    assert all("idx" not in t for t in r["tips"])



def test_raised_a_is_a_near_vowel():
    # اكلتي said with e for a, as the voices do: not marked down
    phrase = {"variants": [("اكلتي", "ʔ a k a l t i")]}
    syms = SYMS + ["t"]
    vocab = {s: i for i, s in enumerate(syms)}
    rows = [np.full(len(syms), 0.05 / (len(syms) - 1)) for _ in range(2)]
    for r in rows:
        r[0] = 0.95
    for ph in ["ʔ", "e", "k", "e", "l", "t", "i"]:
        for p, sym in [(0.9, ph)] * 3 + [(0.95, "<pad>")] * 2:
            r = np.full(len(syms), (1 - p) / (len(syms) - 1)); r[vocab[sym]] = p
            rows.append(r)
    r = Scorer(lambda wav: np.log(np.array(rows)), Vocab(vocab)).score(None, phrase)
    assert r["score"] >= 90 and not r["tips"]


def test_article_vowel_after_a_vowel_may_be_dropped():
    phrase = {"variants": [("على الحمام", "ʕ a l a | i l ħ a m m aː m")]}
    r = scorer_for(emissions(["ʕ", "a", "l", "a", "l", "ħ", "a", "m", "aː", "m"])).score(None, phrase)
    assert r["score"] >= 95 and not r["tips"] and not any(i["missing"] for i in r["issues"])
    # ... but not after a consonant: وين الحمام without the i is still noted
    r = scorer_for(emissions(["w", "eː", "n", "l", "ħ", "a", "m", "aː", "m"])).score(None, TOILET)
    assert any(i["missing"] for i in r["issues"])


def test_rule_28_exempts_final_b_heard_as_p_only_before_a_voiceless_onset():
    phrase = {"variants": [("حليب طارة", "ħ a l iː b | tˤ aː r e")]}
    extra = ["iː", "b", "p", "tˤ", "r"]
    phones = ["ħ", "a", "l", "iː", "p", "tˤ", "aː", "r", "e"]
    scorer = custom_scorer(extra, phones)
    result = scorer.score(None, phrase, diagnostics=True)
    b = next(t for t in result["trace"]["tokens"] if t["ipa"] == "b")
    exemption = next(stage for stage in b["stages"] if stage["stage"] == "exemption")
    assert b["competitor"] == "p" and exemption == {
        "stage": "exemption", "score": 100.0, "applied": "final_b_devoicing"
    }
    assert result["words"][0]["phonemes"][-1]["score"] == 100

    scorer.set_disabled({"final_b_devoicing"})
    without_rule = scorer.score(None, phrase)
    assert without_rule["words"][0]["phonemes"][-1]["score"] < 50

    # The same model confusion before a voiced onset is not covered by the narrow contextual rule.
    voiced = custom_scorer(extra, ["ħ", "a", "l", "iː", "p", "d", "a", "r"])
    no_exemption = voiced.score(None, {"variants": [("حليب دار", "ħ a l iː b | d a r")]})
    assert no_exemption["words"][0]["phonemes"][-1]["score"] < 50


def test_rule_29_floors_a_deleted_final_short_vowel_before_a_vowel_initial_word():
    phrase = {"variants": [("على إيدك", "ʕ a l a | ʔ iː d e k")]}
    scorer = custom_scorer(["iː"], ["ʕ", "a", "l", "ʔ", "iː", "d", "e", "k"])
    result = scorer.score(None, phrase, diagnostics=True)
    final_a = [t for t in result["trace"]["tokens"] if t["word"] == 0][-1]
    exemption = next(stage for stage in final_a["stages"] if stage["stage"] == "exemption")
    assert final_a["ipa"] == "a" and final_a["deleted"]
    assert exemption == {"stage": "exemption", "score": 90.0, "applied": "word_final_vowel_linking"}

    scorer.set_disabled({"word_final_vowel_linking"})
    without_rule = scorer.score(None, phrase, diagnostics=True)
    final_a = [t for t in without_rule["trace"]["tokens"] if t["word"] == 0][-1]
    exemption = next(stage for stage in final_a["stages"] if stage["stage"] == "exemption")
    assert exemption == {"stage": "exemption", "score": 90.0, "applied": "unmarked_short_vowel"}

    # A deleted final vowel before a consonant is not a hiatus and remains penalised.
    consonant = custom_scorer(["r"], ["ʕ", "a", "l", "d", "a", "r"])
    no_exemption = consonant.score(None, {"variants": [("على دار", "ʕ a l a | d a r")]}, diagnostics=True)
    final_a = [t for t in no_exemption["trace"]["tokens"] if t["word"] == 0][-1]
    assert next(s for s in final_a["stages"] if s["stage"] == "exemption")["applied"] != "word_final_vowel_linking"


def test_rule_30_floors_an_unmarked_short_vowel_but_not_checked_internal_u():
    # The conjunction's word-initial u is not a learner check; a native u mislabelled v cannot collapse the word.
    scorer = custom_scorer(["u", "v"], ["a", "v", "ʕ"])
    result = scorer.score(None, {"variants": [("ا و", "a | u ʕ")]}, diagnostics=True)
    u = next(t for t in result["trace"]["tokens"] if t["ipa"] == "u")
    exemption = next(stage for stage in u["stages"] if stage["stage"] == "exemption")
    assert u["competitor"] == "v"
    assert exemption == {"stage": "exemption", "score": 90.0, "applied": "unmarked_short_vowel"}
    assert result["words"][1]["phonemes"][0]["score"] == 90

    # u after a consonant in the same word remains checked by the kubutz rule (3.11).
    checked = custom_scorer(["u", "v"], ["k", "v", "l"])
    result = checked.score(None, {"variants": [("كل", "k u l")]}, diagnostics=True)
    u = next(t for t in result["trace"]["tokens"] if t["ipa"] == "u")
    exemption = next(stage for stage in u["stages"] if stage["stage"] == "exemption")
    assert exemption["applied"] is None and exemption["score"] < 50


ALA = {"variants": [("على الحمام", "ʕ a l a | i l ħ a m m aː m")]}
MISHEARD = ["ʔ", "a", "l", "a", "i", "l", "ħ", "a", "m", "aː", "m"]   # the model's ʕ in على: a glottal stop


def test_native_reference_forgives_what_the_model_mishears():
    s = scorer_for(emissions(MISHEARD))
    plain = s.score(None, ALA)
    assert [t["key"] for t in plain["tips"]] == ["ʕ>ʔ"] and plain["words"][0]["heard_tr"] != plain["words"][0]["tr"]
    ref = s.reference(None, ALA)                       # the native voice is heard the same way
    r = s.score(None, ALA, [ref])
    assert r["score"] == 100 and not r["tips"] and not r["issues"] and r["references"] == 1
    assert r["words"][0]["heard_tr"] == r["words"][0]["tr"]


def test_native_reference_keeps_real_errors():
    ref = scorer_for(emissions(MISHEARD)).reference(None, ALA)
    # the learner also says χ for ħ, which the voice did not
    r = scorer_for(emissions(["ʔ", "a", "l", "a", "i", "l", "χ", "a", "m", "aː", "m"])).score(None, ALA, [ref])
    assert [t["key"] for t in r["tips"]] == ["ħ>x"] and r["score"] < 85
    # a reference in which the model does not recognise the phrase is not a baseline
    assert scorer_for(emissions(["ʃ", "e", "k", "e", "d"])).reference(None, ALA) is None
    assert scorer_for(emissions(["ʕ", "χ", "l", "χ", "i", "l", "χ", "a", "χ", "aː", "χ"])).reference(None, ALA) is None


def test_references_use_one_generated_voice_and_any_cached_one():
    from app.reference import References
    from app.tts import TTSUnavailable

    calls = []

    class FakeTTS:
        down = False

        def cached(self, text, voice):
            return voice == "played"

        def wav(self, text, voice=""):
            calls.append(voice)
            if self.down:
                raise TTSUnavailable("down")
            return b"wav", voice

    import app.reference as reference
    tts, s = FakeTTS(), scorer_for(emissions(MISHEARD))
    orig, reference.decode_audio = reference.decode_audio, lambda data: None
    try:
        refs = References(s, tts, ["first", "never-played", "played"])
        assert len(refs.get(7, ALA)) == 2 and calls == ["first", "played"]
        assert len(refs.get(7, ALA)) == 2 and len(calls) == 2            # kept in memory
        assert refs.invalidate(7, "first") == 1
        assert len(refs.get(7, ALA)) == 2 and calls == ["first", "played", "first"]
        tts.down = True
        assert refs.get(8, ALA) == [] and refs.get(8, ALA) == [] and len(calls) == 5   # failures are not retried at once
    finally:
        reference.decode_audio = orig


WORD_TOPICS = lambda c: c.get("learn") or c.get("match")   # a lesson's new words and their game (test_workbook_new_words)


def test_workbook_lesson_1():
    from app.phrases import CATEGORIES, GAMES, LESSONS, public_phrases

    assert [l["id"] for l in LESSONS if not l.get("soon")] == ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6", "2.7", "2.8", "2.9"]
    assert [c["id"] for c in CATEGORIES if c["lesson"] == "1"] == ["l1-new", "l1-pairs", "l1-pron","l1-verbs", "l1-past", "l1-say", "l1-read", "l1-hw"]
    assert GAMES == [{"id": "game-yalla-verb", "name": "יאללה הפועל", "lesson": "1", "bubble": True, "hidden": True,
                      "unit": "פועל", "task": "אמרו את הפועל בערבית מדוברת: בעבר ואז בעתיד, לפני שהבועה נעלמת"}]
    hw = next(c for c in CATEGORIES if c["id"] == "l1-hw")["write"]   # the workbook's homework: a task and five sentences to write, with nothing to say
    assert hw["lines"] == 5 and hw["task"].startswith("כתבו חמישה משפטים") and "מִין - מי" in hw["task"]
    pub = public_phrases()
    count = lambda cat: sum(p["cat"] == cat for p in pub)
    assert [count(c) for c in ("l1-pron", "l1-verbs", "game-yalla-verb", "l1-past", "l1-say", "l1-read")] == [8, 4, 4, 32, 10, 22]
    assert all(p["cat"] in {c["id"] for c in CATEGORIES + GAMES} for p in pub)
    assert all(p["read"] == (p["cat"] == "l1-read") and "#" not in p["ar"] for p in pub if p["cat"].startswith("l1-"))
    assert all("seq" not in p for p in pub if p["cat"] == "l1-past")   # conjugations are drilled in random order
    by = {(p["cat"], p["he"]): p for p in PHRASES}
    assert by["l1-pron", "אנחנו"]["tr"] == "אִחְנַא"
    assert by["l1-verbs", "ישב (עבר, עתיד)"]["tr"] == "קַעַד, יֻקְעֹד"
    assert by["game-yalla-verb", "ישב (עבר, עתיד)"]["tr"] == "קַעַד, יֻקְעֹד"
    assert by["l1-past", "היא שאלה"]["tr"] == "הִיֵّ סַאְלַת"
    # the prompt voice gets pointed Hebrew (פתח is otherwise read "petach"); the page shows it plain
    assert by["l1-verbs", "פתח (עבר, עתיד)"]["he_say"] == "פָּתַח"
    assert by["l1-past", "את ישבת"]["he_say"] == "אַתְּ יָשַׁבְתְּ" and by["l1-pron", "הם / הן"]["he_say"] == "הֵם"
    assert by["l1-past", "אתה פתחת"]["variants"] == [("انت فتحت", "ʔ i n t e | f a t a ħ t"), ("انت فتحت", "ʔ i n t a | f a t a ħ t")]
    p = next(p for p in PHRASES if p["cat"] == "l1-read" and p["seq"] == 11)
    assert p["tr"] == "אֵלְמְעַלֵّם קַעַד עַלַא אֵ(ל)טַّאוְלֵה וּסַאַל-נַא סֻאַאל."
    ar, ipa = p["variants"][0]
    assert ar.split()[-2] == "وسألنا" and ipa.split(" | ")[-2] == "u s a ʔ a l n a"
    assert any(i.startswith("i l m ʕ a l l i m | q a ʕ a d") for _, i in p["variants"])   # rural ق accepted


def test_workbook_new_words():
    from app.phrases import CATEGORIES, LESSONS, public_phrases

    # every lesson opens with its new words, learned before anything is said, then a game on them; lesson 7, a review,
    # brings too few new words for either
    for l in (l["id"] for l in LESSONS if not l.get("soon")):
        mine = [c for c in CATEGORIES if c["lesson"] == l]
        if l == "7":
            assert not any(WORD_TOPICS(c) for c in mine)
            continue
        words, pairs = mine[:2]
        assert (words["id"], words["name"], words["learn"]) == (f"l{l}-new", "מילים חדשות", True)
        assert (pairs["id"], pairs["name"], pairs["match"]) == (f"l{l}-pairs", "התאימו זוגות", f"l{l}-new")   # it deals the words' cards
        assert not any(WORD_TOPICS(c) for c in mine[2:])
    cards = [p for p in public_phrases() if p["cat"].endswith("-new")]
    assert all(p["listed"] is False and not p["read"] for p in cards)   # no tally, daily round or teacher figure counts them
    assert not any(p["cat"].endswith("-pairs") for p in PHRASES)
    count = lambda l: sum(p["cat"] == f"l{l}-new" for p in cards)
    assert [count(l) for l in ("1", "2", "6", "2.8")] == [19, 20, 10, 10] and min(map(count, ["3", "4", "5", "8", "9", "10"])) >= 13
    # added at the bank's end: the ids of the phrases before them, which recorded attempts name, do not move
    ids = [i for i, p in enumerate(PHRASES) if p["cat"].endswith("-new")]
    assert ids == list(range(len(PHRASES) - len(ids), len(PHRASES)))
    by = {(p["cat"], p["he"]): p for p in cards}
    assert (by["l1-new", "דלת"]["ar"], by["l1-new", "דלת"]["tr"]) == ("باب", "בַּאבּ") and by["l1-new", "מתי"]["tr"] == "אֵימְתַא"
    assert by["l5-new", "בבקשה"]["ar"] == "من فضلك" and by["l2.1-new", "מוקדם"]["ar"] == "بكّير"


def test_levels_plan_every_lesson():
    from app.phrases import CATEGORIES, LESSONS, LEVELS
    from app.workbook import LESSONS as WRITTEN, LEVELS as PLAN, lesson_id

    # every lesson names its level, and each level lists all of its planned lessons, the unwritten ones as soon
    assert [v["id"] for v in LEVELS] == ["1", "2"] and len(LESSONS) == 20
    assert all(isinstance(key, tuple) and key[0] in PLAN for key in WRITTEN)
    # level 2's workbook goes on counting from level 1's: its first lesson is shown as the tenth, and keeps the id "2.1"
    assert [(l["level"], l["n"]) for l in LESSONS] == [("1", n) for n in range(1, 11)] + [("2", n) for n in range(10, 20)]
    assert [l["name"] for l in LESSONS if l["level"] == "2"][:2] == ["שיעור 10", "שיעור 11"] and LESSONS[0]["name"] == "שיעור 1"
    assert [l["id"] for l in LESSONS if l["level"] == "2"][:2] == ["2.1", "2.2"]   # level 1 keeps "1", "2": attempts name them
    assert {l["id"] for l in LESSONS if not l.get("soon")} == {lesson_id(*k) for k in WRITTEN} == {c["lesson"] for c in CATEGORIES}
    assert lesson_id(2, 1) == "2.1" and lesson_id(1, 3) == "3"


def test_workbook_lesson_2():
    from app.phrases import CATEGORIES, public_phrases

    cats = ["l2-dual", "l2-family", "l2-poss", "l2-say", "l2-say2", "l2-read", "l2-talk", "l2-meet"]
    assert [c["id"] for c in CATEGORIES if c["lesson"] == "2" and not WORD_TOPICS(c)] == cats + ["l2-hw"]   # the lesson ends with its written homework
    hw = next(c for c in CATEGORIES if c["id"] == "l2-hw")["write"]
    assert hw["lines"] == 5 and "פתרו את כל השיעור השני" in hw["task"] and "קַעַד / יֻקְעֹד – ישב" in hw["task"]
    pub = public_phrases()
    assert [sum(p["cat"] == c for p in pub) for c in cats] == [17, 24, 48, 23, 25, 13, 27, 24]
    assert all(p["read"] == (p["cat"] == "l2-read") and "#" not in p["ar"] for p in pub if p["cat"].startswith("l2-"))
    assert all("seq" not in p for p in pub if p["cat"] == "l2-poss")   # drilled in random order, like the conjugations
    by = {(p["cat"], p["he"]): p for p in PHRASES}
    assert by["l2-dual", "שולחן, שני שולחנות"]["tr"] == "טַאוְלֵה, טַאוְלְתֵין"   # singular and dual said together
    grandpa = by["l2-family", "סבא (שתי מילים)"]
    grandma = by["l2-family", "סבתא (שתי מילים)"]
    assert grandpa["variants"] == [("سيد، جدّ", "s iː d | dʒ i d d"), ("سيد", "s iː d"), ("جدّ", "dʒ i d d")]
    assert grandma["variants"] == [("ستّ، جدّة", "s i t t | dʒ i d d e"), ("ستّ", "s i t t"), ("جدّة", "dʒ i d d e")]
    # כינויי הקניין: the stem changes before a consonant suffix, and שלך is pointed for the prompt voice
    assert by["l2-poss", "השולחן שלי"]["tr"] == "טַאוְלְת-י" and by["l2-poss", "השולחן שלנו"]["tr"] == "טַאוְלֵת-נַא"
    you = by["l2-poss", "הבת שלך (לאישה)"]
    assert you["tr"] == "בִּנְתֵ-כּ" and you["he_say"] == "הבת שֶׁלָּךְ" and you["variants"] == [("بنتك", "b i n t i k")]
    assert by["l2-poss", "הכלב שלכם"]["variants"] == [("كلبكم", "k a l b k o m"), ("كلبكم", "k a l b k u")]
    assert ("جدّي", "s iː d i") in by["l2-poss", "הסבא שלי"]["variants"]   # the other word for סבא is accepted
    # דוד is pointed for the prompt voice, which otherwise reads it "David"; דודה is read correctly as it is
    assert by["l2-family", "דוד (מצד האב)"]["he_say"] == "דּוֹד (מצד האב)" and "he_say" not in by["l2-family", "דודה (מצד האב)"]
    assert by["l2-say", "איפה כלבכם? הוא אצל דודי (מצד אמא)."]["he_say"] == "איפה כלבכם? הוא אצל דּוֹדִי (מצד אמא)."
    assert by["l2-talk", "ה' ישמור אותם!"]["he_say"] == "אלוהים ישמור אותם!"   # the voice says אלוהים for ה'
    p = next(p for p in PHRASES if p["cat"] == "l2-talk" and p["seq"] == 2)
    assert p["tr"] == "וּעַלֵיכֹּם אֵ(ל)סַّלַאםֻ" and p["variants"][0][1] == "u ʕ a l eː k u m | i s s a l aː m u"
    p = next(p for p in PHRASES if p["cat"] == "l2-say" and p["seq"] == 22)
    assert p["tr"].endswith("אִמّ-י סַאְלַת-וֹ: לֵיש פַתַחְת-וֹ?")
    # חברי is pointed for the prompt voice, which otherwise reads it "chaverei" (my friends)
    assert by["l2-say", "אני ישבתי עם חברי לפני יומיים ואכלנו פיצה בסלון."]["he_say"].split()[3] == "חֲבֵרִי"
    assert "וַחֲבֵרִי" in by["l2-say", "מי ישב בבית השיח'? אני וחברי ישבנו בבית השיח'."]["he_say"]
    assert by["l2-dual", "שבוע, שבועיים"]["he_say"] == "שָׁבוּעַ, שבועיים"   # not "shvua"


def test_workbook_lesson_3():
    from app.phrases import CATEGORIES, public_phrases

    cats = ["l3-verbs", "l3-past", "l3-read", "l3-say", "l3-say2", "l3-nums", "l3-numread", "l3-numsay", "l3-story"]
    mine = [c for c in CATEGORIES if c["lesson"] == "3" and not WORD_TOPICS(c)]
    assert [c["id"] for c in mine] == cats + ["l3-hw"]
    hw = mine[-1]["write"]
    assert hw["lines"] == 5 and "פתרו את כל השיעור השלישי" in hw["task"] and "נַקַל / יֻנְקֹל – עבר (דירה, עבודה)" in hw["task"]
    # the story is laid out like the conversation, but told in the student's own voice: it names none
    assert mine[-2]["story"] and "voices" not in mine[-2] and CATEGORIES[[c["id"] for c in CATEGORIES].index("l2-talk")]["voices"] == ["אמיר", "ראאף"]
    pub = public_phrases()
    assert [sum(p["cat"] == c for p in pub) for c in cats] == [4, 32, 26, 12, 14, 37, 11, 11, 10]
    assert all(p["read"] == (p["cat"] in ("l3-read", "l3-numread")) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l3-"))
    assert all("seq" not in p for p in pub if p["cat"] == "l3-past")
    by = {(p["cat"], p["he"]): p for p in PHRASES}
    # a verb is said in the past and the future together; עבר alone would read as "past", so it names what is moved
    assert by["l3-verbs", "בישל (עבר, עתיד)"]["tr"] == "טַבַּח', יֻטְבֹּח'" and by["l3-verbs", "בישל (עבר, עתיד)"]["he_say"] == "בִּישֵּׁל"
    assert by["l3-verbs", "עבר דירה (עבר, עתיד)"]["variants"][:2] == [("نقل، ينقل", "n a ʔ a l | j u n ʔ u l"), ("نقل، ينقل", "n a ʔ a l | j u n q u l")]
    assert by["l3-past", "היא שחתה"]["tr"] == "הִיֵّ סַבְּחַת" and by["l3-past", "אנחנו בישלנו"]["tr"] == "אִחְנַא טַבַּחְ'נַא"
    # a number shows its digits; the prompt voice says the Hebrew word only
    assert by["l3-nums", "שבע עשרה (17)"]["tr"] == "סַבְּעְתַעַש" and by["l3-nums", "שבע עשרה (17)"]["he_say"] == "שבע עשרה"
    assert by["l3-nums", "עשרת אלפים (10,000)"]["tr"] == "עַשְרַת אַלַאף" and by["l3-nums", "שלוש מאות (300)"]["tr"] == "תַלַאת מִיֵّה"
    p = next(p for p in PHRASES if p["cat"] == "l3-read" and p["seq"] == 9)
    assert "הִיֵّ טַבְּחַ'ת-לוֹ מַקְלוּבֵּה טַיְّבֵּה." in p["tr"]
    p = next(p for p in PHRASES if p["cat"] == "l3-numsay" and p["seq"] == 6)
    assert p["tr"] == "עִנְד-נַא חְדַעְשַר עַםّ וּסַבְּעְתַעְשַר עַמֵّה."   # 11-19 gain a ר before a noun, which stays singular
    p = next(p for p in PHRASES if p["cat"] == "l3-story" and p["seq"] == 1)
    assert p["tr"] == "צַאחִבְּת-י רוּבַּה נַקְלַת עַלַא בֵּית גְ'דִיד פִי חַארֵת-נַא."


def test_workbook_lesson_4():
    from app.phrases import CATEGORIES, public_phrases

    cats = ["l4-verbs", "l4-past", "l4-read", "l4-read2", "l4-say", "l4-say2", "l4-story", "l4-meet", "l4-meetread"]
    mine = [c for c in CATEGORIES if c["lesson"] == "4" and not WORD_TOPICS(c)]
    assert [c["id"] for c in mine] == cats + ["l4-hw"]
    hw = mine[-1]["write"]
    assert hw["lines"] == 5 and "פתרו את כל השיעור הרביעי" in hw["task"] and "אִשְתַרַא / יִשְתְרִי – קנה" in hw["task"]
    story = mine[cats.index("l4-story")]
    assert story["story"] and "voices" not in story
    pub = public_phrases()
    assert [sum(p["cat"] == c for p in pub) for c in cats] == [4, 32, 12, 14, 12, 12, 20, 18, 6]
    reads = ("l4-read", "l4-read2", "l4-meetread")
    assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l4-"))
    by = {(p["cat"], p["he"]): p for p in PHRASES}
    # verbs that end in ا: the ا becomes ي before the endings of "I", "you" and "we"
    assert by["l4-verbs", "בא (עבר, עתיד)"]["tr"] == "אַגַ'א, יִגִ'י" and by["l4-verbs", "נתן (עבר, עתיד)"]["tr"] == "אַעְטַא, יֵעַטִי"
    assert by["l4-past", "אנחנו דיברנו"]["tr"] == "אִחְנַא חַכֵּינַא" and by["l4-past", "היא קנתה"]["tr"] == "הִיֵّ אִשְתַרַת"
    assert by["l4-past", "הם באו"]["variants"][0] == ("همّ اجو", "h u m m e | ʔ a dʒ u")
    p = next(p for p in PHRASES if p["cat"] == "l4-read" and p["seq"] == 4)
    assert p["tr"].startswith("גַ'אר-י אַגַ'א וּאַעַטַא-נִי תִסְעַה וּתַמַאנִין שֵיכֵּל")
    p = next(p for p in PHRASES if p["cat"] == "l4-story" and p["seq"] == 1)
    assert p["tr"] == "קַבֵּל אֻסְבּוּע אַגַ'א עַלַא חַארֵת-נַא גַ'אר גְ'דִיד."
    assert by["l4-story", "הוא אמר: \"ה' ישמור אותם!\""]["he_say"] == "הוא אמר: \"אלוהים ישמור אותם!\""
    assert by["l4-meet", "אני רוצה קפה עם סוכר, תודה!"]["tr"] == "בִּדّ-י קַהְוֵה מַע סֻכַּّר, שֻכְּרַן!"


def test_workbook_lesson_5():
    from app.phrases import CATEGORIES, public_phrases

    # the workbook's order: more of lesson 4's story, the negation, then the new verbs and the market story
    cats = ["l5-meet", "l5-meetread", "l5-neg", "l5-neg2", "l5-verbs", "l5-past", "l5-read", "l5-story", "l5-aread",
            "l5-say", "l5-say2", "l5-say3"]
    mine = [c for c in CATEGORIES if c["lesson"] == "5" and not WORD_TOPICS(c)]
    assert [c["id"] for c in mine] == cats + ["l5-hw"]
    hw = mine[-1]["write"]
    assert hw["lines"] == 5 and "פתרו את כל השיעור החמישי" in hw["task"] and "זַאר / יִזוּר – ביקר" in hw["task"]
    story = mine[cats.index("l5-story")]
    assert story["story"] and "voices" not in story
    pub = public_phrases()
    assert [sum(p["cat"] == c for p in pub) for c in cats] == [13, 12, 13, 13, 4, 32, 23, 15, 12, 13, 11, 12]
    reads = ("l5-meetread", "l5-neg", "l5-neg2", "l5-read", "l5-aread")
    assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l5-"))
    by = {(p["cat"], p["he"]): p for p in PHRASES}
    assert by["l5-verbs", "היה (עבר, עתיד)"]["tr"] == "כַּאן, יִכּוּן" and by["l5-past", "אנחנו היינו"]["tr"] == "אִחְנַא כֻּנַّא"
    assert ("انا قلت", "ʔ a n a | q u l t") in by["l5-past", "אני אמרתי"]["variants"]   # rural ق accepted
    # the negation: مش before a noun or an adjective, ما before a verb, which may also end in ش
    p = next(p for p in PHRASES if p["cat"] == "l5-neg" and p["seq"] == 9)
    assert p["tr"].startswith("אַנַא מַא קַעַדְתֵש עִנְד גַ'אר-י") and p["he"].startswith("אני לא ישבתי")
    p = next(p for p in PHRASES if p["cat"] == "l5-neg2" and p["seq"] == 8)
    assert p["tr"].endswith("קֻלְתִ-לّוֹ: וַאללַّה מַא אִשְתַרֵינַא אִשִי!")   # والله is one word, not "and" + الله
    p = next(p for p in PHRASES if p["cat"] == "l5-story" and p["seq"] == 5)
    assert p["tr"].startswith("לַמַّא וְצִלְנַא עַלַא אֵ(ל)סّוּק") and p["variants"][0][1].split(" | ")[1] == "w sˤ i l n a"
    assert by["l5-story", "ה' ייתן לך בריאות!"]["he_say"] == "אלוהים ייתן לך בריאות!"


def test_workbook_lesson_6():
    from app.phrases import CATEGORIES, public_phrases

    cats = ["l6-fut", "l6-futsay", "l6-verbs", "l6-past", "l6-read", "l6-read2", "l6-story", "l6-meet", "l6-meetread"]
    mine = [c for c in CATEGORIES if c["lesson"] == "6" and not WORD_TOPICS(c)]
    assert [c["id"] for c in mine] == cats + ["l6-hw"]
    hw = mine[-1]["write"]
    assert hw["lines"] == 5 and "פתרו את כל השיעור השישי" in hw["task"] and "פעם אחת בעבר ופעם אחת בעתיד" in hw["task"]
    story = mine[cats.index("l6-story")]
    assert story["story"] and story["voices"] == ["איוב", "נסרין"]
    pub = public_phrases()
    assert [sum(p["cat"] == c for p in pub) for c in cats] == [24, 13, 5, 40, 11, 13, 12, 12, 12]
    reads = ("l6-read", "l6-read2", "l6-meetread")
    assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l6-"))
    assert all("seq" not in p for p in pub if p["cat"] in ("l6-fut", "l6-past"))   # conjugations are drilled in random order
    by = {(p["cat"], p["he"]): p for p in PHRASES}
    # the future: the Hebrew letters אית"ן on the future's base form, and אַ for "I" whatever the base form's vowel
    assert by["l6-fut", "אני אשב"]["tr"] == "אַנַא אַקְעֹד" and by["l6-fut", "אנחנו נשב"]["tr"] == "אִחְנַא נֻקְעֹד"
    assert by["l6-fut", "את תבקרי"]["tr"] == "אִנְתִי תְזוּרִי" and by["l6-fut", "הם ישמעו"]["he_say"] == "הֵם יִשְׁמְעוּ"
    # فات is "entered" like lesson 3's دخل, so its card says more; اخد is the homework's fifth verb
    assert by["l6-verbs", "נכנס ועבר (עבר, עתיד)"]["tr"] == "פַאת, יִפוּת" and by["l6-verbs", "לקח (עבר, עתיד)"]["tr"] == "אַחַ'ד, יֹאחֵ'ד"
    assert by["l6-past", "אתם נכנסתם"]["tr"] == "אִנְתוּ פֻתّוּ" and by["l6-past", "אני ראיתי"]["tr"] == "אַנַא שֻפֵת"
    p = next(p for p in PHRASES if p["cat"] == "l6-futsay" and p["seq"] == 3)
    assert p["tr"] == "אִחְנַא סַבַּחְנַא פִי אֵלְבַּחַר, בֻּכְּרַא נִסְבַּח פִי אֵלְבִּרְכֵּה."
    # a dialogue: the speaker is written before a turn's first line but neither said nor scored;
    # נסרין's turns are in Hanan's voice, איוב's in Raef's
    lines = [p for p in PHRASES if p["cat"] == "l6-story"]
    assert not any(":" in p["he"] or ":" in p["variants"][0][0] for p in lines)
    assert [(p["seq"], p["speaker"]) for p in lines if "speaker" in p] == [(1, "איוב"), (2, "נסרין"), (3, "איוב"), (10, "נסרין")]
    assert [p["seq"] for p in lines if p.get("voice") == "חנאן"] == [2, 10, 11, 12]
    p = next(p for p in PHRASES if p["cat"] == "l6-story" and p["seq"] == 9)
    assert p["tr"] == "לַוְ כַּאן עִנְד-י מַרַה זַיֵّ-כּ..." and p["variants"][0][0].endswith("زيّك...")


def test_workbook_lesson_7():
    from app.phrases import CATEGORIES, public_phrases

    # a review lesson: no new verbs and no story
    cats = ["l7-fem", "l7-dual", "l7-nums", "l7-count", "l7-verbs", "l7-read", "l7-say", "l7-say2"]
    mine = [c for c in CATEGORIES if c["lesson"] == "7" and not WORD_TOPICS(c)]
    assert [c["id"] for c in mine] == cats + ["l7-hw"] and not any(c.get("story") for c in mine)
    assert mine[0]["task"] == "אמרו בערבית מדוברת: בזכר ואז בנקבה" and mine[1]["task"].endswith("ביחיד ואז בזוגי")
    hw = mine[-1]["write"]
    assert hw["lines"] == 5 and "בשיעור השביעי" in hw["task"] and "פעם אחת בעבר ופעם אחת בעתיד" in hw["task"]
    pub = public_phrases()
    assert [sum(p["cat"] == c for p in pub) for c in cats] == [8, 13, 12, 15, 21, 23, 14, 13]
    assert all(p["read"] == (p["cat"] == "l7-read") and "#" not in p["ar"] for p in pub if p["cat"].startswith("l7-"))
    by = {(p["cat"], p["he"]): p for p in PHRASES}
    assert by["l7-fem", "רווק, רווקה"]["tr"] == "עַזַّאבִּי, עַזַّאבִּיֵّה" and by["l7-dual", "מיטה, שתי מיטות"]["tr"] == "תַחְ'ת, תַחְ'תֵין"
    assert by["l7-nums", "מאה שבעים וחמש (175)"]["tr"] == "מִיֵّה חַ'מְסֵה וּסַבְּעִין"   # units before tens
    # 3-10: the noun in the plural and the number without its ة; from 11: the noun in the singular, and 11-19 with a ר
    assert by["l7-count", "שבעה ילדים"]["tr"] == "סַבְּע אַוְלַאד" and by["l7-count", "שתים עשרה בנות"]["tr"] == "תְנַעְשַר בִּנְת"
    # every verb of lessons 1-6, each still said in the past and the future together
    verbs = [p for p in PHRASES if p["cat"] == "l7-verbs"]
    assert [p["tr"] for p in verbs][:2] == ["פַתַח, יִפְתַח", "אַכַּל, יֹאכֹּל"] and verbs[-1]["tr"] == "פַאת, יִפוּת"
    assert all(" | " in p["variants"][0][1] and p["he"].endswith("(עבר, עתיד)") for p in verbs)


def test_workbook_lesson_8():
    from app.phrases import CATEGORIES, public_phrases

    cats = ["l8-howmany", "l8-verbs", "l8-past", "l8-read", "l8-read2", "l8-read3", "l8-colors", "l8-colread", "l8-story",
            "l8-colsay", "l8-colsay2"]
    mine = [c for c in CATEGORIES if c["lesson"] == "8" and not WORD_TOPICS(c)]
    assert [c["id"] for c in mine] == cats + ["l8-hw"]
    hw = mine[-1]["write"]
    assert hw["lines"] == 5 and "פתרו את כל השיעור השמיני" in hw["task"] and "פעלים וצבעים" in hw["task"]
    song = mine[cats.index("l8-story")]
    assert song["name"] == "שיר הצבעים" and song["story"] and "voices" not in song
    assert mine[cats.index("l8-colors")]["task"].endswith("בזכר ואז בנקבה")
    pub = public_phrases()
    assert [sum(p["cat"] == c for p in pub) for c in cats] == [13, 5, 40, 12, 12, 13, 17, 13, 25, 10, 12]
    reads = ("l8-read", "l8-read2", "l8-read3", "l8-colread")
    assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l8-"))
    by = {(p["cat"], p["he"]): p for p in PHRASES}
    # גזרת פִעֵל: the first vowel is lost before the endings of "I", "you" and "we"
    assert by["l8-verbs", "הבין (עבר, עתיד)"]["tr"] == "פִהֵם, יִפְהַם" and by["l8-verbs", "יצא ועלה (עבר, עתיד)"]["tr"] == "טִלֵע, יִטְלַע"
    assert by["l8-past", "אני שתיתי"]["tr"] == "אַנַא שְרִבֵּת" and by["l8-past", "היא חזרה"]["tr"] == "הִיֵّ רִגְ'עַת"
    # a colour is said in the masculine and the feminine together
    assert by["l8-colors", "לבן, לבנה"]["tr"] == "אַבְּיַדֿ, בֵּידַֿא" and by["l8-colors", "לבן, לבנה"]["variants"][0][1] == "ʔ a b j a dˤ | b eː dˤ a"
    assert by["l8-colors", "אפור, אפורה (מהמילה אפר)"]["tr"] == "רַמַאדִי, רַמַאדִיֵّה"
    # how many: اكم and a singular noun for what is counted, قدّيش for what is not
    p = next(p for p in PHRASES if p["cat"] == "l8-howmany" and p["seq"] == 9)
    assert p["tr"].startswith("קַדֵّיש מַצַארִי בִּדّ-וֹ?")
    p = next(p for p in PHRASES if p["cat"] == "l8-howmany" and p["seq"] == 2)
    assert p["tr"].startswith("אַכַּם וַאחַד כַּאן פִי אֵ(ל)סַّיַّארַה?")
    assert by["l8-story", "אני בשמיים נמצא"]["tr"] == "אַנַא פִי אֵ(ל)סַّמַאִי מַוְג'וּד"


def test_workbook_lesson_9():
    from app.phrases import CATEGORIES, public_phrases

    cats = ["l9-story", "l9-aread", "l9-asay", "l9-verbs", "l9-past", "l9-want", "l9-read", "l9-read2", "l9-say", "l9-mix",
            "l9-mixread"]
    mine = [c for c in CATEGORIES if c["lesson"] == "9" and not WORD_TOPICS(c)]
    assert [c["id"] for c in mine] == cats + ["l9-hw"]
    hw = mine[-1]["write"]
    assert hw["lines"] == 5 and "השיעור התשיעי" in hw["task"] and "אחד בעבר ואחד בעתיד" in hw["task"]
    story = mine[cats.index("l9-story")]
    assert story["name"] == "סיפור הקפה" and story["story"] and "voices" not in story
    pub = public_phrases()
    assert [sum(p["cat"] == c for p in pub) for c in cats] == [15, 13, 12, 2, 16, 24, 13, 13, 12, 6, 6]
    reads = ("l9-aread", "l9-read", "l9-read2", "l9-mixread")
    assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l9-"))
    by = {(p["cat"], p["he"]): p for p in PHRASES}
    assert by["l9-verbs", "הודה (עבר, עתיד)"]["tr"] == "שַכַּר, יֻשְכֹּר"
    # "to lunch" is one verb in Arabic: the Hebrew prompt names the meal, once
    assert by["l9-verbs", "אכל צהריים (עבר, עתיד)"]["tr"] == "תְעַ'דַّא, יִתְעַ'דַّא"
    assert by["l9-past", "אנחנו אכלנו צהריים"]["tr"] == "אִחְנַא תְעַ'דֵّינַא"
    # wanting: بدّ with the person's suffix, then the verb in the future (Arabic has no infinitive)
    assert by["l9-want", "אני רוצה לצאת"]["tr"] == "בִּדّ-י אַטְלַע" and by["l9-want", "את רוצה לצאת"]["tr"] == "בִּדֵّ-כּ תִטְלַעִי"
    assert by["l9-want", "הם רוצים לאכול צהריים"]["tr"] == "בִּדّ-הֹם יִתְעַ'דּוּ"
    assert by["l9-want", "אתה רוצה לשמוע"]["variants"][0][1] == "b i d d a k | t i s m a ʕ"
    p = next(p for p in PHRASES if p["cat"] == "l9-story" and p["seq"] == 8)
    assert p["tr"].startswith("שְרִבְּנַא קַהְוֵה סַאדַא")


def test_workbook_lesson_10():
    from app.phrases import CATEGORIES, public_phrases

    cats = ["l10-adj", "l10-compread", "l10-compread2", "l10-verbs", "l10-past", "l10-read", "l10-read2", "l10-say", "l10-say2",
            "l10-dem", "l10-story", "l10-phrases"]
    mine = [c for c in CATEGORIES if c["lesson"] == "10" and not WORD_TOPICS(c)]
    assert [c["id"] for c in mine] == cats + ["l10-hw"]
    hw = mine[-1]["write"]
    assert hw["lines"] == 5 and "השיעור העשירי" in hw["task"] and "אחד בעבר ואחד בעתיד" in hw["task"]
    story = mine[cats.index("l10-story")]
    assert story["name"] == "סיפור דודי סעיד" and story["story"] and "voices" not in story
    assert mine[0]["unit"] == "תואר" and mine[0]["task"].endswith("ערך היתרון שלו")
    pub = public_phrases()
    assert [sum(p["cat"] == c for p in pub) for c in cats] == [33, 8, 8, 5, 40, 12, 9, 12, 9, 12, 25, 19]
    reads = ("l10-compread", "l10-compread2", "l10-read", "l10-read2")
    assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l10-"))
    by = {(p["cat"], p["he"]): p for p in PHRASES}
    # an adjective is said with its comparative (אַפְעַל)
    assert by["l10-adj", "גדול, יותר גדול"]["tr"] == "כְּבִּיר, אַכְּבַּר"
    assert by["l10-adj", "מתוק, יותר מתוק"]["tr"] == "חִלוּ, אַחְלַא" and by["l10-adj", "חשוב, יותר חשוב"]["tr"] == "מֻהִםّ, אַהַםّ"
    assert by["l10-adj", "טעים, יותר טעים (לאוכל בלבד)"]["tr"] == "זַאכִּי, אַזְכַּא"
    assert by["l10-adj", "נקי, יותר נקי"]["variants"][0][1] == "n dˤ iː f | ʔ a n dˤ a f"
    # גזרת פִעֵל again
    assert by["l10-verbs", "שיחק (עבר, עתיד)"]["tr"] == "לִעֵבּ, יִלְעַבּ" and by["l10-verbs", "הגיע (עבר, עתיד)"]["tr"] == "וִצֵל, יוֹצַל"
    assert by["l10-past", "אני שיחקתי"]["tr"] == "אַנַא לְעִבֵּת" and by["l10-past", "היא צחקה"]["tr"] == "הִיֵّ דִֿחְכַּת"
    assert by["l10-past", "אנחנו הגענו"]["tr"] == "אִחְנַא וְצִלְנַא"
    assert by["l10-dem", "אלה"]["tr"] == "הַדוֹל" and by["l10-dem", "הפעם הזאת"]["tr"] == "הַאדִי אֵלְמַרַّה"
    p = next(p for p in PHRASES if p["cat"] == "l10-story" and p["seq"] == 8)
    assert p["tr"].startswith("הוּّ קַאלִّ-י: וַלַא יְהִמֵّ-כּ")


def test_workbook_level_2_lesson_1():
    from app.phrases import CATEGORIES, public_phrases

    # the second workbook (שלב ב') opens with the lesson it numbers the tenth: five verbs, and the future after لازم and رح
    cats = ["l2.1-verbs", "l2.1-past", "l2.1-must", "l2.1-will", "l2.1-read", "l2.1-read2", "l2.1-say", "l2.1-say2", "l2.1-story"]
    mine = [c for c in CATEGORIES if c["lesson"] == "2.1" and not WORD_TOPICS(c)]
    assert [c["id"] for c in mine] == cats + ["l2.1-hw"]
    hw = mine[-1]["write"]
    assert hw["lines"] == 5 and "רַח ולַאזֵם" in hw["task"] and "רַבַּט / יֻרְבֹּט – קשר" in hw["task"]
    story = mine[cats.index("l2.1-story")]
    assert story["name"] == "סיפור המכולת" and story["story"] and "voices" not in story
    assert mine[0]["unit"] == "פועל" and mine[0]["task"].endswith("בעבר ואז בעתיד")
    pub = public_phrases()
    assert [sum(p["cat"] == c for p in pub) for c in cats] == [5, 40, 24, 24, 12, 11, 12, 11, 29]
    reads = ("l2.1-read", "l2.1-read2")
    assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l2.1-"))
    by = {(p["cat"], p["he"]): p for p in PHRASES}
    # a verb is said in the past and the future together
    assert by["l2.1-verbs", "רץ (עבר, עתיד)"]["tr"] == "רַכַּדֿ, יֻרְכֹּדֿ" and by["l2.1-verbs", "שלח (עבר, עתיד)"]["tr"] == "בַּעַת', יִבְּעַת'"
    assert by["l2.1-past", "אני רצתי"]["tr"] == "אַנַא רַכַּדֵֿת" and by["l2.1-past", "היא שילמה"]["tr"] == "הִיֵّ דַפְעַת"
    assert by["l2.1-past", "הם קשרו"]["variants"][0][1] == "h u m m e | r a b a tˤ u"
    # لازم and رح stay as they are; the verb after them is in the future
    assert by["l2.1-must", "את צריכה לראות"]["tr"] == "אִנְתִי לַאזֵם תְשוּפִי" and by["l2.1-must", "הם צריכים לקשור"]["tr"] == "הֹםّ לַאזֵם יֻרֻבְּטוּ"
    assert by["l2.1-will", "אני הולך לרוץ"]["tr"] == "אַנַא רַח אַרְכֹּדֿ" and by["l2.1-will", "אנחנו הולכים לשתות"]["tr"] == "אִחְנַא רַח נִשְרַבּ"
    p = next(p for p in PHRASES if p["cat"] == "l2.1-story" and p["seq"] == 10)
    assert "אֻרְבֹּט (א)לִחְמַאר מַטְרַח מַא יֵקוּל-לַّכּ צַאחְבּ-וֹ" in p["tr"] and "קְשׁוֹר" in p["he_say"]


def test_workbook_level_2_lesson_2():
    from app.phrases import CATEGORIES, public_phrases
    from app.workbook import _present_of

    # the workbook's eleventh lesson: the days of the week and the present, which is بـ on the future form
    cats = ["l2.2-days", "l2.2-pres", "l2.2-read", "l2.2-read2", "l2.2-read3", "l2.2-say", "l2.2-say2", "l2.2-story", "l2.2-fread"]
    mine = [c for c in CATEGORIES if c["lesson"] == "2.2" and not WORD_TOPICS(c)]
    assert [c["id"] for c in mine] == cats + ["l2.2-hw"]
    hw = mine[-1]["write"]
    assert hw["lines"] == 5 and "ואז בהוו'ה" in hw["task"]
    story = mine[cats.index("l2.2-story")]
    assert story["name"] == "סיפור הפלאפל" and story["story"] and "voices" not in story
    assert mine[0]["unit"] == "מילה"
    pub = public_phrases()
    assert [sum(p["cat"] == c for p in pub) for c in cats] == [10, 24, 8, 8, 8, 8, 8, 19, 11]
    reads = ("l2.2-read", "l2.2-read2", "l2.2-read3", "l2.2-fread")
    assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l2.2-"))
    by = {(p["cat"], p["he"]): p for p in PHRASES}
    assert by["l2.2-days", "יום שלישי"]["tr"] == "יוֹם אֵ(ל)תַّלַאתַא" and by["l2.2-days", "חודש"]["tr"] == "שַהֵר"
    # the prefix takes the vowel the form needs, and "we" may take مـ
    assert by["l2.2-pres", "אני שואל"]["tr"] == "אַנַא בַּסְאַל" and by["l2.2-pres", "אתה שואל"]["tr"] == "אִנְתֵ בְּתִסְאַל"
    assert by["l2.2-pres", "הוא רואה"]["tr"] == "הוּّ בִּישוּף" and by["l2.2-pres", "אנחנו רואים"]["tr"] == "אִחְנַא בִּנְשוּף"
    assert by["l2.2-pres", "הם שותים"]["variants"][0][1] == "h u m m e | b i ʃ r a b u"
    assert _present_of("منيجي") == ("מְנִיגִ'י", ["m n iː dʒ i"]) and _present_of("بيشوفني")[0] == "בִּישוּפ-נִי"
    assert _present_of("باكل")[0] == "בַּאכֹּל" and _present_of("بيت") is None and _present_of("بدّي") is None
    p = next(p for p in PHRASES if p["cat"] == "l2.2-fread" and p["seq"] == 5)
    assert p["tr"].endswith("וּבְּנִשְרַבּ שַאיְ מַע בַּעַדֿ.")   # "and" on a present form


def test_workbook_level_2_lesson_3():
    from app.phrases import CATEGORIES, public_phrases

    # the workbook's twelfth lesson: the clock, and בניין שני (פַעַّל) beside the פִעֵל verbs it is made from
    cats = ["l2.3-hours", "l2.3-clock", "l2.3-talk", "l2.3-read", "l2.3-read2", "l2.3-say", "l2.3-say2", "l2.3-verbs", "l2.3-past",
            "l2.3-vread", "l2.3-vread2", "l2.3-vsay", "l2.3-vsay2", "l2.3-story"]
    mine = [c for c in CATEGORIES if c["lesson"] == "2.3" and not WORD_TOPICS(c)]
    assert [c["id"] for c in mine] == cats + ["l2.3-hw"]
    hw = mine[-1]["write"]
    assert hw["lines"] == 5 and "שַרַّבּ – השקה" in hw["task"] and "עבר – עתיד – הווה" in hw["task"]
    talk, song = mine[cats.index("l2.3-talk")], mine[cats.index("l2.3-story")]
    assert talk["story"] and talk["voices"] and song["story"] and "voices" not in song   # a talk has two speakers, a song none
    assert mine[0]["unit"] == "שעה" and mine[cats.index("l2.3-verbs")]["unit"] == "פועל"
    pub = public_phrases()
    assert [sum(p["cat"] == c for p in pub) for c in cats] == [12, 15, 6, 7, 7, 9, 8, 9, 16, 12, 12, 12, 12, 14]
    reads = ("l2.3-read", "l2.3-read2", "l2.3-vread", "l2.3-vread2")
    assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l2.3-"))
    by = {(p["cat"], p["he"]): p for p in PHRASES}
    # an hour is feminine, and the minutes come after it: a quarter to ten is "ten less a quarter"
    assert by["l2.3-hours", "השעה שתיים"]["tr"] == "אֵ(ל)סֵّיעַה תִנְתֵין" and by["l2.3-hours", "השעה אחת עשרה"]["tr"] == "אֵ(ל)סֵّיעַה חְדַעַש"
    assert by["l2.3-clock", "השעה רבע לעשר"]["tr"] == "אֵ(ל)סֵّיעַה עַשַרַה אִלַّא רֻבֵּע"
    assert by["l2.3-clock", "השעה אחת עשרה שלושים וחמש"]["variants"][0][1] == "i s s eː ʕ a | ħ d a ʕ a ʃ | u n u sˤ sˤ | u x a m s e"
    # a verb is said in the past and the future together; لبس beside لبّس in the past
    assert by["l2.3-verbs", "הוריד (עבר, עתיד)"]["tr"] == "נַזַّל, יְנַזֵّל" and by["l2.3-verbs", "השקה (עבר, עתיד)"]["tr"] == "שַרַّבּ, יְשַרֵّבּ"
    assert by["l2.3-past", "היא לבשה"]["tr"] == "הִיֵّ לִבְּסַת" and by["l2.3-past", "היא הלבישה"]["tr"] == "הִיֵّ לַבַּّסַת"
    assert by["l2.3-past", "אנחנו הלבשנו"]["variants"][0][1] == "ʔ i ħ n a | l a b b a s n a"
    # the song asks (שַרֵّבּ), where the exercises tell (שַרַّבּ)
    first = next(p for p in PHRASES if p["cat"] == "l2.3-story" and p["seq"] == 1)
    assert first["tr"] == "שַרֵّבְּ-נִי קַהְוֵה פַתֵّח לִי רַאס-י!"
    told = next(p for p in PHRASES if p["cat"] == "l2.3-vread2" and "קפה קר" in p["he"])
    assert "וּשַרַّבְּ-נִי קַהְוֵה בַּארְדֵה" in told["tr"]


def test_workbook_level_2_lesson_4():
    from app.phrases import CATEGORIES, public_phrases

    # the workbook's thirteenth lesson: five more of בניין שני, the three words for "that" (انّه, ما, الّي), and a phone call
    cats = ["l2.4-verbs", "l2.4-past", "l2.4-vread", "l2.4-vread2", "l2.4-vsay", "l2.4-vsay2", "l2.4-rel", "l2.4-rread", "l2.4-rread2",
            "l2.4-rsay", "l2.4-rsay2", "l2.4-words", "l2.4-talk", "l2.4-talk2"]
    mine = [c for c in CATEGORIES if c["lesson"] == "2.4" and not WORD_TOPICS(c)]
    assert [c["id"] for c in mine] == cats + ["l2.4-hw"]
    hw = mine[-1]["write"]
    assert hw["lines"] == 5 and "פַכַּّר / יְפַכֵּّר – חשב" in hw["task"]
    assert all(mine[cats.index(c)]["story"] and mine[cats.index(c)]["voices"] for c in ("l2.4-talk", "l2.4-talk2"))   # two speakers each
    assert mine[0]["unit"] == "פועל"
    pub = public_phrases()
    assert [sum(p["cat"] == c for p in pub) for c in cats] == [5, 16, 13, 13, 13, 13, 14, 7, 7, 7, 7, 12, 14, 11]
    reads = ("l2.4-vread", "l2.4-vread2", "l2.4-rread", "l2.4-rread2")
    assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l2.4-"))
    by = {(p["cat"], p["he"]): p for p in PHRASES}
    assert by["l2.4-verbs", "שר (עבר, עתיד)"]["tr"] == "עַ'נַّא, יְעַ'נִّי" and by["l2.4-verbs", "חשב (עבר, עתיד)"]["tr"] == "פַכַּّר, יְפַכֵּّר"
    # غنّى ends in a vowel: its past takes ـيت, and "she" and "they" drop it
    assert by["l2.4-past", "אני שרתי"]["tr"] == "אַנַא עַ'נֵّית" and by["l2.4-past", "היא שרה"]["tr"] == "הִיֵّ עַ'נַّת"
    assert by["l2.4-past", "הם חשבו"]["variants"][0][1] == "h u m m e | f a k k a r u"
    assert by["l2.4-rel", "הילד שהלך"]["tr"] == "אֵלְוַלַד אִלִّי רַאח" and by["l2.4-rel", "לפני שאצא מהבית שלו"]["tr"] == "קַבֵּל מַא אַטְלַע מִן בֵּית-וֹ"
    # the present of a פַעַّל verb: בִּי on "he", בַּ on "I"
    son = next(p for p in PHRASES if p["cat"] == "l2.4-rread" and "עברתי לבית חדש" in p["he"])
    assert "אִבְּנ-י בִּיפַכֵּّר יֻנְקֹל" in son["tr"]
    call = next(p for p in PHRASES if p["cat"] == "l2.4-talk2" and p["seq"] == 7)
    assert call["tr"].startswith("טַבַּעַן בַּחַ'בְּّרַ-כּ") and "אוֹתְךָ" in call["he_say"]
    lunch = next(p for p in PHRASES if p["cat"] == "l2.4-rread" and "לבדך" in p["he"])
    assert "כֻּנֵת בַּתְעַ'דַّא מַעַ-כּ" in lunch["tr"]   # "I", where بـ on تتغدّى would be "you"


def test_workbook_level_2_lesson_5():
    from app.phrases import CATEGORIES, public_phrases

    # the workbook's fourteenth lesson: גזרת ע"י, what صار does, and the garage story
    cats = ["l2.5-verbs", "l2.5-past", "l2.5-pres", "l2.5-sar", "l2.5-vread", "l2.5-vread2", "l2.5-vsay", "l2.5-words", "l2.5-story",
            "l2.5-ask", "l2.5-sread", "l2.5-ssay", "l2.5-adj"]
    mine = [c for c in CATEGORIES if c["lesson"] == "2.5" and not WORD_TOPICS(c)]
    assert [c["id"] for c in mine] == cats + ["l2.5-hw"]
    hw = mine[-1]["write"]
    assert hw["lines"] == 5 and "צַאר / יְצִיר – נהיה" in hw["task"]
    story = mine[cats.index("l2.5-story")]
    assert story["name"] == "סיפור המוסך" and story["story"] and "voices" not in story
    assert mine[0]["unit"] == "פועל"
    pub = public_phrases()
    assert [sum(p["cat"] == c for p in pub) for c in cats] == [6, 16, 16, 10, 8, 7, 7, 12, 14, 10, 12, 12, 10]
    reads = ("l2.5-vread", "l2.5-vread2", "l2.5-ask", "l2.5-sread")
    assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l2.5-"))
    by = {(p["cat"], p["he"]): p for p in PHRASES}
    # the past's ا is the future's ي, and the past of "I" loses it: גַ'אבּ, יְגִ'יבּ, גִ'בֵּת
    assert by["l2.5-verbs", "הביא (עבר, עתיד)"]["tr"] == "גַ'אבּ, יְגִ'יבּ" and by["l2.5-verbs", "חיכה (עבר, עתיד)"]["tr"] == "אִסְתַנַّא, יִסְתַנַّא"
    assert by["l2.5-past", "אני הבאתי"]["tr"] == "אַנַא גִ'בֵּת" and by["l2.5-past", "היא נהיתה"]["tr"] == "הִיֵّ צַארַת"
    assert by["l2.5-pres", "הוא מביא"]["tr"] == "הוּّ בִּיגִ'יבּ" and by["l2.5-pres", "אנחנו נהיים"]["variants"][0][1] == "ʔ i ħ n a | b i n sˤ iː r"
    assert by["l2.5-sar", "אתה כבר חצי שעה פה?"]["tr"] == "צַאר-לַכּ נֻצّ סֵיעַה הוֹן?"
    # استنّى is the past of "he"; the present of "I" is made from the future, which is written the same
    line = next(p for p in PHRASES if p["cat"] == "l2.5-story" and p["seq"] == 7)
    assert "צַאר-לִי נֻצّ סֵיעַה בַּסְתַנַّא-כּ הוֹן!" in line["tr"] and "לְךָ" in line["he_say"]
    assert next(p for p in PHRASES if p["cat"] == "l2.5-story" and p["seq"] == 8)["tr"].startswith("טַיֵّבּ וֵינַ-כּ חַבִּיבּ-י?")
    assert by["l2.5-adj", "גדול, גדולה, גדולים"]["tr"] == "כְּבִּיר, כְּבִּירֵה, כְּבַּאר"


def test_workbook_level_2_lesson_6():
    from app.phrases import CATEGORIES, public_phrases

    # the workbook's fifteenth lesson: בניין שלישי, the old jar's story, and "him" after a vowel
    cats = ["l2.6-verbs", "l2.6-past", "l2.6-pres", "l2.6-vread", "l2.6-vread2", "l2.6-vread3", "l2.6-vsay", "l2.6-words", "l2.6-story",
            "l2.6-ask", "l2.6-him"]
    mine = [c for c in CATEGORIES if c["lesson"] == "2.6" and not WORD_TOPICS(c)]
    assert [c["id"] for c in mine] == cats + ["l2.6-hw"]
    hw = mine[-1]["write"]
    assert hw["lines"] == 5 and "לַאקַא / יְלַאקִי – מצא" in hw["task"]
    story = mine[cats.index("l2.6-story")]
    assert story["name"] == "סיפור הכד העתיק" and story["story"] and "voices" not in story
    assert mine[0]["unit"] == "פועל"
    pub = public_phrases()
    assert [sum(p["cat"] == c for p in pub) for c in cats] == [5, 16, 16, 8, 8, 7, 7, 14, 20, 10, 12]
    reads = ("l2.6-vread", "l2.6-vread2", "l2.6-vread3", "l2.6-ask")
    assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l2.6-"))
    by = {(p["cat"], p["he"]): p for p in PHRASES}
    assert by["l2.6-verbs", "עזר (עבר, עתיד)"]["tr"] == "סַאעַד, יְסַאעֵד" and by["l2.6-verbs", "מצא, פגש (עבר, עתיד)"]["tr"] == "לַאקַא, יְלַאקִי"
    assert by["l2.6-past", "אני מצאתי"]["tr"] == "אַנַא לַאקֵית" and by["l2.6-past", "היא עזרה"]["tr"] == "הִיֵّ סַאעַדַת"
    assert by["l2.6-pres", "הוא עוזר"]["tr"] == "הוּّ בִּיסַאעֵד" and by["l2.6-pres", "אנחנו מוצאים"]["variants"][0][1] == "ʔ i ħ n a | b i n l aː ʔ i"
    # after a vowel "him" is a ه that is hardly heard, and "her" is whole
    assert by["l2.6-him", "אנחנו פתחנו אותו"]["tr"] == "אִחְנַא פַתַחְנַא-ה" and by["l2.6-him", "אנחנו פתחנו אותה"]["tr"] == "אִחְנַא פַתַחְנַא-הַא"
    assert by["l2.6-him", "ה' ישמור אותו"]["variants"][0][1] == "ʔ a l l a | j x a l l iː h"
    line = next(p for p in PHRASES if p["cat"] == "l2.6-story" and p["seq"] == 8)
    assert "תַעַאל סַאעֵד-נִי וִחְיַאתַ-כּ" in line["tr"] and "בְּחַיֶּיךָ" in line["he_say"]   # a request: סַאעֵד, not the past's סַאעַד


def test_workbook_level_2_lesson_7():
    from app.phrases import CATEGORIES, public_phrases

    # the workbook's sixteenth lesson: the imperative, more of פִעֵל, and Yusef's bus
    cats = ["l2.7-imp", "l2.7-verbs", "l2.7-past", "l2.7-pres", "l2.7-ex", "l2.7-fill", "l2.7-fill2", "l2.7-isay", "l2.7-words", "l2.7-pre",
            "l2.7-story", "l2.7-ask", "l2.7-sread", "l2.7-ssay"]
    mine = [c for c in CATEGORIES if c["lesson"] == "2.7" and not WORD_TOPICS(c)]
    assert [c["id"] for c in mine] == cats + ["l2.7-hw"]
    hw = mine[-1]["write"]
    assert hw["lines"] == 5 and "ואז בציווי" in hw["task"] and "וִקֵע / יוֹקַע – נפל" in hw["task"]
    story = mine[cats.index("l2.7-story")]
    assert story["name"] == "סיפור יוסף והאוטובוס" and story["story"] and "voices" not in story
    assert mine[cats.index("l2.7-verbs")]["unit"] == "פועל"
    pub = public_phrases()
    assert [sum(p["cat"] == c for p in pub) for c in cats] == [19, 9, 16, 16, 5, 6, 6, 7, 14, 8, 17, 10, 9, 12]
    reads = ("l2.7-fill", "l2.7-fill2", "l2.7-ask", "l2.7-sread")
    assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l2.7-"))
    by = {(p["cat"], p["he"]): p for p in PHRASES}
    # the future without its first letter; two consonants at the start take a helping ا with the future's vowel
    assert by["l2.7-imp", "חשוב!"]["tr"] == "פַכֵּّר!" and by["l2.7-imp", "שתה!"]["tr"] == "אִשְרַבּ!" and by["l2.7-imp", "שב!"]["tr"] == "אֻקְעֹד!"
    assert by["l2.7-imp", "קח! קחי! קחו!"]["variants"][0][1] == "x u d | x u d i | x u d u"
    assert by["l2.7-imp", "אל תשתה קולה ואל תשתה זירו"]["tr"] == "לַא תִשְרַבּ כּוֹלַה וּמַא תִשְרַבּ זִירוֹ"   # "don't" is the future
    assert by["l2.7-verbs", "שכח (עבר, עתיד)"]["tr"] == "נִסִי, יִנְסַא" and by["l2.7-verbs", "נפל (עבר, עתיד)"]["tr"] == "וִקֵע, יוֹקַע"
    assert by["l2.7-past", "אני שכחתי"]["tr"] == "אַנַא נְסִית" and by["l2.7-past", "היא כעסה"]["tr"] == "הִיֵّ זִעְלַת"
    assert by["l2.7-pres", "הוא רץ"]["tr"] == "הוּّ בְּיֻרְכֹּדֿ" and by["l2.7-pres", "הוא כועס"]["tr"] == "הוּّ בִּיזְעַל"
    line = next(p for p in PHRASES if p["cat"] == "l2.7-story" and p["seq"] == 12)
    assert line["tr"].startswith("יַא שַבַּאבּ, סַאעְדוּ-נִי, וַאללַّה וְקִעֵת")
    go = next(p for p in PHRASES if p["cat"] == "l2.7-fill" and "לכי" in p["he"])
    assert "יַאללַّה רוּחִי וּבַּעַדֵין תַעַאלִי" in go["tr"]


def test_workbook_level_2_lesson_8():
    from app.phrases import CATEGORIES, public_phrases

    # the workbook's seventeenth lesson: גזרת הכפולים, عم for what is happening now, and the song شاطر شاطر
    cats = ["l2.8-verbs", "l2.8-past", "l2.8-pres", "l2.8-vread", "l2.8-vread2", "l2.8-vsay", "l2.8-vsay2", "l2.8-am", "l2.8-amsay",
            "l2.8-amsay2", "l2.8-words", "l2.8-story"]
    mine = [c for c in CATEGORIES if c["lesson"] == "2.8" and not WORD_TOPICS(c)]
    assert [c["id"] for c in mine] == cats + ["l2.8-hw"]
    hw = mine[-1]["write"]
    assert hw["lines"] == 5 and "דַֿלّ / יְדַֿלّ – נשאר" in hw["task"] and "ורק אז בציווי" in hw["task"]
    song = mine[cats.index("l2.8-story")]
    assert song["name"] == "השיר: שַאטֵר שַאטֵר" and song["story"] and "voices" not in song
    assert mine[0]["unit"] == "פועל"
    pub = public_phrases()
    assert [sum(p["cat"] == c for p in pub) for c in cats] == [7, 16, 16, 10, 9, 10, 9, 9, 9, 8, 12, 10]
    reads = ("l2.8-vread", "l2.8-vread2")
    assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l2.8-"))
    by = {(p["cat"], p["he"]): p for p in PHRASES}
    assert by["l2.8-verbs", "שם, הניח (עבר, עתיד)"]["tr"] == "חַטّ, יְחֻטّ" and by["l2.8-verbs", "נשאר, המשיך (עבר, עתיד)"]["tr"] == "דַֿלّ, יְדַֿלّ"
    # "I" and "you" take ـيت in the past, "she" and "they" only their ending
    assert by["l2.8-past", "אני נשארתי"]["tr"] == "אַנַא דַֿלֵّית" and by["l2.8-past", "היא נשארה"]["tr"] == "הִיֵّ דַֿלַّת"
    assert by["l2.8-past", "הם שמו"]["variants"][0][1] == "h u m m e | ħ a tˤ tˤ u"
    assert by["l2.8-pres", "אתה נשאר"]["tr"] == "אִנְתֵ בִּתְדַֿלّ" and by["l2.8-pres", "אנחנו אוהבים"]["tr"] == "אִחְנַא בִּנְחִבّ"
    assert by["l2.8-am", "אני מדבר איתך כעת"]["tr"] == "אַנַא עַםְ בַּחְכִּי מַעַ-כּ" and by["l2.8-am", "אני עובד כעת"]["tr"] == "אַנַא עַםְ בַּשְתְעֵ'ל"
    # a request (חֻטّ) beside the past (חַטّ), and the song's "we": מִנְ before the future
    order = next(p for p in PHRASES if p["cat"] == "l2.8-vread" and "צ'יפס" in p["he"])
    assert "חֻטّ-לִי כַּמַאן טְחִינֵה" in order["tr"] and "הוּّ חַטّ-לִי זַיّ מַא" in order["tr"]
    assert next(p for p in PHRASES if p["cat"] == "l2.8-story" and p["seq"] == 6)["tr"] == "מִנְחִבّ-וֹ וּמִנְדַֿלّ מִנְקוּל-לّוֹ שַאטֵר שַאטֵר"


def test_workbook_level_2_lesson_9():
    from app.phrases import CATEGORIES, public_phrases

    # the workbook's eighteenth and last lesson: the active participle, a song from Egypt, and the father, the son and the donkey
    cats = ["l2.9-part", "l2.9-part2", "l2.9-verbs", "l2.9-ex", "l2.9-pread", "l2.9-pread2", "l2.9-psay", "l2.9-psay2", "l2.9-psay3",
            "l2.9-words", "l2.9-song", "l2.9-story"]
    mine = [c for c in CATEGORIES if c["lesson"] == "2.9" and not WORD_TOPICS(c)]
    assert [c["id"] for c in mine] == cats + ["l2.9-hw"]
    hw = mine[-1]["write"]
    assert hw["lines"] == 5 and "בבינוני הפועל" in hw["task"] and "עַאש / יְעִיש – חי" in hw["task"]
    for c in ("l2.9-song", "l2.9-story"):
        assert mine[cats.index(c)]["story"] and "voices" not in mine[cats.index(c)]
    assert mine[cats.index("l2.9-verbs")]["unit"] == "פועל"
    pub = public_phrases()
    assert [sum(p["cat"] == c for p in pub) for c in cats] == [8, 16, 5, 17, 10, 10, 10, 9, 9, 14, 8, 14]
    reads = ("l2.9-pread", "l2.9-pread2")
    assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l2.9-"))
    by = {(p["cat"], p["he"]): p for p in PHRASES}
    # the participle's four forms, and where the middle letter is weak a י comes in (رايح, نايم)
    assert by["l2.9-part", "גר, גרה, גרים, גרות"]["tr"] == "סַאכֵּן, סַאכְּנֵה, סַאכְּנִין, סַאכְּנַאת"
    assert by["l2.9-part", "ישן, ישנה, ישנים, ישנות"]["tr"] == "נַאיֵם, נַאיְמֵה, נַאיְמִין, נַאיְמַאת"
    assert by["l2.9-part2", "ישב – יושב"]["tr"] == "קַעַד, קַאעֵד" and by["l2.9-part2", "אהב, רצה – אוהב, רוצה"]["tr"] == "חַבّ, חַאבֵּבּ"
    assert by["l2.9-verbs", "ישן (עבר, עתיד)"]["tr"] == "נַאם, יְנַאם" and by["l2.9-verbs", "חי (עבר, עתיד)"]["tr"] == "עַאש, יְעִיש"
    # the habit is the present (בַּרוּח), this moment the participle (רַאיֵח)
    assert by["l2.9-ex", "כל יום שני אני הולך לחבר שלי"]["tr"].endswith("אַנַא בַּרוּח לַעִנְד צַאחְבּ-י")
    assert by["l2.9-ex", "אני הולך לחבר שלי עכשיו"]["tr"] == "אַנַא רַאיֵח לַעִנְד צַאחְבּ-י הַלַّק"
    song = [p for p in PHRASES if p["cat"] == "l2.9-song"]
    assert song[2]["tr"].endswith("עַלַא בֻּעְדַ-כּ") and song[3]["tr"].endswith("חַבִּיבּ בַּעְדַ-כּ")   # your distance, and after you
    assert "מַא בֵּינַא זַמַאן" in song[5]["tr"]
    last = next(p for p in PHRASES if p["cat"] == "l2.9-story" and p["seq"] == 14)
    assert last["tr"].startswith("אִנְתֵ עַאיֵש וִלַّא מַאיֵת") and "בְּךָ" in last["he_say"]


def test_sign_in_guards_the_practice_endpoints():
    from fastapi.testclient import TestClient

    from app.main import create_app

    VERB = next(p for p in PHRASES if p["cat"] == "l1-verbs")

    class FakeAuth:
        url, key, teachers, tutors = "https://proj.supabase.co", "sb_publishable_x", frozenset(), frozenset()

        def __init__(self):
            self.rows, self.cls, self.vids, self.txts, self.days, self.wrote = [], [], [], [], {"dailies": [], "library": []}, {}
            self.rank = frozenset()

        def writings(self, token):
            return {t: lines for (u, t), lines in self.wrote.items() if u == "u1"}

        def save_writing(self, token, user_id, topic, lines):
            self.wrote[user_id, topic] = lines

        def all_writings(self):
            return [{"user_id": u, "topic": t, "lines": lines} for (u, t), lines in self.wrote.items()]

        def classes(self, fresh=False):
            return self.cls

        def ranked(self, fresh=False):
            return self.rank

        def set_tutor(self, email, on, by):
            if self.rank is None:
                raise AuthUnavailable("supabase answered 404: no table")
            self.rank = self.rank | {email} if on else self.rank - {email}
            self.by = by

        def save_class(self, id, name, emails, nicknames, lessons, homeworks=(), dailies=None, subject=""):
            row = {"name": name, "emails": emails, "nicknames": nicknames, "lessons": lessons, "homeworks": list(homeworks),
                   **({} if dailies is None else {"dailies": list(dailies)}), **({"subject": subject} if id is None and subject else {})}
            if id is None:
                self.cls.insert(0, {"id": len(self.cls) + 1, **row})
            elif c := next((c for c in self.cls if c["id"] == id), None):
                c.update(row)
            else:
                raise KeyError(id)

        def delete_class(self, id):
            self.cls = [c for c in self.cls if c["id"] != id]

        def videos(self, fresh=False):
            return self.vids

        def level_videos(self, table, fresh=False):
            return self.days[table]

        def add_level_video(self, table, level, title, url):
            self.days[table].append({"id": len(self.days[table]) + 1, "level": level, "title": title, "url": url})

        def delete_level_video(self, table, id):
            self.days[table] = [v for v in self.days[table] if v["id"] != id]

        def rename_level_video(self, table, id, title):
            next(v for v in self.days[table] if v["id"] == id)["title"] = title

        def order_level_videos(self, table, ids):
            self.days[table].sort(key=lambda v: ids.index(v["id"]))

        def add_video(self, lesson, title, url, pos, ord):
            self.vids.append({"id": len(self.vids) + 1, "lesson": lesson, "title": title, "url": url, "pos": pos, "ord": ord})

        def place(self, places):
            for table, id, pos, ord in places:
                next(v for v in {"videos": self.vids, "texts": self.txts}[table] if v["id"] == id).update(pos=pos, ord=ord)

        def texts(self, fresh=False):
            return self.txts

        def add_text(self, lesson, title, voices, lines, pos, ord):
            self.txts.append({"id": len(self.txts) + 1, "lesson": lesson, "title": title, "voices": voices, "lines": lines, "pos": pos, "ord": ord})

        def delete_text(self, id):
            self.txts = [t for t in self.txts if t["id"] != id]

        def delete_video(self, id):
            self.vids = [v for v in self.vids if v["id"] != id]

        def user(self, token):
            return {"id": "u1", "email": "A@b.c"} if token == "good" else None

        def record(self, token, row):
            self.rows.append((token, row))

        def attempts(self, token):   # two tries of one sentence on one Israeli day (across midnight UTC), one unlisted sentence
            return [{"cat": VERB["cat"], "phrase": VERB["he"], "score": 62, "created_at": "2026-10-08T21:30:00.5+00:00"},
                    {"cat": VERB["cat"], "phrase": VERB["he"], "score": 88, "created_at": "2026-10-09T10:00:00+00:00"},
                    {"cat": TOILET["cat"], "phrase": TOILET["he"], "score": 90, "created_at": "2026-10-01T10:00:00.123456+00:00"}]

        def users(self):   # the teacher, a student, and an account that is not on the list
            return [{"id": "u1", "email": "A@b.c", "name": "Alef"}, {"id": "u2", "email": "s@b.c", "name": ""},
                    {"id": "u3", "email": "out@b.c", "name": ""}]

        def all_attempts(self):
            return [{**r, "user_id": "u2"} for r in self.attempts("")[:2]]

        def activity(self, hours=24, bucket_minutes=15):
            self.activity_args = hours, bucket_minutes
            return [{"bucket": "2026-10-13T10:00:00+00:00", "attempts": 4, "active_students": 2}]

        def all_attempt_issues(self):
            return []

    with TestClient(create_app(scorer_for(emissions(["w"])), auth=FakeAuth())) as c:
        page = c.get("/").text
        assert 'AUTH_URL="https://proj.supabase.co"' in page and 'AUTH_KEY="sb_publishable_x"' in page
        assert c.get("/api/phrases").status_code == 200   # the phrase list stays public
        assert c.get("/api/prompt-audio/0").status_code == 401
        assert c.get("/api/tts/0", headers={"Authorization": "Bearer bad"}).status_code == 401
        assert c.get("/api/tts/0", headers={"Authorization": "Bearer good"}).status_code == 503   # in, but no TTS key
        good = {"Authorization": "Bearer good"}
        from app.phrases import CATEGORIES, LESSONS
        ALL = [l["id"] for l in LESSONS]
        NO_HW = {"homework": "", "due": None, "homeworks": [], "dailies": []}
        T1, T2 = (next(k["id"] for k in CATEGORIES if k["lesson"] == l) for l in ALL[:2])   # a topic of each of the first two lessons
        # written homework is kept for the student, a topic's lines in place of the ones before, and read back anywhere
        assert c.get("/api/writing").status_code == 401 and c.get("/api/writing", headers=good).json() == {"writing": {}}
        put = lambda topic, lines: c.put(f"/api/writing/{topic}", json={"lines": lines}, headers=good).status_code
        assert put("l1-hw", ["אַנַא  אַכַּלֵת ", ""]) == 200 and put("l1-hw", ["אַנַא אַכַּלֵת", "", "הִיֵّ פַתְחַת"]) == 200
        assert c.get("/api/writing", headers=good).json() == {"writing": {"l1-hw": ["אַנַא אַכַּלֵת", "", "הִיֵّ פַתְחַת"]}}
        assert put(T1, ["x"]) == 404 and put("l1-hw", ["x"] * 6) == 400 and put("l1-hw", ["x" * 301]) == 400 and put("l1-hw", "x") == 400
        assert c.put("/api/writing/l1-hw", json={"lines": ["x"]}).status_code == 401
        # no class yet: any account is let in, to every lesson
        assert c.get("/api/me", headers=good).json() == {"email": "A@b.c", "teacher": False, "classes": [], "lessons": ALL}
        assert c.get("/api/class").status_code == 401 and c.get("/api/class", headers=good).status_code == 403
        assert c.post("/api/classes", json={"name": "x", "emails": "", "lessons": ALL}, headers=good).status_code == 403   # teachers only
        assert c.get("/api/classmates", headers=good).json() == {"classes": []}
        beginners = {"id": 1, "name": "Beginners", "emails": ["other@b.c"], "nicknames": {}, "lessons": ALL[:1]}
        c.app.state.auth.cls = [beginners]   # a class without this account
        assert c.get("/api/me", headers=good).status_code == 403 and c.get("/api/tts/0", headers=good).status_code == 403
        beginners["emails"].append("a@b.c")   # case does not matter; the student studies their class's lessons
        assert c.get("/api/me", headers=good).json() == {"email": "A@b.c", "teacher": False, "lessons": ALL[:1],
                                                         "classes": [{"name": "Beginners", "lessons": ALL[:1], **NO_HW}]}
        # the class's homework reaches its students: a topic of its lessons, and the day it is due
        beginners.update(homework=T1, due="2026-10-15")
        assert c.get("/api/me", headers=good).json()["classes"] == [{"name": "Beginners", "lessons": ALL[:1], "homework": T1, "due": "2026-10-15",
                                                                             "homeworks": [{"topic": T1, "due": "2026-10-15"}], "dailies": []}]
        beginners.update(homework=T2)   # a topic of a lesson the class does not study is no homework
        assert c.get("/api/me", headers=good).json()["classes"] == [{"name": "Beginners", "lessons": ALL[:1], **NO_HW}]
        del beginners["homework"], beginners["due"]
        # in a second class too: the lessons of both, in the course's order
        c.app.state.auth.cls = [{"id": 2, "name": "Advanced", "emails": ["a@b.c"], "nicknames": {}, "lessons": ALL[1:]}, beginners]
        me = c.get("/api/me", headers=good).json()   # each class with its own lessons: the student chooses one to practise in
        assert me["lessons"] == ALL and me["classes"] == [{"name": "Advanced", "lessons": ALL[1:], **NO_HW}, {"name": "Beginners", "lessons": ALL[:1], **NO_HW}]
        c.app.state.auth.cls = [{**beginners, "lessons": []}]   # a class saved before classes had lessons has every one
        assert c.get("/api/me", headers=good).json()["lessons"] == ALL
        # a teacher is let in without a class, studies every lesson, and opens classes in the panel
        c.app.state.auth.cls, c.app.state.auth.teachers = [], frozenset({"a@b.c"})
        assert c.post("/api/classes", json={"name": "September 2026", "emails": "x@b.c", "lessons": ALL[:1]},
                      headers=good).status_code == 200
        assert c.get("/api/me", headers=good).json() == {"email": "A@b.c", "teacher": True, "tutor": True, "classes": [], "lessons": ALL, "dailies": []}
        activity = c.get("/api/activity?hours=12&bucket_minutes=30", headers=good)
        assert activity.json() == {"hours": 12, "bucket_minutes": 30, "buckets": [
            {"bucket": "2026-10-13T10:00:00+00:00", "attempts": 4, "active_students": 2}]}
        assert c.app.state.auth.activity_args == (12, 30)
        assert c.get("/api/activity?hours=0", headers=good).status_code == 422
        # the content part: a teacher adds a video to a ready lesson by its Vimeo link, kept without the sharing parameters
        READY = [l["id"] for l in LESSONS if not l.get("soon")]
        SOON = next(l["id"] for l in LESSONS if l.get("soon"))
        link = "https://vimeo.com/1233611297/d46ac373c8?share=copy&fl=sv&fe=ci"
        assert c.get("/api/videos").status_code == 401 and c.get("/api/videos", headers=good).json() == {"videos": []}
        for body, code in (({"lesson": READY[0], "title": "  פתיחה   לשיעור ", "url": link}, 200),
                           ({"lesson": READY[1], "title": "public", "url": "vimeo.com/76979871"}, 200),
                           ({"lesson": READY[0], "title": "", "url": link}, 400), ({"lesson": READY[0], "title": "x" * 81, "url": link}, 400),
                           ({"lesson": SOON, "title": "x", "url": link}, 400), ({"lesson": "no-such-lesson", "title": "x", "url": link}, 400),
                           ({"lesson": READY[0], "title": "x", "url": "https://youtube.com/watch?v=1233611297"}, 400),
                           ({"lesson": READY[0], "title": "x", "url": "https://www.youtube.com/playlist?list=PLdQw4w9WgXcQ"}, 400),
                           ({"lesson": READY[0], "title": "x", "url": "https://evil.example/watch?v=dQw4w9WgXcQ"}, 400),
                           ({"lesson": READY[0], "title": "x", "url": "https://evil.example/?u=https://vimeo.com/1233611297"}, 400),
                           ({"lesson": READY[0], "title": "x", "url": "https://vimeo.com/channels/staffpicks"}, 400)):
            r = c.post("/api/videos", json=body, headers=good)
            assert r.status_code == code, (body, r.text)
        first, second = c.get("/api/videos", headers=good).json()["videos"]
        N1 = sum(k["lesson"] == READY[0] for k in CATEGORIES)   # a new video stands after its lesson's topics
        assert first == {"id": 1, "lesson": READY[0], "title": "פתיחה לשיעור", "url": "https://vimeo.com/1233611297/d46ac373c8",
                         "embed": "https://player.vimeo.com/video/1233611297?h=d46ac373c8&dnt=1&vimeo_logo=0&title=0&byline=0&portrait=0", "pos": N1, "ord": 1}
        assert (second["url"], second["embed"]) == ("https://vimeo.com/76979871", "https://player.vimeo.com/video/76979871?dnt=1&vimeo_logo=0&title=0&byline=0&portrait=0")
        from app.main import vimeo_link, video_embed, video_link
        assert vimeo_link("https://player.vimeo.com/video/1233611297?h=d46ac373c8") == first["url"]
        # a YouTube link, in any of its forms, is kept as the watch link and played without cookies
        for yt in ("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=5s", "https://youtube.com/watch?list=PL1&v=dQw4w9WgXcQ", "youtu.be/dQw4w9WgXcQ?si=abc",
                   "https://m.youtube.com/shorts/dQw4w9WgXcQ", "https://www.youtube.com/live/dQw4w9WgXcQ?feature=share",
                   "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?rel=0"):
            assert video_link(yt) == "https://www.youtube.com/watch?v=dQw4w9WgXcQ", yt
        assert video_embed(video_link("youtu.be/dQw4w9WgXcQ")) == "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?rel=0"
        assert video_link("https://www.youtube.com/watch?v=dQw4w9WgXcQQ") is None
        # the teacher moves a lesson's videos among its topics: another one is added, and the two are placed in a new order
        assert c.post("/api/videos", json={"lesson": READY[0], "title": "b", "url": link}, headers=good).status_code == 200
        seen = lambda: [(v["id"], v["pos"]) for v in c.get("/api/videos", headers=good).json()["videos"] if v["lesson"] == READY[0]]
        assert seen() == [(1, N1), (3, N1)]
        order = lambda *o: c.put("/api/content/order", json={"lesson": READY[0], "order": [{"kind": "video", "id": i, "pos": p} for i, p in o]}, headers=good).status_code
        assert order((3, 0), (1, 0)) == 200 and seen() == [(3, 0), (1, 0)]   # both before the first topic, the new one first
        assert order((3, 0), (1, 2)) == 200 and seen() == [(3, 0), (1, 2)]
        assert order((3, 0)) == 409 and order((3, 0), (2, 0)) == 409   # every video of the lesson, and no other's
        assert order((3, 0), (1, N1 + 1)) == 400 and order((3, -1), (1, 0)) == 400 and seen() == [(3, 0), (1, 2)]
        assert c.put("/api/content/order", json={"lesson": SOON, "order": []}, headers=good).status_code == 400
        c.app.state.auth.vids[0].update(pos=None)   # kept before videos had places: after the topics
        assert seen() == [(3, 0), (1, N1)]
        assert c.delete("/api/videos/3", headers=good).status_code == 200
        first.update(pos=N1, ord=1)
        second = c.get("/api/videos", headers=good).json()["videos"][1]
        # a text: its lines in Hebrew and in Arabic written in Hebrew letters, the same number of each; a dialogue names two voices
        he, tr = ["שלום", "מה שלומך?", "שלום"], ["מַרְחַבַּא", "כִּיף חַאלַכּ?", "מַרְחַבַּא יַא זַלַמֶה"]
        talk = {"lesson": READY[0], "title": " בשוק ", "he": he, "tr": tr, "voices": ["אמיר", "ראאף"]}
        assert c.get("/api/texts").status_code == 401 and c.get("/api/texts", headers=good).json() == {"categories": [], "phrases": []}
        for body, code in ((talk, 200), ({**talk, "title": "story", "voices": []}, 200),
                           ({**talk, "tr": tr[:2]}, 400), ({**talk, "he": [], "tr": []}, 400), ({**talk, "tr": ["x", "1", "-"]}, 400),   # not Arabic in Hebrew letters
                           ({**talk, "voices": ["אמיר"]}, 400), ({**talk, "voices": ["אמיר", "nobody"]}, 400),
                           ({**talk, "lesson": SOON}, 400), ({**talk, "title": ""}, 400), ({**talk, "he": he * 80, "tr": tr * 80}, 400)):
            r = c.post("/api/texts", json=body, headers=good)
            assert r.status_code == code, (body, r.text)
        d = c.get("/api/texts", headers=good).json()
        assert d["categories"][0] == {"id": "t1", "name": "בשוק", "lesson": READY[0], "story": True, "text": 1, "voices": ["אמיר", "ראאף"],
                                      "pos": N1, "ord": 2, "guessed": ["זַלַמֶה"]}   # the course has the other words
        assert "voices" not in d["categories"][1] and d["categories"][1]["ord"] == 3
        line1, line2, line3 = d["phrases"][:3]
        assert line1 == {"id": 1_001_000, "cat": "t1", "he": "שלום", "tr": "מַרְחַבַּא", "ar": "مرحبا",
                         "ar_pointed": "مَرْحَبَا", "seq": 1}
        assert (line2["id"], line2["ar"], line3["ar"]) == (1_001_001, "كيف حالك؟", "مرحبا يا زلمة") and len(d["phrases"]) == 6
        # a line is a phrase like the bank's: it has a prompt and an answer to play, and its recordings count (two lines
        # with the same Hebrew stay apart)
        assert c.get("/api/prompt-audio/1001999", headers=good).status_code == 404 and c.get("/api/tts/1001001", headers=good).status_code == 503
        from app.main import built, summary as sums, text_bank
        bank = text_bank(c.app.state.auth.txts)
        assert bank[1_001_002]["key"] == "שלום (2)" and bank[1_001_001]["variants"][0] == ("كيف حالك؟", "k iː f | ħ aː l a k")
        got = sums([{"cat": "t1", "phrase": "שלום (2)", "score": 80, "created_at": "2026-10-09T10:00:00+00:00"},
                    {"cat": "t1", "phrase": "שלום", "score": 60, "created_at": "2026-10-09T10:00:00+00:00"},
                    {"cat": "t9", "phrase": "שלום", "score": 99, "created_at": "2026-10-09T10:00:00+00:00"}], bank)   # a deleted text's
        assert got["best"] == {1_001_002: 80, 1_001_000: 60} and got["recordings"] == 3
        for ar, ipa in (v for p in bank.values() for v in p["variants"]):
            assert len(ar.split()) == len(ipa.split("|"))
        # a text is placed among the topics like a video, and may be a class's homework
        mixed = lambda *o: c.put("/api/content/order", json={"lesson": READY[0], "order": [{"kind": k, "id": i, "pos": p} for k, i, p in o]}, headers=good).status_code
        assert mixed(("text", 2, 0), ("video", 1, 1), ("text", 1, 1)) == 200 and mixed(("text", 2, 0), ("text", 1, 1)) == 409
        assert [(k["id"], k["pos"], k["ord"]) for k in c.get("/api/texts", headers=good).json()["categories"]] == [("t2", 0, 0), ("t1", 1, 2)]
        assert c.get("/api/videos", headers=good).json()["videos"][0]["ord"] == 1
        first.update(pos=1, ord=1)
        assert c.post("/api/classes", json={"name": "T", "emails": "a@b.c", "lessons": READY[:1], "homework": "t1"}, headers=good).status_code == 200
        assert c.get("/api/me", headers=good).json()["classes"][-1]["homework"] == "t1"
        assert c.delete("/api/texts/1", headers=good).status_code == 200   # with its text gone, the homework is none
        assert c.get("/api/me", headers=good).json()["classes"][-1]["homework"] == ""
        assert c.delete("/api/texts/2", headers=good).status_code == 200
        c.app.state.auth.cls = [k for k in c.app.state.auth.cls if k["name"] != "T"]
        # a student gets the videos of their classes' lessons only, and adds or deletes none
        kept, c.app.state.auth.teachers = c.app.state.auth.cls, frozenset()
        c.app.state.auth.cls = [{"id": 7, "name": "V", "emails": ["a@b.c"], "nicknames": {}, "lessons": READY[1:2]}]
        assert c.get("/api/videos", headers=good).json()["videos"] == [{k: v for k, v in second.items() if k != "url"}]   # without the link
        assert c.post("/api/videos", json={"lesson": READY[0], "title": "x", "url": link}, headers=good).status_code == 403
        assert c.delete("/api/videos/1", headers=good).status_code == 403
        assert c.put("/api/content/order", json={"lesson": READY[0], "order": []}, headers=good).status_code == 403
        assert c.post("/api/texts", json={}, headers=good).status_code == 403 and c.delete("/api/texts/1", headers=good).status_code == 403
        c.app.state.auth.cls, c.app.state.auth.teachers = kept, frozenset({"a@b.c"})
        assert c.delete("/api/videos/1", headers=good).status_code == 200
        assert c.get("/api/videos", headers=good).json()["videos"] == [second]
        # the daily videos: a library by level; a class gives a video its day, and its students get it with that day
        from app.phrases import LEVELS
        L1, L2 = LEVELS[0]["id"], LEVELS[1]["id"]
        daily = lambda **body: c.post("/api/dailies", json=body, headers=good).status_code
        assert c.get("/api/dailies").status_code == 401 and c.get("/api/dailies", headers=good).json() == {"dailies": []}
        assert daily(level=L1, title=" יום  1 ", url=link + "?share=copy") == 200 and daily(level=L2, title="other", url="https://youtu.be/dQw4w9WgXcQ") == 200
        assert daily(level="9", title="x", url=link) == 400 and daily(level=L1, title="", url=link) == 400 and daily(level=L1, title="x", url="https://example.com/1") == 400
        lib = c.get("/api/dailies", headers=good).json()["dailies"]
        assert [(v["id"], v["level"], v["title"]) for v in lib] == [(1, L1, "יום 1"), (2, L2, "other")] and lib[0]["embed"].startswith("https://player.vimeo.com/video/")
        D = {"name": "D", "emails": "a@b.c", "lessons": [l["id"] for l in LESSONS if l["level"] == L1][:1]}
        save = lambda days: c.post("/api/classes", json={**D, "dailies": days}, headers=good).status_code
        assert save([{"video": 9, "date": "2026-10-12"}]) == 400 and save([{"video": 1, "date": "12.10"}]) == 400
        assert save([{"video": 1, "date": "2026-10-12"}, {"video": 1, "date": "2026-10-13"}]) == 400 and save("x") == 400
        assert save([{"video": 1, "date": "2026-10-12"}, {"video": 2, "date": "2026-10-11"}, {"video": 9, "date": ""}]) == 200   # one without a day is dropped
        d = c.app.state.auth.cls[0]
        assert d["dailies"] == [{"video": 1, "date": "2026-10-12"}, {"video": 2, "date": "2026-10-11"}]
        mine = lambda: next(k for k in c.get("/api/me", headers=good).json()["classes"] if k["name"] == "D")["dailies"]
        assert mine() == [{"id": 1, "title": "יום 1", "embed": lib[0]["embed"], "date": "2026-10-12"}]   # the other level's is not the class's
        assert c.get("/api/me", headers=good).json()["dailies"] == mine()   # a teacher gets every class's
        # saved without the days they stay; saved with none they are gone
        assert c.put(f"/api/classes/{d['id']}", json=D, headers=good).status_code == 200 and len(mine()) == 1
        assert c.put(f"/api/classes/{d['id']}", json={**D, "dailies": []}, headers=good).status_code == 200 and mine() == [] and d["dailies"] == []
        assert c.put(f"/api/classes/{d['id']}", json={**D, "dailies": [{"video": 1, "date": "2026-10-12"}]}, headers=good).status_code == 200
        assert c.delete("/api/dailies/1", headers=good).status_code == 200 and mine() == []   # gone from the library, gone from the class
        # the library: videos for every student, whatever the level and the class
        shelf = lambda **body: c.post("/api/library", json=body, headers=good).status_code
        assert c.get("/api/library").status_code == 401 and c.get("/api/library", headers=good).json() == {"library": []}
        assert shelf(title="two", url=link) == 200 and shelf(level=L1, title=" one ", url=link) == 200 and shelf(title="gone", url=link) == 200
        assert shelf(title="", url=link) == 400 and shelf(title="x", url="nope") == 400
        assert c.delete("/api/library/3", headers=good).status_code == 200
        shelves = c.get("/api/library", headers=good).json()["library"]
        assert [(v["id"], v["title"], v["url"]) for v in shelves] == [(1, "two", link.split("?")[0]), (2, "one", link.split("?")[0])]
        # a video of the library is renamed, and the library is put in the teacher's order
        rename = lambda id, **body: c.put(f"/api/library/{id}", json=body, headers=good).status_code
        order = lambda ids: c.put("/api/library/order", json={"order": ids}, headers=good).status_code
        assert rename(1, title="  the  first ") == 200 and rename(1, title="") == 400 and rename(1, title="x" * 200) == 400 and rename(9, title="x") == 404
        assert order([2]) == 409 and order([2, 2]) == 409 and order([2, 1, 9]) == 409 and order("x") == 400 and order(["2", "1"]) == 400
        assert order([2, 1]) == 200
        shelves = c.get("/api/library", headers=good).json()["library"]
        assert [(v["id"], v["title"]) for v in shelves] == [(2, "one"), (1, "the first")]
        c.app.state.auth.teachers = frozenset()
        assert c.get("/api/library", headers=good).json()["library"] == [{k: v for k, v in s.items() if k != "url"} for s in shelves]   # all of it, without the links
        assert shelf(title="x", url=link) == 403 and c.delete("/api/library/1", headers=good).status_code == 403
        assert rename(1, title="x") == 403 and order([1, 2]) == 403
        assert c.get("/api/dailies", headers=good).status_code == 403 and daily(level=L1, title="x", url=link) == 403 and c.delete("/api/dailies/2", headers=good).status_code == 403
        c.app.state.auth.cls, c.app.state.auth.teachers = [k for k in c.app.state.auth.cls if k["name"] != "D"], frozenset({"a@b.c"})
        new_class = {"name": " October 2026 ", "emails": "S@b.c, new@b.c\nnew@b.c  דנה  \nx@b.c\n",   # an optional nickname
                     "lessons": ALL[::-1],   # x is in September's class too: a student may be in several
                     "homework": T2, "due": "2026-10-15"}
        assert c.post("/api/classes", json=new_class, headers=good).status_code == 200
        assert c.app.state.auth.cls[0] == {"id": 2, "name": "October 2026", "emails": ["new@b.c", "s@b.c", "x@b.c"],
                                           "nicknames": {"new@b.c": "דנה"}, "lessons": ALL, "homeworks": [{"topic": T2, "due": "2026-10-15"}]}
        for body, code in (({"name": "", "emails": "", "lessons": ALL}, 400), ({"name": "y", "emails": "not-an-email", "lessons": ALL}, 400),
                           ({"name": "y", "emails": "z@b.c w@b.c Dana", "lessons": ALL}, 400),   # whose nickname?
                           ({"name": "y", "emails": "", "lessons": []}, 400), ({"name": "y", "emails": ""}, 400),   # no lesson
                           ({"name": "y", "emails": "", "lessons": ["no-such-lesson"]}, 400),
                           ({"name": "y", "emails": "", "lessons": ALL[:1], "homework": T2}, 400),   # not of the class's lessons
                           ({"name": "y", "emails": "", "lessons": ALL, "homework": ALL[0]}, 400),   # a lesson is not a topic
                           ({"name": "y", "emails": "", "lessons": ALL, "homework": T1, "due": "15/10"}, 400),
                           ({"name": "October 2026", "emails": "", "lessons": ALL}, 409),   # the name is taken,
                           ({"name": " october   2026", "emails": "", "lessons": ALL}, 409)):   # in any letter case and spacing
            r = c.post("/api/classes", json=body, headers=good)
            assert r.status_code == code, (body, r.text)
        assert "October 2026" in r.json()["detail"]
        # renaming a class to another's name is refused too; keeping its own name is not
        assert c.put("/api/classes/1", json={"name": "OCTOBER 2026", "emails": "", "lessons": ALL}, headers=good).status_code == 409
        assert c.put("/api/classes/9", json={"name": "z", "emails": "", "lessons": ALL}, headers=good).status_code == 404
        assert c.put("/api/classes/1", json={"name": "September 2026", "emails": "x@b.c y@b.c", "lessons": ALL[:1]},
                     headers=good).status_code == 200
        # the panel: the classes with their lessons; the classes' accounts, each with {class: nickname}
        d = c.get("/api/class", headers=good).json()
        assert [(k["name"], k["lessons"]) for k in d["classes"]] == [("October 2026", ALL), ("September 2026", ALL[:1])]
        assert [(k["homework"], k["due"]) for k in d["classes"]] == [(T2, "2026-10-15"), ("", None)]
        # a date without homework is dropped; saving without the two clears them
        assert c.put("/api/classes/2", json={**new_class, "homework": ""}, headers=good).status_code == 200
        assert c.app.state.auth.cls[0]["homeworks"] == []
        # a class may have several homeworks, a topic each with its own day: they come back in the order of its lessons
        several = {**new_class, "homework": "", "homeworks": [{"topic": T2, "due": ""}, {"topic": T1, "due": "2026-10-20"}, {"topic": "", "due": "2026-10-21"}]}
        assert c.put("/api/classes/2", json=several, headers=good).status_code == 200
        k = c.get("/api/class", headers=good).json()["classes"][0]
        assert k["homeworks"] == [{"topic": T1, "due": "2026-10-20"}, {"topic": T2, "due": None}] and (k["homework"], k["due"]) == (T1, "2026-10-20")
        for bad in ([{"topic": T1}, {"topic": T1}], [{"topic": "no-such-topic"}], [{"topic": T1, "due": "soon"}], "x", ["x"]):
            assert c.put("/api/classes/2", json={**several, "homeworks": bad}, headers=good).status_code == 400, bad
        assert c.put("/api/classes/2", json={**new_class, "homework": ""}, headers=good).status_code == 200
        student, new, x, y = d["students"]   # the teacher, in no class, is not a student
        assert student["best"] == {str(PHRASES.index(VERB)): 88} and student["tries"] == {str(PHRASES.index(VERB)): 2}
        assert (student["recordings"], student["days"], student["last"]) == (2, 1, "2026-10-09T10:00:00+00:00")
        assert (new["email"], new["joined"], new["recordings"]) == ("new@b.c", False, 0) and student["joined"]
        c.app.state.auth.wrote["u2", "l1-hw"] = ["הִיֵّ פַתְחַת"]   # the panel reads what each student wrote
        assert [k["written"] for k in c.get("/api/class", headers=good).json()["students"][:2]] == [{"l1-hw": ["הִיֵّ פַתְחַת"]}, {}]
        assert (student["classes"], new["classes"]) == ({"October 2026": ""}, {"October 2026": "דנה"})
        assert x["classes"] == {"October 2026": "", "September 2026": ""} and y["classes"] == {"September 2026": ""}
        # a class counts what was practised since it was opened: a new one starts clean (the student's own summary stays whole)
        assert student["since"]["October 2026"]["recordings"] == 2   # a class without its opening time counts everything
        c.app.state.auth.cls[0]["created_at"] = "2026-10-09T00:00:00.123+00:00"   # between the student's two tries
        student = c.get("/api/class", headers=good).json()["students"][0]
        assert student["recordings"] == 2 and student["since"] == {"October 2026": {
            "best": {str(PHRASES.index(VERB)): 88}, "tries": {str(PHRASES.index(VERB)): 1}, "missed": {}, "unmatched": 0,
            "recordings": 1, "days": 1,
            "week": student["since"]["October 2026"]["week"], "last": "2026-10-09T10:00:00+00:00"}}
        # a recording with speech completes the exercise even when it did not match; it is also counted diagnostically
        from app.main import summary
        off = summary([{"cat": VERB["cat"], "phrase": VERB["he"], "score": 12, "unrelated": True, "created_at": "2026-10-09T11:00:00+00:00"},
                       {"cat": VERB["cat"], "phrase": VERB["he"], "score": 70, "unrelated": False, "created_at": "2026-10-09T10:00:00+00:00"}])
        assert (off["best"], off["tries"], off["missed"]) == ({PHRASES.index(VERB): 70}, {PHRASES.index(VERB): 2}, {PHRASES.index(VERB): 1})
        assert (off["unmatched"], off["recordings"], off["last"]) == (1, 2, "2026-10-09T11:00:00+00:00")
        c.app.state.auth.cls[0]["created_at"] = "2026-10-10T00:00:00+00:00"   # opened after both
        since = c.get("/api/class", headers=good).json()["students"][0]["since"]["October 2026"]
        assert (since["best"], since["recordings"], since["last"]) == ({}, 0, None)
        assert c.delete("/api/classes/1", headers=good).status_code == 200 and len(c.app.state.auth.cls) == 1
        # a student sees each of their classes: by nickname, else the Google name; one with neither is only counted
        c.app.state.auth.cls[0]["emails"].append("a@b.c")
        assert c.get("/api/classmates", headers=good).json() == {"classes": [{"name": "October 2026", "unnamed": 2, "students": [
            {"name": "Alef", "me": True}, {"name": "דנה", "me": False}]}]}
        c.app.state.auth.cls[0]["emails"].remove("a@b.c")
        c.app.state.auth.cls = []   # no class: every account that signed in
        assert [s["email"] for s in c.get("/api/class", headers=good).json()["students"]] == ["A@b.c", "s@b.c", "out@b.c"]
    # a scored recording is recorded as the student's attempt
    auth, pid = FakeAuth(), PHRASES.index(TOILET)
    buf = io.BytesIO()
    with wave.open(buf, "wb") as w:
        w.setnchannels(1); w.setsampwidth(2); w.setframerate(16000)
        w.writeframes((0.3 * np.sin(2 * np.pi * 220 * np.arange(16000) / 16000) * 32767).astype("<i2").tobytes())
    logp = emissions(["w", "eː", "n", "i", "l", "χ", "a", "m", "aː", "m"])
    with TestClient(create_app(scorer_for(logp), auth=auth)) as c:
        files = {"audio": ("a.wav", buf.getvalue(), "audio/wav")}
        assert c.post("/api/score", data={"phrase_id": pid}, files=files).status_code == 401 and not auth.rows
        r = c.post("/api/score", data={"phrase_id": pid}, files=files, headers={"Authorization": "Bearer good"})
        assert r.status_code == 200, r.text
        (token, row), = auth.rows
        assert token == "good" and row["phrase"] == TOILET["he"] and row["cat"] == TOILET["cat"]
        assert row["score"] == round(r.json()["score"]) and row["unrelated"] is False
        assert c.get("/api/progress").status_code == 401
        progress = c.get("/api/progress", headers={"Authorization": "Bearer good"}).json()
        assert {k: progress[k] for k in ("best", "tries", "recordings", "days")} == {
            "best": {str(PHRASES.index(VERB)): 88}, "tries": {str(PHRASES.index(VERB)): 2}, "recordings": 3, "days": 2}
        assert 0 <= progress["week_days"] <= 7
        assert set(progress["streak"]) == {"current", "best", "last"}
    with TestClient(create_app(scorer_for(emissions(["w"])))) as c:   # no sign-in: nothing to show
        assert c.get("/api/progress").json() == {"best": {}, "tries": {}, "recordings": 0, "days": 0,
                                                  "week_days": 0, "streak": {"current": 0, "best": 0, "last": None}}
    with TestClient(create_app(scorer_for(emissions(["w"])))) as c:   # not configured: open, as before
        assert 'AUTH_URL=""' in c.get("/").text and c.get("/api/tts/0").status_code == 503


@pytest.mark.parametrize("p", PHRASES, ids=lambda p: p["he"])
def test_letters_cover_the_word_and_its_phonemes(p):
    from app.translit import word_letters, word_to_hebrew

    for ar, ipa in p["variants"]:
        for a, w in zip(ar.split(), ipa.split("|")):
            letters = word_letters(w, a)
            assert "".join(t for t, _ in letters) == word_to_hebrew(w, a)
            assert [j for _, at in letters for j in at] == list(range(len(w.split())))   # each phoneme once, in order


def test_result_scores_each_letter():
    # ħ said as χ: of الحمام, the ח is the weak letter and the rest are fine
    s = scorer_for(emissions(["w", "eː", "n", "i", "l", "χ", "a", "m", "aː", "m"]))
    for _, ipa in TOILET["variants"]:   # every target token is some phoneme's, the doubled م sharing one
        carried = [i for word in s._carriers(ipa) for idx in word for i in idx]
        assert sorted(set(carried)) == list(range(len(s._targets(ipa))))
    r = s.score(None, TOILET)
    for w in r["words"]:
        assert "".join(l["t"] for l in w["letters"]) == w["tr"]
    by = {l["t"][0]: l["score"] for l in r["words"][1]["letters"] if l["score"] is not None}
    assert by["ח"] < 50 and by["מ"] > 75
    # the coloured letter says why, with the advice for that mistake; a letter that was fine says nothing
    het, mem = (next(l for l in r["words"][1]["letters"] if l["t"][0] == c) for c in "חמ")
    assert het["why"] == "ח (ح) נשמעה כמו ח' (خ)" and het["tip"] == r["tips"][0]["text"] and "why" not in mem


def test_a_tutor_flags_a_recording_scored_wrong(monkeypatch):
    from fastapi.testclient import TestClient

    monkeypatch.setenv("SAWTAK_API_KEY", "")   # no native baseline from a local .env: the score is the stub's own

    from app.auth import AuthUnavailable
    from app.main import MODEL_ID, create_app

    class FakeAuth:
        url, key, teachers, tutors = "https://proj.supabase.co", "sb_publishable_x", frozenset(), frozenset({"t@b.c"})

        def __init__(self):
            self.cls, self.flags, self.table, self.rank, self.calibration_rows = [], [], True, frozenset(), []
            self.rule_rows = {}

        def user(self, token):
            return {"t": {"id": "u1", "email": "T@b.c"}, "s": {"id": "u2", "email": "s@b.c"}}.get(token)

        def classes(self, fresh=False):
            return self.cls

        def ranked(self, fresh=False):
            return self.rank

        def set_tutor(self, email, on, by):
            if self.rank is None:
                raise AuthUnavailable("supabase answered 404: no table")
            self.rank = self.rank | {email} if on else self.rank - {email}
            self.by = by

        def level_videos(self, table, fresh=False):
            return []

        def texts(self, fresh=False):
            return []

        def add_flag(self, row, audio, mime, name):
            if not self.table:
                raise AuthUnavailable("supabase answered 404: no table")
            self.flag_recordings = getattr(self, "flag_recordings", {})
            self.flag_recordings[len(self.flags) + 1] = (audio, mime)
            self.flags.append({**row, "audio": name})
            return True

        def list_flags(self, status="pending"):
            if status != "pending":
                return []
            return [{"id": i + 1, **row, "status": status} for i, row in enumerate(self.flags)]

        def review_flag(self, flag_id, values):
            self.review = (flag_id, values)

        def flag_audio(self, flag_id):
            return getattr(self, "flag_recordings", {}).get(flag_id)

        def calibrations(self, status=None):
            return [r for r in self.calibration_rows if not status or r["status"] == status]

        def add_calibration(self, row):
            made = {"id": len(self.calibration_rows) + 1, **row}
            self.calibration_rows.append(made)
            return made

        def update_calibration(self, calibration_id, values):
            next(r for r in self.calibration_rows if r["id"] == calibration_id).update(values)

        def delete_calibration(self, calibration_id):
            before = len(self.calibration_rows)
            self.calibration_rows = [r for r in self.calibration_rows if r["id"] != calibration_id]
            return len(self.calibration_rows) < before

        def scoring_rules(self):
            if self.rule_rows is None:
                raise AuthUnavailable("supabase answered 404: no table")
            return list(self.rule_rows.values())

        def set_scoring_rule(self, rule_id, active, by):
            if self.rule_rows is None:
                raise AuthUnavailable("supabase answered 404: no table")
            self.rule_rows[rule_id] = {"id": rule_id, "active": active, "updated_by": by}

        def all_attempt_issues(self):
            return [{"user_id": f"u{i}", "phrase": TOILET["he"], "score": 60,
                     "issues": [{"expected": "ħ", "heard": "χ", "score": 20}],
                     "model": MODEL_ID, "version": "v"} for i in range(3)]

    t = np.arange(16000) / 16000
    buf = io.BytesIO()
    with wave.open(buf, "wb") as w:
        w.setnchannels(1); w.setsampwidth(2); w.setframerate(16000)
        w.writeframes((0.3 * np.sin(2 * np.pi * 220 * t) * 32767).astype("<i2").tobytes())
    pid = PHRASES.index(TOILET)
    send = lambda c, token, **data: c.post("/api/flags", data={"phrase_id": pid, **data}, headers={"Authorization": f"Bearer {token}"},
                                           files={"audio": ("rec", buf.getvalue(), "audio/wav")})
    # said with a Hebrew chet, as the model hears it: the tutor says it was right
    with TestClient(create_app(scorer_for(emissions(["w", "eː", "n", "i", "l", "χ", "a", "m", "aː", "m"])), auth=FakeAuth())) as c:
        a = c.app.state.auth
        assert c.get("/api/me", headers={"Authorization": "Bearer t"}).json()["tutor"] is True
        assert "tutor" not in c.get("/api/me", headers={"Authorization": "Bearer s"}).json()
        assert c.post("/api/flags", data={"phrase_id": pid}, files={"audio": ("rec", b"x", "audio/wav")}).status_code == 401
        assert send(c, "s").status_code == 403 and not a.flags   # tutors only
        r = send(c, "t", note="  אמרתי  ח נכונה ")
        row, = a.flags
        assert r.status_code == 200 and r.json() == {"ok": True, "audio": True, "flag": row}, r.text
        assert row["email"] == "t@b.c" and row["cat"] == TOILET["cat"] and row["he"] == TOILET["he"] and row["note"] == "אמרתי ח נכונה"
        assert row["ipa"] == "w eː n | i l ħ a m m aː m" and "χ" in row["heard_ipa"] and row["score"] == row["result"]["score"] < 100
        assert row["result"]["tips"][0]["key"] == "ħ>x" and row["result"]["trace"]["tokens"]
        assert "phoneme_heatmap" not in row["result"]
        assert send(c, "t", phrase_id=10**6).status_code == 404
        a.teachers = frozenset({"s@b.c"})   # a teacher flags too
        assert c.get("/api/me", headers={"Authorization": "Bearer s"}).json()["tutor"] is True and send(c, "s").status_code == 200
        hs = {"Authorization": "Bearer s"}
        listed = c.get("/api/flags", headers=hs)
        assert listed.status_code == 200 and len(listed.json()["flags"]) == 2
        played = c.get("/api/flags/1/audio", headers=hs)
        assert played.status_code == 200 and played.content == buf.getvalue()
        assert c.get("/api/flags/999/audio", headers=hs).status_code == 404
        review = c.put("/api/flags/1", headers=hs, json={"status": "confirmed", "category": "recognition",
                       "false_issues": [{"word": 1, "expected": "ħ", "heard": "χ"}], "review_note": "  תקין  "})
        assert review.status_code == 200
        assert a.review[0] == 1 and a.review[1]["false_issues"] == [{"word": 1, "expected": "ħ", "heard": "χ"}]
        assert a.review[1]["review_note"] == "תקין" and a.review[1]["reviewed_by"] == "s@b.c"
        assert c.put("/api/flags/1", headers=hs, json={"status": "pending", "category": "other"}).status_code == 400
        patterns = c.get("/api/attempt-patterns", headers=hs).json()["patterns"]
        assert patterns[0]["expected"] == "ħ" and patterns[0]["heard"] == "χ"
        assert patterns[0]["students"] == 3 and patterns[0]["student_rate"] == 1
        proposal = c.post("/api/calibrations", headers=hs, json={"expected": "ħ", "heard": "χ",
                          "evidence": {"reports": 5, "reporters": 2, "phrases": [TOILET["he"]]}})
        assert proposal.status_code == 200 and proposal.json()["calibration"]["status"] == "draft"
        applied = c.put("/api/calibrations/1", headers=hs, json={"status": "applied", "floor": 70,
                        "scope": "phrase", "phrase": TOILET["he"]})
        assert applied.status_code == 200 and applied.json()["active"] == 1
        assert c.app.state.scorer.calibrations[("ħ", "χ", TOILET["he"])] == 70
        deleted = c.delete("/api/calibrations/1", headers=hs)
        assert deleted.status_code == 200 and deleted.json()["active"] == 0
        assert not c.app.state.scorer.calibrations and c.delete("/api/calibrations/1", headers=hs).status_code == 404
        dismissed = c.post("/api/calibrations", headers=hs, json={"expected": "r", "heard": "ɣ",
                           "evidence": {"dismissed": True, "source": "confirmed_flags"}})
        assert dismissed.status_code == 200 and dismissed.json()["calibration"]["status"] == "draft"
        assert dismissed.json()["calibration"]["evidence"] == {"dismissed": True, "source": "confirmed_flags"}
        # the rules of pronunciation-rules.md: all on, a teacher switches one off, and the scorer follows at once
        listed = c.get("/api/scoring-rules", headers=hs).json()
        assert listed["stored"] and all(r["active"] for r in listed["rules"]) and len(listed["rules"]) >= 20
        assert all(isinstance(r["detectable"], bool) and r["title"] and r["description"] for r in listed["rules"])
        assert c.put("/api/scoring-rules/dialect_Q", headers=hs, json={"active": False}).json()["disabled"] == ["dialect_Q"]
        assert c.app.state.scorer.disabled == {"dialect_Q"} and a.rule_rows["dialect_Q"]["updated_by"] == "s@b.c"
        assert not next(r for r in c.get("/api/scoring-rules", headers=hs).json()["rules"] if r["id"] == "dialect_Q")["active"]
        assert c.put("/api/scoring-rules/nope", headers=hs, json={"active": False}).status_code == 400
        assert c.put("/api/scoring-rules/margins", headers=hs, json={"active": "no"}).status_code == 400
        assert c.put("/api/scoring-rules/margins", headers={"Authorization": "Bearer t"}, json={"active": False}).status_code == 403
        assert c.put("/api/scoring-rules/dialect_Q", headers=hs, json={"active": True}).json()["disabled"] == []
        a.rule_rows = None   # no table yet: the list still shows, all on, with the SQL to create it
        missing = c.get("/api/scoring-rules", headers=hs).json()
        assert not missing["stored"] and "create table public.scoring_rules" in missing["create_sql"]
        assert c.put("/api/scoring-rules/margins", headers=hs, json={"active": False}).status_code == 503
        a.teachers = frozenset()
        a.table = False   # before the table is created: the command to create it
        r = send(c, "t")
        assert r.status_code == 503 and "create table public.flags" in r.json()["detail"]
        # a tutor is let in without a class, a student is not
        a.cls = [{"id": 1, "name": "B", "emails": ["other@b.c"], "nicknames": {}, "lessons": []}]
        assert c.get("/api/me", headers={"Authorization": "Bearer t"}).status_code == 200
        assert c.get("/api/me", headers={"Authorization": "Bearer s"}).status_code == 403
        # the rank the teacher gives a student in the panel: theirs in every class, and it lets nobody in by itself
        tok = lambda t: {"Authorization": f"Bearer {t}"}
        rank = lambda t, **body: c.put("/api/tutors", json=body, headers=tok(t))
        a.users, a.all_attempts, a.all_writings = lambda: [{"id": "u2", "email": "s@b.c", "name": ""}], lambda: [], lambda: []
        assert rank("s", email="s@b.c", tutor=True).status_code == 403   # teachers only (s is not in a class either)
        a.cls[0]["emails"].append("s@b.c"); a.teachers = frozenset({"t@b.c"})
        assert rank("s", email="s@b.c", tutor=True).status_code == 403 and "tutor" not in c.get("/api/me", headers=tok("s")).json()
        assert rank("t", email="nobody", tutor=True).status_code == 400 and rank("t", email="s@b.c", tutor="yes").status_code == 400
        assert rank("t", email=" S@b.c ", tutor=True).status_code == 200 and a.rank == {"s@b.c"} and a.by == "t@b.c"
        assert c.get("/api/me", headers=tok("s")).json()["tutor"] is True and send(c, "s").status_code == 503   # may flag (the table is still missing)
        panel = c.get("/api/class", headers=tok("t")).json()
        assert [(s["email"], s["tutor"], s.get("tutor_fixed")) for s in panel["students"]] == [("s@b.c", True, None), ("other@b.c", False, None)]
        assert rank("t", email="s@b.c", tutor=False).status_code == 200 and "tutor" not in c.get("/api/me", headers=tok("s")).json()
        a.rank = frozenset({"out@b.c"})   # ranked, but in no class: not let in
        known = a.user
        a.user = lambda token: {"id": "u3", "email": "out@b.c"} if token == "x" else known(token)
        assert c.get("/api/me", headers=tok("x")).status_code == 403
        a.rank = None   # before the table is created: the panel says what to run, and so does giving the rank
        assert "create table public.tutors" in c.get("/api/class", headers=tok("t")).json()["setup"][-1]["sql"]
        r = rank("t", email="s@b.c", tutor=True)
        assert r.status_code == 503 and "create table public.tutors" in r.json()["detail"]


def test_switched_off_rules_bring_back_the_old_behaviour():
    kaddeesh = emissions(["k", "a", "d", "eː", "ʃ", "i", "s", "aː", "ʕ", "a"])
    s = scorer_for(kaddeesh)
    assert not s.score(None, TIME)["tips"]                                    # rural ق accepted
    s.set_disabled({"dialect_Q", "not-a-rule"})
    assert s.disabled == {"dialect_Q"}
    assert [t["key"] for t in s.score(None, TIME)["tips"]] == ["ʔ>k"]          # as before the document
    # the emphatic margin: off, plain t winning by less than twice is a mistake again
    phrase = {"variants": [("طين", "tˤ i n")]}
    s = margin_scorer(1.8)
    assert not s.score(None, phrase)["tips"]
    s.set_disabled({"margins"})
    assert [t["key"] for t in s.score(None, phrase)["tips"]] == ["tˤ>t"]
    # a section-3 rule off: the general tip and severity instead of the document's
    s = custom_scorer(["p", "f", "dʒ"], ["p", "i", "n", "dʒ", "aː", "n"])
    fin = {"variants": [("فنجان", "f i n dʒ aː n")]}
    assert s.score(None, fin)["tips"][0]["text"].startswith("נהגתה p")
    s.set_disabled({"rule_f"})
    tip = s.score(None, fin)["tips"][0]
    assert tip["key"] == "f>p" and not tip["text"].startswith("נהגתה p") and tip["severity"] == 2
    # the final vowel of ة: off, i for e is marked down like any other vowel
    s = scorer_for(emissions(["ʔ", "a", "h", "w", "i"]))
    assert s.score(None, COFFEE)["words"][0]["phonemes"][-1]["score"] >= 95
    s.set_disabled({"final_vowel", "near_vowel"})
    assert s.score(None, COFFEE)["words"][0]["phonemes"][-1]["score"] < 50


THANKS = {"variants": [("شكرا كتير", "ʃ u k r a n | k t iː r")]}
CLUSTER_SYMS = ["r", "t", "iː", "u", "ʃ"]


def test_vowel_between_two_consonants_is_a_mistake_mid_sentence():
    clean = custom_scorer(CLUSTER_SYMS, ["ʃ", "u", "k", "r", "a", "n", "k", "t", "iː", "r"]).score(None, THANKS)
    assert not clean["tips"] and clean["score"] >= 95
    s = custom_scorer(CLUSTER_SYMS, ["ʃ", "u", "k", "r", "a", "n", "k", "a", "t", "iː", "r"])   # katiir
    r = s.score(None, THANKS)
    t = next(p for p in r["words"][1]["phonemes"] if p["ipa"] == "t")
    assert t["inserted"] == "a" and t["score"] == 60 and [x["key"] for x in r["tips"]] == ["cluster"]
    assert r["tips"][0]["severity"] == 1 and "ktiir" in r["tips"][0]["text"] and r["score"] < clean["score"]
    letter = next(l for l in r["words"][1]["letters"] if "ת" in l["t"])
    assert letter["why"].startswith("נוספה") and "ktiir" in letter["tip"]
    s.set_disabled({"rule_cluster"})                       # switched off: not checked, as before
    assert not s.score(None, THANKS)["tips"]


def test_vowel_in_the_cluster_that_opens_the_sentence_is_fine():
    # ketiir at the start of a sentence (6.3 #16), and the way into a glide (shuway)
    r = custom_scorer(["t", "iː", "r"], ["k", "e", "t", "iː", "r"]).score(None, {"variants": [("كتير", "k t iː r")]})
    assert not r["tips"] and r["score"] >= 95
    phrase = {"variants": [("شو شوي", "ʃ u | ʃ w a j j")]}
    r = custom_scorer(["ʃ", "u", "j"], ["ʃ", "u", "ʃ", "u", "w", "a", "j"]).score(None, phrase)
    assert not any(t["key"] == "cluster" for t in r["tips"])


LAWYER = {"variants": [("محامي", "m u ħ aː m i")]}


@pytest.mark.parametrize("said,flagged", [
    (["m", "u", "ħ", "aː", "m", "i"], False),
    (["m", "ħ", "aː", "m", "i"], True),            # mħaami: the u swallowed
    (["m", "e", "ħ", "aː", "m", "i"], True),       # said as e
    (["m", "o", "ħ", "aː", "m", "i"], False),      # o is a near vowel
])
def test_kubutz_is_a_distinct_vowel(said, flagged):
    r = custom_scorer(["u", "o"], said).score(None, LAWYER)
    u = r["words"][0]["phonemes"][1]
    assert u["ipa"] == "u" and u["flagged"] == flagged, u
    if flagged:
        assert r["tips"][0]["text"].startswith("ה־u") and r["tips"][0]["severity"] == 1


def test_only_short_u_after_a_consonant_is_flagged():
    s = custom_scorer(["u", "o"], ["m", "ħ", "aː", "m", "i"])
    s.set_disabled({"rule_kubutz"})                       # switched off: like any other short vowel
    assert not s.score(None, LAWYER)["words"][0]["phonemes"][1]["flagged"]
    # the و of "and" (u m a) and a long uː (ʃ uː f) are not the kubutz rule's
    r = custom_scorer(["u"], ["m", "a"]).score(None, {"variants": [("وما", "u m a")]})
    assert not r["words"][0]["phonemes"][0]["flagged"]
    r = custom_scorer(["u", "uː", "f", "ʃ"], ["ʃ", "e", "f"]).score(None, {"variants": [("شوف", "ʃ uː f")]})
    assert not r["words"][0]["phonemes"][1]["flagged"] and not r["tips"]


def test_teacher_trace_follows_every_stage():
    # ħ said as χ, then forgiven by a teacher floor: each stage of the trace shows where the score came from
    logp = emissions(["w", "eː", "n", "i", "l", "χ", "a", "m", "aː", "m"])
    scorer = scorer_for(logp)
    assert "trace" not in scorer.score(None, TOILET)                     # students do not get it
    scorer.set_calibrations([{"expected": "ħ", "heard": "χ", "floor": 60}])
    t = scorer.score_logp(logp, TOILET, diagnostics=True)["trace"]
    assert t["config"]["flag_below"] == 50 and t["config"]["leniency"] == 0.35 and t["variants"][0]["chosen"]
    assert t["config"]["word_min_weight"] == 0.35
    h = next(x for x in t["tokens"] if x["ipa"] == "ħ")
    assert [s["stage"] for s in h["stages"]] == ["gop", "exemption", "insertion", "baseline", "calibration"]
    assert h["stages"][0]["score"] < 50 and h["stages"][4]["applied"] and h["final"] == 60
    assert h["competitor"] == "χ" and h["rule"]["severity"] == 3 and not h["flagged"]
    assert t["sentence"]["final"] == round(0.5 * t["sentence"]["phone_mean"] + 0.5 * t["sentence"]["word_mean"])


def test_trace_carries_frames_curves_and_paths():
    from app.scorer import frame_energy

    r = scorer_for(emissions(["w", "eː", "n", "i", "l", "ħ", "a", "m", "aː", "m"])).score(None, TOILET, diagnostics=True)
    t = r["trace"]
    assert t["frames"]["count"] > 0 and t["frames"]["energy"] is None                 # no audio: no energy
    tok = t["tokens"][0]
    assert len(tok["curves"]["target"]) == len(tok["curves"]["blank"]) and tok["curves"]["prev"] is None
    assert all(v["tokens"] for v in t["variants"]) and sum(v["chosen"] for v in t["variants"]) == 1
    e = frame_energy(np.r_[np.zeros(320), np.ones(320)], 2)
    assert np.allclose(e, [0, 1])


def test_flag_threshold_per_sound():
    # ħ said as χ: flagged at the global 50, not once its own threshold is under the score; other sounds unchanged
    logp = emissions(["w", "eː", "n", "i", "l", "χ", "a", "m", "aː", "m"])
    scorer = scorer_for(logp)
    h = lambda r: next(p for w in r["words"] for p in w["phonemes"] if p["ipa"] == "ħ")
    before = h(scorer.score(None, TOILET))
    assert before["flagged"] and before["heard"] == "χ"
    scorer.cfg = replace(scorer.cfg, flag_below_sound={"ħ": before["score"]})
    r = scorer.score_logp(logp, TOILET, diagnostics=True)
    assert not h(r)["flagged"] and h(r)["score"] == before["score"]
    assert r["trace"]["config"]["flag_below_sound"] == {"ħ": before["score"]}


def test_flag_thresholds_belong_to_their_model():
    from app.scorer import scorer_config
    assert scorer_config("facebook/wav2vec2-xlsr-53-espeak-cv-ft").flag_below_sound == {"ħ": 20.0}
    assert scorer_config("checkpoints/ihki-ft").flag_below_sound == {}
    assert scorer_config("checkpoints/ihki-ft").flag_below == 50


def test_each_class_topic_opens_on_its_own_screen():
    from fastapi.testclient import TestClient
    from app.main import create_app

    with TestClient(create_app(scorer_for(emissions(["w"])))) as c:
        for path in ("/", "/arabic"):   # "/" stays Arabic
            page = c.get(path).text
            assert 'data-subject="arabic"' in page and "/static/logo-intro.svg" in page and "<title>אהלא וסהלא</title>" in page
        math = c.get("/math").text
        assert 'data-subject="math"' in math and "/static/logo-math.svg" in math and "<title>מתמטיקה בכיף</title>" in math
        assert "__SUBJECT__" not in math and "__START_LABEL__" not in math
        assert c.get("/history").status_code == 404
        assert c.get("/static/logo-math.svg").status_code == 200
        # its installed app has its own name, colour and start page; Arabic keeps the static manifest
        assert '<meta name="theme-color" content="#000000">' in math and 'href="/math/manifest.webmanifest"' in math
        assert '"__' not in math and '<meta name="theme-color" content="#4B6A2C">' in c.get("/").text
        assert re.search(r'<script src="/static/math\.js\?v=[0-9a-f]{7}" defer>', math)   # the path's script, stamped
        assert 'href="/static/manifest.webmanifest"' in c.get("/").text
        installed = c.get("/math/manifest.webmanifest")
        assert installed.headers["content-type"].startswith("application/manifest+json")
        assert {k: installed.json()[k] for k in ("id", "name", "start_url", "scope")} == {
            "id": "/math", "name": "מתמטיקה בכיף", "start_url": "/math", "scope": "/"}
        assert c.get("/arabic/manifest.webmanifest").status_code == 404


def test_an_account_is_sent_to_its_subjects_pages():
    from fastapi.testclient import TestClient
    from app.main import create_app, subject_code

    assert subject_code("מתמטיקה") == "math" and subject_code(" ערבית ") == "arabic"
    assert subject_code("חשבון") == subject_code("") == subject_code(None) == ""

    class FakeAuth:
        url, key, teachers, tutors, developers = "https://proj.supabase.co", "sb_publishable_x", frozenset(), frozenset(), {"d@b.c"}
        users_ = {"s": {"id": "u1", "email": "s@b.c"}, "t": {"id": "u2", "email": "t@b.c", "name": "T"},
                  "d": {"id": "u3", "email": "d@b.c"}}

        def __init__(self):
            self.cls, self.access = [], {"u2": {"user_id": "u2", "roles": ["admin"], "subject": "מתמטיקה"}}

        def user(self, token):
            return self.users_.get(token)

        def users(self):
            return list(self.users_.values())

        def classes(self, fresh=False):
            return self.cls

        def access_for(self, user_id):
            return self.access.get(user_id)

        def access_rows(self):
            return list(self.access.values())

        def set_admin_subject(self, target, subject, actor):
            self.access[target["id"]]["subject"] = subject

        def ranked(self, fresh=False):
            return frozenset()

        def __getattr__(self, name):   # the content tables /api/me reads: empty
            return lambda *a, **k: []

    tok = lambda t: {"Authorization": f"Bearer {t}"}
    with TestClient(create_app(scorer_for(emissions(["w"])), auth=FakeAuth())) as c:
        auth = c.app.state.auth
        auth.cls = [{"id": 1, "name": "ט1", "emails": ["s@b.c"], "nicknames": {}, "lessons": [], "subject": "מתמטיקה"}]
        me = c.get("/api/me", headers=tok("s")).json()   # a student goes where their class is
        assert me["subject"] == "math" and me["classes"][0]["subject"] == "math"
        assert c.get("/api/me", headers=tok("t")).json()["subject"] == "math"   # an admin, where their own subject is
        assert "subject" not in c.get("/api/me", headers=tok("d")).json()   # a developer stays on the page they opened
        auth.cls[0]["subject"] = "חשבון"   # a subject outside the list sends nowhere
        assert "subject" not in c.get("/api/me", headers=tok("s")).json()
        # the developer picks an admin's subject from the list; free text is refused
        listed = c.get("/api/admin-subjects", headers=tok("d")).json()
        assert listed["subjects"] == [{"code": "arabic", "label": "ערבית"}, {"code": "math", "label": "מתמטיקה"}]
        assert listed["admins"] == [{"id": "u2", "email": "t@b.c", "name": "T", "subject": "מתמטיקה"}]
        put = lambda subject: c.put("/api/admin-subjects/u2", json={"subject": subject}, headers=tok("d"))
        assert put("חשבון").status_code == 400 and auth.access["u2"]["subject"] == "מתמטיקה"
        assert put(" ערבית ").json() == {"ok": True, "subject": "ערבית"} and auth.access["u2"]["subject"] == "ערבית"
        assert c.get("/api/me", headers=tok("t")).json()["subject"] == "arabic"