formulaa 0.1.0

WYSIWYG TUI math editor rendering Unicode/ASCII-art formulas
Documentation
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
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
//! Key-driven UI tests: everything goes through the shared keymap
//! (`Editor::input`), i.e. the exact path the TUI and the wasm bindings
//! use, so these tests pin down "press this, get that" behaviour.
//!
//! A tiny key-script DSL keeps the cases readable:
//!   - a bare token is typed character by character (`x+1` = three keys)
//!   - `\frac` types the minibuffer command and executes it (trailing
//!     Enter); a lone `\` just opens the minibuffer
//!   - named keys: Left Right Up Down Home End Tab Enter Backspace
//!     Delete Esc Space, with `S-` (shift) / `C-` (ctrl) prefixes

use formulaa::ast::Field;
use formulaa::ast::normalize;
use formulaa::editor::{Ask, Editor};
use formulaa::input::{Effect, Key};
use formulaa::latex::row_to_latex;
use formulaa::parse::parse;
use formulaa::render::{RenderCtx, render_root};

fn named(tok: &str) -> Option<Key> {
    Some(match tok {
        "Left" => Key::Left,
        "Right" => Key::Right,
        "Up" => Key::Up,
        "Down" => Key::Down,
        "Home" => Key::Home,
        "End" => Key::End,
        "Tab" => Key::Tab,
        "Enter" => Key::Enter,
        "Backspace" => Key::Backspace,
        "Delete" => Key::Delete,
        "Esc" => Key::Esc,
        "Space" => Key::Char(' '),
        _ => return None,
    })
}

fn type_script(ed: &mut Editor, script: &str) -> Vec<Effect> {
    let mut effects = Vec::new();
    for tok in script.split_whitespace() {
        let (tok, shift, ctrl) = match tok.strip_prefix("S-") {
            Some(rest) => (rest, true, false),
            None => match tok.strip_prefix("C-") {
                Some(rest) => (rest, false, true),
                None => (tok, false, false),
            },
        };
        if let Some(key) = named(tok) {
            effects.push(ed.input(key, shift, ctrl));
        } else if let Some(cmd) = tok.strip_prefix('\\').filter(|c| !c.is_empty()) {
            effects.push(ed.input(Key::Char('\\'), false, false));
            for c in cmd.chars() {
                effects.push(ed.input(Key::Char(c), false, false));
            }
            effects.push(ed.input(Key::Enter, false, false));
        } else {
            for c in tok.chars() {
                effects.push(ed.input(Key::Char(c), shift, ctrl));
            }
        }
    }
    effects
}

fn latex(ed: &Editor) -> String {
    row_to_latex(&normalize(&ed.root))
}

fn aa(ed: &Editor) -> String {
    render_root(&normalize(&ed.root), None, &RenderCtx::canonical()).to_text()
}

#[test]
fn typing_a_formula_by_keys() {
    let mut ed = Editor::new();
    type_script(&mut ed, r"x ^ 2 Tab + \frac 1 Down 2");
    assert_eq!(latex(&ed), "x^{2}+\\frac{1}{2}");
}

#[test]
fn minibuffer_esc_cancels_and_backspace_erases() {
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ f r a Esc a");
    assert_eq!(latex(&ed), "a");
    // Backspacing past the start closes the minibuffer.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ f Backspace Backspace b");
    assert_eq!(latex(&ed), "b");
    assert!(ed.minibuffer.is_none());
}

#[test]
fn selection_wraps_into_fraction() {
    let mut ed = Editor::new();
    type_script(&mut ed, r"a+b S-Left S-Left S-Left \frac 2");
    assert_eq!(latex(&ed), "\\frac{a+b}{2}");
}

#[test]
fn wrap_keys_wrap_the_selection() {
    // ^ and ( spell the same wrap-Edits the \commands resolve to: the
    // selection lands in the first slot, the cursor steps past.
    let mut ed = Editor::new();
    type_script(&mut ed, "y x S-Left ^");
    assert_eq!(latex(&ed), "y^{x}");
    let mut ed = Editor::new();
    type_script(&mut ed, "a + b S-Left S-Left S-Left (");
    assert_eq!(latex(&ed), "\\left(a+b\\right)");
}

#[test]
fn every_radical_wraps_the_selection() {
    // \qdrt used to insert instead of wrap — the roots are uniform now.
    for (cmd, want) in [
        ("sqrt", "\\sqrt{a+b}"),
        ("cbrt", "\\sqrt[3]{a+b}"),
        ("qdrt", "\\sqrt[4]{a+b}"),
    ] {
        let mut ed = Editor::new();
        type_script(&mut ed, r"a + b S-Left S-Left S-Left");
        ed.execute(cmd);
        assert_eq!(latex(&ed), want, "\\{}", cmd);
    }
}

#[test]
fn rm_box_space_commits() {
    // Space is not \rm content (names are alphanumerics + dots): it
    // commits the box, like in \op.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\rm a Space b");
    assert!(ed.op_entry.is_none());
    assert_eq!(latex(&ed), "\\mathrm{a}b");
    // \text keeps spaces as real content.
    let mut ed = Editor::new();
    ed.execute("text");
    for c in "if x".chars() {
        ed.input(Key::Char(c), false, false);
    }
    ed.input(Key::Enter, false, false);
    assert_eq!(latex(&ed), "\\text{if x}");
}

#[test]
fn box_caret_moves_and_edge_commits() {
    // ←/→ edit inside the box: type "ac", step left, insert "b".
    let mut ed = Editor::new();
    type_script(&mut ed, r"\rm a c Left b Enter");
    assert_eq!(latex(&ed), "\\operatorname{abc}");
    // Stepping past the right edge commits and the arrow then acts on
    // the formula (here: nothing left to move over).
    let mut ed = Editor::new();
    type_script(&mut ed, r"\rm a b Right");
    assert!(ed.op_entry.is_none(), "right edge committed the box");
    assert_eq!(latex(&ed), "\\operatorname{ab}");
    // …and past the left edge likewise, with the cursor stepping left
    // of the freshly committed run.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\rm a b Left Left Left");
    assert!(ed.op_entry.is_none(), "left edge committed the box");
    assert_eq!(latex(&ed), "\\operatorname{ab}");
    assert_eq!(ed.col, 0, "the exiting ← still moved the cursor");
    // Delete works at the caret; Home/End jump.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\rm a b c Home Delete End d Enter");
    assert_eq!(latex(&ed), "\\operatorname{bcd}");
}

#[test]
fn tex_box_reads_latex_in_place() {
    // \tex opens a box; typing (or pasting) LaTeX and committing
    // splices the parsed nodes at the cursor.
    let mut ed = Editor::new();
    ed.execute("latex");
    assert!(ed.op_entry.is_some());
    for c in r"\frac{1}{2}+\alpha".chars() {
        ed.op_type(c);
    }
    ed.op_commit();
    assert_eq!(latex(&ed), "\\frac{1}{2}+\\alpha ");
    // The cursor sits after the spliced nodes; typing continues.
    ed.input(Key::Char('x'), false, false);
    assert_eq!(latex(&ed), "\\frac{1}{2}+\\alpha x");
    // \latex is the same box; junk input inserts nothing and the
    // editor stays consistent.
    let mut ed = Editor::new();
    ed.execute("latex");
    for c in r"\begin{".chars() {
        ed.op_type(c);
    }
    ed.op_commit();
    assert_eq!(ed.root, vec![]);
    // Keys reach the box through the shared keymap too: space is
    // content, Enter commits.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ l a t e x Space");
    assert!(ed.op_entry.is_some(), "{:?}", ed.op_entry);
    for c in r"x ^ 2".chars() {
        ed.input(Key::Char(c), false, false);
    }
    ed.input(Key::Enter, false, false);
    assert_eq!(latex(&ed), "x^{2}");
}

#[test]
fn minibuffer_previews_the_commit() {
    // Symbol commands preview their character…
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ a l p h a");
    assert_eq!(ed.command_preview(), Some('α'));
    assert_eq!(
        ed.command_preview_row(),
        Some(vec![formulaa::ast::Node::Sym('α')])
    );
    // …∑-class commands their operator…
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ s u m");
    assert_eq!(ed.command_preview(), Some(''));
    // …structures the empty template (single-char preview: none).
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ f r a c");
    assert_eq!(ed.command_preview(), None);
    assert!(matches!(
        ed.command_preview_row().as_deref(),
        Some([formulaa::ast::Node::Frac { .. }])
    ));
    // An unknown spelling previews nothing…
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ f r");
    assert_eq!(ed.command_preview_row(), None);
    // …a name box previews the slot it opens, since that is what
    // committing gives you…
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ l a t e x");
    assert_eq!(
        ed.command_preview_row(),
        Some(vec![formulaa::ast::Node::Sym('')])
    );
    // …and the commands that act on their surroundings preview
    // nothing, because what they do depends on where the cursor is.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ a d d r o w");
    assert_eq!(ed.command_preview_row(), None);
    // The preview never touches the formula.
    let mut ed = Editor::new();
    type_script(&mut ed, r"x \ f r a c");
    assert_eq!(latex(&ed), "x");
}

#[test]
fn ctrl_keys_return_host_effects() {
    let mut ed = Editor::new();
    assert_eq!(ed.input(Key::Char('q'), false, true), Effect::Quit);
    assert_eq!(ed.input(Key::Char('y'), false, true), Effect::CopyAa);
    assert_eq!(ed.input(Key::Char('o'), false, true), Effect::Write);
    assert_eq!(ed.input(Key::Char('w'), false, true), Effect::WriteQuit);
    // Plain typing never asks the host for anything.
    assert_eq!(ed.input(Key::Char('q'), false, false), Effect::None);
}

/// ^H is Backspace — aliased ahead of the key layers, so the modes
/// that read text get it as readily as the formula does.
#[test]
fn ctrl_h_deletes_left_everywhere() {
    let mut ed = Editor::new();
    type_script(&mut ed, "a b C-h");
    assert_eq!(latex(&ed), "a");
    type_script(&mut ed, r"\ a l C-h");
    assert_eq!(ed.minibuffer.as_deref(), Some("a"));
    let mut ed = Editor::new();
    ed.ask_path("");
    type_script(&mut ed, "x y C-h");
    assert_eq!(ed.ask, Some(Ask::Path("x".into())));
}

/// The status-line questions: the host opens one, the key layer runs
/// it, and the answer comes back as an effect. Nothing else may act
/// while a question stands — a chord typed into a file name would
/// otherwise both edit the formula and land in the name.
#[test]
fn status_line_questions_answer_the_host() {
    let mut ed = Editor::new();
    type_script(&mut ed, "a");
    ed.ask_path("");
    let fx = type_script(&mut ed, "f o o . a a");
    assert!(fx.iter().all(|e| *e == Effect::None), "{:?}", fx);
    assert_eq!(ed.ask, Some(Ask::Path("foo.aa".into())), "not collected");
    assert_eq!(latex(&ed), "a", "the name reached the formula");
    assert_eq!(
        ed.input(Key::Enter, false, false),
        Effect::WriteTo("foo.aa".into())
    );
    assert_eq!(ed.ask, None, "the question outlived its answer");

    // Esc drops the question and leaves the save undone.
    let mut ed = Editor::new();
    ed.ask_path("");
    type_script(&mut ed, "x Esc");
    assert_eq!(ed.ask, None);
    assert_eq!(latex(&ed), "", "the typing leaked into the formula");

    // A ctrl chord is spent on the question, not on the editor.
    let mut ed = Editor::new();
    ed.ask_path("");
    assert_eq!(ed.input(Key::Char('q'), false, true), Effect::None);
    assert_eq!(ed.ask, Some(Ask::Path(String::new())), "\\q leaked");

    // The unsaved-work question is a y/n, with Enter taking the [Y/n]
    // default.
    for (script, want) in [
        ("y", Effect::WriteQuit),
        ("n", Effect::Discard),
        ("Enter", Effect::WriteQuit),
    ] {
        let mut ed = Editor::new();
        ed.ask_save_first();
        let fx = type_script(&mut ed, script);
        assert!(fx.contains(&want), "{}: {:?}", script, fx);
        assert_eq!(ed.ask, None);
    }
    // …and Esc keeps the editor where it was.
    let mut ed = Editor::new();
    ed.ask_save_first();
    let fx = type_script(&mut ed, "Esc");
    assert!(fx.iter().all(|e| *e == Effect::None), "{:?}", fx);
    assert_eq!(ed.ask, None);

    // Anything else is spent on the question, which simply stands:
    // a key that is not an answer must not become one, and must not
    // reach the formula or the chords either.
    let mut ed = Editor::new();
    type_script(&mut ed, "a");
    ed.ask_save_first();
    let fx = type_script(&mut ed, "x Space Tab Down C-y");
    assert!(fx.iter().all(|e| *e == Effect::None), "{:?}", fx);
    assert_eq!(ed.ask, Some(Ask::SaveFirst), "the question was answered");
    assert_eq!(latex(&ed), "a", "a key leaked into the formula");
}

#[test]
fn esc_cancels_modes_before_quitting() {
    let mut ed = Editor::new();
    // With a selection, Esc only clears it …
    type_script(&mut ed, "a S-Left");
    assert_eq!(ed.input(Key::Esc, false, false), Effect::None);
    assert_eq!(ed.selection(), None);
    // … in the minibuffer it closes that …
    assert_eq!(ed.input(Key::Char('\\'), false, false), Effect::None);
    assert_eq!(ed.input(Key::Esc, false, false), Effect::None);
    assert!(ed.minibuffer.is_none());
    // … and with nothing left to cancel it quits.
    assert_eq!(ed.input(Key::Esc, false, false), Effect::Quit);
}

#[test]
fn ctrl_e_jumps_to_the_end_outside_grids() {
    let mut ed = Editor::new();
    type_script(&mut ed, r"a+b C-a");
    assert_eq!((ed.path.len(), ed.col), (0, 0));
    type_script(&mut ed, "C-e");
    assert_eq!((ed.path.len(), ed.col), (0, 3), "formula end");
    // ^E is the end jump even inside a grid cell (grid mode is ^G).
    let mut ed = Editor::new();
    type_script(&mut ed, r"\bmatrix22 x C-g");
    assert!(ed.grid.is_some());
}

#[test]
fn accent_wraps_selection_as_wide_accent() {
    let mut ed = Editor::new();
    type_script(&mut ed, r"abc S-Left S-Left S-Left \hat");
    assert_eq!(latex(&ed), "\\widehat{abc}");
    // Stacking the other side: no selection needed, the accent command
    // right after the node fills its free slot.
    type_script(&mut ed, r"\underline");
    assert_eq!(latex(&ed), "\\widehat{\\underline{abc}}");
    // A one-char selection is the ordinary compact accent.
    let mut ed = Editor::new();
    type_script(&mut ed, r"x S-Left \vec");
    assert_eq!(latex(&ed), "\\vec{x}");
    // Tall bases work too (the band rides over the whole block).
    let mut ed = Editor::new();
    type_script(&mut ed, r"\frac 1 Down 2 Tab S-Left \bar");
    assert_eq!(latex(&ed), "\\overline{\\frac{1}{2}}");
    // Two marks on the same side stack, like a compact accent's.
    let mut ed = Editor::new();
    type_script(&mut ed, r"abc S-Left S-Left S-Left \hat");
    type_script(&mut ed, r"\vec");
    assert_eq!(latex(&ed), "\\overrightarrow{\\widehat{abc}}");
    // The under tilde wraps a selection like any under mark.
    let mut ed = Editor::new();
    type_script(&mut ed, r"AB S-Left S-Left \utilde");
    assert_eq!(latex(&ed), "\\utilde{AB}");
    let mut ed = Editor::new();
    type_script(&mut ed, r"x S-Left \utilde");
    assert_eq!(latex(&ed), "\\utilde{x}");
}

#[test]
fn caret_underscore_commands_insert_scripts() {
    // \^z and \_i make real Sup/Sub nodes (not modifier-letter atoms).
    let mut ed = Editor::new();
    type_script(&mut ed, r"x \^z");
    assert_eq!(latex(&ed), "x^{z}");
    let mut ed = Editor::new();
    type_script(&mut ed, r"x \_10 \^gamma");
    assert_eq!(latex(&ed), "x_{10}^{\\gamma }");
    // The marker may lead, trail, or both: \^z = \z^ = \^z^.
    for spelling in [r"x \z^", r"x \^z^"] {
        let mut ed = Editor::new();
        type_script(&mut ed, spelling);
        assert_eq!(latex(&ed), "x^{z}", "{}", spelling);
    }
    let mut ed = Editor::new();
    type_script(&mut ed, r"x \i_");
    assert_eq!(latex(&ed), "x_{i}");
}

#[test]
fn command_known_tracks_execute() {
    let ed = Editor::new();
    for ok in [
        "frac",
        "sqrt",
        "alpha",
        "sin",
        "lim",
        "argmax",
        "hat",
        "^z",
        "rmdx",
        "pmatrix22",
        "lr(]",
    ] {
        assert!(ed.command_known(ok), "\\{} should be known", ok);
    }
    // Known means Enter inserts something. A spelling that only prints
    // its usage (`\lr`, `\matrix` — an argument is part of the name)
    // is as unready as a half-typed one.
    for bad in [
        "",
        "fra",
        "nosuchthing",
        "zzz",
        "lr",
        "lr(",
        "matrix",
        "pmatrix",
        "matrix3",
    ] {
        assert!(!ed.command_known(bad), "\\{} should be unknown", bad);
    }
    // The probe never touches the real editor.
    let mut ed = Editor::new();
    type_script(&mut ed, "x");
    assert!(ed.command_known("frac"));
    assert_eq!(latex(&ed), "x");
}

#[test]
fn aliases_resolve_to_the_same_command() {
    // Every alias must do exactly what its target does. Command
    // aliases are extra patterns on their `resolve` arm (this list
    // names each of them once); symbol aliases are the `NAMES`
    // spellings that are not their char's canonical one.
    let command_aliases = [
        ("sqrt3", "cbrt"),
        ("sqrt4", "qdrt"),
        ("negate", "!"),
        ("xrightarrow", "xto"),
        ("xleftarrow", "xfrom"),
        ("xRightarrow", "xTo"),
        ("xLeftarrow", "xFrom"),
        ("operatorname", "op"),
        ("operatorname*", "op*"),
        ("limits", "op*"),
        ("delim", "lr"),
    ];
    let symbol_aliases = formulaa::symbols::NAMES
        .entries()
        .filter_map(|(&from, &ch)| {
            // A spelling an earlier resolve stage claims (\ch is the
            // hyperbolic function, not χ) only acts as this char in \^ch
            // positions; a styled shortcut (\RR) has no canonical command
            // spelling. Neither has a command to compare against here.
            if formulaa::symbols::is_func_name(from) {
                return None;
            }
            let to = formulaa::symbols::latex_name(ch)?;
            // Compound `\not\xxx` spellings are not typeable as one name.
            if to.contains('\\') {
                return None;
            }
            (from != to).then_some((from, to))
        });
    for (from, to) in command_aliases.into_iter().chain(symbol_aliases) {
        // The box commands open a mode rather than insert; compare the
        // mode instead of the formula.
        let (mut a, mut b) = (Editor::new(), Editor::new());
        a.execute(from);
        b.execute(to);
        assert_eq!(
            (normalize(&a.root), a.op_entry.as_ref().map(|e| e.0)),
            (normalize(&b.root), b.op_entry.as_ref().map(|e| e.0)),
            "\\{} should be \\{}",
            from,
            to
        );
        assert!(
            a.message.is_empty() || !a.message.starts_with("unknown"),
            "\\{}",
            from
        );
    }
}

#[test]
fn typing_inside_a_norm_works() {
    // Regression: Norm's field accessors were missing their Seg(0)
    // arms, so entering the node and typing panicked.
    let mut ed = Editor::new();
    ed.execute("norm");
    ed.input(Key::Char('x'), false, false);
    assert_eq!(latex(&ed), "\\left\\|x\\right\\|");
}

#[test]
fn plain_motions_shed_the_anchor() {
    // Home/End are plain motions: keeping the anchor would silently
    // grow the selection to the whole line and the next Backspace
    // would delete it all.
    let mut ed = Editor::new();
    type_script(&mut ed, r"a+b S-Left Home");
    assert_eq!(ed.selection(), None);
    type_script(&mut ed, r"Backspace");
    assert_eq!(latex(&ed), "a+b", "nothing left of the cursor to delete");
    let mut ed = Editor::new();
    type_script(&mut ed, r"a+b Left S-Left End");
    assert_eq!(ed.selection(), None);
    // A dormant anchor inside an inset must not resurrect when
    // Backspace lands beside it: the press selects the inset whole
    // (the announced two-step delete), never the stale inner range.
    let mut ed = Editor::new();
    type_script(&mut ed, r"x ^ abc Left Left S-Right Tab Backspace");
    assert_eq!(ed.selection(), Some((1, 2)), "the sup is selected whole");
    type_script(&mut ed, r"Backspace");
    assert_eq!(latex(&ed), "x", "the second press deletes the sup");
    // Same via the contextual close key.
    let mut ed = Editor::new();
    type_script(&mut ed, r"( abc Left Left S-Right ) Backspace");
    assert_eq!(ed.selection(), Some((0, 1)), "the pair is selected whole");
    type_script(&mut ed, r"Backspace");
    assert_eq!(latex(&ed), "");
}

#[test]
fn enter_on_empty_formula_does_not_crash() {
    // All-empty segments: the ┈ separator still needs a column (fuzz
    // found a zero-width vstack panic here).
    let mut ed = Editor::new();
    type_script(&mut ed, "Enter Enter Up Down a");
    assert_eq!(latex(&ed), "a");
}

#[test]
fn grid_edit_mode() {
    // ^G: cell-unit cursor; Enter leaves the mode to edit that cell.
    let mut ed = Editor::new();
    type_script(
        &mut ed,
        r"\bmatrix22 a C-g Right Enter b C-g Down Left Enter c C-g Right Enter d",
    );
    assert_eq!(
        latex(&ed),
        "\\begin{bmatrix} a & b \\\\ c & d \\end{bmatrix}"
    );
    // Row lanes: r selects the cursor's row (purple), ⌫ deletes it.
    type_script(&mut ed, "C-g r Backspace");
    assert_eq!(latex(&ed), "\\begin{bmatrix} a & b \\end{bmatrix}");
    // Column lanes from row mode: c switches axis; ⌫ deletes the
    // column; a 1x1 grid inside brackets normalizes to plain content.
    type_script(&mut ed, "c Backspace");
    assert_eq!(latex(&ed), "\\left[a\\right]");
    // Gap insert: | enters column mode, ← steps onto the left gap
    // (green), Enter inserts a column there and lands on it; a
    // cross-axis arrow drops back to cell selection of that lane.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\bmatrix22 x C-g | Left Enter Up Enter y");
    assert_eq!(
        latex(&ed),
        "\\begin{bmatrix}  & x &  \\\\ y &  &  \\end{bmatrix}"
    );
    // Undo works inside grid mode (row deletion is one step).
    let mut ed = Editor::new();
    type_script(&mut ed, r"\bmatrix22 a Down b C-g r Backspace C-z");
    assert!(
        latex(&ed).contains("\\\\"),
        "undo restored the row: {}",
        latex(&ed)
    );
    // ^G outside a grid only reports.
    let mut ed = Editor::new();
    type_script(&mut ed, "x C-g d");
    assert_eq!(latex(&ed), "xd");
}

#[test]
fn grid_selection_promotes_and_clears() {
    // Backspace on a full-column CELL selection clears the contents…
    let mut ed = Editor::new();
    type_script(&mut ed, r"\bmatrix22 a Down b C-g S-Up Backspace");
    assert_eq!(latex(&ed), "\\begin{bmatrix}  &  \\\\  &  \\end{bmatrix}");
    // …while pushing the selection past the edge promotes it to the
    // column itself, where Backspace deletes the column.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\bmatrix22 a Down b C-g S-Up S-Up");
    assert!(
        matches!(
            ed.grid,
            Some(formulaa::editor::GridSel::Lanes {
                cols: true,
                pos: 1,
                ..
            })
        ),
        "{:?}",
        ed.grid
    );
    type_script(&mut ed, "Backspace");
    assert_eq!(latex(&ed), "\\begin{bmatrix}  \\\\  \\end{bmatrix}");
}

#[test]
fn esc_exits_grid_mode_from_lane_mode() {
    let mut ed = Editor::new();
    type_script(&mut ed, r"\bmatrix22 x C-g c Esc");
    assert!(ed.grid.is_none(), "{:?}", ed.grid);
}

/// A display redraw after any editor state: decorated + extents must
/// never panic (the TUI calls these on every frame).
fn redraw(ed: &Editor) {
    let _ = ed.decorated();
    let _ = ed.marker_extents();
}

#[test]
fn grid_state_survives_undo_redo_and_clicks() {
    // Undo shrinks the grid under a lane cursor parked past the end:
    // used to drain past the cells vec / index out of bounds.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\bmatrix22 a C-g | Right Right Right Enter C-z");
    redraw(&ed);
    type_script(&mut ed, "Backspace");
    redraw(&ed);
    type_script(&mut ed, "Down Enter");
    redraw(&ed);
    // Undo shrinks the grid under a cell anchor.
    let mut ed = Editor::new();
    type_script(
        &mut ed,
        r"\bmatrix22 a C-g S-Down C-c Down Right C-v Down Down Right S-Up C-z",
    );
    redraw(&ed);
    type_script(&mut ed, "C-c C-v Backspace");
    redraw(&ed);
    // A click relocates the cursor (possibly outside any grid): the
    // grid state follows or ends, never dangles.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\bmatrix22 a Tab Tab + x");
    type_script(&mut ed, "C-a"); // back to formula start…
    // …enter the matrix and grid mode with an anchor:
    type_script(&mut ed, r"Right C-g S-Down");
    ed.click(1000, 1000); // far away: lands at the formula edge
    redraw(&ed);
    type_script(&mut ed, "C-c C-v");
    redraw(&ed);
}

#[test]
fn lane_selection_copies_its_cells() {
    // ^C on a purple lane copies the lane's cells (it used to be a
    // no-op). The chords are silent, so the paste is the proof.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\bmatrix22 a Down b C-g S-Up S-Up C-c");
    type_script(&mut ed, "Esc Tab Tab C-v");
    assert!(
        latex(&ed).ends_with("\\begin{matrix} a \\\\ b \\end{matrix}"),
        "{}",
        latex(&ed)
    );
}

#[test]
fn cell_clip_pastes_over_cells_even_outside_grid_mode() {
    // With the cursor in a grid cell but ^G off, a cell clipboard
    // still pastes as an overwrite — never a nested matrix.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\bmatrix22 a Down b C-g S-Up C-c Esc Right C-v");
    assert_eq!(
        latex(&ed),
        "\\begin{bmatrix} a & a \\\\ b & b \\end{bmatrix}"
    );
}

#[test]
fn vmatrix_wraps_a_grid_in_a_norm() {
    let mut ed = Editor::new();
    ed.execute("Vmatrix22");
    type_script(&mut ed, "a Right b");
    assert_eq!(latex(&ed), "\\begin{Vmatrix} a & b \\\\  &  \\end{Vmatrix}");
}

#[test]
fn grid_cells_copy_paste() {
    // Copy a 2x1 block, paste at the far corner: overwrite semantics,
    // and the grid grows to fit the overhang.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\bmatrix22 a Down b C-g S-Up C-c Down Right C-v");
    assert_eq!(
        latex(&ed),
        "\\begin{bmatrix} a &  \\\\ b & a \\\\  & b \\end{bmatrix}"
    );
    // Outside a grid, the same cell clipboard pastes as a bare Array.
    type_script(&mut ed, "Esc Tab Tab C-v");
    assert!(
        latex(&ed).ends_with("\\begin{matrix} a \\\\ b \\end{matrix}"),
        "{}",
        latex(&ed)
    );
}

#[test]
fn rm_and_text_boxes() {
    // \rm opens the box; dots are part of the name (i.i.d.).
    let mut ed = Editor::new();
    type_script(&mut ed, r"\rm i.i.d. Enter x");
    assert_eq!(latex(&ed), "\\operatorname{i.i.d.}x");
    // A dictionary word still falls back to its Func.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\rm sin Enter");
    assert_eq!(latex(&ed), "\\operatorname{sin}");
    // \text takes free content incl. spaces, committed as "…".
    let mut ed = Editor::new();
    type_script(&mut ed, r"\text if Space x Enter");
    assert_eq!(latex(&ed), "\\text{if x}");
    // Esc cancels either box.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\rm foo Esc \text bar Esc");
    assert!(ed.root.is_empty());
}

#[test]
fn undo_redo() {
    let mut ed = Editor::new();
    type_script(&mut ed, r"a+b \frac 1 Down 2 Tab");
    let full = latex(&ed);
    type_script(&mut ed, "C-z");
    assert_eq!(latex(&ed), "a+b\\frac{1}{}");
    type_script(&mut ed, "C-z C-z C-z");
    assert_eq!(latex(&ed), "a+");
    // Redo walks forward again, restoring the cursor with each state.
    type_script(&mut ed, "C-r C-r C-r C-r");
    assert_eq!(latex(&ed), full);
    // A fresh edit clears the redo branch — and lands where the undone
    // edit happened (the cursor is restored with the state, here the
    // empty numerator).
    type_script(&mut ed, "C-z C-z x");
    assert_eq!(latex(&ed), "a+b\\frac{x}{}");
    type_script(&mut ed, "C-r");
    assert_eq!(latex(&ed), "a+b\\frac{x}{}");
    // Cursor-only motion is not an undo step.
    let mut ed = Editor::new();
    type_script(&mut ed, "a b Left Left Right C-z");
    assert_eq!(latex(&ed), "a");
    // Undo restores the cursor of the undone state: typing lands where
    // the removed edit happened.
    type_script(&mut ed, "z");
    assert_eq!(latex(&ed), "az");
}

#[test]
fn op_box_via_keys() {
    // \op* opens the in-place name box; the band name is one piece,
    // so Space commits (it used to pose as a piece separator and then
    // silently vanish from the name). Enter commits into the lower
    // limit.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\op* esssup Enter n Tab");
    assert_eq!(latex(&ed), "\\operatorname*{esssup}_{n}");
    let mut ed = Editor::new();
    type_script(&mut ed, r"\op* ess Space");
    // (The empty-limit band normalizes to the bare name, hence no *.)
    assert_eq!(
        latex(&ed),
        "\\operatorname{ess}",
        "Space did not commit the \\op* box"
    );
    // Arrow keys (anything not part of the name) commit the box too.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\op vol Right +1");
    assert_eq!(latex(&ed), "\\operatorname{vol}+1");
    // Esc cancels.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\op foo Esc");
    assert!(ed.root.is_empty());
    // \limits is an alias for \op*.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\limits vol Enter n Tab");
    assert_eq!(latex(&ed), "\\operatorname*{vol}_{n}");
}

#[test]
fn enter_at_top_level_breaks_the_line() {
    let mut ed = Editor::new();
    type_script(&mut ed, "a+b Enter =c");
    assert_eq!(latex(&ed), "a+b \\\\ =c");
    let pic = aa(&ed);
    assert!(
        pic.lines().nth(1).is_some_and(|l| l.trim_end() == ""),
        "separator row:\n{}",
        pic
    );
    // ↑/↓ move between the lines; Backspace at a line start merges.
    type_script(&mut ed, "Up");
    assert!(ed.col < 4, "moved to line 1, col {}", ed.col);
    type_script(&mut ed, "Down Home Backspace");
    assert_eq!(latex(&ed), "a+b=c");
}

#[test]
fn enter_inside_an_inset_is_inert() {
    // Enter only breaks lines at the top level; inside a grid cell it
    // does nothing (rows are added in ^G grid mode or with \addrow).
    let mut ed = Editor::new();
    type_script(&mut ed, r"\pmatrix22 a Enter");
    let pic = aa(&ed);
    assert_eq!(pic.matches('').count(), 1, "still 2×2:\n{}", pic);
    assert!(
        !ed.root
            .iter()
            .any(|n| matches!(n, formulaa::ast::Node::Break))
    );
    // \addrow still works.
    type_script(&mut ed, r"\addrow");
    assert_eq!(aa(&ed).matches('').count(), 2);
}

#[test]
fn brackets_insert_a_delimiter_pair() {
    let mut ed = Editor::new();
    type_script(&mut ed, "[ x ]");
    assert_eq!(latex(&ed), "\\left[x\\right]");
    // `"` opens the text box; typing through the closing quote makes
    // a \text run, and \" escapes a literal quote inside.
    let mut ed = Editor::new();
    for k in ['"', 'i', 'f', ' ', 'x', '"'] {
        ed.input(Key::Char(k), false, false);
    }
    assert_eq!(latex(&ed), "\\text{if x}");
    let mut ed = Editor::new();
    for k in ['"', 'a', '\\', '"', 'b', '"'] {
        ed.input(Key::Char(k), false, false);
    }
    assert_eq!(latex(&ed), "\\text{a\"b}");
}

#[test]
fn double_slash_makes_a_fraction() {
    let mut ed = Editor::new();
    type_script(&mut ed, "a // 1 Down 2 Tab");
    assert_eq!(latex(&ed), "a\\frac{1}{2}");
    // A lone slash stays an atom.
    let mut ed = Editor::new();
    type_script(&mut ed, "a / b");
    assert_eq!(latex(&ed), "a/b");
}

#[test]
fn copy_cut_paste_by_keys() {
    let mut ed = Editor::new();
    type_script(&mut ed, "a+b S-Left S-Left C-c End C-v");
    assert_eq!(latex(&ed), "a+b+b");
    type_script(&mut ed, "S-Left S-Left C-x Home C-v");
    assert_eq!(latex(&ed), "+ba+b");
}

#[test]
fn arrows_collapse_selection_to_its_ends() {
    let mut ed = Editor::new();
    type_script(&mut ed, "a+b S-Left S-Left Left x");
    assert_eq!(latex(&ed), "ax+b");
    let mut ed = Editor::new();
    type_script(&mut ed, "a+b S-Left S-Left Right y");
    assert_eq!(latex(&ed), "a+by");
}

#[test]
fn ctrl_a_jumps_to_document_start() {
    let mut ed = Editor::new();
    type_script(&mut ed, r"a // 1 Down 2 C-a x");
    assert_eq!(latex(&ed), "xa\\frac{1}{2}");
    assert!(ed.path.is_empty());
}

#[test]
fn vertical_exits_a_grid_at_its_edge() {
    // ↓ on the bottom row leaves the matrix (after it); ↑ on the top
    // row leaves before it.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\pmatrix22 a Down b Down x");
    assert!(ed.path.is_empty(), "path: {:?}", ed.path);
    assert!(latex(&ed).ends_with('x'), "latex: {}", latex(&ed));
    let mut ed = Editor::new();
    type_script(&mut ed, r"\pmatrix22 a Up y");
    assert!(ed.path.is_empty());
    assert!(latex(&ed).starts_with('y'), "latex: {}", latex(&ed));
}

#[test]
fn free_cursor_mode_snaps_on_enter() {
    // x + 1/2, cursor at the top-row end; ^F, move down, Enter → the
    // free cursor over the denominator area snaps into the denominator.
    let mut ed = Editor::new();
    type_script(&mut ed, r"x + \frac 1 Down 2 Tab C-f Down Enter");
    assert!(
        matches!(ed.path.last(), Some((_, formulaa::ast::Field::FracDen))),
        "path: {:?}",
        ed.path
    );
    // Esc cancels back to the original position.
    let mut ed = Editor::new();
    type_script(&mut ed, r"a+b");
    let before = (ed.path.clone(), ed.col);
    type_script(&mut ed, "C-f Left Left Esc");
    assert_eq!((ed.path.clone(), ed.col), before);
    assert!(ed.free.is_none());
}

#[test]
fn free_cursor_auto_expands_collapsed_elements() {
    // x² + ∑ (both collapsed); walking the free cursor toward them
    // materializes the ∑ slots and expands the ² (with hysteresis:
    // they stay open while the cursor is nearby).
    let mut ed = Editor::new();
    type_script(&mut ed, r"x ^ 2 Tab + \sum Tab C-f");
    assert!(ed.ghost.is_empty());
    type_script(&mut ed, "Left");
    assert!(
        !ed.ghost.is_empty(),
        "approaching the bare ∑ must materialize its slots"
    );
    // Walk further left towards the ²: it expands too, and the ∑
    // ghosts persist within the hysteresis radius.
    for _ in 0..4 {
        type_script(&mut ed, "Left");
    }
    assert!(
        ed.ghost
            .iter()
            .any(|p| matches!(p.last(), Some((_, formulaa::ast::Field::SupArg)))),
        "ghosts: {:?}",
        ed.ghost
    );
    // Enter snaps somewhere valid; ghosts survive until real input.
    type_script(&mut ed, "Enter");
    assert!(ed.free.is_none());
    assert!(ed.col <= ed.cur_row().len());
}

#[test]
fn click_moves_the_cursor() {
    // Flat row: clicking between a and + lands the cursor at col 1.
    let mut ed = Editor::new();
    type_script(&mut ed, "a+b");
    ed.click(1, 0);
    assert!(ed.path.is_empty());
    assert_eq!(ed.col, 1);
    // Clicking the denominator row of a fraction enters it.
    let mut ed = Editor::new();
    type_script(&mut ed, r"x + \frac 1 Down 22 Tab");
    ed.click(4, 2);
    assert!(
        matches!(ed.path.last(), Some((_, formulaa::ast::Field::FracDen))),
        "path: {:?}",
        ed.path
    );
}

#[test]
fn shift_up_selects_enclosing_structure() {
    let mut ed = Editor::new();
    type_script(&mut ed, r"x + \frac 1 Down 2 S-Up Backspace");
    assert_eq!(latex(&ed), "x+");
}

#[test]
fn space_is_a_formatting_spacer() {
    let mut ed = Editor::new();
    type_script(&mut ed, "a Space b");
    assert_eq!(aa(&ed), "𝑎 𝑏");
    assert_eq!(latex(&ed), "ab"); // spacers never reach LaTeX
}

#[test]
fn selection_does_not_survive_leaving_the_row() {
    // Select inside the numerator, Tab out, then ^: the stale anchor
    // must not wrap outer-row nodes (this used to panic via a
    // drain past the row end — found by the random-key property test).
    let mut ed = Editor::new();
    type_script(&mut ed, r"\frac ab S-Left Tab ^ x");
    assert_eq!(latex(&ed), "\\frac{ab}{}^{x}");
}

/// A command that inserts without consuming the selection must clear
/// it: the insert shifts every index, so a surviving anchor would
/// designate a different range and the next Backspace would eat it.
#[test]
fn a_symbol_replaces_the_selection() {
    // Typing a plain symbol over a selection replaces it (standard
    // editor behavior) — typed directly or through the minibuffer.
    let mut ed = Editor::new();
    type_script(&mut ed, r"a b S-Left S-Left");
    assert_eq!(ed.selection(), Some((0, 2)));
    type_script(&mut ed, r"\alpha");
    assert_eq!(ed.selection(), None, "the selection was consumed");
    assert_eq!(latex(&ed), "\\alpha ");
    let mut ed = Editor::new();
    type_script(&mut ed, r"abc S-Left S-Left x");
    assert_eq!(latex(&ed), "ax");
    // The / atom replaces too (its // fraction shortcut is untouched).
    let mut ed = Editor::new();
    type_script(&mut ed, r"ab S-Left /");
    assert_eq!(latex(&ed), "a/");
    // A wrapping command still consumes the selection it was given.
    let mut ed = Editor::new();
    type_script(&mut ed, r"a b S-Left S-Left \hat");
    assert_eq!(latex(&ed), "\\widehat{ab}");
}

/// Every content-inserting edit replaces the selection: spacer, Func,
/// ∑-band, grid, name box, and the Enter line break.
#[test]
fn content_inserts_replace_the_selection() {
    // Space: the range becomes one formatting spacer.
    let mut ed = Editor::new();
    type_script(&mut ed, r"abc S-Left S-Left Space");
    assert_eq!(latex(&ed), "a");
    // A dictionary function name.
    let mut ed = Editor::new();
    type_script(&mut ed, r"ab S-Left \sin");
    assert_eq!(latex(&ed), "a\\operatorname{sin}");
    // A ∑-class band replaces and enters its lower limit.
    let mut ed = Editor::new();
    type_script(&mut ed, r"ab S-Left \sum n Tab");
    assert_eq!(latex(&ed), "a\\sum_{n}");
    // A grid replaces and enters its first cell.
    let mut ed = Editor::new();
    type_script(&mut ed, r"ab S-Left \pmatrix22 x");
    assert_eq!(latex(&ed), "a\\begin{pmatrix} x &  \\\\  &  \\end{pmatrix}");
    // A name box deletes the selection when it opens; the commit
    // lands where the range was.
    let mut ed = Editor::new();
    type_script(&mut ed, r"ab S-Left \rm d Enter");
    assert_eq!(latex(&ed), "a\\mathrm{d}");
    // Enter: the range becomes the line break.
    let mut ed = Editor::new();
    type_script(&mut ed, r"ab S-Left Enter c");
    assert_eq!(latex(&ed), "a \\\\ c");
}

/// ^B starts on the slot the cursor stands in when that slot holds
/// more than one node: a numerator, a limit, a cell taken whole is the
/// one range no ancestor step can name. The step out is the structure
/// that owns it.
#[test]
fn block_select_starts_with_the_slot_it_stands_in() {
    let mut ed = Editor::new();
    // 𝑎+𝑏 over 2: inside the numerator, ^B rings 𝑎+𝑏 …
    type_script(&mut ed, r"// a + b Down 2 Up C-b Enter");
    assert_eq!(ed.selection(), Some((0, 3)), "not the whole numerator");
    assert_eq!(latex(&ed), "\\frac{a+b}{2}", "the formula moved");
    // …and one step out is the fraction itself.
    let mut ed = Editor::new();
    type_script(&mut ed, r"// a + b Down 2 Up C-b Up Enter");
    assert_eq!(ed.path, vec![], "the step out stayed inside");
    assert_eq!(ed.selection(), Some((0, 1)), "not the fraction");
    // A slot holding one node still offers itself — that box is the
    // node inside it, which no ancestor step names.
    let mut ed = Editor::new();
    type_script(&mut ed, r"// a Down 2 Up C-b Enter");
    assert_eq!(
        ed.path,
        vec![(0, Field::FracNum)],
        "not the numerator's own slot"
    );
    assert_eq!(ed.selection(), Some((0, 1)));
    // …but seen from *inside* that node the two coincide, and the ring
    // paints no box twice: from inside the radical the steps are the
    // radical, then the fraction, then the row.
    let mut ed = Editor::new();
    type_script(&mut ed, r"// \sqrt x Down 2 Up");
    let boxes = ed.block_targets();
    assert_eq!(
        boxes.len(),
        3,
        "a coincident slot/node pair was painted twice: {:?}",
        boxes
    );
}

/// \mid is contextual: the divides atom ∣ in a plain row, the segment
/// separator directly inside a delimiter block.
#[test]
fn block_select_mode_selects_a_structure() {
    let mut ed = Editor::new();
    // Cursor inside the fraction's denominator: ^B highlights the
    // denominator's contents, ↑ the fraction; Enter selects it.
    type_script(&mut ed, r"1 // 2 Down 3 C-b Up");
    // The block marks must appear in the decorated view.
    let (root, cursor) = ed.decorated();
    assert!(cursor.is_some(), "cursor stays threaded during modes");
    assert!(
        root.iter().any(
            |n| matches!(n, formulaa::ast::Node::Sym(c) if (0xE000..0xE0F0).contains(&(*c as u32)))
        ),
        "block mark missing: {:?}",
        root
    );
    type_script(&mut ed, "Enter");
    assert_eq!(ed.selection(), Some((1, 2)));
    // The cursor leaves on the right end; Shift+← must still grow the
    // selection leftward (the ends flip instead of collapsing).
    type_script(&mut ed, "S-Left");
    assert_eq!(ed.selection(), Some((0, 2)));
    type_script(&mut ed, "S-Right");
    assert_eq!(ed.selection(), Some((1, 2)), "shrinks back");
    type_script(&mut ed, "Backspace");
    assert_eq!(latex(&ed), "1");
    // Shift+←/→ inside the mode: select the highlighted block and move
    // straight into the linear selection.
    let mut ed = Editor::new();
    type_script(&mut ed, r"1 // 2 Down 3 C-b Up S-Left");
    assert!(ed.block.is_none(), "mode exits");
    assert_eq!(ed.selection(), Some((0, 2)));
    // Arrow walk: outward Array -> Delim; a second ^B cancels without
    // moving the cursor.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\pmatrix22 x");
    let (path, col) = (ed.path.clone(), ed.col);
    type_script(&mut ed, r"C-b");
    assert_eq!(
        ed.block.as_ref().map(Vec::len),
        Some(3),
        "cell + Array + Delim"
    );
    assert_eq!(ed.block_sel, 0, "the cell it stands in first");
    type_script(&mut ed, "Up");
    assert_eq!(ed.block_sel, 1);
    type_script(&mut ed, "Down");
    assert_eq!(ed.block_sel, 0);
    type_script(&mut ed, "C-b");
    assert!(ed.block.is_none());
    assert_eq!((ed.path, ed.col), (path, col), "cursor untouched");
    // Enter on the outer ancestor selects the delimiter block, ready
    // for wrapping.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\pmatrix22 x C-b Up Up Enter \sqrt");
    assert_eq!(
        latex(&ed),
        "\\sqrt{\\begin{pmatrix} x &  \\\\  &  \\end{pmatrix}}"
    );
    // A letter key is no longer a label: it cancels the mode.
    let mut ed = Editor::new();
    type_script(&mut ed, r"1 // 2 Down 3 C-b a");
    assert!(ed.block.is_none());
}

/// Shift+↑ places a whole selection with the cursor on an end the
/// user did not pick: the first Shift+←/→ flips to grow on that side,
/// then plain shrink semantics resume.
#[test]
fn whole_selection_flip_then_plain_semantics() {
    let mut ed = Editor::new();
    // Cursor in the denominator: Shift+↑ selects the whole fraction.
    type_script(&mut ed, r"1 // 2 Down 3 S-Up S-Left");
    assert_eq!(ed.selection(), Some((0, 2)));
    type_script(&mut ed, "S-Right");
    assert_eq!(ed.selection(), Some((1, 2)), "shrinks back");
    // A hand-made selection keeps the plain semantics: Shift+→ back
    // onto the anchor clears it.
    let mut ed = Editor::new();
    type_script(&mut ed, r"abcde Left Left S-Left");
    assert_eq!(ed.selection(), Some((2, 3)));
    type_script(&mut ed, "S-Right");
    assert_eq!(ed.selection(), None, "shrink collapses");
}

#[test]
fn mid_is_divides_outside_a_delimiter() {
    let mut ed = Editor::new();
    type_script(&mut ed, r"a \mid b");
    assert_eq!(latex(&ed), "a\\mid b");
    let mut ed = Editor::new();
    type_script(&mut ed, r"a \!mid b");
    assert_eq!(latex(&ed), "a\\nmid b");
    // Inside a paren it still splits the segment.
    let mut ed = Editor::new();
    type_script(&mut ed, r"( x \mid P");
    assert_eq!(latex(&ed), "\\left(x\\middle|P\\right)");
}

/// `\!` right after a symbol toggles it with its slashed negation.
#[test]
fn bang_toggles_the_preceding_symbol() {
    let mut ed = Editor::new();
    type_script(&mut ed, r"a = \! b");
    assert_eq!(latex(&ed), "a\\ne b");
    // The toggle closes: ∈ → ∉ → ∈ → ∉.
    let mut ed = Editor::new();
    type_script(&mut ed, r"x \in \!");
    assert_eq!(latex(&ed), "x\\notin ");
    type_script(&mut ed, r"\!");
    assert_eq!(latex(&ed), "x\\in ");
    type_script(&mut ed, r"\!");
    assert_eq!(latex(&ed), "x\\notin ");
    // A directly-typed slashed atom un-negates the same way.
    let mut ed = Editor::new();
    type_script(&mut ed, r"a \ne \! b");
    assert_eq!(latex(&ed), "a=b");
    // No negation for a letter; nothing left of the cursor at col 0.
    let mut ed = Editor::new();
    type_script(&mut ed, r"a \!");
    assert!(ed.message_error);
    let mut ed = Editor::new();
    type_script(&mut ed, r"\!");
    assert!(ed.message_error);
}

/// `!`-prefixed (and `!`-suffixed) spellings are the slashed
/// relations: `\!=` and `\=!` are ≠, `\!in` is ∉.
#[test]
fn bang_spellings_negate_relations() {
    let mut ed = Editor::new();
    type_script(&mut ed, r"a \!= b");
    assert_eq!(latex(&ed), "a\\ne b");
    let mut ed = Editor::new();
    type_script(&mut ed, r"a \=! b");
    assert_eq!(latex(&ed), "a\\ne b");
    let mut ed = Editor::new();
    type_script(&mut ed, r"a \!in B");
    assert_eq!(latex(&ed), "a\\notin B");
    let mut ed = Editor::new();
    type_script(&mut ed, r"a \!le b");
    assert_eq!(latex(&ed), "a\\nleq b");
    let mut ed = Editor::new();
    type_script(&mut ed, r"a \subset! b");
    assert_eq!(latex(&ed), "a\\not\\subset b");
}

/// \rm of a digit has no upright/italic distinction to preserve: it
/// canonicalizes to the plain atom, so gluing it to a letter cannot
/// break the roundtrip (Roman('1') next to an alpha once did).
#[test]
fn rm_of_a_digit_is_just_the_digit() {
    let mut ed = Editor::new();
    type_script(&mut ed, r"\rm1 Tab x");
    assert_roundtrip(&ed, &[]);
    assert_eq!(latex(&ed), "1x");
}

/// A formula line break lives only at the top level: a selection may
/// not span one, so neither a wrap nor a copy can carry one into an
/// inset (the picture would lose it, breaking the roundtrip).
#[test]
fn a_line_break_stays_out_of_insets() {
    let mut ed = Editor::new();
    type_script(&mut ed, r"a Enter b S-Left S-Left S-Left");
    assert_eq!(ed.selection(), Some((2, 3)), "selection stops at the break");
    type_script(&mut ed, "^");
    assert_eq!(latex(&ed), "a \\\\ ^{b}");
    assert_roundtrip(&ed, &[]);
    // Shift+↑ selects a whole top-level row, breaks included — an
    // accent's base is an inset, so the break must not ride in.
    let mut ed = Editor::new();
    type_script(&mut ed, r"a Enter b S-Up \vec");
    assert_roundtrip(&ed, &[]);
    assert!(
        !latex(&ed).contains("\\vec{a \\\\ b}"),
        "no break in the base: {}",
        latex(&ed)
    );
    // \mid only splits a real delimiter; a norm numbers its field the
    // same way but is not a pair, so inside one it is the divides
    // atom (like any plain row).
    let mut ed = Editor::new();
    type_script(&mut ed, r"\norm \mid");
    assert_eq!(latex(&ed), "\\left\\|\\mid \\right\\|");
    // The same guard the other way round: with the cursor right before
    // the break, Shift+→ refuses to cross it.
    let mut ed = Editor::new();
    type_script(&mut ed, r"a Enter b Left Left S-Right");
    assert_eq!(ed.selection(), None, "and stops before it too");
}

// ----- random key sequences: never panic, always roundtrip -----

struct Rng(u64);
impl Rng {
    fn next(&mut self) -> u64 {
        let mut x = self.0;
        x ^= x << 13;
        x ^= x >> 7;
        x ^= x << 17;
        self.0 = x;
        x
    }
    fn pick<'a, T>(&mut self, xs: &'a [T]) -> &'a T {
        &xs[(self.next() % xs.len() as u64) as usize]
    }
}

/// The same invariant the TUI's RoundtripGuard enforces at runtime,
/// checked after every keystroke of a random session — plus "the caret
/// survives every render composition" (a missed propagation would show
/// no cursor at all).
fn assert_roundtrip(ed: &Editor, history: &[String]) {
    let (droot, cursor) = ed.decorated();
    if let Some((p, c)) = cursor {
        let b = render_root(&droot, Some((&p[..], c)), &RenderCtx::canonical());
        assert!(
            b.caret.is_some(),
            "caret lost\n--- keys ---\n{}",
            history.join(" ")
        );
    }
    let row = normalize(&ed.root);
    if row.is_empty() {
        return;
    }
    let aa = render_root(&row, None, &RenderCtx::canonical()).to_text();
    let expected = normalize(&formulaa::render::absorb_spacers(&row));
    let parsed = parse(&aa).unwrap_or_else(|e| {
        panic!(
            "parse failed: {}\n--- AA ---\n{}\n--- keys ---\n{}",
            e,
            aa,
            history.join(" ")
        )
    });
    assert_eq!(
        parsed,
        expected,
        "AST mismatch\n--- AA ---\n{}\n--- keys ---\n{}",
        aa,
        history.join(" ")
    );
}

#[test]
fn property_random_key_sequences_roundtrip() {
    let n: usize = std::env::var("FORMULAA_UI_PROP_N")
        .ok()
        .and_then(|v| v.parse().ok())
        .unwrap_or(200);
    let seed: u64 = std::env::var("FORMULAA_UI_PROP_SEED")
        .ok()
        .and_then(|v| v.parse().ok())
        .unwrap_or(0xDEC0DE);
    let mut rng = Rng(seed);

    let chars: Vec<char> = "abxyn12+=-*/.,<>|'~αβ∑∫()^_{}[]\\\" ".chars().collect();
    let named_pool = [
        Key::Left,
        Key::Right,
        Key::Up,
        Key::Down,
        Key::Home,
        Key::End,
        Key::Tab,
        Key::Enter,
        Key::Backspace,
        Key::Delete,
        Key::Esc,
    ];

    for _ in 0..n {
        let mut ed = Editor::new();
        let mut history: Vec<String> = Vec::new();
        for _ in 0..60 {
            let r = rng.next() % 100;
            let (key, shift, ctrl) = if r < 55 {
                (Key::Char(*rng.pick(&chars)), false, false)
            } else if r < 75 {
                (*rng.pick(&named_pool), false, false)
            } else if r < 85 {
                // Selections.
                (*rng.pick(&[Key::Left, Key::Right]), true, false)
            } else if r < 95 {
                // Ctrl toggles (host effects are inert here).
                (
                    Key::Char(
                        *rng.pick(&['t', 'b', 'e', 'y', 's', 'z', 'r', 'c', 'x', 'v', 'f', 'a']),
                    ),
                    false,
                    true,
                )
            } else if r < 98 {
                (Key::Char(*rng.pick(&chars)), true, false)
            } else {
                // Occasional mouse click (not a key; roundtrip-checked too).
                let (x, y) = ((rng.next() % 24) as usize, (rng.next() % 8) as usize);
                history.push(format!("Click({},{})", x, y));
                ed.click(x, y);
                assert_roundtrip(&ed, &history);
                continue;
            };
            history.push(format!(
                "{}{}{:?}",
                if ctrl { "C-" } else { "" },
                if shift { "S-" } else { "" },
                key
            ));
            let _ = ed.input(key, shift, ctrl);
            assert_roundtrip(&ed, &history);
        }
    }
}

/// Backspace just inside a delimiter unwraps it in two steps, so the
/// bracket can be shed without losing what it holds: the first press
/// arms the pair (the display lights it up), the second lifts the
/// contents out and selects them, a third deletes those — and an arrow
/// key after the second leaves the unwrap standing.
#[test]
fn backspace_unwraps_a_delimiter() {
    // ( f o o ) with the cursor just after the opening bracket.
    let open = |ed: &mut Editor| {
        type_script(ed, "( foo Home");
    };
    let mut ed = Editor::new();
    open(&mut ed);
    assert_eq!(latex(&ed), "\\left(foo\\right)");

    // First press arms: nothing is deleted yet.
    type_script(&mut ed, "Backspace");
    assert_eq!(latex(&ed), "\\left(foo\\right)", "the first press deletes");
    // Second press unwraps and selects what came out.
    type_script(&mut ed, "Backspace");
    assert_eq!(latex(&ed), "foo");
    assert_eq!(ed.selection(), Some((0, 3)), "the contents are selected");
    // Third press deletes the selection.
    type_script(&mut ed, "Backspace");
    assert_eq!(latex(&ed), "");

    // …and an arrow key instead just walks away from the unwrap.
    let mut ed = Editor::new();
    open(&mut ed);
    type_script(&mut ed, "Backspace Backspace Right");
    assert_eq!(latex(&ed), "foo");
    assert_eq!(ed.selection(), None);

    // The arming is one-shot: a key in between disarms it, so the next
    // Backspace starts over rather than unwrapping by surprise.
    let mut ed = Editor::new();
    open(&mut ed);
    type_script(&mut ed, "Backspace Right Left Backspace");
    assert_eq!(latex(&ed), "\\left(foo\\right)");
}

/// ^D deletes forward, and against the closing bracket it unwraps the
/// same way Backspace does against the opening one.
#[test]
fn ctrl_d_deletes_forward_and_unwraps() {
    let mut ed = Editor::new();
    type_script(&mut ed, "abc Home C-d");
    assert_eq!(latex(&ed), "bc", "^D deletes the character ahead");

    let mut ed = Editor::new();
    type_script(&mut ed, "( foo");
    // The cursor sits at the end of the contents, against the ')'.
    type_script(&mut ed, "C-d");
    assert_eq!(latex(&ed), "\\left(foo\\right)", "the first press deletes");
    type_script(&mut ed, "C-d");
    assert_eq!(latex(&ed), "foo");
    assert_eq!(ed.selection(), Some((0, 3)));
}

/// Shift-selecting *onto* a bracket arms the pair — the selection
/// asked for "just the bracket", and a bracket's meaning is its pair —
/// so the next Backspace/Delete unwraps. A second shift step selects
/// the node whole, and extending an existing selection swallows it in
/// one step, as before.
#[test]
fn shift_selecting_a_bracket_arms_the_pair() {
    // From the left: arm (nothing selected, nothing deleted), unwrap.
    // The gesture named the bracket, so the unwrap selects nothing —
    // the contents just stay, with the cursor keeping its side.
    let mut ed = Editor::new();
    type_script(&mut ed, "( foo ) Home S-Right");
    assert_eq!(latex(&ed), "\\left(foo\\right)");
    assert_eq!(ed.selection(), None, "arming made a selection");
    type_script(&mut ed, "Backspace");
    assert_eq!(latex(&ed), "foo");
    assert_eq!(ed.selection(), None, "the shift unwrap selected something");
    assert_eq!(ed.col, 0, "the cursor left its side");

    // From the right, with Delete.
    let mut ed = Editor::new();
    type_script(&mut ed, "( foo ) S-Left Delete");
    assert_eq!(latex(&ed), "foo");
    assert_eq!((ed.selection(), ed.col), (None, 3));

    // While armed, the display lights the pair up.
    let mut ed = Editor::new();
    type_script(&mut ed, "( foo ) Home S-Right");
    let (root, _) = ed.decorated();
    let has_mark = root.iter().any(|n| {
        matches!(n, formulaa::ast::Node::Sym(c)
            if formulaa::glyphs::Mark::decode(*c) == Some(formulaa::glyphs::Mark::Delims { open: true }))
    });
    assert!(has_mark, "no armed marks in {:?}", root);

    // The second step takes the node whole, as before.
    let mut ed = Editor::new();
    type_script(&mut ed, "( foo ) Home S-Right S-Right");
    assert_eq!(ed.selection(), Some((0, 1)));
    type_script(&mut ed, "Backspace");
    assert_eq!(latex(&ed), "");

    // Extending an existing selection swallows the pair in one step.
    let mut ed = Editor::new();
    type_script(&mut ed, "x ( foo ) Home S-Right S-Right Backspace");
    assert_eq!(latex(&ed), "");

    // A pair with middles has no single contents: the first shift
    // step selects it whole rather than arming.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\lr(|) a ) Home S-Right");
    assert_eq!(ed.selection(), Some((0, 1)));

    // From inside, the row's edge is the bracket too: Shift there
    // arms the same way, and either delete key then unwraps (the
    // staged flow, contents selected).
    let mut ed = Editor::new();
    type_script(&mut ed, "( foo Home S-Left Backspace");
    assert_eq!(latex(&ed), "foo");
    assert_eq!(ed.selection(), Some((0, 3)));
    let mut ed = Editor::new();
    type_script(&mut ed, "( foo S-Right Backspace");
    assert_eq!(latex(&ed), "foo");
}

/// A radical unwraps like a pair: Backspace at the start of its
/// argument — the side its root glyph is on — arms it, and
/// shift-selecting the root does the same from outside. The far end
/// has nothing to delete toward, so ^D there keeps its old no-op.
#[test]
fn a_radical_unwraps_like_a_pair() {
    let mut ed = Editor::new();
    type_script(&mut ed, r"\sqrt foo Home Backspace");
    assert_eq!(latex(&ed), "\\sqrt{foo}", "the first press deletes");
    type_script(&mut ed, "Backspace");
    assert_eq!(latex(&ed), "foo");
    assert_eq!(ed.selection(), Some((0, 3)));

    // From outside, selecting the root arms the radical.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\sqrt foo Right S-Left Backspace");
    assert_eq!(latex(&ed), "foo");

    // The argument's end arms nothing — there is nothing ahead — for
    // ^D and Shift+→ alike (the Backspace then deletes a char).
    let mut ed = Editor::new();
    type_script(&mut ed, r"\sqrt foo C-d C-d");
    assert_eq!(latex(&ed), "\\sqrt{foo}");
    let mut ed = Editor::new();
    type_script(&mut ed, r"\sqrt foo S-Right Backspace");
    assert_eq!(latex(&ed), "\\sqrt{fo}");
}

/// `\norm` unwraps like the pair it is: from either edge inside, and
/// from a shift-selection outside.
#[test]
fn norm_unwraps_like_a_delimiter() {
    let mut ed = Editor::new();
    type_script(&mut ed, r"\norm x Home Backspace Backspace");
    assert_eq!(latex(&ed), "x");

    let mut ed = Editor::new();
    type_script(&mut ed, r"\norm x C-d C-d");
    assert_eq!(latex(&ed), "x");

    let mut ed = Editor::new();
    type_script(&mut ed, r"\norm x Right S-Left Backspace");
    assert_eq!(latex(&ed), "x");
}

/// A pair with │ middles, and a fused matrix, keep the old behaviour:
/// there is no single "contents" to lift out of either.
#[test]
fn unwrap_leaves_middles_and_grids_alone() {
    // Both halves must actually reach the guard: the cursor has to sit
    // at the start of the delimiter's own segment, or the presses never
    // consult `unwrappable` and the assertions pin nothing. The
    // outcome that separates guarded from armed is the SECOND press —
    // an armed pair unwraps on it.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\set x Home");
    assert_eq!(ed.col, 0, "cursor is against the opening brace");
    assert!(
        matches!(ed.path.last(), Some((_, formulaa::ast::Field::Seg(0)))),
        "cursor is in the delimiter's segment: {:?}",
        ed.path
    );
    type_script(&mut ed, "Backspace Backspace");
    assert!(
        latex(&ed).contains("\\middle|"),
        "the set-builder bar survived: {}",
        latex(&ed)
    );

    // A fused grid: the cursor starts in the first cell, so step out
    // to the segment before pressing — otherwise Backspace never
    // reaches the delimiter at all.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\pmatrix22 x Tab Home");
    assert!(
        matches!(ed.path.last(), Some((_, formulaa::ast::Field::Seg(0)))),
        "cursor is in the delimiter's segment: {:?}",
        ed.path
    );
    type_script(&mut ed, "Backspace Backspace");
    assert!(
        latex(&ed).contains("pmatrix"),
        "the matrix survived: {}",
        latex(&ed)
    );

    // …and the empty pair still goes in one press, as it always did.
    let mut ed = Editor::new();
    type_script(&mut ed, "x ( Backspace");
    assert_eq!(latex(&ed), "x", "an empty pair needs one Backspace");
}

/// Tab opens the completion list, the arrows pick a row and Enter
/// takes it — and the row that lands is the one that was highlighted,
/// not whatever was typed.
#[test]
fn tab_completion_picks_a_row() {
    // \al + Tab + Enter commits the first row: α.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ a l Down Enter");
    assert_eq!(latex(&ed), "\\alpha ");
    assert!(ed.completion.is_none(), "the popup outlived the pick");
    assert!(ed.minibuffer.is_none());

    // ↓ moves to the next row, and that row is what Enter inserts.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ a l Down");
    let second = ed.completion.as_ref().unwrap().items[1]
        .commit()
        .unwrap()
        .to_string();
    type_script(&mut ed, "Down Enter");
    let mut expected = Editor::new();
    expected.execute(&second);
    assert_eq!(latex(&ed), latex(&expected), "picked \\{}", second);

    // ↑ from the first row wraps to the last: the list cycles.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ a l Down Up");
    let list = ed.completion.as_ref().unwrap();
    assert_eq!(list.sel, list.items.len() - 1);
}

/// The popup tracks what is typed, and Esc peels it before the
/// minibuffer so a stray Tab is one keypress to undo.
#[test]
fn tab_completion_follows_the_query() {
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ a l Down");
    assert_eq!(
        ed.completion.as_ref().unwrap().items[0].symbol,
        "α",
        "the α row leads \\al"
    );
    // Typing on narrows the list without closing it.
    type_script(&mut ed, "e p h");
    let list = ed.completion.as_ref().expect("the popup stayed open");
    assert_eq!(list.items[0].commit(), Some("aleph"));
    // Backspace widens it again.
    type_script(&mut ed, "Backspace Backspace Backspace");
    assert_eq!(ed.completion.as_ref().unwrap().items[0].symbol, "α");
    // Esc closes the popup, keeping what was typed.
    type_script(&mut ed, "Esc");
    assert!(ed.completion.is_none());
    assert_eq!(ed.minibuffer.as_deref(), Some("al"));
    // …and a second Esc closes the minibuffer.
    type_script(&mut ed, "Esc");
    assert!(ed.minibuffer.is_none());
}

/// A query nothing matches leaves the popup closed and says so,
/// rather than opening an empty box.
#[test]
fn tab_completion_with_no_matches_stays_quiet() {
    // No matches: the popup simply does not open — that already says
    // everything, so there is no notice to read or to clear.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ q q z z Tab");
    assert!(ed.completion.is_none());
    assert!(ed.message.is_empty(), "{:?}", ed.message);
    // The minibuffer is untouched, so the typing can be fixed.
    assert_eq!(ed.minibuffer.as_deref(), Some("qqzz"));
}

/// ^B paints the highlighted ancestor and the one step outward, not
/// the whole chain: the arrows move one step at a time, and the inner
/// step is always where the selection just came from.
#[test]
fn block_select_paints_only_a_step_in_each_direction() {
    use formulaa::glyphs::Mark;
    let mut ed = Editor::new();
    // Nest deeply: a matrix cell inside a fraction inside a bracket.
    type_script(&mut ed, r"( 1 // \pmatrix22 x");
    type_script(&mut ed, "C-b");
    let depth = ed.block.as_ref().map(Vec::len).unwrap_or(0);
    assert!(depth >= 4, "expected a deep chain, got {}", depth);

    let painted = |ed: &Editor| -> Vec<usize> {
        let (root, _) = ed.decorated();
        fn walk(row: &formulaa::ast::Row, out: &mut Vec<usize>) {
            for n in row {
                if let formulaa::ast::Node::Sym(c) = n
                    && let Some(Mark::BlockOpen { rank }) = Mark::decode(*c)
                {
                    out.push(rank);
                }
                for f in n.fields() {
                    walk(n.field(f), out);
                }
            }
        }
        let mut out = Vec::new();
        walk(&root, &mut out);
        out.sort_unstable();
        out
    };

    // Itself and the one step *outward*, never the whole chain and
    // never the inner step — selection starts at the innermost
    // ancestor, so the way back in needs no announcing.
    assert_eq!(painted(&ed), vec![0, 1]);
    type_script(&mut ed, "Up");
    assert_eq!(painted(&ed), vec![1, 2]);
    type_script(&mut ed, "Up");
    assert_eq!(painted(&ed), vec![2, 3]);
    // Outermost: itself alone (nothing further out to step to).
    for _ in 0..depth {
        type_script(&mut ed, "Up");
    }
    assert_eq!(painted(&ed), vec![depth - 1]);
}

/// The pending unwrap is a one-shot that belongs to the tree and the
/// place it was armed in. Anything that walks away from either must
/// disarm it, or the display keeps promising an unwrap that the next
/// Backspace performs on a tree the user never armed.
#[test]
fn arming_does_not_survive_undo_or_a_click() {
    // ^Z returns from `input` before the key layer's one-shot take, so
    // it has to clear the arming itself. The undo has to land back on
    // the armed spot for the staleness to bite: same path, column 0,
    // and a pair that still has contents to lift out.
    let mut ed = Editor::new();
    type_script(&mut ed, "( a Home x Home Backspace C-z Backspace");
    assert!(
        latex(&ed).contains("\\left("),
        "undo left the pair armed, so one Backspace unwrapped it: {}",
        latex(&ed)
    );

    // A mouse click moves the cursor without passing through the key
    // layer at all. The arming must go with it — otherwise the pair
    // stays lit while Backspace does something else entirely.
    let lit = |ed: &Editor| {
        use formulaa::glyphs::Mark;
        fn walk(row: &formulaa::ast::Row, out: &mut bool) {
            for n in row {
                if let formulaa::ast::Node::Sym(c) = n
                    && matches!(Mark::decode(*c), Some(Mark::Delims { .. }))
                {
                    *out = true;
                }
                for f in n.fields() {
                    walk(n.field(f), out);
                }
            }
        }
        let (root, _) = ed.decorated();
        let mut found = false;
        walk(&root, &mut found);
        found
    };
    // The click has to land INSIDE the same segment: a click that
    // leaves the path makes decorate_plain's path test fail on its own,
    // so it would pass whether or not the arming was cleared.
    let mut ed = Editor::new();
    type_script(&mut ed, "( foo Home Backspace");
    let armed_path = ed.path.clone();
    assert!(lit(&ed), "the pair should be lit after arming");
    ed.click(3, 0); // still inside (foo), one cell along
    assert_eq!(ed.path, armed_path, "the click stayed in the segment");
    assert_ne!(ed.col, 0, "…but moved off the armed column");
    assert!(!lit(&ed), "a click left the pair lit up as armed");
}

/// A click also closes the completion popup. It is invisible while the
/// minibuffer is shut, so an orphaned one springs back on the next `\`
/// — with the old query — and Enter commits a row for something the
/// user can no longer see.
#[test]
fn a_click_closes_the_completion_popup() {
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ a l Down");
    assert!(ed.completion.is_some());
    ed.click(0, 0);
    assert!(ed.completion.is_none(), "the popup outlived the click");
    // …so `\` + Enter is an empty command again, not a stale pick.
    type_script(&mut ed, r"\ Enter");
    assert_eq!(latex(&ed), "");
}

/// The popup is a live list, not a snapshot: both commit keys take the
/// highlighted row, a query that matches nothing leaves it open so
/// backspacing brings the list back, and the status line does not keep
/// a notice from a keystroke ago.
#[test]
fn the_popup_tracks_the_query_and_both_commit_keys_take_it() {
    // Space commits the highlighted row, exactly as Enter does.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ a l Down Down");
    let picked = ed
        .completion
        .as_ref()
        .unwrap()
        .selected()
        .and_then(|i| i.commit())
        .unwrap()
        .to_string();
    type_script(&mut ed, "Space");
    let mut expected = Editor::new();
    expected.execute(&picked);
    assert_eq!(latex(&ed), latex(&expected), "Space took \\{}", picked);

    // Typing past every match keeps the popup open (empty), so
    // backspacing restores the list instead of needing another Tab.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ a l Down");
    assert!(!ed.completion.as_ref().unwrap().items.is_empty());
    type_script(&mut ed, "q q z");
    assert!(
        ed.completion.as_ref().is_some_and(|l| l.items.is_empty()),
        "the popup closed on a non-matching query"
    );
    type_script(&mut ed, "Backspace Backspace Backspace");
    assert!(
        ed.completion.as_ref().is_some_and(|l| !l.items.is_empty()),
        "backspacing did not bring the list back"
    );

    // An empty list commits the typed text rather than nothing.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ a l Down q q z Enter");
    assert!(ed.message.contains("is not a command"), "{:?}", ed.message);
}

/// Tab finishes, the arrows browse. A name that is already a command
/// commits on Tab — that is what "complete" means once there is
/// nothing left to complete — and only a name that is not a command
/// makes Tab ask for the list.
#[test]
fn tab_finishes_and_the_arrows_browse() {
    // \alpha is a command: Tab runs it.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ a l p h a Tab");
    assert_eq!(latex(&ed), "\\alpha ");
    assert!(ed.minibuffer.is_none() && ed.completion.is_none());

    // \al is a command too (a shorthand for the same α).
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ a l Tab");
    assert_eq!(latex(&ed), "\\alpha ");

    // \xyzz is not: Tab runs nothing (and with no matches at all,
    // shows nothing either — the closed popup is the answer).
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ x y z z Tab");
    assert_eq!(latex(&ed), "", "Tab executed a non-command");
    assert!(ed.minibuffer.is_some(), "the typing was lost");

    // An arrow opens the list on the first press without skipping its
    // first row, and Tab then takes whatever is highlighted.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ a l Down");
    let first = ed.completion.as_ref().unwrap();
    assert_eq!(first.sel, 0, "the revealing press skipped a row");
    type_script(&mut ed, "Down");
    let second = ed
        .completion
        .as_ref()
        .unwrap()
        .selected()
        .and_then(|i| i.commit())
        .unwrap()
        .to_string();
    type_script(&mut ed, "Tab");
    let mut expected = Editor::new();
    expected.execute(&second);
    assert_eq!(latex(&ed), latex(&expected), "Tab took \\{}", second);
}

/// A step that would change nothing closes the list instead: `\frak`'s
/// own row leaves `frak` typed and the rest is free input the list
/// cannot enumerate, so a second accept means "let me type". (It used
/// to rebuild the identical list, and Enter looked dead.)
#[test]
fn a_stalled_step_closes_the_list() {
    // The first accept completes the prefix; the second yields.
    // (`\fra`'s first row is `frac`; the frak family sits under it.)
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ f r a Down Down Enter");
    assert_eq!(
        ed.minibuffer.as_deref(),
        Some("frak"),
        "the step stalled early"
    );
    assert!(ed.completion.is_some(), "the list closed with a step taken");
    type_script(&mut ed, "Enter");
    assert_eq!(ed.minibuffer.as_deref(), Some("frak"));
    assert!(
        ed.completion.is_none(),
        "the list stayed up with nothing to add"
    );
    // …and typing carries straight on.
    type_script(&mut ed, "a Enter");
    assert_eq!(latex(&ed), "\\mathfrak{a}");
}

/// A shape row is a step, not an answer: taking it writes the next
/// piece of the spelling and asks for the rest, so a delimiter spec
/// can be built by picking tokens without ever knowing them by heart.
#[test]
fn shape_rows_extend_the_spelling() {
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ l r Down");
    let list = ed.completion.as_ref().expect("the spec tokens are listed");
    // The spec rows lead the list (ordinary matches for the letters
    // `lr` keep a tail below them) and none of them can be taken.
    let spec: Vec<_> = list
        .items
        .iter()
        .filter(|i| i.names.starts_with("lr"))
        .collect();
    assert!(spec.len() >= 8, "{:?}", list.items);
    assert!(spec.iter().all(|i| i.is_step()), "{:?}", spec);
    assert!(list.selected().is_none(), "a step row was committable");

    // Enter writes the highlighted token and offers what may follow.
    let token = ed
        .completion
        .as_ref()
        .unwrap()
        .highlighted()
        .and_then(|i| i.step_to())
        .map(str::to_string);
    type_script(&mut ed, "Enter");
    assert_eq!(ed.minibuffer, token, "the token was not written");
    assert!(latex(&ed).is_empty(), "a step row executed something");
    assert!(
        ed.completion.as_ref().is_some_and(|l| !l.items.is_empty()),
        "the list did not ask for the rest"
    );

    // The arrows walk shape rows too: a row hidden below them has to
    // be reachable.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ f r a k Down");
    let first = ed.completion.as_ref().unwrap().sel;
    type_script(&mut ed, "Down");
    assert_ne!(
        ed.completion.as_ref().unwrap().sel,
        first,
        "the list did not move"
    );

    // A family row leaves the family's own prefix typed.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ f r Down");
    while ed
        .completion
        .as_ref()
        .and_then(|l| l.highlighted())
        .is_some_and(|i| !i.names.starts_with("frak{"))
    {
        type_script(&mut ed, "Down");
    }
    type_script(&mut ed, "Enter");
    assert_eq!(ed.minibuffer.as_deref(), Some("frak"));
    // …and the styled letter still types, which is the point of the row.
    type_script(&mut ed, "A Enter");
    assert_eq!(aa(&ed), "𝔄");
}

/// The delimiter names are spec tokens, not commands: `\lceil` alone
/// would have to mean a pair, and then `\rceil` alone would mean the
/// same one, which reads as nonsense. They work where they are read
/// in visual order — inside `\lr`.
#[test]
fn delimiter_names_are_spec_tokens() {
    for cmd in ["lparen", "lbrack", "lceil", "lfloor", "rparen", "rceil"] {
        let mut ed = Editor::new();
        ed.execute(cmd);
        assert!(
            ed.message.contains("is not a command"),
            "\\{}: {:?}",
            cmd,
            ed.message
        );
        assert!(aa(&ed).is_empty(), "\\{} inserted something", cmd);
    }
    // \mid and \dot keep the meanings they already had.
    let mut ed = Editor::new();
    ed.execute("mid");
    assert_eq!(aa(&ed), "", "\\mid is the divides atom outside a pair");
    // A spec can name ceil/floor, so a mismatched pair is writable.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\lr\lceil\rfloor x");
    assert_eq!(aa(&ed), "⌈𝑥⌋");
    // …and bare \lr is the start of a spec, not the ↔ arrow.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\lr");
    assert!(aa(&ed).is_empty(), "bare \\lr inserted {}", aa(&ed));
}

/// Tab means "run this", so it must not fire on a spelling that only
/// explains itself: `\lr` is the start of a spec, and pressing Tab on
/// it used to print the usage line instead of offering the tokens.
#[test]
fn tab_does_not_commit_a_half_written_spec() {
    for q in [r"\ l r", r"\ d e l i m"] {
        let mut ed = Editor::new();
        type_script(&mut ed, &format!("{} Tab", q));
        assert!(ed.message.is_empty(), "{:?} -> {:?}", q, ed.message);
        assert!(ed.minibuffer.is_some(), "{:?} closed the minibuffer", q);
        assert!(
            ed.completion.as_ref().is_some_and(|l| !l.items.is_empty()),
            "{:?} offered nothing",
            q
        );
    }
    // …while a spelling that really is a command still commits on Tab.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ a l p h a Tab");
    assert_eq!(latex(&ed), "\\alpha ");
}

#[test]
fn container_commands_wrap_the_selection() {
    // \norm used to *replace* the selection — ‖ ‖ is a container, and
    // its contents were being typed over like a symbol would be.
    let mut ed = Editor::new();
    type_script(&mut ed, r"ab S-Left S-Left \norm");
    assert_eq!(latex(&ed), "\\left\\|ab\\right\\|");

    // The named delimiter pairs wrap the way `(` does…
    let mut ed = Editor::new();
    type_script(&mut ed, r"ab S-Left S-Left \abs");
    assert_eq!(latex(&ed), "\\left|ab\\right|");
    let mut ed = Editor::new();
    type_script(&mut ed, r"ab S-Left S-Left \lr(]");
    assert_eq!(latex(&ed), "\\left(ab\\right]");

    // …and a pair with a middle lands the selection in its first
    // segment with the cursor in the next, ready for the other half.
    let mut ed = Editor::new();
    type_script(&mut ed, r"a S-Left \braket b");
    assert_eq!(latex(&ed), "\\left\\langle a\\middle|b\\right\\rangle ");
    let mut ed = Editor::new();
    type_script(&mut ed, r"a S-Left \set b");
    assert_eq!(latex(&ed), "\\left\\{a\\middle|b\\right\\}");
}

/// The mode commands: minibuffer spellings for the ctrl chords, so a
/// terminal that steals ^F/^B/^G/^C/^Q still has every mode. They
/// run on commit like any command, and the ordinary edits keep their
/// meaning beside them (\g is a mode, \ga and \gamma stay edits).
#[test]
fn mode_commands_run_from_the_minibuffer() {
    // \free enters free-cursor mode; \f is the short form.
    let mut ed = Editor::new();
    type_script(&mut ed, r"x \free");
    assert!(ed.free.is_some(), "free mode did not start");
    let mut ed = Editor::new();
    type_script(&mut ed, r"x \f");
    assert!(ed.free.is_some());

    // \b starts block select, \g toggles grid edit inside a matrix.
    let mut ed = Editor::new();
    type_script(&mut ed, r"x ^ 2 \b");
    assert!(ed.block.is_some(), "block select did not start");
    let mut ed = Editor::new();
    type_script(&mut ed, r"\pmatrix22 x \g");
    assert!(ed.grid.is_some(), "grid mode did not start");

    // \clipboard puts the AA on the *system* clipboard, exactly like
    // ^Y. No \c: one letter beside ^C (the internal copy) would read
    // as the same thing, and it is not.
    let mut ed = Editor::new();
    let fx = type_script(&mut ed, r"ab \clipboard");
    assert!(fx.contains(&Effect::CopyAa), "{:?}", fx);
    let mut ed = Editor::new();
    let fx = type_script(&mut ed, r"ab \c");
    assert!(!fx.contains(&Effect::CopyAa), "\\c still copies");

    // \write saves; \wq saves and leaves. Both only *ask* the host —
    // the editor knows nothing about files.
    let mut ed = Editor::new();
    let fx = type_script(&mut ed, r"ab \write");
    assert!(fx.contains(&Effect::Write), "{:?}", fx);
    let mut ed = Editor::new();
    let fx = type_script(&mut ed, r"ab \wq");
    assert!(fx.contains(&Effect::WriteQuit), "{:?}", fx);

    // \quit quits: the effect reaches the host. The one-letter \q
    // does not — a quit one typo away is the wrong price for brevity.
    let mut ed = Editor::new();
    let fx = type_script(&mut ed, r"\quit");
    assert!(fx.contains(&Effect::Quit), "{:?}", fx);
    let mut ed = Editor::new();
    let fx = type_script(&mut ed, r"\q");
    assert!(!fx.contains(&Effect::Quit), "\\q still quits");

    // Tab never runs a mode command — no matter how complete the
    // spelling or which row is highlighted, only an explicit
    // Enter/Space commits one.
    let mut ed = Editor::new();
    type_script(&mut ed, r"x \ f Tab");
    assert!(ed.free.is_none(), "Tab ran a mode command");
    type_script(&mut ed, r"Tab Tab");
    assert!(ed.free.is_none(), "Tab took the mode row");
    type_script(&mut ed, r"Enter");
    assert!(ed.free.is_some(), "Enter did not run it");

    // …and the neighbouring edits are untouched.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\tau");
    assert_eq!(latex(&ed), "\\tau ");
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ta");
    assert_eq!(latex(&ed), "\\tau ");
}

/// Grid mode leaves the way the other modes do, and Enter keeps the
/// cell meaningful: its contents become the ordinary selection, so a
/// wrap or a replacement can act on the cell at once.
#[test]
fn grid_mode_exits_on_backslash_and_enter_keeps_the_cell() {
    // `\` leaves the mode like it leaves ^F/^B (consumed, no
    // minibuffer yet — the next `\` opens it).
    let mut ed = Editor::new();
    type_script(&mut ed, r"\pmatrix22 ab C-g");
    assert!(ed.grid.is_some());
    type_script(&mut ed, r"\");
    assert!(ed.grid.is_none(), "backslash did not leave grid mode");
    assert!(
        ed.minibuffer.is_none(),
        "the leaving key opened the minibuffer"
    );

    // Enter: out of the mode with the cell's contents selected —
    // `\norm` can wrap them immediately.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\pmatrix22 ab C-g Enter");
    assert!(ed.grid.is_none());
    assert_eq!(ed.selection(), Some((0, 2)), "the cell is not selected");
    type_script(&mut ed, r"\norm");
    assert!(latex(&ed).contains("\\|ab"), "{}", latex(&ed));

    // A multi-cell rectangle has no linear reading: Enter just leaves.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\pmatrix22 ab C-g S-Right Enter");
    assert!(ed.grid.is_none());
    assert_eq!(ed.selection(), None);
}

/// A mouse pick in the completion popup accepts the clicked row like
/// Enter would: command rows run, step rows continue the spelling, and
/// an out-of-range index is a no-op.
#[test]
fn clicking_a_completion_row_accepts_it() {
    // Click the second row of \al's list and get exactly what
    // Enter on it gives.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ a l Down");
    let second = ed.completion.as_ref().unwrap().items[1]
        .commit()
        .unwrap()
        .to_string();
    ed.completion_click(1);
    let mut expected = Editor::new();
    expected.execute(&second);
    assert_eq!(latex(&ed), latex(&expected), "clicked \\{}", second);
    assert!(ed.completion.is_none() && ed.minibuffer.is_none());

    // A step row continues the spelling instead of running.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ l r Down");
    ed.completion_click(0);
    assert!(ed.minibuffer.as_deref().is_some_and(|m| m.len() > 2));
    assert!(latex(&ed).is_empty(), "a step row executed something");

    // Out of range: nothing happens.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ a l Down");
    ed.completion_click(99);
    assert!(ed.completion.is_some() && latex(&ed).is_empty());
}

/// ^B walks outward all the way to the whole formula: it works at the
/// top level (no "no enclosing block" message needed), ↑ from any
/// depth ends on everything, and a single root node does not get the
/// same box twice.
#[test]
fn block_select_reaches_the_whole_formula() {
    let mut ed = Editor::new();
    type_script(&mut ed, "x+y C-b");
    assert!(ed.block.is_some());
    type_script(&mut ed, "Enter");
    assert_eq!(ed.selection(), Some((0, 3)));

    let mut ed = Editor::new();
    type_script(&mut ed, "x+ ( y C-b Up Up Up Enter");
    assert_eq!(ed.selection(), Some((0, 3)));

    // A single root node: its contents, then the node — and the root
    // row, which is that same box, is not added twice.
    let mut ed = Editor::new();
    type_script(&mut ed, "( x C-b");
    assert_eq!(ed.block.as_ref().map(Vec::len), Some(2));

    // An empty formula: silent no-op.
    let mut ed = Editor::new();
    type_script(&mut ed, "C-b");
    assert!(ed.block.is_none() && ed.message.is_empty());
}

/// Backspace behind an accented atom peels the outermost mark first —
/// the inverse of how it was typed — and only a bare atom deletes.
#[test]
fn backspace_peels_accents_before_the_base() {
    let mut ed = Editor::new();
    type_script(&mut ed, r"a \hat \vec");
    assert_eq!(latex(&ed), "\\vec{\\hat{a}}");
    type_script(&mut ed, "Backspace");
    assert_eq!(latex(&ed), "\\hat{a}", "the outermost mark peels first");
    type_script(&mut ed, "Backspace");
    assert_eq!(latex(&ed), "a", "the last mark leaves a bare atom");
    type_script(&mut ed, "Backspace");
    assert_eq!(latex(&ed), "");

    // Unders peel after overs.
    let mut ed = Editor::new();
    type_script(&mut ed, r"a \underline \hat Backspace");
    assert_eq!(latex(&ed), "\\underline{a}");
}

/// A wide accent's base is a real field now: the cursor walks in and
/// edits it, deleting at the inner edge unwraps the accent in the
/// staged bracket flow, and from outside the accent deletes like any
/// structure (select whole, then remove).
#[test]
fn wide_accents_edit_and_unwrap() {
    // Walk in and edit the base in place.
    let mut ed = Editor::new();
    type_script(&mut ed, r"ab S-Left S-Left \hat");
    assert_eq!(latex(&ed), "\\widehat{ab}");
    type_script(&mut ed, "Left c");
    assert_eq!(latex(&ed), "\\widehat{abc}");

    // Inner edge: arm, then the accent unwraps leaving the contents
    // selected.
    type_script(&mut ed, "Home Backspace");
    assert_eq!(latex(&ed), "\\widehat{abc}", "the first press deletes");
    type_script(&mut ed, "Backspace");
    assert_eq!(latex(&ed), "abc");
    assert_eq!(ed.selection(), Some((0, 3)));

    // From outside: first delete selects the whole accent, second
    // removes it.
    let mut ed = Editor::new();
    type_script(&mut ed, r"ab S-Left S-Left \hat Backspace");
    assert_eq!(latex(&ed), "\\widehat{ab}", "the first press deletes");
    assert_eq!(ed.selection(), Some((0, 1)), "the accent is selected whole");
    type_script(&mut ed, "Backspace");
    assert_eq!(latex(&ed), "");
}

/// A │ middle can be removed by pointing at it: Shift toward the mid
/// from either side arms it (that one column lights up), and the next
/// delete removes just the separator, merging its two segments.
#[test]
fn an_armed_mid_deletes_and_merges() {
    // ⟨a│b⟩: arm the mid from the right segment's start, Backspace.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\braket a Right b");
    assert_eq!(latex(&ed), "\\left\\langle a\\middle|b\\right\\rangle ");
    type_script(&mut ed, "Home S-Left Backspace");
    assert_eq!(latex(&ed), "\\left\\langle ab\\right\\rangle ");

    // …and from the left segment's end, with Delete.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\braket a Right b Left Left S-Right Delete");
    assert_eq!(latex(&ed), "\\left\\langle ab\\right\\rangle ");

    // The arming is one-shot: an unrelated key in between disarms
    // (End moves to the segment's end; Backspace then deletes b, and
    // the mid survives).
    let mut ed = Editor::new();
    type_script(&mut ed, r"\braket a Right b Home S-Left End Backspace");
    assert_eq!(
        latex(&ed),
        "\\left\\langle a\\middle|\\right\\rangle ",
        "a disarmed Backspace still merged"
    );
}

/// \divides is the ∣ atom by name, everywhere — unlike \mid, whose
/// meaning depends on standing inside a pair.
#[test]
fn divides_is_the_atom_everywhere() {
    let mut ed = Editor::new();
    type_script(&mut ed, r"a \divides b");
    assert_eq!(latex(&ed), "a\\mid b");
    // Inside a pair it is still the atom, not a segment.
    let mut ed = Editor::new();
    type_script(&mut ed, r"( x \divides y");
    assert_eq!(latex(&ed), "\\left(x\\mid y\\right)");
}

/// The whole-formula ^B target shares the root row with the outermost
/// ancestor, so its marks must not be displaced by the ancestor's own
/// insertions: the ring has to close after the LAST root node, and
/// nesting has to hold whichever side the deep target sits on.
#[test]
fn whole_formula_ring_encloses_everything() {
    use formulaa::ast::Node;
    use formulaa::glyphs::Mark;
    let decode = |row: &[Node]| -> Vec<String> {
        row.iter()
            .map(|n| match n {
                Node::Sym(c) => match Mark::decode(*c) {
                    Some(Mark::BlockOpen { rank }) => format!("open{rank}"),
                    Some(Mark::BlockClose) => "close".into(),
                    _ => "atom".into(),
                },
                _ => "node".into(),
            })
            .collect()
    };

    // a (frac) a — the ring must close after the trailing a.
    let mut ed = Editor::new();
    type_script(&mut ed, r"a Space 1 // 2 Tab Space a");
    type_script(&mut ed, "Left Left Left C-b");
    assert!(ed.block.is_some());
    let (root, _) = ed.decorated();
    let seq = decode(&root);
    assert_eq!(
        seq.last().map(String::as_str),
        Some("close"),
        "the ring does not reach the end: {seq:?}"
    );
    assert_eq!(seq.first().map(String::as_str), Some("open1"), "{seq:?}");

    // (frac) a a — mirrored: everything after the fraction must be
    // INSIDE the ring but OUTSIDE the fraction's own box.
    let mut ed = Editor::new();
    type_script(
        &mut ed,
        r"1 // 2 Tab Space a Space a Left Left Left Left Left C-b",
    );
    assert!(ed.block.is_some());
    let (root, _) = ed.decorated();
    let seq = decode(&root);
    assert_eq!(seq.first().map(String::as_str), Some("open1"), "{seq:?}");
    assert!(seq.iter().any(|s| s == "open0"), "{seq:?}");
    assert_eq!(seq.last().map(String::as_str), Some("close"), "{seq:?}");
    let closes: Vec<usize> = seq
        .iter()
        .enumerate()
        .filter(|(_, s)| *s == "close")
        .map(|(i, _)| i)
        .collect();
    assert_eq!(closes.len(), 2, "{seq:?}");
    assert!(closes[0] < seq.len() - 1, "the boxes collapsed: {seq:?}");
}

/// Review findings, 2026-08: the armed-│ one-shot must not survive a
/// click or an undo (both bypass the key layer's take), ^D must honor
/// it like the other deletes, and a chord must never type into the
/// minibuffer.
#[test]
fn armed_mid_one_shot_covers_every_exit() {
    // ^D completes the armed gesture.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\braket a Right b Home S-Left C-d");
    assert_eq!(latex(&ed), "\\left\\langle ab\\right\\rangle ");

    // A click disarms: the later Backspace must not merge.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\braket a Right b Home S-Left");
    ed.click(0, 0);
    type_script(&mut ed, "Backspace");
    assert!(
        latex(&ed).contains("middle"),
        "a stale armed mid merged after a click: {}",
        latex(&ed)
    );

    // Undo disarms: the arming belonged to the replaced tree.
    let mut ed = Editor::new();
    type_script(&mut ed, r"\braket a Right b Home S-Left C-z Backspace");
    assert!(
        latex(&ed).contains("middle"),
        "a stale armed mid merged after undo: {}",
        latex(&ed)
    );
}

/// A ctrl chord with the minibuffer open is not typing: ^V must not
/// append a v to the query.
#[test]
fn chords_do_not_type_into_the_minibuffer() {
    let mut ed = Editor::new();
    type_script(&mut ed, r"\ a C-v C-z C-y");
    assert_eq!(ed.minibuffer.as_deref(), Some("a"));
}

/// A click-away commit of an open box is an edit like any other: it
/// gets its own undo step instead of fusing into the previous
/// keystroke's history entry.
#[test]
fn click_commit_gets_its_own_undo_step() {
    let mut ed = Editor::new();
    type_script(&mut ed, r"abc \text h i");
    ed.click(0, 0); // commits the box
    assert!(latex(&ed).contains("text"), "{}", latex(&ed));
    type_script(&mut ed, "C-z");
    assert_eq!(latex(&ed), "abc", "undo fused the commit with older edits");
}

/// Enter in grid mode selects the highlighted CELL even when the edit
/// cursor is parked deeper inside it: with the cursor in a frac's
/// denominator, ^G Enter hands the whole cell over, so typing
/// replaces the cell, not the frac's inner row.
#[test]
fn grid_commit_selects_the_cell_not_the_inner_row() {
    let mut ed = Editor::new();
    type_script(&mut ed, r"\bmatrix22 1 // 2 C-g Enter x");
    let tex = latex(&ed);
    assert!(!tex.contains("frac"), "the inner row was selected: {tex}");
    assert!(tex.contains('x'), "{tex}");
}

/// The copy blip belongs to copies: ^C in grid mode raises it, a
/// destructive clear (Backspace) does not.
#[test]
fn grid_copy_blips_and_clear_does_not() {
    let mut ed = Editor::new();
    type_script(&mut ed, r"\bmatrix22 a C-g C-c");
    assert!(ed.copy_flash, "a cell copy got no acknowledgement");
    let mut ed = Editor::new();
    type_script(&mut ed, r"\bmatrix22 a C-g Backspace");
    assert!(!ed.copy_flash, "a destructive clear blipped as a copy");
}

/// A pending \-escape in a \text box dies with the box: a click-away
/// commit skips the key layer, and the leaked escape would swallow
/// the next box's closing quote.
#[test]
fn a_stale_text_escape_does_not_leak_into_the_next_box() {
    let mut ed = Editor::new();
    type_script(&mut ed, r"\text a");
    ed.input(Key::Char('\\'), false, false); // escape pending
    ed.click(30, 0); // commits the box without a key dispatch
    type_script(&mut ed, r"\text");
    ed.input(Key::Char('"'), false, false); // must close the box
    assert!(
        ed.op_entry.is_none(),
        "the stale escape swallowed the closing quote"
    );
}