ek-ui 0.2.21

ek 生态常驻主界面(egui/eframe):插件即界面——观测页 + 日志页,车头与 UI 同插件
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
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
3199
3200
3201
3202
3203
3204
3205
3206
3207
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
3226
3227
3228
3229
3230
3231
3232
3233
3234
3235
3236
3237
3238
3239
3240
3241
3242
3243
3244
3245
3246
3247
3248
3249
3250
3251
3252
3253
3254
3255
3256
3257
3258
3259
3260
3261
3262
3263
3264
3265
3266
3267
3268
3269
3270
3271
3272
3273
3274
3275
3276
3277
3278
3279
3280
3281
3282
3283
3284
3285
3286
3287
3288
3289
3290
3291
3292
3293
3294
3295
3296
3297
3298
3299
3300
3301
3302
3303
3304
3305
3306
3307
3308
3309
3310
3311
3312
3313
3314
3315
3316
3317
3318
3319
3320
3321
3322
3323
3324
3325
3326
3327
3328
3329
3330
3331
3332
3333
3334
3335
3336
3337
3338
3339
3340
3341
3342
3343
3344
3345
3346
3347
3348
3349
3350
3351
3352
3353
3354
3355
3356
3357
3358
3359
3360
3361
3362
3363
3364
3365
3366
3367
3368
3369
3370
3371
3372
3373
3374
3375
3376
3377
3378
3379
3380
3381
3382
3383
3384
3385
3386
3387
3388
3389
3390
3391
3392
3393
3394
3395
3396
3397
3398
3399
3400
3401
3402
3403
3404
3405
3406
3407
3408
3409
3410
3411
3412
3413
3414
3415
3416
3417
3418
3419
3420
3421
3422
3423
3424
3425
3426
3427
3428
3429
3430
3431
3432
3433
3434
3435
3436
3437
3438
3439
3440
3441
3442
3443
3444
3445
3446
3447
3448
3449
3450
3451
3452
3453
3454
3455
3456
3457
3458
3459
3460
3461
3462
3463
3464
3465
3466
3467
3468
3469
3470
3471
3472
3473
3474
3475
3476
3477
3478
3479
3480
3481
3482
3483
3484
3485
3486
3487
3488
3489
3490
3491
3492
3493
3494
3495
3496
3497
3498
3499
3500
3501
3502
3503
3504
3505
3506
3507
3508
3509
3510
3511
3512
3513
3514
3515
3516
3517
3518
3519
3520
3521
3522
3523
3524
3525
3526
3527
3528
3529
3530
3531
3532
3533
3534
3535
3536
3537
3538
3539
3540
3541
3542
3543
3544
3545
3546
3547
3548
3549
3550
3551
3552
3553
3554
3555
3556
3557
3558
3559
3560
3561
3562
3563
3564
3565
3566
3567
3568
3569
3570
3571
3572
3573
3574
3575
3576
3577
3578
3579
3580
3581
3582
3583
3584
3585
3586
3587
3588
3589
3590
3591
3592
3593
3594
3595
3596
3597
3598
3599
3600
3601
3602
3603
3604
3605
3606
3607
3608
3609
3610
3611
3612
3613
3614
3615
3616
3617
3618
3619
3620
3621
3622
3623
3624
3625
3626
3627
3628
3629
3630
3631
3632
3633
3634
3635
3636
3637
3638
3639
3640
3641
3642
3643
3644
3645
3646
3647
3648
3649
3650
3651
3652
3653
3654
3655
3656
3657
3658
3659
3660
3661
3662
3663
3664
3665
3666
3667
3668
3669
3670
3671
3672
3673
3674
3675
3676
3677
3678
3679
3680
3681
3682
3683
3684
3685
3686
3687
3688
3689
3690
3691
3692
3693
3694
3695
3696
3697
3698
3699
3700
3701
3702
3703
3704
3705
3706
3707
3708
3709
3710
3711
3712
3713
3714
3715
3716
3717
3718
3719
3720
3721
3722
3723
3724
3725
3726
3727
3728
3729
3730
3731
3732
3733
3734
3735
3736
3737
3738
3739
3740
3741
3742
3743
3744
3745
3746
3747
3748
3749
3750
3751
3752
3753
3754
3755
3756
3757
3758
3759
3760
3761
3762
3763
3764
3765
3766
3767
3768
3769
3770
3771
3772
3773
3774
3775
3776
3777
3778
3779
3780
3781
3782
3783
3784
3785
3786
3787
3788
3789
3790
3791
3792
3793
3794
3795
3796
3797
3798
3799
3800
3801
3802
3803
3804
3805
3806
3807
3808
3809
3810
3811
3812
3813
3814
3815
3816
3817
3818
3819
3820
3821
3822
3823
3824
3825
3826
3827
3828
3829
3830
3831
3832
3833
3834
3835
3836
3837
3838
3839
3840
3841
3842
3843
3844
3845
3846
3847
3848
3849
3850
3851
3852
3853
3854
3855
3856
3857
3858
3859
3860
3861
3862
3863
3864
3865
3866
3867
3868
3869
3870
3871
3872
3873
3874
3875
3876
3877
3878
3879
3880
3881
3882
3883
3884
3885
3886
3887
3888
3889
3890
3891
3892
3893
3894
3895
3896
3897
3898
3899
3900
3901
3902
3903
3904
3905
3906
3907
3908
3909
3910
3911
3912
3913
3914
3915
3916
3917
3918
3919
3920
3921
3922
3923
3924
3925
3926
3927
3928
3929
3930
3931
3932
3933
3934
3935
3936
3937
3938
3939
3940
3941
3942
3943
3944
3945
3946
3947
3948
3949
3950
3951
3952
3953
3954
3955
3956
//! L3:kittest 官方建构(egui 0.33.3 同版)—— AccessKit 节点断言 + 就地交互 +
//! 静态布局首帧快照。人工核查 0 项。
//!
//! 渲染入口与真实 App 同源:[`ek_ui::render_body`](ek_ui::render_body)(L2)。
//!
//! v4 布局钉口径:弹窗(运维/CTP/查询合约/K线)默认关闭 —— 需要弹窗内容的
//! 钉先 `set_*_window_open(true)` 再建构(菜单点击流程另有 s17 钉走 UI 面)。
//! 右下组默认「资金」tab —— 日志面钉先 `set_bottomright_tab(Logs)`。
//! 可达性教训(BD-UI-27/D7):AccessKit 可见 ≠ 视觉可达,快照钉必须配
//! 带内滚动面(auto_shrink=false)让 baseline 覆盖全内容。

use std::sync::Arc;

use egui_kittest::{
    Harness,
    kittest::{NodeT, Queryable},
};
use ek_station::wire::PlugRow;
use ek_ui::state::UiCmd;
use ek_ui::{BusSnap, UiShared, render_body};

fn row(name: &str) -> PlugRow {
    PlugRow {
        name: name.to_string(),
        version: "0.1.0".to_string(),
        state: "Running".to_string(),
        deps: vec![],
        spilled: 0,
        panics: 0,
    }
}

/// 自定状态行(隔离红标/依赖图用)。
fn row_with(name: &str, state: &str, deps: Vec<String>) -> PlugRow {
    PlugRow {
        name: name.to_string(),
        version: "0.1.0".to_string(),
        state: state.to_string(),
        deps,
        spilled: 0,
        panics: 0,
    }
}

/// 金样本表(测试面;数字全同源,与 ui_market.rs 的 hand-computed 一致)。
fn golden_bar(dt_s: i64) -> ek_data::bar::BarData {
    ek_data::bar::BarData {
        symbol: "rb2605".to_string(),
        exchange: ek_data::constant::Exchange::SHFE,
        datetime: dt_s * 1_000_000_000,
        interval: Some(ek_data::constant::Interval::MINUTE),
        volume: 18.0,
        open_price: 100.0,
        high_price: 103.0,
        low_price: 99.5,
        close_price: 99.5,
        ..ek_data::bar::BarData::default()
    }
}

/// 喂行情库:2 帧 eBar.(同址更新面)+ 4 条 eBarUpdate. + 1 次回灌登记。
/// (v4:行情带上屏已改 tick 源;此面只供 K线窗格数据。)
fn feed_market_frame(shared: &UiShared, frames_expect: u64, updates_expect: u64) {
    let frame = ek_dm::wire::BarsFrame {
        name: "dm".to_string(),
        interval: 60,
        bars: vec![golden_bar(36_000)],
    };
    shared.market().on_frame(&frame);
    let frame2 = ek_dm::wire::BarsFrame {
        name: "dm".to_string(),
        interval: 60,
        bars: vec![golden_bar(36_060)],
    };
    shared.market().on_frame(&frame2);
    for _ in 0..4 {
        shared.market().on_update(&golden_bar(36_060));
    }
    shared.market().note_reload();
    let (f, u, r) = shared.market().counts();
    assert_eq!(f, frames_expect);
    assert_eq!(u, updates_expect);
    assert_eq!(r, 1);
}

/// 行情带 tick 样本(数字全同源)。
fn tick(
    ex: ek_data::constant::Exchange,
    symbol: &str,
    name: &str,
    last: f64,
    pct: f64,
) -> ek_data::tick::TickData {
    ek_data::tick::TickData {
        gateway_name: "ctp".to_string(),
        symbol: symbol.to_string(),
        exchange: ex,
        datetime: 36_000 * 1_000_000_000,
        name: name.to_string(),
        last_price: last,
        pct_change: pct,
        volume: 1234.0,
        open_price: 100.0,
        high_price: 103.0,
        low_price: 99.0,
        bid_price_1: last - 0.5,
        bid_volume_1: 3.0,
        ask_price_1: last + 0.5,
        ask_volume_1: 4.0,
        update_time: "10:00:00".to_string(),
        ..ek_data::tick::TickData::default()
    }
}

/// 查询合约窗样本行。
fn contract(
    ex: ek_data::constant::Exchange,
    symbol: &str,
    name: &str,
) -> ek_data::contract::ContractData {
    ek_data::contract::ContractData {
        gateway_name: "ctp".to_string(),
        symbol: symbol.to_string(),
        exchange: ex,
        name: name.to_string(),
        asset: ek_data::constant::Asset::default(),
        instrument_type: None,
        size: 10.0,
        price_tick: 1.0,
        product_id: None,
        max_limit_order_volume: None,
        min_limit_order_volume: None,
        max_market_order_volume: None,
        min_market_order_volume: None,
        delivery_year: None,
        delivery_month: None,
        open_date: None,
        pre_close: None,
        up_stop_price: None,
        down_stop_price: None,
        float_volume: None,
        total_volume: None,
        instrument_status: None,
        is_trading: None,
    }
}

fn demo_rows() -> Vec<PlugRow> {
    vec![row("svctl"), row("dm"), row("oms"), row("ui")]
}

/// 多账号面测试种子:注册一个账户行并指定为行情源(订阅路由的前置条件)。
/// 回带派生注册名(= 订阅 cmd 的 gateway 位)。
fn seed_md_source(shared: &UiShared, broker: &str, account: &str) -> String {
    let form = ek_ui::state::CtpForm {
        broker_id: broker.to_string(),
        td_user: account.to_string(),
        ..Default::default()
    };
    let row = ek_ui::state::AccountRow::derive(&form).expect("派生注册名");
    shared.add_account(row.clone()).expect("添加账户");
    shared.set_md_source(&row.gateway).expect("指定行情源");
    row.gateway
}

/// 四态测试种子:把某账户标成「已装配」——等价于插件侧拿 svctl.list 快照回写
/// (`sync_accounts_connected`:名字在场 → `Assembled` + 在场位真)。
/// 0.2.18 起 `default_gateway()` 只认已装配账户(规划 Q4),需要默认网关的测试先走这一步。
fn mark_assembled(shared: &UiShared, gateway: &str) {
    shared.sync_accounts_connected(&[row(gateway)]);
}

/// 快照互斥(进程内;wgpu buffer 面不并肩 —— 并发测试线程各自 Harness 的
/// pixel readback 相撞 → BufferAsyncError,单测直跑无恙)。
fn snapshot_lock() -> &'static std::sync::Mutex<()> {
    static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
    LOCK.get_or_init(|| std::sync::Mutex::new(()))
}

/// 填一次固定快照(svctl.list 形状)。
fn feed_snapshot(shared: &UiShared, services: Vec<(String, Vec<String>)>) {
    shared.set_snapshot(BusSnap {
        rows: demo_rows(),
        services,
    });
}

/// 建构钉统一实窗尺寸(v5 = 1280×860,runtime.rs 同源)。
/// (D7 教训:800×600 缺省建构面与实窗不符,带内折叠口径失真。
/// BD-UI-29 面板化后走 ctx 级建构 `build`(render_body 自开顶层面板,
/// 不得再包 CentralPanel —— panel.rs 官方纪律)。)
fn harness_for(shared: &UiShared) -> Harness<'_> {
    Harness::builder()
        .with_size([1280.0, 860.0])
        .build(|ctx| render_body(ctx, shared))
}

/// 快照回读串行化(BD-UI-35):本机多 wgpu 设备并发 map_async 回读会
/// BufferAsyncError(实锤:5 个快照测试并发时随机成组失败,
/// `--test-threads=1` 全绿)。回读串行 = 确定性;渲染本身仍并发。
static SNAP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

fn snap(harness: &mut Harness<'_>, name: &str) {
    // 环境稳健钉(BD-UI-38 轮):本机后装的 "GameViewer Virtual Display Adapter"
    // 被 wgpu 缺省择优选中,其 Dx12/DX11 纹理回读坏(BufferAsyncError,单测
    // 串行也复现;VULKAN/GL 实测好)。Windows 下缺省钉 Vulkan 后端——
    // LazyRenderer 首次快照才建 wgpu 实例,OnceLock 保证钉在实例创建前落位;
    // 用户已设 WGPU_BACKEND 则尊重其选择。
    static WGPU_ENV: std::sync::OnceLock<()> = std::sync::OnceLock::new();
    WGPU_ENV.get_or_init(|| {
        #[cfg(windows)]
        if std::env::var_os("WGPU_BACKEND").is_none() {
            // SAFETY: OnceLock 保证全程单次写,且先于任何 wgpu 实例创建。
            unsafe { std::env::set_var("WGPU_BACKEND", "VULKAN") };
        }
    });
    let _g = SNAP_LOCK
        .lock()
        .unwrap_or_else(std::sync::PoisonError::into_inner);
    harness.snapshot(name);
}

#[test]
fn s0_rows_and_service_directory_visible() {
    let shared = Arc::new(UiShared::new());
    feed_snapshot(
        &shared,
        vec![
            (
                "svctl".to_string(),
                vec![
                    "svctl.list".to_string(),
                    "svctl.start".to_string(),
                    "svctl.remove".to_string(),
                ],
            ),
            ("dm".to_string(), vec!["dm.reload".to_string()]),
            ("oms".to_string(), vec!["oms.query".to_string()]),
            ("ui".to_string(), vec![]),
        ],
    );
    // v4:插件/服务面收纳在运维窗;日志钮在右下组「日志」tab。
    shared.set_ops_window_open(true);
    shared.set_bottomright_tab(ek_ui::state::BottomRightTab::Logs);

    let mut harness = harness_for(&shared);

    // 插件表四行:逐行名称标签可查询;不存在的行查不到。
    assert!(harness.query_by_label("dm").is_some());
    assert!(harness.query_by_label("oms").is_some());
    assert!(harness.query_by_label("ui").is_some());
    assert!(harness.query_by_label("ghost").is_none());
    // 状态列:四行同显 Running(准确计数,不是"至少一个")。
    assert_eq!(harness.get_all_by_label("Running").count(), 4);

    // 服务目录:三口逐 id 成行(首列 ="{名}服务",多口同名 → 精确计数);"ui"无服务占位一行。
    assert_eq!(harness.get_all_by_label("svctl 服务").count(), 3);
    assert!(harness.query_by_label("svctl.list").is_some());
    assert!(harness.query_by_label("svctl.start").is_some());
    assert!(harness.query_by_label("svctl.remove").is_some());
    assert!(harness.query_by_label("dm 服务").is_some());
    assert!(harness.query_by_label("dm.reload").is_some());
    assert!(harness.query_by_label("oms.query").is_some());
    assert!(harness.query_by_label("ui 服务").is_some());
    assert!(
        harness.query_by_label("清空日志").is_some(),
        "就地操作按钮在场"
    );

    harness.run(); // 至少一帧无 panic
}

#[test]
fn s1_clear_logs_button_local_action() {
    let shared = Arc::new(UiShared::new());
    feed_snapshot(&shared, vec![]);
    shared.set_bottomright_tab(ek_ui::state::BottomRightTab::Logs);
    shared.log_line("[INFO][demo] 样例 001");
    shared.log_line("[WARN][demo] 样例 002");
    shared.log_line("[INFO][demo] 样例 003");

    let mut harness = harness_for(&shared);
    assert_eq!(shared.logs_len(), 3);

    // 就地动作:点按钮 → 清空(零往返,shared 直变)。
    harness.get_by_label("清空日志").click_accesskit();
    harness.run();
    assert_eq!(shared.logs_len(), 0, "清空按钮就地生效");
    // 清空后按钮仍在,再次点击幂等。
    harness.get_by_label("清空日志").click_accesskit();
    harness.run();
    assert_eq!(shared.logs_len(), 0);
}

#[test]
fn s2_log_lines_rendered_with_text() {
    let shared = Arc::new(UiShared::new());
    feed_snapshot(&shared, vec![]);
    shared.set_bottomright_tab(ek_ui::state::BottomRightTab::Logs);
    shared.log_line("[INFO][demo] 固定样例行 甲");
    shared.log_line("[INFO][demo] 固定样例行 乙");

    let harness = harness_for(&shared);
    // 日志正文以 Label 呈现,按包含查询命中。
    assert!(harness.query_by_label_contains("固定样例行 甲").is_some());
    assert!(harness.query_by_label_contains("固定样例行 乙").is_some());
}

/// 静态布局首帧快照(v5 面板化框架面;baseline 用 UPDATE_SNAPSHOTS=true 生成。
/// 只含静态内容:固定行 + 固定日志(无实时数据路径)。
/// BD-UI-29 结构钉(先于字体让位执行,任何环境都跑):2×2 格响应式分配
/// 生效 = 右列存在且贴边不裁切、底行沉到中央面板下部(无死区)、下单侧栏
/// 在左。dbg 实锤的机制回归(horizontal 行不吃满高度 → 定高失效 → 死区)
/// 会直接把底行拉回 y<500,本钉当场抓住。
#[test]
fn s3_static_layout_snapshot() {
    let _g = snapshot_lock().lock().unwrap();
    let shared = Arc::new(UiShared::new());
    feed_snapshot(
        &shared,
        vec![
            (
                "svctl".to_string(),
                vec![
                    "svctl.list".to_string(),
                    "svctl.start".to_string(),
                    "svctl.remove".to_string(),
                ],
            ),
            ("dm".to_string(), vec!["dm.reload".to_string()]),
            ("oms".to_string(), vec![]),
            ("ui".to_string(), vec![]),
        ],
    );
    shared.set_bottomright_tab(ek_ui::state::BottomRightTab::Logs);
    shared.log_line("[INFO][demo] 固定样例行 001");
    shared.log_line("[INFO][demo] 固定样例行 002");
    shared.log_line("[INFO][demo] 固定样例行 003");

    let mut harness = harness_for(&shared);
    harness.run();

    // ---- BD-UI-29 结构钉(1280×860 实窗尺寸) ----
    let rect_of = |label: &str| {
        harness
            .query_by_label(label)
            .unwrap_or_else(|| panic!("结构钉:{label} 必须存在"))
            .rect()
    };
    let unfilled = rect_of("未成交"); // 左上组(委托|未成交 tab)
    let trades = rect_of("成交"); // 右上组
    let pos = rect_of("持仓"); // 左下组
    let funds = rect_of("资金"); // 右下组
    let cancel_all = rect_of("全撤"); // 下单侧栏钮
    assert!(cancel_all.min.x < 250.0, "下单侧栏在左(SidePanel 缺省 250)");
    assert!(
        unfilled.min.x > 250.0 && unfilled.min.y < 500.0,
        "委托组在中央面板左上"
    );
    assert!(trades.min.x > 700.0, "成交组在右列(响应式第二列存在)");
    assert!(
        funds.min.x > 700.0 && funds.max.x < 1280.0,
        "资金组在右列且贴边不裁切"
    );
    assert!(
        pos.min.y > 500.0 && funds.min.y > 500.0,
        "底行沉到中央面板下部(响应式吃满高度,无死区;定高回归时此处 <500);实测 pos.y={:.0} funds.y={:.0}",
        pos.min.y,
        funds.min.y
    );
    assert!(
        pos.min.y < 760.0 && funds.min.y < 760.0,
        "底行在窗内(不溢出窗口)"
    );
    let col_w = funds.min.x - pos.min.x;
    assert!(
        (450.0..=540.0).contains(&col_w),
        "两列等宽且随窗口分配(实测列距 {col_w:.0},期望 ≈ (可用宽-间距)/2)"
    );

    let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx); // BD-UI-26:快照与实窗同字体面
    if hit.is_none() {
        // 无 CJK 字体环境:基线由有字体面生成,豆腐渲染必 diff——本钉让位
        // (s3 本体即快照;结构面另有 s0–s2 等 AccessKit 钉覆盖,不在此钉)。
        return;
    }
    snap(&mut harness, "s3_static_layout");
}

// ================= S2:svctl 操作面(kittest 流程;v4 收纳运维窗) =================

fn feed_rows(shared: &UiShared, rows: Vec<PlugRow>) {
    shared.set_snapshot(BusSnap {
        rows,
        services: vec![],
    });
}

/// S2 流程:移除走二次确认(首点 armed 无指令 → 二点发令)。
#[test]
fn s4_remove_two_step_confirm_flow() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, vec![row("svctl"), row("dm")]);
    shared.set_ops_window_open(true);

    let mut harness = harness_for(&shared);

    // 首点:进入待确认(按钮翻确认文案),无指令入队。
    harness.get_by_label("移除 dm").click_accesskit();
    harness.run();
    assert_eq!(shared.pending_confirm().as_deref(), Some("dm"));
    assert!(shared.drain_cmds().is_empty(), "armed must not enqueue");
    assert!(harness.query_by_label_contains("待二次确认").is_some());
    assert!(harness.query_by_label("确认移除 dm ?").is_some());

    // 二点:发令 + 待确认清空。
    harness.get_by_label("确认移除 dm ?").click_accesskit();
    harness.run();
    assert_eq!(shared.pending_confirm(), None);
    assert_eq!(
        shared.drain_cmds(),
        vec![UiCmd::Remove {
            name: "dm".to_string(),
        }]
    );
    // 横幅消失。
    assert!(harness.query_by_label_contains("待二次确认").is_none());
}

/// S2 流程:重注入单步直发。
#[test]
fn s5_restart_direct_enqueue() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, vec![row("oms")]);
    shared.set_ops_window_open(true);
    let mut harness = harness_for(&shared);

    harness.get_by_label("重注入 oms").click_accesskit();
    harness.run();
    assert_eq!(
        shared.drain_cmds(),
        vec![UiCmd::Start {
            factory: "oms".to_string(),
            name: String::new(),
        }]
    );
}

/// S2:取消确认钮 + 隔离红标 + 依赖图文本。
#[test]
fn s6_cancel_confirm_quarantine_deps() {
    let shared = Arc::new(UiShared::new());
    feed_rows(
        &shared,
        vec![
            row_with("dice", "Quarantined", vec![]),
            row_with("p2", "Running", vec!["p1".to_string()]),
        ],
    );
    shared.set_ops_window_open(true);
    let mut harness = harness_for(&shared);

    // 取消待确认:armed → 取消钮 → 待确认清空(按钮回原文案)。
    harness.get_by_label("移除 dice").click_accesskit();
    harness.run();
    assert_eq!(shared.pending_confirm().as_deref(), Some("dice"));
    harness.get_by_label("取消确认").click_accesskit();
    harness.run();
    assert_eq!(shared.pending_confirm(), None);
    assert!(harness.query_by_label("移除 dice").is_some());

    // 隔离红标:状态字样在场(着色为视觉面;状态机侧 is_quarantined 见 L1)。
    assert!(harness.query_by_label("Quarantined").is_some());

    // 依赖图文本:p2 → p1 在场;无依赖段不显示"全部无依赖"。
    assert!(harness.query_by_label_contains("p2 → p1").is_some());
    assert!(harness.query_by_label_contains("全部无依赖").is_none());
}

/// v4 行情带:tick 行逐位 + 交易所二级 tab 切换 + 空态占位。
#[test]
fn s8_market_band_tick_rows() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    shared.update_tick(&tick(
        ek_data::constant::Exchange::SHFE,
        "rb2605",
        "螺纹钢2605",
        101.0,
        1.25,
    ));
    shared.update_tick(&tick(
        ek_data::constant::Exchange::SHFE,
        "au2706",
        "黄金2706",
        777.0,
        -0.5,
    ));
    shared.update_tick(&tick(
        ek_data::constant::Exchange::DCE,
        "a2709",
        "黄玉米2709",
        2222.0,
        0.0,
    ));

    let mut harness = harness_for(&shared);
    // 默认组 = 首组(DCE 在前:BTreeMap 序)。状态行「tick N 格 / 最后更新」
    // 已撤(BD-UI-35 用户裁定)——反向钉:此文字不得再上屏。
    assert!(
        harness.query_by_label_contains("tick 3 格").is_none(),
        "状态行已撤,不得回归"
    );
    assert!(harness.query_by_label("DCE").is_some());
    assert!(harness.query_by_label("SHFE").is_some());
    assert!(harness.query_by_label("a2709").is_some());
    assert!(harness.query_by_label_contains("黄玉米2709").is_some());
    assert_eq!(harness.get_all_by_label("2222.0").count(), 1, "最新价列");
    assert_eq!(harness.get_all_by_label("1234").count(), 1, "成交量列");
    assert_eq!(harness.get_all_by_label("10:00:00").count(), 1, "时间列");

    // 二级 tab 切换:点 SHFE → SHFE 组上屏,DCE 组隐。
    harness.get_by_label("SHFE").click_accesskit();
    harness.run();
    assert!(harness.query_by_label("rb2605").is_some());
    assert!(harness.query_by_label_contains("螺纹钢2605").is_some());
    assert_eq!(harness.get_all_by_label("101.0").count(), 1);
    assert!(harness.query_by_label("a2709").is_none(), "非当前组不上屏");

    // 行单击 = 带入下单面板(方案「行单击 = 选中合约」落地,ek 增值钉)。
    harness.get_by_label("rb2605").click_accesskit();
    harness.run();
    let f = shared.order_form();
    assert_eq!(f.symbol, "rb2605", "单击带入代码");
    assert_eq!(f.exchange, "SHFE", "单击带入交易所");

    // 空态占位(无 tick 源)。
    let shared2 = Arc::new(UiShared::new());
    feed_rows(&shared2, demo_rows());
    let harness2 = harness_for(&shared2);
    assert!(
        harness2
            .query_by_label_contains("等待 eTick 订阅")
            .is_some()
    );
}

/// S4 K线窗首帧快照:open_kline → 窗内金样本格图表在场;首帧快照入 baseline
/// (BD-UI-8:快照图入库即视为 review 约定)。
#[test]
fn s10_kline_window_snapshot() {
    let _g = snapshot_lock().lock().unwrap();
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    feed_market_frame(&shared, 2, 4);
    shared.open_kline("rb2605");
    let mut harness = harness_for(&shared);
    let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx); // BD-UI-26:快照与实窗同字体面
    assert!(
        harness
            .query_by_label_contains("K线 rb2605@60s(2 根)")
            .is_some(),
        "kline window header missing"
    );
    harness.run();
    if hit.is_none() {
        return; // 无 CJK 字体环境:快照基线不适用(理由见 s3 同款闸)
    }
    snap(&mut harness, "s10_kline_first_frame");
}

/// S5 交易监控格:TradeSnap 帧 → 委托/成交/持仓读数逐位 + 活动行撤单钮入列。
#[test]
fn s11_trade_monitor_rows() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    let mut active = ek_data::order::OrderData {
        gateway_name: "ctp".to_string(),
        symbol: "rb2605".to_string(),
        exchange: ek_data::constant::Exchange::SHFE,
        orderid: "CTP_0_1".to_string(),
        direction: Some(ek_data::constant::Direction::LONG),
        offset: ek_data::constant::Offset::OPEN,
        price: 102.5,
        volume: 10.0,
        status: ek_data::constant::Status::SUBMITTING,
        ..ek_data::order::OrderData::default()
    };
    let done = ek_data::order::OrderData {
        orderid: "CTP_0_2".to_string(),
        traded: 8.0,
        traded_price: 102.5,
        status: ek_data::constant::Status::ALLTRADED,
        ..active.clone()
    };
    active.traded = 4.0;
    active.status = ek_data::constant::Status::PARTTRADED;
    shared.set_trade_snap(ek_ui::state::TradeSnap {
        orders: vec![active, done],
        trades: vec![ek_data::trade::TradeData {
            gateway_name: "ctp".to_string(),
            symbol: "rb2605".to_string(),
            exchange: ek_data::constant::Exchange::SHFE,
            orderid: "CTP_0_2".to_string(),
            tradeid: "T1".to_string(),
            direction: Some(ek_data::constant::Direction::LONG),
            offset: ek_data::constant::Offset::OPEN,
            price: 102.5,
            volume: 8.0,
            datetime: Some(36_000_000_000_000),
            ..ek_data::trade::TradeData::default()
        }],
        positions: vec![ek_data::position::PositionData {
            gateway_name: "ctp".to_string(),
            symbol: "rb2605".to_string(),
            exchange: ek_data::constant::Exchange::SHFE,
            direction: ek_data::constant::Direction::LONG,
            volume: 18.0,
            avg_price: 102.5,
            ..ek_data::position::PositionData::default()
        }],
        accounts: vec![],
    });
    // 本钉用 step_dt=0.1 建构(kittest 每事件一帧,缺省 0.25s 会把两击间隔拉到
    // 0.5s > egui max_double_click_delay 0.3s,双击检测失真;0.1s 下两击 0.2s,
    // 与真实用户双击同路径。其余断言与 step_dt 无关)。
    let mut harness = Harness::builder()
        .with_size([1280.0, 860.0])
        .with_step_dt(0.1)
        .build(|ctx| render_body(ctx, &shared));
    // 读数与喂入帧逐位一致(get_all_by_label 精确匹配)。
    assert_eq!(harness.get_all_by_label("CTP_0_1").count(), 1, "委托号列");
    assert_eq!(
        harness.get_all_by_label("CTP_0_2").count(),
        2,
        "委托表委托号 + 成交表委托号(T1 挂 CTP_0_2)"
    );
    assert!(harness.query_by_label_contains("PARTTRADED").is_some());
    assert!(harness.query_by_label_contains("ALLTRADED").is_some());
    assert!(harness.query_by_label_contains("SUBMITTING").is_none());
    assert_eq!(
        harness.get_all_by_label("102.5").count(),
        4,
        "委托价×2 + 成交均价×1 + 成交价×1(持仓均价 102.50 非精确串)"
    );
    assert!(harness.query_by_label_contains("T1").is_some());
    // 撤单钮只在活动行(PARTTRADED 行有,SUBMITTING 已转 PARTTRADED;ALLTRADED 行无)。
    let cancel_label = format!("{} CTP_0_1", ek_ui::render::BTN_CANCEL_ORDER);
    harness.get_by_label(&cancel_label).click_accesskit();
    harness.run();
    let cmds = shared.drain_cmds();
    assert_eq!(cmds.len(), 1);
    assert!(
        matches!(
            &cmds[0],
            ek_ui::state::UiCmd::CancelOrder { orderid, .. } if orderid == "CTP_0_1"
        ),
        "cancel cmd mismatch: {cmds:?}"
    );
    // 委托行双击 = 撤单(Python OrderMonitor itemDoubleClicked→cancel_order 等价)。
    // Node 借用 harness 树,先取 rect 再驱动事件(借用检查)。
    let pos = {
        let row = harness.get_all_by_label("rb2605").next().unwrap();
        row.rect().center()
    };
    harness.hover_at(pos);
    harness.step();
    for pressed in [true, false] {
        harness.event(egui::Event::PointerButton {
            pos,
            button: egui::PointerButton::Primary,
            pressed,
            modifiers: egui::Modifiers::default(),
        });
    }
    harness.step();
    assert!(
        shared.drain_cmds().is_empty(),
        "单击委托行不得入列(Python 同口径)"
    );
    for pressed in [true, false] {
        harness.event(egui::Event::PointerButton {
            pos,
            button: egui::PointerButton::Primary,
            pressed,
            modifiers: egui::Modifiers::default(),
        });
    }
    harness.step();
    let cmds2 = shared.drain_cmds();
    assert!(
        matches!(&cmds2[..], [ek_ui::state::UiCmd::CancelOrder { orderid, .. }] if orderid == "CTP_0_1"),
        "双击撤单入列失败: {cmds2:?}"
    );
}

/// v4 下单面板(OrderForm 面):好单 → SendOrder 入列逐位;坏价 → 红字 + 不入列。
/// (票据 TicketForm 已退役;解析断言另有 ui_state r7。)
#[test]
fn s12_order_panel_submit() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    shared.set_order_form(ek_ui::state::OrderForm {
        exchange: "SHFE".to_string(),
        symbol: "rb2605".to_string(),
        direction: ek_data::constant::Direction::LONG,
        offset: ek_data::constant::Offset::OPEN,
        order_type: ek_data::constant::OrderType::LIMIT,
        price: "102.5".to_string(),
        volume: "10".to_string(),
        gateway: "ctp".to_string(),
        follow_price: false,
        auto_split: true,
    });
    let mut harness = harness_for(&shared);
    // 「委托」两面同名(Python 口径:下单钮 = 委托,tab = 委托;表题已撤,
    // 第五轮审计)→ get_by_label 必炸;显式钉两面在场,点先渲染的下单钮。
    let order_nodes: Vec<_> = harness
        .get_all_by_label(ek_ui::render::BTN_SEND_ORDER)
        .collect();
    assert_eq!(order_nodes.len(), 2, "委托钮 + 委托 tab");
    order_nodes[0].click_accesskit();
    harness.run();
    let cmds = shared.drain_cmds();
    assert_eq!(cmds.len(), 1);
    match &cmds[0] {
        ek_ui::state::UiCmd::SendOrder { req, gateway, mode } => {
            assert_eq!(req.symbol, "rb2605");
            assert_eq!(req.exchange, ek_data::constant::Exchange::SHFE);
            assert_eq!(req.price, 102.5);
            assert_eq!(req.volume, 10.0);
            assert_eq!(req.direction, ek_data::constant::Direction::LONG);
            assert_eq!(gateway, "ctp", "网关选择子随单入列(S6)");
            assert_eq!(
                *mode,
                ek_oms::LockMode::Auto,
                "自动开平默认勾选 = Auto(s22 钉勾选翻转)"
            );
        }
        other => panic!("expected SendOrder, got {other:?}"),
    }
    // 坏价:不入列 + 红字双面(label 点击帧 + 日志跨帧)。
    let mut bad = shared.order_form();
    bad.price = "abc".to_string();
    shared.set_order_form(bad);
    harness.run();
    let bad_nodes: Vec<_> = harness
        .get_all_by_label(ek_ui::render::BTN_SEND_ORDER)
        .collect();
    bad_nodes[0].click_accesskit();
    harness.step(); // 点击帧即停(run() 的后续空闲帧会撤走单帧红字)
    assert!(shared.drain_cmds().is_empty(), "bad form must not enqueue");
    assert!(harness.query_by_label_contains("价格无效").is_some());
    assert!(
        shared
            .logs_snapshot()
            .0
            .iter()
            .any(|l| l.contains("价格无效")),
        "order parse error log missing"
    );
}

/// 0.1.1 BD-UI-30 s22:「自动开平」勾选 → mode 逐位钉(默认勾选 = Auto;
/// 取消勾选 = None 旁路拆单器;勾选双向可逆)。
#[test]
fn s22_auto_split_checkbox_mode() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    shared.set_order_form(ek_ui::state::OrderForm {
        exchange: "SHFE".to_string(),
        symbol: "rb2605".to_string(),
        price: "102.5".to_string(),
        volume: "2".to_string(),
        gateway: "ctp".to_string(),
        follow_price: false,
        ..Default::default()
    });
    let mut harness = harness_for(&shared);
    // 勾选在场(开平行旁,默认已勾)。
    harness.get_by_label("自动开平").click_accesskit();
    harness.run();
    let order_nodes: Vec<_> = harness
        .get_all_by_label(ek_ui::render::BTN_SEND_ORDER)
        .collect();
    order_nodes[0].click_accesskit();
    harness.step();
    let cmds = shared.drain_cmds();
    assert!(
        matches!(
            &cmds[..],
            [ek_ui::state::UiCmd::SendOrder {
                mode: ek_oms::LockMode::None,
                ..
            }]
        ),
        "取消勾选后 mode 应为 None: {cmds:?}"
    );
    // 再勾回 → Auto(勾选双向;表单持久化经 set_order_form)。
    harness.get_by_label("自动开平").click_accesskit();
    harness.run();
    let order_nodes: Vec<_> = harness
        .get_all_by_label(ek_ui::render::BTN_SEND_ORDER)
        .collect();
    order_nodes[0].click_accesskit();
    harness.step();
    let cmds = shared.drain_cmds();
    assert!(
        matches!(
            &cmds[..],
            [ek_ui::state::UiCmd::SendOrder {
                mode: ek_oms::LockMode::Auto,
                ..
            }]
        ),
        "再勾选后 mode 应为 Auto: {cmds:?}"
    );
}

/// 0.2.0 BD-UI-31 s23:表格行选中钉 —— 委托行首格单击 = 选中(每表一行,
/// 换行即换选);成交行首格单击 = 选中(成交表独立槽,不挤委托表选中)。
#[test]
fn s23_row_selection() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    shared.set_trade_snap(ek_ui::state::TradeSnap {
        orders: vec![
            ek_data::order::OrderData {
                gateway_name: "ctp".to_string(),
                symbol: "rb2605".to_string(),
                exchange: ek_data::constant::Exchange::SHFE,
                orderid: "CTP_0_1".to_string(),
                order_type: ek_data::constant::OrderType::LIMIT,
                direction: Some(ek_data::constant::Direction::LONG),
                offset: ek_data::constant::Offset::OPEN,
                price: 101.0,
                volume: 2.0,
                traded: 0.0,
                traded_price: 0.0,
                status: ek_data::constant::Status::SUBMITTING,
                status_msg: String::new(),
                name: String::new(),
                reference: "P1".to_string(),
                datetime: Some(36_000_000_000_000),
            },
            ek_data::order::OrderData {
                gateway_name: "ctp".to_string(),
                symbol: "rb2605".to_string(),
                exchange: ek_data::constant::Exchange::SHFE,
                orderid: "CTP_1_1".to_string(),
                order_type: ek_data::constant::OrderType::LIMIT,
                direction: Some(ek_data::constant::Direction::SHORT),
                offset: ek_data::constant::Offset::CLOSE,
                price: 102.0,
                volume: 1.0,
                traded: 0.0,
                traded_price: 0.0,
                status: ek_data::constant::Status::SUBMITTING,
                status_msg: String::new(),
                name: String::new(),
                reference: "P2".to_string(),
                datetime: Some(37_000_000_000_000),
            },
        ],
        trades: vec![ek_data::trade::TradeData {
            gateway_name: "ctp".to_string(),
            symbol: "rb2605".to_string(),
            exchange: ek_data::constant::Exchange::SHFE,
            tradeid: "T1".to_string(),
            orderid: "CTP_0_1".to_string(),
            direction: Some(ek_data::constant::Direction::LONG),
            offset: ek_data::constant::Offset::OPEN,
            price: 101.0,
            volume: 2.0,
            traded_amount: 202.0,
            name: String::new(),
            datetime: Some(36_500_000_000_000),
            commission: 0.0,
            slippage: 0.0,
            trade_pnl: 0.0,
        }],
        positions: Vec::new(),
        accounts: Vec::new(),
    });
    let mut harness = harness_for(&shared);

    // 委托行 1 单击 → 选中(rb2605 两行 + 成交行代码列同名,取序 0 = 委托行 1)。
    let rows: Vec<_> = harness.get_all_by_label("rb2605").collect();
    rows[0].click_accesskit();
    harness.run();
    assert!(
        shared.row_selected("orders", "ctp.CTP_0_1"),
        "委托行首格单击应选中该行"
    );

    // 委托行 2 单击 → 换选(每表一行;行 1 落选)。
    let row2: Vec<_> = harness.get_all_by_label("rb2605").collect();
    row2[1].click_accesskit();
    harness.run();
    assert!(shared.row_selected("orders", "ctp.CTP_1_1"));
    assert!(!shared.row_selected("orders", "ctp.CTP_0_1"));

    // 成交行首格单击 → 成交表独立槽选中(不挤委托表)。
    harness.get_by_label("T1").click_accesskit();
    harness.run();
    assert!(shared.row_selected("trades", "ctp.T1"));
    assert!(shared.row_selected("orders", "ctp.CTP_1_1"));
}

/// 账户面板(多账号面,规划 §5.B / §5.C + 0.2.18 四态):
/// **『添加账户』只写清册(零指令)**,『连接』才下发(`UiCmd::Connect` 一条做完
/// `svctl.start` + `{gw}.configure`)—— 旧口径"添加即一键装配两令"作废(规划裁定「添加 ≠ 连接」)。
/// 单源纪律钉:**无源且无在场账户 → 首个账户自动指定为元账号(md 前置保留)**;
/// 第二账户按纯 TD 装配(md 位清空,**留档与下发同一份**),元账号不被抢走。
/// 订阅钮仍不在本窗(用户裁定)——订阅面见 s18 查询合约窗钉。
#[test]
fn s13_account_add_enqueue() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();
    // 面板在场,配置弹窗未开(Commit B:『添加账户』= 开弹窗,不再内嵌一张共享表单)。
    assert!(
        harness
            .query_by_label(ek_ui::render::BTN_ACCOUNT_ADD)
            .is_some(),
        "账户面板添加钮在场"
    );
    assert!(!shared.account_edit_open(), "初始:配置弹窗未开");
    assert!(
        harness
            .query_by_label_contains("订阅不在本窗定死")
            .is_none(),
        "Commit B:订阅口径句挪进**配置弹窗**,账户面板不再内嵌表单"
    );
    harness
        .get_by_label(ek_ui::render::BTN_ACCOUNT_ADD)
        .click_accesskit();
    harness.run();
    assert!(
        shared.account_edit_open(),
        "『添加账户』= 开配置弹窗(Commit B 口径)"
    );
    assert!(
        harness.query_by_label("保存").is_some() && harness.query_by_label("保存并连接").is_some(),
        "弹窗须给『保存』与『保存并连接』两个出口(规划 §3.3 四态)"
    );
    assert!(
        harness
            .query_by_label_contains("订阅不在本窗定死")
            .is_some(),
        "订阅撤出配置窗的口径句在场(弹窗内)"
    );
    assert!(shared.accounts().is_empty(), "开弹窗本身不写清册");
    assert!(shared.drain_cmds().is_empty(), "开弹窗不下发任何指令");

    // 弹窗打开后填草稿(开窗会重置草稿位,开窗前填的会被覆盖)。
    shared.set_ctp_form(ek_ui::state::CtpForm {
        broker_id: "9999".to_string(),
        td_addr: "tcp://182.254.243.31:30001".to_string(),
        td_user: "278153".to_string(),
        td_password: "pw-secret".to_string(),
        td_auth_code: "0000000000000000".to_string(),
        td_app_id: "app".to_string(),
        md_addr: "tcp://182.254.243.31:30011".to_string(),
        md_user: String::new(),
        md_password: String::new(),
        symbols: String::new(),
        ..Default::default() // 0.2.12 收录勾选:取缺省(只期货)
    });
    harness.run();
    click_dialog(&mut harness, "保存");
    // ① 四态分离第一钉:『保存』**不下发任何指令**(旧口径"添加即一键装配两令"作废)。
    let cmds = shared.drain_cmds();
    assert!(
        cmds.is_empty(),
        "保存只写清册,不得下发装配/凭据两令: {cmds:?}"
    );
    assert_eq!(shared.accounts().len(), 1, "账户清册落一行");
    assert_eq!(shared.accounts()[0].gateway, "ctp_9999_278153");
    assert_eq!(
        shared.accounts()[0].stage,
        ek_ui::state::AccountStage::NotAssembled,
        "添加后 = 未装配(不是装配中,更不是已装配)"
    );
    assert_eq!(
        shared.md_source(),
        "ctp_9999_278153",
        "无源 + 无在场账户 → 新账户即元账号(§5.C 在添加这一刻定源)"
    );
    assert!(shared.accounts()[0].md_source, "行内元账号标记同步落位");
    assert_eq!(
        shared.accounts()[0].form.md_addr,
        "tcp://182.254.243.31:30011",
        "首个账户 = 元账号 → md 前置必须留档(清掉它 = 它自己也永远收不到 tick)"
    );
    // ② 界面给出『连接』入口(状态列「未装配」旁的动作位)。
    assert!(
        harness.query_by_label("连接 278153").is_some(),
        "未装配行须有『连接』钮(四态分离的入口)"
    );
    harness.get_by_label("连接 278153").click_accesskit();
    harness.run();
    let cmds = shared.drain_cmds();
    assert_eq!(cmds.len(), 1, "『连接』= 一条 Connect 令: {cmds:?}");
    match &cmds[0] {
        UiCmd::Connect { gateway, form } => {
            assert_eq!(gateway, "ctp_9999_278153", "连接随派生名寻址");
            assert_eq!(form.broker_id, "9999");
            assert_eq!(form.td_addr, "tcp://182.254.243.31:30001");
            assert_eq!(form.td_user, "278153");
            assert_eq!(
                form.td_password, "pw-secret",
                "密码原值随表单入列(不回显在界面)"
            );
            assert_eq!(
                form.md_addr, "tcp://182.254.243.31:30011",
                "元账号连接时 md 前置保留(§5.C)"
            );
            let req = form.to_configure_req();
            assert_eq!(req.td_app_id, "app");
            assert!(req.md_user.is_empty(), "回落归插件侧裁决,表单不预处理");
        }
        other => panic!("expected Connect, got {other:?}"),
    }

    // 第二账户(同 broker 不同账号):已有元账号 → 按纯 TD 装配,md 位清空,
    // 元账号不被抢走(单源纪律 §5.C)。
    // 注:『保存』后草稿位凭据已清空(s34 口径),所以这里必须自己填 td 前置与密码;
    // broker / AppID / AuthCode 由 `draft_from_existing()` 沿用(规划 Q20 缓解)。
    click_account_add(&mut harness);
    harness.run();
    shared.set_ctp_form(ek_ui::state::CtpForm {
        td_user: "220380".to_string(),
        td_addr: "tcp://182.254.243.31:30001".to_string(),
        td_password: "pw-secret-2".to_string(),
        ..shared.ctp_form()
    });
    harness.run();
    click_dialog(&mut harness, "保存");
    let cmds2 = shared.drain_cmds();
    assert!(cmds2.is_empty(), "第二账户同样『保存不下发』: {cmds2:?}");
    assert_eq!(
        shared.accounts()[1].form.md_addr,
        "",
        "非源账户 md 前置在**留档**里就清空(旧口径只清下发不清留档 → 编辑页会带一根用不上的行情前置)"
    );
    harness.get_by_label("连接 220380").click_accesskit();
    harness.run();
    let cmds2 = shared.drain_cmds();
    assert_eq!(cmds2.len(), 1, "『连接』一条令: {cmds2:?}");
    match &cmds2[0] {
        UiCmd::Connect { gateway, form } => {
            assert_eq!(
                gateway, "ctp_9999_220380",
                "同 broker 不同账号 = 不同派生名"
            );
            assert_eq!(form.td_user, "220380", "TD 会话各账号独立");
            assert!(
                form.md_addr.is_empty(),
                "非源账户 md 前置一律留空(单源纪律 §5.C)"
            );
        }
        other => panic!("expected Connect, got {other:?}"),
    }
    assert_eq!(
        shared.md_source(),
        "ctp_9999_278153",
        "元账号不被第二账户抢走"
    );
    assert_eq!(shared.accounts().len(), 2, "清册两行");
}

/// S6 资金格(右下组默认 tab):TradeSnap.accounts → 账户行读数逐位;空帧占位行。
#[test]
fn s14_accounts_rows() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    shared.set_trade_snap(ek_ui::state::TradeSnap {
        accounts: vec![ek_data::account::AccountData {
            gateway_name: "ctp".to_string(),
            accountid: "278153".to_string(),
            balance: 1_000_000.0,
            frozen: 12_345.0,
        }],
        ..ek_ui::state::TradeSnap::default()
    });
    let harness = harness_for(&shared);
    assert_eq!(harness.get_all_by_label("278153").count(), 1, "账户列");
    assert!(
        harness.query_by_label_contains("1000000.00").is_some(),
        "权益"
    );
    assert!(
        harness.query_by_label_contains("12345.00").is_some(),
        "冻结"
    );
    assert!(
        harness.query_by_label_contains("987655.00").is_some(),
        "可用 = balance-frozen"
    );
    // 空帧占位。
    let shared2 = Arc::new(UiShared::new());
    let h2 = harness_for(&shared2);
    assert!(h2.query_by_label_contains("等网关 eAccount").is_some());
}

/// v4 菜单 → 弹窗开合(BD-UI-31 R1:一键直达 —— 菜单钮即入口,单击直接弹窗,
/// 不再有下拉层;运维/网关/查询合约三入口走 UI 面,非直改状态)。
#[test]
fn s17_menu_opens_windows() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    let mut harness = harness_for(&shared);

    // 默认全关。
    assert!(!shared.ops_window_open());
    assert!(!shared.accounts_window_open());
    assert!(!shared.contracts_window_open());

    // 运维钮单击 → 窗开(窗内容面证)。
    harness
        .get_by_label(ek_ui::render::MENU_OPS)
        .click_accesskit();
    harness.run();
    assert!(shared.ops_window_open());
    // 0.2.10(BD-UI-38):「流控查询」钮随 sim 退场撤除——窗开证据改由
    // 运维表固定表头行证(demo_rows 快照在场)。
    assert!(harness.query_by_label_contains("插件").is_some());

    // 账户钮单击 → 账户面板(0.2.15 多账号面:旧「网关」窗退役收纳于此)。
    harness
        .get_by_label(ek_ui::render::MENU_ACCOUNTS)
        .click_accesskit();
    harness.run();
    assert!(shared.accounts_window_open());
    assert!(
        harness
            .query_by_label(ek_ui::render::BTN_ACCOUNT_ADD)
            .is_some(),
        "账户面板表单在场"
    );

    // 查询合约钮单击 → 查询合约窗。
    harness
        .get_by_label(ek_ui::render::MENU_CONTRACTS)
        .click_accesskit();
    harness.run();
    assert!(shared.contracts_window_open());
    assert!(harness.query_by_label_contains("代码或交易所").is_some());
}

/// v4 查询合约窗(Python ContractManager 等价):勾选 → 订阅入列(裸代码);
/// 查询串过滤;空勾选不入列 + 日志。
#[test]
fn s18_contracts_window_query_select_subscribe() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    // 订阅路由前置:先指定行情源(未指定 = 订阅不发,规划 §5.C)。
    let gw = seed_md_source(&shared, "9999", "278153");
    shared.set_contracts_window_open(true);
    shared.update_contract(&contract(
        ek_data::constant::Exchange::SHFE,
        "rb2605",
        "螺纹钢2605",
    ));
    shared.update_contract(&contract(
        ek_data::constant::Exchange::DCE,
        "a2709",
        "黄玉米2709",
    ));

    let mut harness = harness_for(&shared);
    // 勾选钮标签 = en_symbol(可定位)。
    assert!(harness.query_by_label("DCE.a2709").is_some());
    assert!(harness.query_by_label("SHFE.rb2605").is_some());

    // 勾选 → 订阅选中 → CtpSubscribe 裸代码入列。
    harness.get_by_label("SHFE.rb2605").click_accesskit();
    harness.run();
    assert_eq!(shared.selected_contracts(), vec!["SHFE.rb2605".to_string()]);
    harness
        .get_by_label(ek_ui::render::BTN_SUBSCRIBE_SELECTED)
        .click_accesskit();
    harness.run();
    let cmds = shared.drain_cmds();
    assert_eq!(cmds.len(), 1);
    match &cmds[0] {
        UiCmd::CtpSubscribe { gateway, symbols } => {
            assert_eq!(gateway, &gw, "订阅一律路由到行情元账号");
            assert_eq!(symbols, &vec!["rb2605".to_string()], "en_symbol → 裸代码");
        }
        other => panic!("expected CtpSubscribe, got {other:?}"),
    }

    // 查询串过滤:DCE → 只剩 DCE 行。
    shared.set_contract_query("DCE");
    harness.run();
    assert!(harness.query_by_label("DCE.a2709").is_some());
    assert!(
        harness.query_by_label("SHFE.rb2605").is_none(),
        "过滤后不上屏"
    );

    // 取消勾选 → 空订阅不入列 + 日志口径。
    shared.set_contract_query(String::new());
    harness.run();
    harness.get_by_label("SHFE.rb2605").click_accesskit(); // 取消
    harness.run();
    assert!(shared.selected_contracts().is_empty());
    harness
        .get_by_label(ek_ui::render::BTN_SUBSCRIBE_SELECTED)
        .click_accesskit();
    harness.run();
    assert!(shared.drain_cmds().is_empty(), "空勾选不得入列");
    assert!(
        shared
            .logs_snapshot()
            .0
            .iter()
            .any(|l| l.contains("未勾选任何合约")),
        "空勾选日志口径缺位"
    );
}

/// v4 全撤钮:活动委托逐个走撤单 ask(界面侧扇出;终态行不发)。
#[test]
fn s19_cancel_all_fanout() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    let mk = |id: &str, status: ek_data::constant::Status| ek_data::order::OrderData {
        gateway_name: "ctp".to_string(),
        symbol: "rb2605".to_string(),
        exchange: ek_data::constant::Exchange::SHFE,
        orderid: id.to_string(),
        status,
        ..ek_data::order::OrderData::default()
    };
    shared.set_trade_snap(ek_ui::state::TradeSnap {
        orders: vec![
            mk("A1", ek_data::constant::Status::NOTTRADED),
            mk("A2", ek_data::constant::Status::PARTTRADED),
            mk("T1", ek_data::constant::Status::ALLTRADED),
            mk("T2", ek_data::constant::Status::CANCELLED),
        ],
        ..ek_ui::state::TradeSnap::default()
    });
    let mut harness = harness_for(&shared);
    harness
        .get_by_label(ek_ui::render::BTN_CANCEL_ALL)
        .click_accesskit();
    harness.run();
    let cmds = shared.drain_cmds();
    assert_eq!(cmds.len(), 2, "只活动委托扇出");
    let ids: Vec<String> = cmds
        .iter()
        .map(|c| match c {
            ek_ui::state::UiCmd::CancelOrder { orderid, .. } => orderid.clone(),
            other => panic!("expected CancelOrder, got {other:?}"),
        })
        .collect();
    assert_eq!(ids, vec!["A1".to_string(), "A2".to_string()]);
    assert!(
        shared
            .logs_snapshot()
            .0
            .iter()
            .any(|l| l.contains("全撤入列 2 笔")),
        "全撤日志留痕"
    );
}

/// BD-UI-26 CJK 渲染专项快照:乱码根因(epaint 缺省字体组无 CJK 字形)的
/// 视觉钉 —— 全汉字密集页(菜单/带标题/表头/按钮/日志行)入 baseline,
/// 豆腐块再现即快照 diff 爆红。
#[test]
fn s16_cjk_font_snapshot() {
    let _g = snapshot_lock().lock().unwrap();
    let shared = Arc::new(UiShared::new());
    feed_snapshot(
        &shared,
        vec![("svctl".to_string(), vec!["svctl.list".to_string()])],
    );
    feed_rows(
        &shared,
        vec![ek_station::wire::PlugRow {
            name: "观测".to_string(),
            version: "0.1.0".to_string(),
            state: "运行中".to_string(),
            deps: vec!["依赖甲".to_string()],
            spilled: 0,
            panics: 0,
        }],
    );
    shared.set_ops_window_open(true);
    shared.set_bottomright_tab(ek_ui::state::BottomRightTab::Logs);
    shared.update_tick(&tick(
        ek_data::constant::Exchange::SHFE,
        "rb2605",
        "螺纹钢2605",
        101.0,
        1.25,
    ));
    shared.log_line(
        "[INFO][字体] 观测台插件状态版本依赖行情委托成交持仓资金冻结订阅授权密码查询合约运维撤单",
    );
    let mut harness = harness_for(&shared);
    let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
    assert!(
        hit.is_some(),
        "验收环境必须有 CJK 候选字体(乱码环境不得跑本钉)"
    );
    harness.run();
    snap(&mut harness, "s16_cjk_font");
}

/// 订阅入口一钉:下单面板代码框回车 = CtpSubscribe 入列(Python TradingWidget
/// returnPressed → set_en_symbol 等价;BD-UI-27 订阅两入口之一)。
/// 代码框 = 全 UI 首个 TextInput(下单面板先画;价格/数量框在后,ComboBox 非 TextInput)。
#[test]
fn s20_enter_subscribes_symbol() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    // 订阅路由前置:先指定行情源(未指定 = 订阅不发,规划 §5.C)。
    let gw = seed_md_source(&shared, "9999", "278153");
    let mut harness = harness_for(&shared);
    harness.run();
    // Node 借用 harness 树:先取 accesskit id 再驱动事件(借用检查)。
    let target = {
        let sym = harness
            .get_all_by_role(egui::accesskit::Role::TextInput)
            .next()
            .expect("代码框 TextInput 在场");
        sym.accesskit_node().id()
    };
    harness.event(egui::Event::AccessKitActionRequest(
        egui::accesskit::ActionRequest {
            action: egui::accesskit::Action::Focus,
            target,
            data: None,
        },
    ));
    harness.step();
    harness.event(egui::Event::Text("rb2605".to_string()));
    harness.step();
    harness.key_press(egui::Key::Enter);
    harness.step();
    let cmds = shared.drain_cmds();
    assert_eq!(cmds.len(), 1, "回车订阅恰一次入列: {cmds:?}");
    match &cmds[0] {
        ek_ui::state::UiCmd::CtpSubscribe { gateway, symbols } => {
            assert_eq!(gateway, &gw, "订阅一律路由到行情元账号");
            assert_eq!(symbols, &vec!["rb2605".to_string()], "裸代码订阅");
        }
        other => panic!("expected CtpSubscribe, got {other:?}"),
    }
    assert!(
        shared
            .logs_snapshot()
            .0
            .iter()
            .any(|l| l.contains("代码回车订阅")),
        "回车订阅日志留痕"
    );
    // 守卫:空代码回车不入列。
    shared.set_order_form(ek_ui::state::OrderForm::default());
    harness.run();
    harness.key_press(egui::Key::Enter);
    harness.step();
    assert!(shared.drain_cmds().is_empty(), "空代码回车不得入列");
}

/// BD-UI-28④ 表列逐值补全的视觉钉:满表(委托 17 列/成交 12 列/持仓 16 列)
/// 入 baseline —— 列面变化或 Grid sizing 叠行(BD-UI-27)再现即快照 diff 爆红。
#[test]
fn s21_full_tables_snapshot() {
    let _g = snapshot_lock().lock().unwrap();
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    shared.update_tick(&tick(
        ek_data::constant::Exchange::SHFE,
        "rb2605",
        "螺纹钢2605",
        101.0,
        1.25,
    ));
    shared.set_trade_snap(ek_ui::state::TradeSnap {
        orders: vec![
            ek_data::order::OrderData {
                gateway_name: "ctp".to_string(),
                symbol: "rb2605".to_string(),
                exchange: ek_data::constant::Exchange::SHFE,
                orderid: "CTP_7701".to_string(),
                order_type: ek_data::constant::OrderType::LIMIT,
                direction: Some(ek_data::constant::Direction::LONG),
                offset: ek_data::constant::Offset::OPEN,
                price: 101.0,
                volume: 2.0,
                traded: 1.0,
                traded_price: 101.0,
                status: ek_data::constant::Status::PARTTRADED,
                status_msg: "部分成交".to_string(),
                name: "螺纹钢2605".to_string(),
                reference: "LOCAL-1".to_string(),
                datetime: Some(36_000_000_000_000),
            },
            ek_data::order::OrderData {
                gateway_name: "ctp".to_string(),
                symbol: "rb2605".to_string(),
                exchange: ek_data::constant::Exchange::SHFE,
                orderid: "CTP_7702".to_string(),
                order_type: ek_data::constant::OrderType::LIMIT,
                direction: Some(ek_data::constant::Direction::SHORT),
                offset: ek_data::constant::Offset::CLOSE,
                price: 102.0,
                volume: 1.0,
                traded: 0.0,
                traded_price: 0.0,
                status: ek_data::constant::Status::REJECTED,
                status_msg: "资金不足".to_string(),
                name: "螺纹钢2605".to_string(),
                datetime: Some(36_000_000_000_000),
                ..ek_data::order::OrderData::default()
            },
        ],
        trades: vec![ek_data::trade::TradeData {
            gateway_name: "ctp".to_string(),
            symbol: "rb2605".to_string(),
            exchange: ek_data::constant::Exchange::SHFE,
            orderid: "CTP_7701".to_string(),
            tradeid: "T-8801".to_string(),
            direction: Some(ek_data::constant::Direction::LONG),
            offset: ek_data::constant::Offset::OPEN,
            price: 101.0,
            volume: 1.0,
            traded_amount: 101.0,
            name: "螺纹钢2605".to_string(),
            datetime: Some(36_000_000_000_000),
            ..ek_data::trade::TradeData::default()
        }],
        positions: vec![ek_data::position::PositionData {
            gateway_name: "ctp".to_string(),
            symbol: "rb2605".to_string(),
            exchange: ek_data::constant::Exchange::SHFE,
            direction: ek_data::constant::Direction::LONG,
            volume: 1.0,
            yd_volume: 0.0,
            can_use_volume: 1.0,
            frozen: 0.0,
            on_road_volume: 0.0,
            market_value: 101.0,
            price: 101.0,
            avg_price: 101.0,
            last_price: 101.0,
            pnl: 0.0,
            profit_rate: 0.0,
            name: "螺纹钢2605".to_string(),
        }],
        accounts: vec![],
    });
    let mut harness = harness_for(&shared);
    let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx); // BD-UI-26 同款闸
    assert!(hit.is_some(), "验收环境必须有 CJK 候选字体");
    harness.run();
    snap(&mut harness, "s21_full_tables");
}

/// s24(0.2.3 回归钉,Immediate 路径守卫,「查询合约窗关不掉」根因面):
/// egui 销毁判据 = 子 viewport 本拍未 used(源码 context.rs「never used this
/// pass」)——show_viewport_immediate 每拍无条件调用 = 每拍 used = OS 窗永不
/// 被销毁(X 点了被重新用起来)。注入 recording renderer(模拟支持多窗的
/// 后端:eframe 桌面同款 set_immediate_viewport_renderer)+ embed=false,
/// 钉三拍:位关不 show / 位开恰 show 一次 / 关闭翻位后不再 show。
/// (Embedded 回退面靠 .open 旗标本就正确——0.2.2 的 21 绿正是被回退掩盖,
/// 此钉补的就是无覆盖的 Immediate 面。)
#[test]
fn s24_contracts_viewport_guard_immediate_path() {
    use std::cell::RefCell;
    use std::rc::Rc;

    let seen: Rc<RefCell<Vec<String>>> = Rc::new(RefCell::new(Vec::new()));
    let sink = Rc::clone(&seen);
    egui::Context::set_immediate_viewport_renderer(move |_ctx, vp| {
        // 记录窗标题;cb 必须在一次性临时 Context 里跑(egui 要求 renderer 调
        // cb,而 cb 的 CentralPanel 不能撞 harness 根视口已开的面板纪律——
        // 真实 eframe 是用子窗 raw input 嵌套 run,此处同构简化)。
        sink.borrow_mut()
            .push(vp.builder.title.clone().unwrap_or_default());
        let tmp = egui::Context::default();
        let mut vp = vp;
        let _out = tmp.run(egui::RawInput::default(), |c| {
            (vp.viewport_ui_cb)(c);
        });
    });

    let shared = Arc::new(UiShared::new());

    // 计数走增量口径(kittest build 内部拍数不钉死,钉的是"位关=零 show /
    // 位开=有 show / 翻负=零 show"的守卫语义)。
    // 拍 1:状态位关 → 必须零 show(0.2.2 病灶:每拍都 show)。
    let n0 = seen.borrow().len();
    let _h0 = Harness::builder().with_size([1280.0, 860.0]).build(|ctx| {
        ctx.set_embed_viewports(false); // 模拟支持多窗的后端
        render_body(ctx, &shared);
    });
    assert_eq!(
        seen.borrow().len(),
        n0,
        "状态位关却调了 show_viewport_immediate = 窗永不被 GC(关不掉根因)"
    );

    // 拍 2:状态位开 → 必须有 show。
    shared.set_contracts_window_open(true);
    let n1 = seen.borrow().len();
    let _h1 = Harness::builder().with_size([1280.0, 860.0]).build(|ctx| {
        ctx.set_embed_viewports(false);
        render_body(ctx, &shared);
    });
    assert!(seen.borrow().len() > n1, "状态位开必须 show(否则窗出不来)");
    assert!(
        seen.borrow()[n1..].iter().all(|t| t == "查询合约窗"),
        "Immediate 面 show 的窗必须是查询合约窗"
    );

    // 拍 3:模拟 OS X 之后(cb 已把位翻负)→ 不得再 show(再 show = 永不销毁)。
    shared.set_contracts_window_open(false);
    let n2 = seen.borrow().len();
    let _h2 = Harness::builder().with_size([1280.0, 860.0]).build(|ctx| {
        ctx.set_embed_viewports(false);
        render_body(ctx, &shared);
    });
    assert_eq!(
        seen.borrow().len(),
        n2,
        "关闭后不得再 show(再 show = 窗永不销毁)"
    );
}

/// s25(BD-UI-34 K线窗两钉):
/// - ESC 快捷关闭(Embedded 回退面可测):开 K线窗 → 按 ESC → 出 kline 列表
///   (Immediate 面同构:body 返回 true → closed → close_kline)。
/// - 默认最大化(Immediate 面 builder 钉):recording renderer 捕获
///   builder.maximized = Some(true)(OS 窗属性,回退面无此概念不测)。
#[test]
fn s25_kline_esc_close_and_maximized() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    feed_market_frame(&shared, 2, 4);
    shared.open_kline("rb2605");
    let mut harness = harness_for(&shared);
    harness.run();
    assert!(shared.kline_symbols().contains(&"rb2605".to_string()));
    harness.input_mut().events.push(egui::Event::Key {
        key: egui::Key::Escape,
        physical_key: None,
        pressed: true,
        repeat: false,
        modifiers: egui::Modifiers::NONE,
    });
    harness.run();
    assert!(
        shared.kline_symbols().is_empty(),
        "ESC 应关闭 K线窗(Embedded 面;Immediate 面同构由 body 返回 true 走同一 close_kline)"
    );

    use std::cell::RefCell;
    use std::rc::Rc;
    type Seen = Rc<RefCell<Vec<(String, Option<bool>)>>>;
    let seen: Seen = Rc::new(RefCell::new(Vec::new()));
    let sink = Rc::clone(&seen);
    egui::Context::set_immediate_viewport_renderer(move |_ctx, vp| {
        sink.borrow_mut().push((
            vp.builder.title.clone().unwrap_or_default(),
            vp.builder.maximized,
        ));
        let tmp = egui::Context::default();
        let mut vp = vp;
        let _out = tmp.run(egui::RawInput::default(), |c| {
            (vp.viewport_ui_cb)(c);
        });
    });
    let shared2 = Arc::new(UiShared::new());
    shared2.open_kline("rb2605");
    let _h = Harness::builder().with_size([1280.0, 860.0]).build(|ctx| {
        ctx.set_embed_viewports(false);
        render_body(ctx, &shared2);
    });
    let hits: Vec<(String, Option<bool>)> = seen
        .borrow()
        .iter()
        .filter(|(t, _)| t.contains("K线"))
        .cloned()
        .collect();
    assert!(!hits.is_empty(), "Immediate 面 K线窗必须 show");
    assert!(
        hits.iter().all(|(_, m)| *m == Some(true)),
        "K线窗 builder 必须默认最大化(BD-UI-34 用户裁定)"
    );
}

/// s26(BD-UI-34 空表口径,0.2.6 修):空态不得缩格——委托/成交/持仓三表在
/// 零数据时,表头 Label 与空态提示必须同时在场(旧版提前 return 把整格缩成
/// 一行字,用户实看报「未成交里表格缩得很小」)。
#[test]
fn s26_empty_tables_keep_headers() {
    let shared = Arc::new(UiShared::new());
    let mut harness = harness_for(&shared);
    harness.run();
    for probe in [
        "(无委托:等网关 send_order)",
        "(无成交)",
        "(无持仓)",
        "成交号",
        "昨仓",
    ] {
        assert!(
            harness.query_by_label_contains(probe).is_some(),
            "空态结构钉:{probe} 必须存在(表头/提示都在)"
        );
    }
}

/// s27(BD-UI-35 缝隙实证钉):滚轮下滚委托表后,表头带上方不得出现数据行
/// 像素(条纹底/文字)。根因 = ScrollArea 内容裁剪 = inner_rect 外扩
/// clip_rect_margin(3px),旧覆盖带从 inner_rect 顶起画 → 滚动行从带上方
/// 3px 余量露出。修 = 带向上/左右扩该余量。此测试滚动后出快照
/// s27_scrolled_orders,像素扫描在闸外做(扫描口径见 CHANGELOG 0.2.8)。
#[test]
fn s27_scrolled_orders_no_peek_above_header() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    let orders: Vec<ek_data::order::OrderData> = (0..12)
        .map(|i| ek_data::order::OrderData {
            gateway_name: "ctp".to_string(),
            symbol: format!("rb{i}"),
            exchange: ek_data::constant::Exchange::SHFE,
            orderid: format!("SCROLL_{i}"),
            order_type: ek_data::constant::OrderType::LIMIT,
            direction: Some(ek_data::constant::Direction::LONG),
            offset: ek_data::constant::Offset::OPEN,
            price: 100.0 + i as f64,
            volume: 1.0,
            traded: 0.0,
            traded_price: 0.0,
            status: ek_data::constant::Status::SUBMITTING,
            status_msg: String::new(),
            name: String::new(),
            reference: String::new(),
            datetime: Some(36_000_000_000_000),
        })
        .collect();
    shared.set_trade_snap(ek_ui::state::TradeSnap {
        orders,
        ..Default::default()
    });
    let mut harness = harness_for(&shared);
    let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx); // BD-UI-26:快照与实窗同字体面
    assert!(hit.is_some(), "验收环境必须有 CJK 候选字体(s16 同款闸)");
    harness.run();
    // 指针移到委托表体中央 → 滚轮下滚 150px(负 delta = 内容上移)。
    harness
        .input_mut()
        .events
        .push(egui::Event::PointerMoved(egui::pos2(500.0, 450.0)));
    harness.input_mut().events.push(egui::Event::MouseWheel {
        unit: egui::MouseWheelUnit::Point,
        delta: egui::vec2(0.0, -150.0),
        modifiers: egui::Modifiers::NONE,
    });
    harness.run();
    snap(&mut harness, "s27_scrolled_orders");
}

/// 账户面板守卫(多账号面,规划 §5.B / D3):
/// ① 元账号连接后锁定(有账户在线 = 切换被拒且口径句上屏);
/// ② 移除守卫(该账户有活跃委托 = 该行不给移除钮,只给提示);
/// ③ 撤干净后守卫解除。
#[test]
fn s28_account_md_lock_and_remove_guard() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    let gw1 = seed_md_source(&shared, "9999", "278153");
    let row2 = ek_ui::state::AccountRow::derive(&ek_ui::state::CtpForm {
        broker_id: "9999".to_string(),
        td_user: "220380".to_string(),
        ..Default::default()
    })
    .expect("派生注册名");
    let gw2 = row2.gateway.clone();
    shared.add_account(row2).expect("第二账户");
    assert_eq!(gw2, "ctp_9999_220380", "同 broker 不同账号 = 不同派生名");

    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();

    // ① 配置阶段:两行都在场,尚未锁定(精确标签 = 账户行;移除钮标签也含派生名,
    // 故用 exact 查询,contains 会两中)。
    assert!(harness.query_by_label(&gw1).is_some(), "账户行 1");
    assert!(harness.query_by_label(&gw2).is_some(), "账户行 2");
    assert!(!shared.accounts_locked(), "无账户在线 = 可指定行情源");

    // ② 模拟 gw1 上线(svctl 快照回填)→ 元账号锁定(D3:连接后不允许切换)。
    let newly = shared.sync_accounts_connected(&[row("svctl"), row(&gw1), row("dm")]);
    assert_eq!(newly, vec![gw1.clone()], "新上线检测只回本轮新连上的");
    assert!(shared.accounts_locked());
    let err = shared.set_md_source(&gw2).unwrap_err();
    assert!(err.contains("锁定"), "连接后切换必须被拒:{err}");
    harness.run();
    assert!(
        harness.query_by_label_contains("行情源已锁定").is_some(),
        "锁定口径句上屏"
    );

    // ③ 移除守卫:gw1 有活跃委托 → 不给移除钮,给提示 + 出口(规划 §5.B / U10)。
    shared.set_trade_snap(ek_ui::state::TradeSnap {
        orders: vec![ek_data::order::OrderData {
            gateway_name: gw1.clone(),
            symbol: "rb2605".to_string(),
            exchange: ek_data::constant::Exchange::SHFE,
            orderid: format!("{gw1}.CTP_1_1"),
            status: ek_data::constant::Status::NOTTRADED,
            ..ek_data::order::OrderData::default()
        }],
        ..ek_ui::state::TradeSnap::default()
    });
    harness.run();
    assert!(
        harness.query_by_label_contains("先撤单才能移除").is_some(),
        "挂单守卫提示上屏"
    );
    assert!(
        harness.query_by_label("先撤 1 笔").is_some(),
        "守卫不能只堵门:须给『先撤 N 笔』出口(规划 U10)"
    );
    assert!(
        harness.query_by_label("移除 278153").is_none(),
        "有挂单的账户不给移除钮(柜台还在跑单而没人能撤 = 观测黑洞)"
    );

    // ④ 撤干净 → 守卫解除,移除钮回到场。
    // Commit B 口径:移除钮用**显示名**(别名/账号),与同一行『编辑 278153』同一口径(规划 U10);
    // 寻址仍走派生注册名(指令里的 `UiCmd::Remove.name` 不变)。
    shared.set_trade_snap(ek_ui::state::TradeSnap::default());
    harness.run();
    assert!(
        harness.query_by_label("移除 278153").is_some(),
        "守卫解除后移除钮回到场(显示名口径)"
    );
    assert!(
        harness.query_by_label(&format!("移除 {gw1}")).is_none(),
        "移除钮不再写派生名(同一行『编辑 278153』与『移除 278153』口径必须一致)"
    );
}

// ============ 多账号面新增钉 s29–s31(方案 docs/ek-ma-integration-test-plan.md §I4)============

/// 两账户夹具:元账号(行情源)+ 纯 TD 账号,双双已装配。回带两个派生注册名。
fn two_accounts(shared: &UiShared) -> (String, String) {
    let gw1 = seed_md_source(shared, "9999", "278153");
    let row2 = ek_ui::state::AccountRow::derive(&ek_ui::state::CtpForm {
        broker_id: "9999".to_string(),
        td_user: "220380".to_string(),
        ..Default::default()
    })
    .expect("派生注册名");
    let gw2 = row2.gateway.clone();
    shared.add_account(row2).expect("第二账户");
    let newly = shared.sync_accounts_connected(&[
        row("svctl"),
        row("dm"),
        row("oms"),
        row(&gw1),
        row(&gw2),
    ]);
    assert_eq!(newly.len(), 2, "两账户都该被检出新上线");
    assert!(shared.accounts_locked(), "有账户在线 = 元账号锁定(D3)");
    (gw1, gw2)
}

/// s29 两账户在场:下单钮账户阶梯 + 手动选**非行情源账户**随单入列;
/// 订阅面仍一律路由到行情元账号(下单选谁都不改订阅目标 = 路由定死在界面侧)。
#[test]
fn s29_two_accounts_order_follows_selected_account() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    let (gw1, gw2) = two_accounts(&shared);
    shared.set_contracts_window_open(true);
    shared.update_contract(&contract(
        ek_data::constant::Exchange::SHFE,
        "rb2605",
        "螺纹钢2605",
    ));
    shared.set_order_form(ek_ui::state::OrderForm {
        exchange: "SHFE".to_string(),
        symbol: "rb2605".to_string(),
        direction: ek_data::constant::Direction::LONG,
        offset: ek_data::constant::Offset::OPEN,
        order_type: ek_data::constant::OrderType::LIMIT,
        price: "102.5".to_string(),
        volume: "1".to_string(),
        gateway: gw2.clone(),
        follow_price: false,
        auto_split: true,
    });

    // ① 账户阶梯钉:表单网关位留空 → 取第一个已连接账户(旧「空 → 字面量 ctp」已退役)。
    let ladder = {
        let mut f = shared.order_form();
        f.gateway = String::new();
        shared.set_order_form(f);
        shared.default_gateway()
    };
    assert_eq!(
        ladder.as_deref(),
        Some(gw1.as_str()),
        "阶梯 = 第一个已连接账户"
    );

    let mut harness = harness_for(&shared);
    harness.run();

    // ② 手动选非行情源账户 → 网关位随单入列(纯 TD 账号发得出去单)。
    let mut f = shared.order_form();
    f.gateway = gw2.clone();
    shared.set_order_form(f);
    harness.run();
    let nodes: Vec<_> = harness
        .get_all_by_label(ek_ui::render::BTN_SEND_ORDER)
        .collect();
    assert_eq!(nodes.len(), 2, "委托钮 + 委托 tab");
    nodes[0].click_accesskit();
    harness.run();
    let cmds = shared.drain_cmds();
    assert_eq!(cmds.len(), 1, "下单一条入列: {cmds:?}");
    match &cmds[0] {
        UiCmd::SendOrder { req, gateway, .. } => {
            assert_eq!(gateway, &gw2, "下单随所选账户(非行情源也发得出去)");
            assert_eq!(req.symbol, "rb2605");
            assert_eq!(req.price, 102.5);
        }
        other => panic!("expected SendOrder, got {other:?}"),
    }

    // ③ 订阅面不因下单所选账户而改道(元账号是唯一 MD 会话)。
    harness.get_by_label("SHFE.rb2605").click_accesskit();
    harness.run();
    harness
        .get_by_label(ek_ui::render::BTN_SUBSCRIBE_SELECTED)
        .click_accesskit();
    harness.run();
    let cmds = shared.drain_cmds();
    assert_eq!(cmds.len(), 1, "订阅一条入列: {cmds:?}");
    match &cmds[0] {
        UiCmd::CtpSubscribe { gateway, symbols } => {
            assert_eq!(gateway, &gw1, "订阅一律路由到行情元账号(下单选的是 {gw2})");
            assert_eq!(symbols, &vec!["rb2605".to_string()]);
        }
        other => panic!("expected CtpSubscribe, got {other:?}"),
    }
}

/// s30 全撤扇出随单走:两账户的活跃委托各自带**自己**的网关入列(不再回落单网关);
/// 终态行不发。
#[test]
fn s30_cancel_all_follows_each_orders_gateway() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    let (gw1, gw2) = two_accounts(&shared);
    let mk = |gw: &str, id: &str, status: ek_data::constant::Status| ek_data::order::OrderData {
        gateway_name: gw.to_string(),
        symbol: "rb2605".to_string(),
        exchange: ek_data::constant::Exchange::SHFE,
        orderid: id.to_string(),
        status,
        ..ek_data::order::OrderData::default()
    };
    shared.set_trade_snap(ek_ui::state::TradeSnap {
        orders: vec![
            mk(&gw1, "A1", ek_data::constant::Status::NOTTRADED),
            mk(&gw1, "A2", ek_data::constant::Status::PARTTRADED),
            mk(&gw2, "B1", ek_data::constant::Status::NOTTRADED),
            mk(&gw2, "B2", ek_data::constant::Status::PARTTRADED),
            mk(&gw1, "T1", ek_data::constant::Status::ALLTRADED),
            mk(&gw2, "T2", ek_data::constant::Status::CANCELLED),
        ],
        ..ek_ui::state::TradeSnap::default()
    });
    let mut harness = harness_for(&shared);
    harness.run();
    harness
        .get_by_label(ek_ui::render::BTN_CANCEL_ALL)
        .click_accesskit();
    harness.run();
    let cmds = shared.drain_cmds();
    assert_eq!(cmds.len(), 4, "只活动委托扇出(终态不发): {cmds:?}");
    let pairs: Vec<(String, String)> = cmds
        .iter()
        .map(|c| match c {
            UiCmd::CancelOrder { orderid, gateway } => (orderid.clone(), gateway.clone()),
            other => panic!("expected CancelOrder, got {other:?}"),
        })
        .collect();
    let to_gw1 = pairs.iter().filter(|(_, g)| g == &gw1).count();
    let to_gw2 = pairs.iter().filter(|(_, g)| g == &gw2).count();
    assert_eq!(to_gw1, 2, "元账号的两笔撤单目标 = 元账号");
    assert_eq!(to_gw2, 2, "纯 TD 账号的两笔撤单目标 = 它自己(撤单随单走)");
    assert!(
        pairs.iter().all(|(id, g)| {
            let own = if id.starts_with('A') { &gw1 } else { &gw2 };
            g == own
        }),
        "逐笔核对:撤单 ask 目标必须等于该单自己的网关:{pairs:?}"
    );
    assert!(
        shared
            .logs_snapshot()
            .0
            .iter()
            .any(|l| l.contains("全撤入列 4 笔")),
        "全撤日志留痕"
    );
}

/// s31 移除在场账户的解锁路径:摘掉行情源账户 → 源位清空(等重新指定)且订阅集保留;
/// 只要还有账户在线就仍锁(D3 不因摘源而松动);全部摘除后重新添加的账户自动定源。
#[test]
fn s31_remove_connected_account_unlocks_md_source() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    let (gw1, gw2) = two_accounts(&shared);
    shared.note_subscriptions(&["rb2605".to_string()]);
    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();

    // ① 二次确认摘除元账号(无挂单 → 移除钮在场;首点翻确认文案)。
    // Commit B 口径:移除钮用显示名(无别名 = 账号),寻址仍是派生名。
    harness.get_by_label("移除 278153").click_accesskit();
    harness.run();
    assert_eq!(
        shared.pending_confirm().as_deref(),
        Some(gw1.as_str()),
        "首点 = 待二次确认(内部仍按派生名记槽)"
    );
    assert!(shared.drain_cmds().is_empty(), "armed 不入列");
    harness.get_by_label("确认移除 278153 ?").click_accesskit();
    harness.run();
    let cmds = shared.drain_cmds();
    assert!(
        cmds.iter()
            .any(|c| matches!(c, UiCmd::Remove { name } if name == &gw1)),
        "摘除走 svctl.remove: {cmds:?}"
    );
    assert_eq!(shared.accounts().len(), 1, "清册只剩纯 TD 账号");
    assert!(
        shared.md_source().is_empty(),
        "元账号被摘 → 源位清空(等重新指定)"
    );
    assert_eq!(
        shared.subscriptions(),
        vec!["rb2605".to_string()],
        "订阅集保留(规划 §5.C)"
    );

    // ② 仍锁:纯 TD 账号在线就不允许切换(D3);摘源不等于解锁。
    let err = shared.set_md_source(&gw2).unwrap_err();
    assert!(err.contains("锁定"), "有账户在线仍须拒:{err}");
    harness.run();
    assert!(
        harness.query_by_label_contains("行情源已锁定").is_some(),
        "锁定口径句仍在场"
    );

    // ③ 全摘 → 解锁;重新保存的账户自动定源(§5.C 保存即定源)。
    harness.get_by_label("移除 220380").click_accesskit();
    harness.run();
    harness.get_by_label("确认移除 220380 ?").click_accesskit();
    harness.run();
    let _ = shared.drain_cmds();
    assert!(shared.accounts().is_empty(), "清册空");
    assert!(!shared.accounts_locked(), "无账户在线 = 解锁");
    let row3 = ek_ui::state::AccountRow::derive(&ek_ui::state::CtpForm {
        broker_id: "9999".to_string(),
        td_user: "220380".to_string(),
        ..Default::default()
    })
    .expect("派生注册名");
    assert_eq!(row3.gateway, gw2, "重加同名账户 = 同一派生名");
    // 走真实界面路径(『添加账户』开弹窗 → 弹窗『保存』),不走 state 直写
    // (自动定源在保存路径里)。
    add_via_dialog(
        &mut harness,
        &shared,
        acct_form(
            "9999",
            "220380",
            "tcp://182.254.243.31:30001",
            "tcp://182.254.243.31:30011",
        ),
    );
    let cmds = shared.drain_cmds();
    assert!(
        cmds.is_empty(),
        "重加 = 只写清册(0.2.18 四态:『保存』不下发装配/凭据两令): {cmds:?}"
    );
    assert_eq!(
        shared.md_source(),
        gw2,
        "无源 + 无在场账户 → 新添加的账户自动成为元账号(添加即定源)"
    );
    harness.run();
    assert!(harness.query_by_label(&gw2).is_some(), "重加后账户行回到场");
    assert!(
        harness.query_by_label("连接 220380").is_some(),
        "重加后须点『连接』才装配(四态分离入口在场)"
    );
}

/// s32 无账户 = 无网关可下单(旧「空 → 字面量 ctp」兜底已退役):阶梯回 None、
/// 禁用口径句上屏、点禁用钮不入列;添加账户后同一钮恢复可用且网关位 = 派生名。
#[test]
fn s32_no_account_disables_send_button() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    shared.set_order_form(ek_ui::state::OrderForm {
        exchange: "SHFE".to_string(),
        symbol: "rb2605".to_string(),
        direction: ek_data::constant::Direction::LONG,
        offset: ek_data::constant::Offset::OPEN,
        order_type: ek_data::constant::OrderType::LIMIT,
        price: "102.5".to_string(),
        volume: "1".to_string(),
        gateway: String::new(),
        follow_price: false,
        auto_split: true,
    });
    let mut harness = harness_for(&shared);
    harness.run();

    // ① 无账户:阶梯空 + 口径句上屏。
    assert_eq!(shared.default_gateway(), None, "无账户 = 无默认网关");
    assert!(
        harness.query_by_label_contains("委托钮已禁用").is_some(),
        "禁用口径句在场(不是静默禁用)"
    );

    // ② 点禁用钮不入列(旧兜底若还在,这里会冒出一条 gateway="ctp" 的令)。
    let nodes: Vec<_> = harness
        .get_all_by_label(ek_ui::render::BTN_SEND_ORDER)
        .collect();
    nodes[0].click_accesskit();
    harness.run();
    assert!(shared.drain_cmds().is_empty(), "禁用态点击不得入列");

    // ③ 添加账户后同一钮可用,网关位 = 派生名(不再是字面量 ctp)。
    let gw = seed_md_source(&shared, "9999", "278153");
    // 0.2.18 阶梯(规划 Q4):**只添加了还没装配的账户不进默认** ——
    // 旧阶梯"退到第一个账户"会把未连接的账户选成下单归属,发出去只撞 unknown:gateway。
    assert_eq!(
        shared.default_gateway(),
        None,
        "只添加未装配 = 仍无默认网关(阶梯不再退到第一个账户)"
    );
    harness.run();
    assert!(
        harness.query_by_label_contains("委托钮已禁用").is_some(),
        "未装配账户不得把下单钮点亮"
    );
    mark_assembled(&shared, &gw);
    harness.run();
    assert_eq!(shared.default_gateway().as_deref(), Some(gw.as_str()));
    assert!(
        harness.query_by_label_contains("委托钮已禁用").is_none(),
        "有账户后禁用句撤场"
    );
    let nodes: Vec<_> = harness
        .get_all_by_label(ek_ui::render::BTN_SEND_ORDER)
        .collect();
    nodes[0].click_accesskit();
    harness.run();
    let cmds = shared.drain_cmds();
    assert_eq!(cmds.len(), 1, "有账户后下单入列: {cmds:?}");
    match &cmds[0] {
        UiCmd::SendOrder { gateway, .. } => {
            assert_eq!(gateway, &gw, "网关位取账户派生名(旧字面量 ctp 已退役)");
        }
        other => panic!("expected SendOrder, got {other:?}"),
    }
}

// ============ 账户配置界面核查钉 s33–s38(2026-10-21 界面巡回后补)============
// 口径:这些钉**钉的是现状**,每条都在名字或断言里写明「待修/开放项」——
// 巡回里账户面板的六处问题(docs/ek-ma-business-flow-report.md §10 界面侧)先固化成证据,
// 修哪一条就把对应断言翻成期望口径。

/// 表单夹具(密码用可断言的假值,测试面不碰真凭据)。
fn acct_form(broker: &str, account: &str, td: &str, md: &str) -> ek_ui::state::CtpForm {
    ek_ui::state::CtpForm {
        broker_id: broker.to_string(),
        td_addr: td.to_string(),
        td_user: account.to_string(),
        td_password: "fake-pw-1234".to_string(),
        td_auth_code: "fake-auth".to_string(),
        td_app_id: "fake-app".to_string(),
        md_addr: md.to_string(),
        md_user: String::new(),
        md_password: String::new(),
        symbols: String::new(),
        accept_futures: true,
        accept_options: false,
    }
}

/// 点一次「添加账户」(面板路径,不碰内部 API)。
/// 0.2.18 Commit B 口径:这一下**只开配置弹窗**,不写清册也不下发。
fn click_account_add(harness: &mut Harness<'_>) {
    let nodes: Vec<_> = harness
        .get_all_by_label(ek_ui::render::BTN_ACCOUNT_ADD)
        .collect();
    assert!(!nodes.is_empty(), "『添加账户』钮应在场");
    nodes[0].click_accesskit();
}

/// 配置弹窗里的钮(『保存』/『保存并连接』/『取消』)。
fn click_dialog(harness: &mut Harness<'_>, label: &str) {
    let nodes: Vec<_> = harness.get_all_by_label(label).collect();
    assert!(!nodes.is_empty(), "配置弹窗钮 {label:?} 应在场");
    nodes[0].click_accesskit();
    harness.run();
}

/// Commit B 的完整添加路径:面板『添加账户』开弹窗 → **弹窗打开后**填草稿
/// (`begin_account_edit` 会重置草稿位,开窗前填的会被覆盖)→ 弹窗『保存』写清册。
fn add_via_dialog(harness: &mut Harness<'_>, shared: &UiShared, form: ek_ui::state::CtpForm) {
    click_account_add(harness);
    harness.run();
    assert!(
        shared.account_edit_open(),
        "『添加账户』= 开配置弹窗(Commit B 口径)"
    );
    shared.set_ctp_form(form);
    harness.run();
    click_dialog(harness, "保存");
    assert!(
        !shared.account_edit_open(),
        "『保存』成功 = 弹窗关闭(被拦才留着显形)"
    );
}

/// s33 装配失败后的**期望口径**(0.2.18 修 U3):失败**不回滚清册行**,状态列显
/// 「装配失败:<原因>」红字,行上给『重试』钮 —— 再点『重试』直接下发一条 `Connect` 令,
/// **不需要先移除**(旧口径"必须先移除才能重试"作废)。
/// 生产路径里 `note_connect_sent/result` 由 ui 插件在 `UiCmd::Connect` 出队时回写;
/// 本钉没有宿主,故手工模拟同一条回写(等价性由 plugin.rs 的 Connect 分支保证)。
#[test]
fn s33_failed_start_leaves_row_and_allows_retry() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();
    add_via_dialog(
        &mut harness,
        &shared,
        acct_form(
            "9999",
            "278153",
            "tcp://10.0.0.1:40001",
            "tcp://10.0.0.1:40011",
        ),
    );
    assert!(
        shared.drain_cmds().is_empty(),
        "『保存』只写清册,不下发装配令"
    );
    assert_eq!(shared.accounts().len(), 1, "清册一行");

    // ① 『连接』入口 → 一条 Connect 令 + 状态「装配中」。
    harness.run();
    harness.get_by_label("连接 278153").click_accesskit();
    harness.run();
    let cmds = shared.drain_cmds();
    assert_eq!(cmds.len(), 1, "『连接』入列一条令: {cmds:?}");
    assert!(matches!(cmds[0], UiCmd::Connect { .. }));
    shared.note_connect_sent("ctp_9999_278153");
    harness.run();
    assert!(
        harness.query_by_label_contains("装配中").is_some(),
        "装配中须在状态列显形"
    );

    // ② 装配失败(svctl.start 被拒 / 超时 / configure 被拒,插件侧都落到这一形)。
    shared.note_connect_result(
        "ctp_9999_278153",
        Err("svctl.start 被拒:工厂 ctp 不在册".to_string()),
    );
    harness.run();
    let acc = shared
        .account("ctp_9999_278153")
        .expect("期望口径:失败行不回滚,留在清册给重试");
    assert_eq!(acc.stage, ek_ui::state::AccountStage::AssembleFailed);
    assert_eq!(
        acc.fail_note.as_deref(),
        Some("svctl.start 被拒:工厂 ctp 不在册"),
        "失败原因须留档"
    );
    assert!(
        harness.query_by_label_contains("装配失败").is_some(),
        "装配失败必须在界面上显形(旧口径:零显形)"
    );
    assert!(
        harness.query_by_label("重试 278153").is_some(),
        "失败行须给『重试』入口"
    );

    // ③ 再点『重试』→ 直接入列,不必先移除。
    harness.get_by_label("重试 278153").click_accesskit();
    harness.run();
    let cmds = shared.drain_cmds();
    assert_eq!(
        cmds.len(),
        1,
        "重试 = 一条 Connect 令(不需先移除): {cmds:?}"
    );
    assert!(matches!(cmds[0], UiCmd::Connect { .. }));
    assert_eq!(shared.accounts().len(), 1, "失败行没被回滚,也没多建行");
}

/// s34 **每账户独立表单**(0.2.18 修 U4):『添加』成功后草稿位的**凭据位一律清空**
/// (账号 / 密码 / 前置),只沿用非凭据字段(broker / AppID / AuthCode,规划 Q20 缓解);
/// 纯 TD 账户的 md 位在**留档**里就清空(旧口径只清下发不清留档 → 编辑页会带一根用不上的行情前置)。
#[test]
fn s34_account_form_is_per_account() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();
    add_via_dialog(
        &mut harness,
        &shared,
        acct_form(
            "9999",
            "278153",
            "tcp://10.0.0.1:40001",
            "tcp://10.0.0.1:40011",
        ),
    );
    let _ = shared.drain_cmds();

    // ① 草稿位:『保存』关弹窗后凭据清空,非凭据沿用。
    let draft = shared.ctp_form();
    assert!(draft.td_user.is_empty(), "期望口径:保存后账号位清空");
    assert!(
        draft.td_password.is_empty(),
        "期望口径:上一家密码不得留在草稿位"
    );
    assert!(draft.td_addr.is_empty(), "期望口径:前置也清空");
    assert_eq!(draft.broker_id, "9999", "非凭据字段沿用(Q20 缓解)");
    assert_eq!(draft.td_app_id, "fake-app", "AppID 沿用");
    assert_eq!(draft.td_auth_code, "fake-auth", "AuthCode 沿用");

    // ② 该账户自己的留档不受清空影响。
    let acc1 = shared.account("ctp_9999_278153").expect("第一账户");
    assert_eq!(
        acc1.form.td_password, "fake-pw-1234",
        "留档保留自己的凭据(清空只作用于草稿位)"
    );

    // ③ 第二账户:必须自己填密码,拿不到上一家的。
    click_account_add(&mut harness);
    harness.run();
    shared.set_ctp_form(ek_ui::state::CtpForm {
        td_user: "220380".to_string(),
        td_addr: "tcp://10.0.0.1:40001".to_string(),
        td_password: "pw-of-220380".to_string(),
        ..shared.ctp_form()
    });
    harness.run();
    click_dialog(&mut harness, "保存");
    let _ = shared.drain_cmds();
    let acc2 = shared.account("ctp_9999_220380").expect("第二账户已入清册");
    assert_eq!(
        acc2.form.td_password, "pw-of-220380",
        "期望口径:新账户只带自己的密码,不继承上一家"
    );
    assert_eq!(
        acc2.form.md_addr, "",
        "期望口径:纯 TD 账户的 md 位在**留档**里就清空(旧缺陷:只清下发不清留档)"
    );
    assert_eq!(
        acc1.form.md_addr, "tcp://10.0.0.1:40011",
        "元账号的 md 留档不被第二账户的单源清空波及"
    );
}

/// s35 编辑态与『取消』(0.2.18 Commit B 修 U6):『编辑 <账户>』开配置弹窗并载入
/// **该账户自己的**留档;『取消』(与关窗同口径)= 放弃未保存且**草稿位凭据一律清空**
/// → 不可能"改一格就建出错配账户"。
/// 编辑模式 broker / 账号只读(规划 Q25)的界面证据 = 只读口径句上屏 + s40 快照钉
/// (kittest 查询面只按 label 命中,取不到 TextEdit 节点做 disabled 断言 —— 已记入核查)。
#[test]
fn s35_cancel_edit_clears_credentials() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    let gw1 = seed_md_source(&shared, "9999", "278153");
    shared.update_account_form(
        &gw1,
        acct_form(
            "9999",
            "278153",
            "tcp://10.0.0.1:40001",
            "tcp://10.0.0.1:40011",
        ),
    );
    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();

    // ① 『编辑 278153』→ 开配置弹窗(编辑模式),草稿 = 该账户留档(含密码)。
    harness.get_by_label("编辑 278153").click_accesskit();
    harness.run();
    assert!(shared.account_edit_open(), "『编辑』= 开配置弹窗(Commit B)");
    assert_eq!(
        shared.editing_account().as_deref(),
        Some(gw1.as_str()),
        "弹窗模式 = 编辑该账户"
    );
    assert_eq!(
        shared.ctp_form().td_password,
        "fake-pw-1234",
        "编辑载入该账户自己的表单(含密码)"
    );
    assert!(
        harness
            .query_by_label_contains("要改这两格请『移除后重加』")
            .is_some(),
        "编辑模式 broker/账号只读的口径句上屏(规划 Q25)"
    );
    assert!(
        harness.query_by_label("保存并连接").is_some(),
        "编辑模式给『保存并连接』出口"
    );

    // ② 『取消』→ 弹窗关 + 草稿位凭据清空。
    click_dialog(&mut harness, "取消");
    assert!(!shared.account_edit_open(), "『取消』= 关弹窗");
    assert!(shared.editing_account().is_none(), "编辑模式解除");
    let draft = shared.ctp_form();
    assert!(
        draft.td_password.is_empty(),
        "期望口径:取消清空凭据(旧口径:原封不动留在共享位)"
    );
    assert!(draft.td_user.is_empty(), "期望口径:账号位也清空");
    assert!(draft.td_addr.is_empty(), "期望口径:前置也清空");
    assert_eq!(draft.broker_id, "9999", "非凭据字段沿用");

    // ③ 此时点『添加账户』→ 开弹窗(草稿空)→ 『保存』被形制校验拦下:
    // 弹窗**不关** + 原因显形 + 不入列(规划 Q26)。
    click_account_add(&mut harness);
    harness.run();
    click_dialog(&mut harness, "保存");
    assert!(shared.account_edit_open(), "被拦不得关弹窗(失败要能就地改)");
    assert_eq!(
        shared.accounts().len(),
        1,
        "清册仍一行(没建出凭据错配的账户)"
    );
    assert!(shared.drain_cmds().is_empty(), "空凭据草稿不得入列");
    assert!(shared.edit_error().is_some(), "拦截原因须在弹窗里显形");
    let (lines, _) = shared.logs_snapshot();
    assert!(
        lines.iter().any(|l| l.contains("保存被拦")),
        "拦截须在日志里显形: {lines:?}"
    );
}

/// s36 前置形制校验(0.2.18 修 U7):旧口径是"`AccountRow::derive` 只校验 broker/账号,
/// **td/md 前置为空也照样建账户并下发两令**"。现在『添加』先过 `CtpForm::validate`:
/// 前置形制不对 → 当场拦下、不入清册、不下发,并在日志里显形。
/// md 前置**允许为空**(纯 TD 账户,单源纪律),td 前置不允许为空。
#[test]
fn s36_empty_front_address_is_rejected() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();
    click_account_add(&mut harness);
    harness.run();
    assert!(shared.account_edit_open(), "『添加账户』= 开配置弹窗");
    // td 前置为空 → 『保存』被拦:不入列、不下发、**弹窗不关**、原因显形(规划 Q26)。
    shared.set_ctp_form(acct_form("9999", "278153", "", ""));
    harness.run();
    click_dialog(&mut harness, "保存");
    assert!(
        shared.drain_cmds().is_empty(),
        "期望口径:前置为空不得下发装配/凭据两令"
    );
    assert!(
        shared.account("ctp_9999_278153").is_none(),
        "期望口径:空前置的账户不入清册(旧口径:原样入库)"
    );
    assert!(shared.account_edit_open(), "被拦不得关弹窗(失败要能就地改)");
    assert!(shared.edit_error().is_some(), "弹窗里须显形拦截原因");
    let (lines, _) = shared.logs_snapshot();
    assert!(
        lines
            .iter()
            .any(|l| l.contains("保存被拦") && l.contains("td 前置")),
        "拦截原因须在日志显形: {lines:?}"
    );

    // 形制不对(缺端口 / 端口非数字)同样被拦 —— 弹窗开着 = 就地改,不必重开重填。
    shared.set_ctp_form(acct_form("9999", "278153", "tcp://10.0.0.1", ""));
    harness.run();
    click_dialog(&mut harness, "保存");
    assert!(shared.account("ctp_9999_278153").is_none(), "缺端口须被拦");
    shared.set_ctp_form(acct_form("9999", "278153", "tcp://10.0.0.1:abc", ""));
    harness.run();
    click_dialog(&mut harness, "保存");
    assert!(
        shared.account("ctp_9999_278153").is_none(),
        "端口非数字须被拦"
    );

    // 合规(td 前置齐、md 前置留空 = 纯 TD)→ 保存成功、弹窗关闭。
    shared.set_ctp_form(acct_form("9999", "278153", "tcp://10.0.0.1:40001", ""));
    harness.run();
    click_dialog(&mut harness, "保存");
    assert!(!shared.account_edit_open(), "保存成功 = 弹窗关闭");
    let acc = shared.account("ctp_9999_278153").expect("合规表单须入清册");
    assert_eq!(acc.form.td_addr, "tcp://10.0.0.1:40001");
    assert!(acc.form.md_addr.is_empty(), "md 前置留空 = 纯 TD 账户,合法");
}

/// s37 账户面板的状态可见性(0.2.18 半修 U5):账户行仍只有**装配口径**
/// (「已装配·登录态未知」/「未装配」/「装配失败:原因」),登录成败、掉线、查询断供
/// 仍无逐行可见位(ek-ctp 无状态口可查 = 界面不谎称);新增的是面板底部一句
/// **状态口径说明**,点名这一缺口(规划 §3.5 缓解)。
/// 状态列两段口径(0.2.19 起):装配位 + 登录态读数,**没有读数时仍写「登录态未知」**。
///
/// 0.2.18 这条钉的是「界面只有在场位、登录成败不可见」(F2 因此在面板上与正常账户同形)。
/// 0.2.19 接上 `{gw}.status` 后口径翻转:缺口从「不可见」变成「本轮还没读到」——
/// 两者在界面上必须长得不一样,否则用户会把「没问到」读成「在线」。
#[test]
fn s37_account_panel_shows_stage_and_login_state() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    let (gw1, gw2) = two_accounts(&shared);
    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();

    assert!(harness.query_by_label(&gw1).is_some(), "账户行 1");
    assert!(harness.query_by_label(&gw2).is_some(), "账户行 2");
    let seen = harness.query_all_by_label_contains("已装配").count();
    assert_eq!(
        seen, 3,
        "两行账户的状态列(「已装配·登录态未知」)+ 一句『行情源已锁定(有账户已装配)』口径句 = 3 处『已装配』"
    );
    assert!(
        harness.query_by_label_contains("未装配").is_none(),
        "两账户都在场 → 没有『未装配』行"
    );
    // 装配成功但一次状态口都没问到:必须显形「登录态未知」,不得只显示「已装配」。
    assert_eq!(
        harness
            .query_all_by_label_contains("已装配·登录态未知")
            .count(),
        2,
        "两行都必须带「登录态未知」(装配 ≠ 在线,规划 U5 口径)"
    );
    assert_eq!(
        harness.query_all_by_label_contains("已装配·在线").count(),
        0,
        "没有任何登录证据时不得出现「在线」"
    );
    // 面板底部口径句改说事实源与两个动作的分工(0.2.18 的「开放项 ctp.status」已退役)。
    assert!(
        harness.query_by_label_contains("网关状态口").is_some(),
        "面板底部须说明状态列的两个来源,以及『断开』/『移除』的分工"
    );
    for needle in ["掉线", "断供"] {
        assert_eq!(
            harness.query_all_by_label_contains(needle).count(),
            0,
            "{needle} 只在真读到该状态时才上屏;两账户现在都是「未知」,一处都不该有"
        );
    }
    // 『断开』钮只给已装配的行(规划裁定 1 + Q21:没有会话可停时不留死钮)。
    // 按钮文案用**显示名**(账号/别名),寻址仍用派生名 —— 与『移除』钮同一口径(s47 同源)。
    for account in ["278153", "220380"] {
        assert!(
            harness.query_by_label(&format!("断开 {account}")).is_some(),
            "已装配账户 {account} 应有『断开』钮"
        );
        assert!(
            harness.query_by_label(&format!("连接 {account}")).is_none(),
            "已装配且会话未停的行不给『连接』钮"
        );
    }
}

/// s38 摘除账户后的账本残留(界面侧,0.2.18 Commit B 修 U8):清册行消失,但该账户的
/// 委托/持仓行仍在交易监控里(摘除不清账本 = F5 实连复测口径),现在**残留行带
/// 「(已摘除)」标注** —— 旧口径是零标注,用户会把已摘除账户的委托当成在场账户的。
#[test]
fn s38_removed_account_ledger_rows_stay_visible() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    let (gw1, gw2) = two_accounts(&shared);
    shared.set_trade_snap(ek_ui::state::TradeSnap {
        orders: vec![ek_data::order::OrderData {
            gateway_name: gw2.clone(),
            symbol: "rb2605".to_string(),
            exchange: ek_data::constant::Exchange::SHFE,
            orderid: format!("{gw2}.CTP_9_9"),
            status: ek_data::constant::Status::NOTTRADED,
            ..ek_data::order::OrderData::default()
        }],
        positions: vec![ek_data::position::PositionData {
            gateway_name: gw2.clone(),
            symbol: "rb2605".to_string(),
            exchange: ek_data::constant::Exchange::SHFE,
            volume: 2.0,
            ..ek_data::position::PositionData::default()
        }],
        ..ek_ui::state::TradeSnap::default()
    });
    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();

    // 有持仓无挂单?这里有挂单 → 守卫先挡(与 s28 同口径),先撤快照再摘。
    assert!(
        harness.query_by_label_contains("先撤单才能移除").is_some(),
        "有活跃委托 → 移除钮不给"
    );
    shared.set_trade_snap(ek_ui::state::TradeSnap {
        positions: vec![ek_data::position::PositionData {
            gateway_name: gw2.clone(),
            symbol: "rb2605".to_string(),
            exchange: ek_data::constant::Exchange::SHFE,
            volume: 2.0,
            ..ek_data::position::PositionData::default()
        }],
        ..ek_ui::state::TradeSnap::default()
    });
    harness.run();
    harness.get_by_label("移除 220380").click_accesskit();
    harness.run();
    harness.get_by_label("确认移除 220380 ?").click_accesskit();
    harness.run();
    let cmds = shared.drain_cmds();
    assert!(
        cmds.iter()
            .any(|c| matches!(c, UiCmd::Remove { name } if name == &gw2)),
        "摘除令入列: {cmds:?}"
    );
    assert!(shared.account(&gw2).is_none(), "清册行已摘");
    assert!(
        harness.query_by_label("移除 220380").is_none(),
        "账户面板里这个账户的移除钮撤场"
    );
    assert!(
        harness.query_by_label("编辑 220380").is_none(),
        "账户面板里这个账户的编辑钮撤场"
    );
    // 摘除后下一拍 eTimer 全量查询照带该账户的账本行(oms 不随摘除清账)——
    // 现状:委托/持仓行原样留着,界面上没有任何「该账户已摘除」的标注。
    shared.set_trade_snap(ek_ui::state::TradeSnap {
        orders: vec![ek_data::order::OrderData {
            gateway_name: gw2.clone(),
            symbol: "rb2605".to_string(),
            exchange: ek_data::constant::Exchange::SHFE,
            orderid: format!("{gw2}.CTP_9_9"),
            status: ek_data::constant::Status::NOTTRADED,
            ..ek_data::order::OrderData::default()
        }],
        positions: vec![ek_data::position::PositionData {
            gateway_name: gw2.clone(),
            symbol: "rb2605".to_string(),
            exchange: ek_data::constant::Exchange::SHFE,
            volume: 2.0,
            ..ek_data::position::PositionData::default()
        }],
        ..ek_ui::state::TradeSnap::default()
    });
    harness.run();
    let snap = shared.trade_snap();
    assert!(
        snap.orders.iter().any(|o| o.gateway_name == gw2),
        "现状:摘除后委托行仍在账本"
    );
    assert!(
        snap.positions.iter().any(|p| p.gateway_name == gw2),
        "现状:摘除后持仓行仍在账本"
    );
    assert!(
        harness.query_all_by_label_contains("已摘除").count() >= 2,
        "0.2.18 Commit B 修 U8:残留行须带『已摘除』标注(委托行 + 持仓行各一处)——\
         摘除不清账本(F5),但界面必须说清这一行属于谁"
    );
    assert!(shared.account(&gw1).is_some(), "元账号 {gw1} 不受影响");
}

/// s39 账户面板视觉快照(布局核查证据):两账户行 + 表单 9 输入位 + 收录勾选 +
/// 锁定口径句 + 添加钮,一屏之内。
#[test]
fn s39_account_panel_layout_snapshot() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    let (gw1, gw2) = two_accounts(&shared);
    shared.update_account_form(
        &gw1,
        acct_form(
            "9999",
            "278153",
            "tcp://182.254.242.81:30001",
            "tcp://182.254.242.81:30011",
        ),
    );
    shared.update_account_form(
        &gw2,
        acct_form("9999", "220380", "tcp://182.254.242.81:30001", ""),
    );
    // 表单里放**真实形状**的前置(30 字符),核查输入位能否看全一根前置。
    shared.set_ctp_form(acct_form(
        "9999",
        "278153",
        "tcp://182.254.242.81:30001",
        "tcp://182.254.242.81:30011",
    ));
    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();
    let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx); // BD-UI-26:快照与实窗同字体面
    if hit.is_none() {
        return; // 无 CJK 字体环境:豆腐渲染必 diff,本钉让位
    }
    harness.run();
    snap(&mut harness, "s39_account_panel_layout");
    assert!(harness.query_by_label(&gw1).is_some(), "账户行 1 在场");
    assert!(harness.query_by_label(&gw2).is_some(), "账户行 2 在场");
    // Commit B 列表口径:添加钮在列表**上方**,窗内不再内嵌一张共享表单。
    assert!(
        harness.query_by_label_contains("当前行情源 =").is_some(),
        "列表上方须有行情源一览"
    );
    assert!(
        harness.query_by_label_contains("账户 2/8").is_some(),
        "计数含软上限(达限即禁用『添加账户』的可见依据)"
    );
    assert!(
        harness.query_by_label_contains("收录(勾→实)").is_some(),
        "收录列:勾选意图与网关回带事实并排(规划 U9)"
    );
}

// ============ 0.2.18 Commit B(账户列表 + 配置弹窗)新增钉 s40–s45 ============

/// s40 配置弹窗快照(**添加模式**):身份行(别名 + broker + td 前置 + td 用户)/
/// 凭据行 / md 行 / 收录勾选 / 操作行(保存 · 保存并连接 · 取消)一屏之内。
#[test]
fn s40_account_add_dialog_snapshot() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();
    click_account_add(&mut harness);
    harness.run();
    shared.set_ctp_form(acct_form(
        "9999",
        "278153",
        "tcp://182.254.242.81:30001",
        "tcp://182.254.242.81:30011",
    ));
    shared.set_edit_alias("主账户");
    harness.run();
    let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
    if hit.is_none() {
        return;
    }
    harness.run();
    snap(&mut harness, "s40_account_add_dialog");
    assert!(
        harness.query_by_label("保存并连接").is_some(),
        "添加模式给『保存并连接』"
    );
    assert!(
        harness.query_by_label_contains("有未保存修改").is_some(),
        "dirty 提示在场(关闭 = 放弃)"
    );
}

/// s41 配置弹窗快照(**编辑模式**):broker/账号只读口径句 + 只读回带区
/// (网关实际收录 / 装配状态)+ 非源账户 md 位置灰口径句。
#[test]
fn s41_account_edit_dialog_snapshot() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    let (_gw1, gw2) = two_accounts(&shared);
    shared.update_account_form(
        &gw2,
        acct_form("9999", "220380", "tcp://182.254.242.81:30001", ""),
    );
    shared.set_account_alias(&gw2, "从账户");
    shared.note_effective_product_classes(&gw2, &["1".to_string(), "2".to_string()]);
    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();
    harness.get_by_label("编辑 从账户").click_accesskit();
    harness.run();
    let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
    if hit.is_none() {
        return;
    }
    harness.run();
    snap(&mut harness, "s41_account_edit_dialog");
    assert_eq!(shared.editing_account().as_deref(), Some(gw2.as_str()));
    assert!(
        harness
            .query_by_label_contains("要改这两格请『移除后重加』")
            .is_some(),
        "编辑模式 broker/账号只读口径句(规划 Q25)"
    );
    assert!(
        harness
            .query_by_label_contains("非行情源账户:md 位留空")
            .is_some(),
        "非源 + 已锁定 → md 位置灰口径句(规划 Q12)"
    );
    assert_eq!(
        harness.query_all_by_label_contains("期货+期权").count(),
        2,
        "网关实际收录回带两处上屏:列表 gw2 行的『实:期货+期权』+ 弹窗只读回带(规划 U9)"
    );
}

/// s42 别名(规划 Q3 / U10):别名上列表、上按钮文案,但**不参与寻址** ——
/// `Connect` / `Remove` 指令里的网关仍是派生注册名;重名别名被拦且弹窗不关。
#[test]
fn s42_alias_shown_but_not_addressable() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();
    click_account_add(&mut harness);
    harness.run();
    shared.set_ctp_form(acct_form(
        "9999",
        "278153",
        "tcp://10.0.0.1:40001",
        "tcp://10.0.0.1:40011",
    ));
    shared.set_edit_alias("主账户");
    harness.run();
    click_dialog(&mut harness, "保存");
    let acc = shared
        .account("ctp_9999_278153")
        .expect("带别名保存须入清册");
    assert_eq!(acc.alias, "主账户", "别名随行入册");
    harness.run();
    assert!(
        harness.query_by_label("主账户").is_some(),
        "别名上列表(别名列)"
    );
    assert!(
        harness.query_by_label("移除 主账户").is_some(),
        "移除钮文案取别名(与『编辑 主账户』同一口径)"
    );
    assert!(
        harness.query_by_label("编辑 主账户").is_some(),
        "编辑钮文案取别名"
    );
    // 寻址仍是派生名:行内『连接 <显示名>』钮(与『编辑/移除』同一口径)发出的指令里网关仍是派生名。
    assert!(
        harness.query_by_label("连接 主账户").is_some(),
        "连接钮文案也取显示名(同一行四个钮口径必须一致)"
    );
    harness.get_by_label("连接 主账户").click_accesskit();
    harness.run();
    let cmds = shared.drain_cmds();
    assert_eq!(cmds.len(), 1, "连接仍是一条令: {cmds:?}");
    match &cmds[0] {
        UiCmd::Connect { gateway, .. } => assert_eq!(
            gateway, "ctp_9999_278153",
            "别名不参与寻址(网关位仍是派生注册名)"
        ),
        other => panic!("expected Connect, got {other:?}"),
    }

    // 重名别名:第二个账户用同一别名 → 保存被拦,弹窗不关,清册不增行。
    click_account_add(&mut harness);
    harness.run();
    shared.set_ctp_form(ek_ui::state::CtpForm {
        td_user: "220380".to_string(),
        td_addr: "tcp://10.0.0.1:40001".to_string(),
        td_password: "pw-2".to_string(),
        ..shared.ctp_form()
    });
    shared.set_edit_alias("主账户");
    harness.run();
    click_dialog(&mut harness, "保存");
    assert!(
        shared.account("ctp_9999_220380").is_none(),
        "重名别名须被拦(别名唯一)"
    );
    assert!(shared.account_edit_open(), "被拦不得关弹窗");
    assert!(
        shared
            .edit_error()
            .map(|e| e.contains("别名"))
            .unwrap_or(false),
        "拦截原因须点名别名: {:?}",
        shared.edit_error()
    );
    assert_eq!(shared.accounts().len(), 1, "清册不增行");
}

/// s43 软上限(规划 §3.6):达 `MAX_ACCOUNTS` 后『添加账户』钮撤场 + 提示句在场,
/// 第 9 个账户开不出配置弹窗(界面侧先拦,不给宿主侧注定被拒的请求)。
#[test]
fn s43_soft_cap_hides_add_button() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    for i in 0..ek_ui::state::MAX_ACCOUNTS {
        let row = ek_ui::state::AccountRow::derive(&ek_ui::state::CtpForm {
            broker_id: "9999".to_string(),
            td_addr: "tcp://10.0.0.1:40001".to_string(),
            td_user: format!("17{i:04}"),
            td_password: "fake-pw-1234".to_string(),
            td_app_id: "fake-app".to_string(),
            td_auth_code: "fake-auth".to_string(),
            ..Default::default()
        })
        .expect("派生注册名");
        shared.add_account(row).expect("账户入册");
    }
    assert!(!shared.can_add_account(), "达软上限");
    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();
    assert!(
        harness
            .query_by_label(ek_ui::render::BTN_ACCOUNT_ADD)
            .is_none(),
        "达限后『添加账户』钮撤场(不是灰着让人白点)"
    );
    assert!(
        harness.query_by_label_contains("账户已达软上限").is_some(),
        "达限提示句在场"
    );
    assert_eq!(
        harness.query_all_by_label_contains("未装配").count(),
        ek_ui::state::MAX_ACCOUNTS,
        "八行账户状态列都是『未装配』"
    );
}

/// s44 『保存并连接』(规划 §3.3 四态):保存 + **一条** Connect 令(不是两令),弹窗关闭。
#[test]
fn s44_save_and_connect_single_command() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();
    click_account_add(&mut harness);
    harness.run();
    shared.set_ctp_form(acct_form(
        "9999",
        "278153",
        "tcp://10.0.0.1:40001",
        "tcp://10.0.0.1:40011",
    ));
    harness.run();
    click_dialog(&mut harness, "保存并连接");
    assert!(!shared.account_edit_open(), "成功 = 弹窗关闭");
    assert_eq!(shared.accounts().len(), 1, "先写清册");
    let cmds = shared.drain_cmds();
    assert_eq!(cmds.len(), 1, "『保存并连接』= 一条 Connect 令: {cmds:?}");
    assert!(matches!(cmds[0], UiCmd::Connect { .. }));
    assert!(
        harness.query_by_label("连接 278153").is_none(),
        "已入列连接的行不再给『连接』钮(避免重复入列)"
    );
}

/// s45 『先撤 N 笔』出口(规划 U10):守卫不能只堵门 —— 按该账户活跃委托逐笔入列
/// `CancelOrder`,网关位 = 该账户自己的派生名(与「全撤」同一条 oms 通道)。
#[test]
fn s45_cancel_all_before_remove_entry() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    let (_, gw2) = two_accounts(&shared);
    shared.set_trade_snap(ek_ui::state::TradeSnap {
        orders: vec![
            ek_data::order::OrderData {
                gateway_name: gw2.clone(),
                symbol: "rb2605".to_string(),
                exchange: ek_data::constant::Exchange::SHFE,
                orderid: format!("{gw2}.CTP_9_1"),
                status: ek_data::constant::Status::NOTTRADED,
                ..ek_data::order::OrderData::default()
            },
            ek_data::order::OrderData {
                gateway_name: gw2.clone(),
                symbol: "cu2709".to_string(),
                exchange: ek_data::constant::Exchange::SHFE,
                orderid: format!("{gw2}.CTP_9_2"),
                status: ek_data::constant::Status::NOTTRADED,
                ..ek_data::order::OrderData::default()
            },
        ],
        ..ek_ui::state::TradeSnap::default()
    });
    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();
    harness.get_by_label("先撤 2 笔").click_accesskit();
    harness.run();
    let cmds = shared.drain_cmds();
    assert_eq!(cmds.len(), 2, "两笔活跃委托逐笔入列: {cmds:?}");
    for c in &cmds {
        match c {
            UiCmd::CancelOrder { gateway, .. } => {
                assert_eq!(gateway, &gw2, "撤单随单走(该账户自己的派生名)")
            }
            other => panic!("expected CancelOrder, got {other:?}"),
        }
    }
    let (lines, _) = shared.logs_snapshot();
    assert!(
        lines
            .iter()
            .any(|l| l.contains("先撤") && l.contains("撤完才可移除")),
        "出口须留痕: {lines:?}"
    );
}

/// s46 非 modal 弹窗 × 摘除的连带口径(执行核查抓到的坑):弹窗开着时点同一行『移除』
/// → 必须连带关弹窗放弃。否则编辑目标消失后弹窗会静默退化成「添加模式」
/// (只读句消失、字段全可编辑),点『保存』会拿旧凭据建出一个**新账户**。
#[test]
fn s46_remove_while_editing_closes_dialog() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    let (_gw1, gw2) = two_accounts(&shared);
    shared.update_account_form(
        &gw2,
        acct_form("9999", "220380", "tcp://10.0.0.1:40001", ""),
    );
    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();
    harness.get_by_label("编辑 220380").click_accesskit();
    harness.run();
    assert!(shared.account_edit_open(), "编辑弹窗已开");
    assert_eq!(
        shared.editing_account().as_deref(),
        Some(gw2.as_str()),
        "编辑模式 = 该行"
    );

    // 弹窗开着 → 同一行『移除』二次确认 → 摘除成功且弹窗**连带关闭**。
    harness.get_by_label("移除 220380").click_accesskit();
    harness.run();
    harness.get_by_label("确认移除 220380 ?").click_accesskit();
    harness.run();
    let cmds = shared.drain_cmds();
    assert!(
        cmds.iter()
            .any(|c| matches!(c, UiCmd::Remove { name } if name == &gw2)),
        "摘除令入列: {cmds:?}"
    );
    assert!(shared.account(&gw2).is_none(), "行已摘");
    assert!(
        !shared.account_edit_open(),
        "摘除须连带关弹窗(不得留下退化成『添加模式』的编辑窗)"
    );
    assert!(shared.editing_account().is_none(), "编辑模式解除");
    assert!(
        shared.ctp_form().td_password.is_empty(),
        "连带放弃:草稿位凭据清空(旧凭据不得留在共享位)"
    );

    // 防御层二:编辑不存在的账户 = 不开窗(即使外部拿着陈旧 gateway 串)。
    let before = shared.accounts().len();
    assert!(
        !shared.begin_account_edit(Some(&gw2)),
        "编辑不在清册的账户须被拒"
    );
    assert!(!shared.account_edit_open(), "不开窗");
    assert_eq!(shared.accounts().len(), before, "清册不变");
}

/// s47 下单归属下拉 × 摘除(执行核查补的洞):清册非空时,已摘除账户的网关不得留在
/// 下单下拉里,且表单里「选过它」的残留选择须就地回落到默认阶梯并落盘 ——
/// 否则下一笔单注定撞 `unknown:gateway`。撤单不受影响(随每笔单自己的网关走)。
#[test]
fn s47_removed_gateway_leaves_order_dropdown() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    let (gw1, gw2) = two_accounts(&shared);
    // 表单手动选过 gw2(模拟"上一笔单挂在从账户上")。
    let mut f = shared.order_form();
    f.gateway = gw2.clone();
    shared.set_order_form(f);
    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();
    assert!(
        harness.query_by_label(&gw2).is_some(),
        "摘除前:下单下拉选中 gw2"
    );

    harness.get_by_label("移除 220380").click_accesskit();
    harness.run();
    harness.get_by_label("确认移除 220380 ?").click_accesskit();
    harness.run();
    shared.drain_cmds();
    harness.run();

    assert!(
        harness.query_by_label(&gw1).is_some(),
        "在场账户仍是下单下拉的选中项(默认阶梯)"
    );
    assert!(
        harness.query_by_label(&gw2).is_none(),
        "已摘除网关不得留在下单下拉(选它 = unknown:gateway)"
    );
    assert_eq!(
        shared.order_form().gateway,
        gw1,
        "表单里的陈旧选择须就地回落并落盘"
    );
    assert_eq!(
        shared.default_gateway().as_deref(),
        Some(gw1.as_str()),
        "默认阶梯只认已装配账户"
    );
}

// ---- 0.2.19 状态面钉(s48–s52):登录态徽标 / 断供两判据 / 问不到退回 / 『断开』 ----

/// 造一份网关状态读数(ek-ctp 0.9.0 `GatewayStatus`;其余位走 Default = 全 false/0/None)。
fn status_of(
    gateway: &str,
    f: impl FnOnce(&mut ek_ctp::plugin::GatewayStatus),
) -> ek_ctp::plugin::GatewayStatus {
    let mut st = ek_ctp::plugin::GatewayStatus {
        gateway: gateway.to_string(),
        sampled_ms: ek_ctp::map::now_epoch_ms(),
        ..Default::default()
    };
    f(&mut st);
    st
}

/// s48 状态列随读数变:在线 / 掉线×N / 登录失败 三档各自显形,且**旧读数一律不留档**。
///
/// 关键判据:`td_handle_up = true` 而 `td_logged_in = false` 时必须显示「掉线」而不是「在线」
/// —— 网关的"句柄已建"在掉线时仍是真(ek-ctp DESIGN D-STATUS ①),界面照抄它就等于谎称在线。
#[test]
fn s48_login_badge_follows_gateway_status_readings() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    let (gw1, _gw2) = two_accounts(&shared);
    // 状态读数分 TD/MD 两侧,判据是「该行的 td 前置留档非空」。
    // `two_accounts` 的夹具表单没有前置(它只测派生名与单源纪律),这里补上,
    // 否则测到的是 MD 半边分支(s48–s50 要的是 TD 半边)。
    shared.update_account_form(
        &gw1,
        acct_form(
            "9999",
            "278153",
            "tcp://127.0.0.1:30001",
            "tcp://127.0.0.1:30011",
        ),
    );
    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();
    assert_eq!(
        harness
            .query_all_by_label_contains("已装配·登录态未知")
            .count(),
        2,
        "起点:两行都还没有读数"
    );

    let now = ek_ctp::map::now_epoch_ms();
    // 读数一:登录成功 + 刚有资金回报 → 在线(唯一硬证据 = td_logged_in)。
    shared.note_gateway_status(
        &gw1,
        &status_of(&gw1, |st| {
            st.td_handle_up = true;
            st.td_logged_in = true;
            st.td_last_login_ok_ms = Some(now);
            st.td_last_account_ms = Some(now);
        }),
    );
    harness.run();
    assert!(
        harness.query_by_label("已装配·在线").is_some(),
        "读到 td_logged_in → 「已装配·在线」"
    );
    assert_eq!(
        harness
            .query_all_by_label_contains("已装配·登录态未知")
            .count(),
        1,
        "另一行不受影响,仍是未知"
    );

    // 读数二:句柄还在、登录掉了、断线计数 2 → 掉线(且细节带次数)。
    shared.note_gateway_status(
        &gw1,
        &status_of(&gw1, |st| {
            st.td_handle_up = true;
            st.td_logged_in = false;
            st.td_disconnects = 2;
            st.td_last_error = Some("柜台连接断开".to_string());
        }),
    );
    harness.run();
    assert!(
        harness.query_by_label("已装配·掉线").is_some(),
        "handle_up 真而 logged_in 假 → 掉线(不得显示在线)"
    );
    assert_eq!(
        harness.query_all_by_label_contains("已装配·在线").count(),
        0,
        "旧读数不得留档"
    );
    assert!(
        harness.query_by_label_contains("掉线 2 次").is_some(),
        "细节行须带次数(F2 那种反复断供的取证入口)"
    );

    // 读数三:登录失败与掉线是两档(网关侧 td_login_failed 独立于 disconnects)。
    shared.note_gateway_status(
        &gw1,
        &status_of(&gw1, |st| {
            st.td_handle_up = true;
            st.td_login_failed = true;
            st.td_last_error = Some("登录被拒:账号或密码错误".to_string());
        }),
    );
    harness.run();
    assert!(
        harness.query_by_label("已装配·登录失败").is_some(),
        "登录失败独立显形(与掉线不混成一档)"
    );
    assert!(
        harness
            .query_by_label_contains("登录被拒:账号或密码错误")
            .is_some(),
        "网关的原始错误文本须上屏,不是只给一个档名"
    );
}

/// s49 「查询断供」两判据(规划 Q10):连续被拒达阈 **或** 资金回报超时;阈值可覆盖。
///
/// 阈值住在界面不住在网关:ek-ctp 只给 `td_query_reject_streak` 与时刻,
/// 「几次算断供 / 多久算断供」是界面口径。缺省 3 次 / 30 秒。
#[test]
fn s49_query_starved_two_criteria_and_thresholds() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    let (gw1, _gw2) = two_accounts(&shared);
    // 状态读数分 TD/MD 两侧,判据是「该行的 td 前置留档非空」。
    // `two_accounts` 的夹具表单没有前置(它只测派生名与单源纪律),这里补上,
    // 否则测到的是 MD 半边分支(s48–s50 要的是 TD 半边)。
    shared.update_account_form(
        &gw1,
        acct_form(
            "9999",
            "278153",
            "tcp://127.0.0.1:30001",
            "tcp://127.0.0.1:30011",
        ),
    );
    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();
    let now = ek_ctp::map::now_epoch_ms();

    // 判据一(未达阈):连续被拒 2 次 < 缺省 3 → 仍算在线,不冤枉正在重试的账户。
    shared.note_gateway_status(
        &gw1,
        &status_of(&gw1, |st| {
            st.td_handle_up = true;
            st.td_logged_in = true;
            st.td_last_account_ms = Some(now);
            st.td_query_rejects = 2;
            st.td_query_reject_streak = 2;
        }),
    );
    harness.run();
    assert!(
        harness.query_by_label("已装配·在线").is_some(),
        "2 次被拒没达阈值 3 → 不算断供"
    );

    // 判据一(达阈):第 3 次 → 断供,细节带次数。
    shared.note_gateway_status(
        &gw1,
        &status_of(&gw1, |st| {
            st.td_handle_up = true;
            st.td_logged_in = true;
            st.td_last_account_ms = Some(now);
            st.td_query_rejects = 3;
            st.td_query_reject_streak = 3;
        }),
    );
    harness.run();
    assert!(
        harness.query_by_label("已装配·查询断供").is_some(),
        "连续被拒达阈 → 查询断供"
    );
    assert!(
        harness
            .query_by_label_contains("查询连续被拒 3 次")
            .is_some(),
        "细节须说清是哪一判据命中的"
    );

    // 判据二:登录着、一次都没被拒,但资金回报 60 秒没更新 → 也是断供(带秒数)。
    shared.note_gateway_status(
        &gw1,
        &status_of(&gw1, |st| {
            st.td_handle_up = true;
            st.td_logged_in = true;
            st.td_last_account_ms = Some(now - 60_000);
        }),
    );
    harness.run();
    assert!(
        harness.query_by_label("已装配·查询断供").is_some(),
        "登录着但资金回报超时 → 同样断供(被拒 0 次不豁免)"
    );
    assert!(
        harness
            .query_by_label_contains("资金回报已 60s 未更新")
            .is_some(),
        "细节须带超时秒数"
    );

    // 登录着、资金一次都没到手:轮转第一拍本就要几秒 → 在线 + 写明「尚未到手」,不冤枉成断供。
    shared.note_gateway_status(
        &gw1,
        &status_of(&gw1, |st| {
            st.td_handle_up = true;
            st.td_logged_in = true;
            st.td_last_login_ok_ms = Some(now);
        }),
    );
    harness.run();
    assert!(
        harness.query_by_label("已装配·在线").is_some(),
        "「从未回报过」≠「断供」(None 不当 0 用)"
    );

    // 阈值覆盖:夹具收紧到「1 次 / 30 秒」后,1 次被拒即断供。
    shared.set_status_thresholds(1, 30);
    assert_eq!(shared.status_thresholds(), (1, 30), "覆盖位须可读回");
    shared.note_gateway_status(
        &gw1,
        &status_of(&gw1, |st| {
            st.td_handle_up = true;
            st.td_logged_in = true;
            st.td_last_account_ms = Some(ek_ctp::map::now_epoch_ms());
            st.td_query_reject_streak = 1;
        }),
    );
    harness.run();
    assert!(
        harness.query_by_label("已装配·查询断供").is_some(),
        "阈值覆盖生效:1 次被拒即断供"
    );
}

/// s50 状态口问不到(网关不在场 / ask 超时)→ 退回「登录态未知」并写明原因。
///
/// 这一钉守的是反向谎称:上一轮读到「在线」,这一轮问不到时**不得把「在线」留在屏上**
/// —— 问不到就是没有证据。
#[test]
fn s50_unreachable_status_falls_back_to_unknown() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    let (gw1, _gw2) = two_accounts(&shared);
    // 状态读数分 TD/MD 两侧,判据是「该行的 td 前置留档非空」。
    // `two_accounts` 的夹具表单没有前置(它只测派生名与单源纪律),这里补上,
    // 否则测到的是 MD 半边分支(s48–s50 要的是 TD 半边)。
    shared.update_account_form(
        &gw1,
        acct_form(
            "9999",
            "278153",
            "tcp://127.0.0.1:30001",
            "tcp://127.0.0.1:30011",
        ),
    );
    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();

    let now = ek_ctp::map::now_epoch_ms();
    shared.note_gateway_status(
        &gw1,
        &status_of(&gw1, |st| {
            st.td_handle_up = true;
            st.td_logged_in = true;
            st.td_last_account_ms = Some(now);
        }),
    );
    harness.run();
    assert!(
        harness.query_by_label("已装配·在线").is_some(),
        "先读到在线"
    );

    shared.note_status_unreachable(&gw1, "ask 超时");
    harness.run();
    assert_eq!(
        harness.query_all_by_label_contains("已装配·在线").count(),
        0,
        "问不到时旧「在线」必须消失"
    );
    assert_eq!(
        harness
            .query_all_by_label_contains("已装配·登录态未知")
            .count(),
        2,
        "两行都回到未知(问不到 = 没有证据)"
    );
    assert!(
        harness.query_by_label_contains("状态口问不到").is_some(),
        "须写明为什么未知,而不是静默变灰"
    );
}

/// s51 『断开』钮:入列 `UiCmd::Disconnect`(不是 Remove);回执后徽标「已断开」、
/// 『断开』消失、『连接』回来(裁定 1:断开 = 停会话,实例与订阅保留,『连接』可恢复)。
#[test]
fn s51_disconnect_button_enqueues_and_badge_follows() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    let (gw1, _gw2) = two_accounts(&shared);
    // 状态读数分 TD/MD 两侧,判据是「该行的 td 前置留档非空」。
    // `two_accounts` 的夹具表单没有前置(它只测派生名与单源纪律),这里补上,
    // 否则测到的是 MD 半边分支(s48–s50 要的是 TD 半边)。
    shared.update_account_form(
        &gw1,
        acct_form(
            "9999",
            "278153",
            "tcp://127.0.0.1:30001",
            "tcp://127.0.0.1:30011",
        ),
    );
    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();

    harness.get_by_label("断开 278153").click_accesskit();
    harness.run();
    let cmds = shared.drain_cmds();
    assert!(
        cmds.iter()
            .any(|c| matches!(c, UiCmd::Disconnect { gateway } if gateway == &gw1)),
        "『断开』须入列 Disconnect(不是 Remove:那是停实例,另一颗钮)"
    );
    assert!(
        !cmds.iter().any(|c| matches!(c, UiCmd::Remove { .. })),
        "『断开』不得顺手发移除令"
    );
    assert!(
        shared
            .logs_snapshot()
            .0
            .iter()
            .any(|l| l.contains("断开") && l.contains("已入列")),
        "按了钮须有一条入列日志"
    );

    // 插件侧回执(等价于 run_cmds 里 ask `{gw}.disconnect` 成功后的 mark_account_stopped)。
    shared.mark_account_stopped(&gw1);
    harness.run();
    assert!(
        harness.query_by_label("已装配·已断开").is_some(),
        "回执后徽标 = 已断开(装配态不变:实例还在)"
    );
    assert!(
        harness.query_by_label("断开 278153").is_none(),
        "会话已停的行不再给『断开』钮(没有会话可停)"
    );
    assert!(
        harness.query_by_label("连接 278153").is_some(),
        "恢复入口仍是『连接』(插件侧走「已在场 → 重投凭据」)"
    );
    assert!(
        harness.query_by_label("移除 278153").is_some(),
        "『移除』与『断开』并存:两个动作两条口,不共用"
    );
}

/// s52 未装配的行没有『断开』钮(规划 Q21:口不存在/无会话可停时不留死钮)。
#[test]
fn s52_not_assembled_row_has_no_disconnect_button() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    let (gw1, gw2) = two_accounts(&shared);
    // 第三行:只写清册,不装配(规划裁定「添加 ≠ 连接」)。
    let gw3 =
        ek_ui::state::AccountRow::derive(&acct_form("9999", "777001", "tcp://127.0.0.1:30001", ""))
            .expect("派生注册名")
            .gateway;
    assert_ne!(gw3, gw1);
    assert_ne!(gw3, gw2);
    shared
        .add_account(
            ek_ui::state::AccountRow::derive(&acct_form(
                "9999",
                "777001",
                "tcp://127.0.0.1:30001",
                "",
            ))
            .expect("派生"),
        )
        .expect("添加第三账户");
    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();

    assert!(harness.query_by_label(&gw3).is_some(), "第三行在场");
    assert!(
        harness.query_by_label("断开 777001").is_none(),
        "未装配 = 没有会话可停,不留死钮(Q21)"
    );
    assert!(
        harness.query_by_label("连接 777001").is_some(),
        "未装配给『连接』"
    );
    assert!(
        harness.query_by_label("断开 278153").is_some(),
        "已装配那行仍给『断开』"
    );
}

/// s53 纯行情账户看 MD 半边:td 前置留档为空的行,状态口 TD 读数永远全假,
/// 照抄它只会得到「未知」——那是假信息,必须改看 `md_logged_in`。
#[test]
fn s53_md_only_row_reads_the_md_side() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    let (gw1, _gw2) = two_accounts(&shared);
    // 把 gw1 的留档改成"只有行情前置"(TD 半边空)。
    shared.update_account_form(
        &gw1,
        acct_form("9999", "278153", "", "tcp://127.0.0.1:30011"),
    );
    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();

    let now = ek_ctp::map::now_epoch_ms();
    // TD 半边全假、MD 半边登录成功 → 必须「在线」。
    shared.note_gateway_status(
        &gw1,
        &status_of(&gw1, |st| {
            st.md_handle_up = true;
            st.md_logged_in = true;
            st.md_last_login_ok_ms = Some(now);
            st.md_last_tick_ms = Some(now);
            st.md_subscribed = 1;
        }),
    );
    harness.run();
    assert!(
        harness.query_by_label("已装配·在线").is_some(),
        "纯行情账户的在线证据在 MD 半边"
    );
    assert!(
        harness.query_by_label_contains("纯行情账户").is_some(),
        "细节须写明这一行没有 TD 会话(否则「在线」会被读成账户都能下单)"
    );

    // MD 掉线同样显形(带次数)。
    shared.note_gateway_status(
        &gw1,
        &status_of(&gw1, |st| {
            st.md_handle_up = true;
            st.md_disconnects = 3;
        }),
    );
    harness.run();
    assert!(
        harness.query_by_label("已装配·掉线").is_some(),
        "MD 掉线同样显形"
    );
    assert!(harness.query_by_label_contains("掉线 3 次").is_some());
    // 纯行情账户没有 TD 查询,不参与「查询断供」判定。
    assert_eq!(
        harness.query_all_by_label_contains("查询断供").count(),
        0,
        "TD 判据不得套在纯行情账户上"
    );
}

/// s54 『断开』与『移除』共用同一道活跃委托守卫:柜台还在跑单时不画『断开』。
///
/// 为什么必须同守卫:会话一停就撤不了单、也不再回报 —— 与『移除』要挡的是同一个
/// 观测黑洞(规划 §5.B)。出口也同一个:旁边那句『先撤 N 笔』。
#[test]
fn s54_disconnect_respects_active_order_guard() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    let (gw1, gw2) = two_accounts(&shared);
    shared.set_trade_snap(ek_ui::state::TradeSnap {
        orders: vec![ek_data::order::OrderData {
            gateway_name: gw2.clone(),
            symbol: "rb2605".to_string(),
            exchange: ek_data::constant::Exchange::SHFE,
            orderid: format!("{gw2}.CTP_9_1"),
            status: ek_data::constant::Status::NOTTRADED,
            ..ek_data::order::OrderData::default()
        }],
        ..ek_ui::state::TradeSnap::default()
    });
    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();

    // gw2 有 1 笔活跃委托:『断开』不画(不是画出来再禁用),守卫句与出口在场。
    assert!(
        harness.query_by_label("断开 220380").is_none(),
        "有活跃委托时不画『断开』(会话一停就撤不了单)"
    );
    assert!(
        harness.query_by_label_contains("有 1 笔活跃委托").is_some(),
        "守卫句须显形"
    );
    assert!(
        harness.query_by_label("先撤 1 笔").is_some(),
        "守卫须给出口"
    );
    // 没有委托的 gw1 照常可断。
    assert!(
        harness.query_by_label("断开 278153").is_some(),
        "无活跃委托的已装配账户仍可断开"
    );

    // 撤完(快照清空)后同一行的『断开』必须出现 —— 守卫是状态闸,不是一次性禁用。
    shared.set_trade_snap(ek_ui::state::TradeSnap::default());
    harness.run();
    assert!(
        harness.query_by_label("断开 220380").is_some(),
        "委托撤完『断开』即恢复"
    );
    assert!(
        harness.query_by_label("移除 220380").is_some(),
        "同一行『移除』也在场(两个动作两条口)"
    );
    assert_eq!(gw1, "ctp_9999_278153", "寻址仍是派生名");
}

/// s55 实例被摘除后,登录态读数一并作废:徽标回「未装配」,状态细节不得留着。
///
/// 会话读数属于那个实例。实例没了还印「已装配·掉线 / 掉线 2 次」,
/// 就是替一个已经不存在的会话说话。
#[test]
fn s55_removed_instance_voids_its_status_readings() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    let (gw1, gw2) = two_accounts(&shared);
    shared.update_account_form(
        &gw1,
        acct_form(
            "9999",
            "278153",
            "tcp://127.0.0.1:30001",
            "tcp://127.0.0.1:30011",
        ),
    );
    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();

    shared.note_gateway_status(
        &gw1,
        &status_of(&gw1, |st| {
            st.td_handle_up = true;
            st.td_disconnects = 2;
        }),
    );
    harness.run();
    assert!(
        harness.query_by_label("已装配·掉线").is_some(),
        "先读到掉线"
    );
    assert!(
        harness.query_by_label_contains("状态细节").is_some(),
        "细节行在场"
    );

    // svctl 快照不再带回这两个名字(被摘/宿主崩了)。
    shared.sync_accounts_connected(&[row("svctl"), row("dm"), row("oms")]);
    harness.run();
    assert_eq!(
        harness.query_all_by_label_contains("未装配").count(),
        2,
        "两行都回「未装配」"
    );
    assert_eq!(
        harness.query_all_by_label_contains("已装配·掉线").count(),
        0,
        "实例不在场了还印「掉线」= 替不存在的会话说话"
    );
    assert_eq!(
        harness.query_all_by_label_contains("状态细节").count(),
        0,
        "状态细节同样不得留着(只给已装配的行)"
    );
    let rows = shared.accounts();
    let a = rows.iter().find(|r| r.gateway == gw1).expect("行还在");
    assert_eq!(a.stage, ek_ui::state::AccountStage::NotAssembled);
    assert_eq!(a.login, ek_ui::state::LoginState::Unknown, "读数作废");
    assert!(a.status_note.is_none(), "细节作废");
    assert!(
        harness.query_by_label("连接 278153").is_some(),
        "回「未装配」即重新给『连接』钮"
    );
    assert!(
        harness.query_by_label("断开 278153").is_none(),
        "没有实例就没有会话可停"
    );
    assert_eq!(gw2, "ctp_9999_220380");
}

/// s56(Q17 收口,0.2.20):账户清册里的实例不在运维窗起停。
///
/// 账户面板的『移除』带全套守卫(活跃委托闸 + 二次确认 + 持仓提示),运维窗那条是
/// 同一动作的无守卫副本 —— 同一个实例两条移除路、守卫只长在一条上,等于守卫可绕过。
/// 实例仍列在表里(看得见),给的是去处而不是死钮。
#[test]
fn s56_ops_window_leaves_account_instances_to_the_account_panel() {
    let shared = Arc::new(UiShared::new());
    feed_rows(
        &shared,
        vec![
            row("svctl"),
            row("dm"),
            row("oms"),
            row("ui"),
            row("ctp_9999_278153"),
        ],
    );
    let (gw1, _gw2) = two_accounts(&shared);
    shared.set_ops_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();

    // 工厂插件照旧可在运维窗起停。
    assert!(
        harness.query_by_label("移除 dm").is_some(),
        "工厂插件仍可在运维窗移除"
    );
    assert!(
        harness.query_by_label("重注入 oms").is_some(),
        "工厂插件仍可重注入"
    );
    // 账户实例仍列在表里(运维可见性不砍)。
    assert!(
        harness.query_by_label_contains("ctp_9999_278153").is_some(),
        "账户实例仍列在运维表里"
    );
    assert!(
        harness.query_by_label("移除 ctp_9999_278153").is_none(),
        "清册里的实例不在运维窗移除(Q17 边界收口)"
    );
    assert!(
        harness.query_by_label("重注入 ctp_9999_278153").is_none(),
        "同上:重注入对命名实例本来就是 unknown factory"
    );
    assert!(
        harness
            .query_by_label_contains("起停归『账户』面板")
            .is_some(),
        "给出的是去处,不是死钮"
    );
    assert_eq!(gw1, "ctp_9999_278153");
}

/// s57(F4 口径显形,0.2.20):「谁查谁过滤」写死成一句话,面板与弹窗各一处。
///
/// 实连 S14b:账户 B 把勾选改成「期货+期权」,公用合约表仍 852 条、期权 0 条 ——
/// 用户没有任何办法判断是自己没生效还是网关没查到。
#[test]
fn s57_product_class_scope_is_stated_in_panel_and_dialog() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    let (_gw1, _gw2) = two_accounts(&shared);
    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();

    // 面板底部口径句在场(F4)。
    assert!(
        harness
            .query_by_label_contains("公用合约表 = 在场各网关的并集(合约与行情公用,规划 D3)")
            .is_some(),
        "面板底部口径句在场(F4)"
    );
    harness.get_by_label("编辑 278153").click_accesskit();
    harness.run();
    // 同一句常量在**面板与弹窗两处都在**(共用 `TXT_PRODUCT_CLASS_SCOPE`,不留两处字面量)。
    assert_eq!(
        harness
            .query_all_by_label_contains("收录勾选只管该网关自己收录并上抛的合约")
            .count(),
        2,
        "面板底部 + 配置弹窗各一处"
    );
    assert_eq!(
        harness
            .query_all_by_label_contains("改勾选须『连接/重试』才下发到网关")
            .count(),
        2,
        "生效条件同样两处都在"
    );
}

/// s58(接 ek-ctp 0.9.1 分项,0.2.20):合约查询被拒在状态细节里点名,且不重复点名。
///
/// 总数与连续串不分路(消费侧要的是「查询这条路连续被拒几次」),但用户要知道被拒的
/// 是哪一路:合约一条没到手时,下一笔单会被 ContractGate 以 `contract:unknown` 拒掉,
/// 而状态列只写「在线」就是假信息。
#[test]
fn s58_instrument_query_rejection_is_named_in_status_detail() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    let (gw1, _gw2) = two_accounts(&shared);
    shared.update_account_form(
        &gw1,
        acct_form(
            "9999",
            "278153",
            "tcp://127.0.0.1:30001",
            "tcp://127.0.0.1:30011",
        ),
    );
    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();
    let now = ek_ctp::map::now_epoch_ms();

    // 一:登录着、资金刚到手,但合约查询被拒过一次(未达断供阈)→ 在线 + 点名。
    shared.note_gateway_status(
        &gw1,
        &status_of(&gw1, |st| {
            st.td_handle_up = true;
            st.td_logged_in = true;
            st.td_last_login_ok_ms = Some(now);
            st.td_last_account_ms = Some(now);
            st.td_query_rejects = 1;
            st.td_query_reject_streak = 1;
            st.td_instrument_rejects = 1;
        }),
    );
    harness.run();
    assert!(
        harness.query_by_label("已装配·在线").is_some(),
        "连续串 1 未达阈 3:不算断供"
    );
    assert!(
        harness
            .query_by_label_contains("合约查询被拒 1 次")
            .is_some(),
        "0.9.1 分项须在状态细节里显形"
    );

    // 二:连续串达阈 → 断供句 + 分项句并存,且分项只点名一次。
    shared.note_gateway_status(
        &gw1,
        &status_of(&gw1, |st| {
            st.td_handle_up = true;
            st.td_logged_in = true;
            st.td_last_login_ok_ms = Some(now);
            st.td_last_account_ms = Some(now);
            st.td_query_rejects = 3;
            st.td_query_reject_streak = 3;
            st.td_instrument_rejects = 2;
        }),
    );
    harness.run();
    assert!(
        harness.query_by_label("已装配·查询断供").is_some(),
        "连续串达阈 → 断供"
    );
    let rows = shared.accounts();
    let a = rows.iter().find(|r| r.gateway == gw1).expect("行还在");
    let note = a.status_note.clone().unwrap_or_default();
    assert!(
        note.contains("查询连续被拒 3 次") && note.contains("合约查询被拒 2 次"),
        "两句并存,分路看得见: {note}"
    );
    assert_eq!(
        note.matches("合约查询被拒").count(),
        1,
        "同一件事不重复点名: {note}"
    );

    // 三:分项为 0 = 不加句子(健康行的状态细节不无谓变长)。
    shared.note_gateway_status(
        &gw1,
        &status_of(&gw1, |st| {
            st.td_handle_up = true;
            st.td_logged_in = true;
            st.td_last_login_ok_ms = Some(now);
            st.td_last_account_ms = Some(now);
        }),
    );
    harness.run();
    let rows = shared.accounts();
    let a = rows.iter().find(|r| r.gateway == gw1).expect("行还在");
    assert!(
        a.status_note.is_none(),
        "没有事实就不写句子: {:?}",
        a.status_note
    );
}

/// s59(日志窗口径,0.2.20):溢出后界面必须说清「这是窗口不是历史」。
///
/// 巡回 G1 的实连证据:重加账户后 90 秒内没抓到「交易服务器登录成功」行,但同一账户
/// 的资金行下一拍就刷新了 —— 登录其实成功,是 512 行环被状态推送流挤满把登录行顶掉了。
/// 取证的事实源是网关状态口(状态列 + 状态细节),不是日志回扫。
#[test]
fn s59_log_window_says_it_is_a_window_not_history() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    // 日志面板只在右下组「日志」tab 在场(默认不是它)。
    shared.set_bottomright_tab(ek_ui::state::BottomRightTab::Logs);
    for i in 0..(ek_ui::state::RING_CAP + 3) {
        shared.log_line(format!("[INFO][demo] 状态推送 {i:04}"));
    }
    let mut harness = harness_for(&shared);
    harness.run();
    assert!(
        harness
            .query_by_label_contains("日志窗是窗口不是历史")
            .is_some(),
        "溢出时必须把口径说出来(光有『已丢弃 N 条』不够)"
    );
    assert!(
        harness.query_by_label_contains("别回扫这里").is_some(),
        "并给出该去哪儿"
    );
    // 计数本身仍在场(溢出事实不抹)。
    assert!(
        harness.query_by_label_contains("已丢弃 3 条").is_some(),
        "溢出计数显形"
    );
}

/// s60(0.2.21 第三条断供判据,实连 S15f 逼出来的):登录着、一次查询都没被拒、
/// 但资金回报**从未到手** —— 超过 stale 阈值必须翻「查询断供」,不得永远显示「在线」。
///
/// 实连证据(2026-10-22 runA/runB):账户 B `logged_in=true`、`td_query_rejects=0`、
/// `td_last_account_ms=None` 持续 390 秒以上 —— 旧口径下它一直是「在线 + 资金回报尚未到手」。
#[test]
fn s60_never_received_account_report_becomes_starved() {
    let shared = Arc::new(UiShared::new());
    feed_rows(&shared, demo_rows());
    let (gw1, _gw2) = two_accounts(&shared);
    shared.update_account_form(
        &gw1,
        acct_form(
            "9999",
            "278153",
            "tcp://127.0.0.1:30001",
            "tcp://127.0.0.1:30011",
        ),
    );
    shared.set_accounts_window_open(true);
    let mut harness = harness_for(&shared);
    harness.run();
    let now = ek_ctp::map::now_epoch_ms();

    // 刚登录 5 秒:还没到手 = 在线 + 一句话(轮转第一拍本就要 2~6 秒,不能算断供)。
    shared.note_gateway_status(
        &gw1,
        &status_of(&gw1, |st| {
            st.td_handle_up = true;
            st.td_logged_in = true;
            st.td_last_login_ok_ms = Some(now - 5_000);
        }),
    );
    harness.run();
    assert!(
        harness.query_by_label("已装配·在线").is_some(),
        "刚登录几秒不算断供"
    );
    assert!(
        harness
            .query_by_label_contains("资金回报尚未到手")
            .is_some(),
        "但这句话要在场"
    );

    // 登录已 40 秒、资金一次都没到手、查询被拒 0 次 → 断供(实连 B 的形状)。
    shared.note_gateway_status(
        &gw1,
        &status_of(&gw1, |st| {
            st.td_handle_up = true;
            st.td_logged_in = true;
            st.td_last_login_ok_ms = Some(now - 40_000);
        }),
    );
    harness.run();
    assert!(
        harness.query_by_label("已装配·查询断供").is_some(),
        "从未到手超时即断供(不得永远在线)"
    );
    let rows = shared.accounts();
    let a = rows.iter().find(|r| r.gateway == gw1).expect("行还在");
    let note = a.status_note.clone().unwrap_or_default();
    assert!(
        note.contains("资金回报从未到手(登录已 40s)"),
        "细节要带时长: {note}"
    );

    // 真到手即回在线(判据不粘)。
    shared.note_gateway_status(
        &gw1,
        &status_of(&gw1, |st| {
            st.td_handle_up = true;
            st.td_logged_in = true;
            st.td_last_login_ok_ms = Some(now - 40_000);
            st.td_last_account_ms = Some(now);
        }),
    );
    harness.run();
    assert!(
        harness.query_by_label("已装配·在线").is_some(),
        "到手即回在线"
    );
}