ek-ui 0.4.2

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
//! L1:状态机单元测试(线程面/环形/revision;零渲染)。

use std::sync::Arc;
use std::thread;
use std::time::Duration;

use ek_data::constant::{Direction, Exchange, Offset, OrderType};
use ek_station::wire::PlugRow;
use ek_ui::state::{BottomRightTab, OrderForm, TopLeftTab};
use ek_ui::{BusSnap, LogRing, UiShared};

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,
    }
}

// ---- 环形日志 ----

#[test]
fn r0_ring_cap_and_spill_count() {
    let mut ring = LogRing::new(3);
    for i in 0..5 {
        ring.push(format!("L{i}"));
    }
    assert_eq!(ring.len(), 3);
    assert_eq!(ring.spilled(), 2);
    let snap = ring.snapshot();
    assert_eq!(snap, vec!["L2", "L3", "L4"]);
}

#[test]
fn r1_ring_clear_keeps_spilled_fact() {
    let mut ring = LogRing::new(2);
    for i in 0..4 {
        ring.push(format!("L{i}"));
    }
    assert_eq!(ring.spilled(), 2);
    ring.clear();
    assert!(ring.is_empty());
    assert_eq!(ring.spilled(), 2, "溢出事实不抹");
}

// ---- UiShared ----

#[test]
fn r2_shared_default_is_clean() {
    let s = UiShared::new();
    assert_eq!(s.rev(), 0);
    assert!(!s.is_stop());
    assert!(s.snapshot().rows.is_empty());
    let (lines, spilled) = s.logs_snapshot();
    assert!(lines.is_empty());
    assert_eq!(spilled, 0);
}

#[test]
fn r3_shared_trace_and_revision() {
    let s = UiShared::new();
    s.log_line("a");
    s.log_line("b");
    let r1 = s.rev();
    assert!(r1 >= 2);
    s.clear_logs();
    assert!(s.rev() > r1);
    let (lines, _) = s.logs_snapshot();
    assert!(lines.is_empty());
}

#[test]
fn r4_snapshot_roundtrip_shared_arc() {
    let s = Arc::new(UiShared::new());
    let mut snap = BusSnap::default();
    snap.rows.push(row("dm"));
    snap.services
        .push(("dm".to_string(), vec!["dm.reload".to_string()]));
    s.set_snapshot(snap);
    let got = s.snapshot();
    assert_eq!(got.rows.len(), 1);
    assert_eq!(got.rows[0].name, "dm");
    assert_eq!(got.services[0].1, vec!["dm.reload".to_string()]);
}

// ---- 停止位 ----

#[test]
fn r5_stop_flag_set_and_read() {
    let s = Arc::new(UiShared::new());
    let s2 = Arc::clone(&s);
    let h = thread::spawn(move || {
        thread::sleep(Duration::from_millis(50));
        s2.request_stop();
    });
    let mut saw = false;
    for _ in 0..50 {
        if s.is_stop() {
            saw = true;
            break;
        }
        thread::sleep(Duration::from_millis(20));
    }
    h.join().unwrap();
    assert!(saw, "stop 位跨线程可见");
}

// ---- v4 布局面(r6 tick 库 / r7 下单表单 / r8 组 tab) ----

fn tick(ex: Exchange, symbol: &str, last: f64) -> ek_data::tick::TickData {
    ek_data::tick::TickData {
        symbol: symbol.to_string(),
        exchange: ex,
        last_price: last,
        ..Default::default()
    }
}

#[test]
fn r6_tick_store_en_symbol_key_and_groups() {
    let s = UiShared::new();
    s.update_tick(&tick(Exchange::SHFE, "cu2709", 107660.0));
    s.update_tick(&tick(Exchange::DCE, "a2709", 3500.0));
    s.update_tick(&tick(Exchange::SHFE, "au2706", 786.5));
    // 覆盖:同 en_symbol 覆盖不增格
    s.update_tick(&tick(Exchange::SHFE, "cu2709", 107670.0));
    assert_eq!(s.tick_count(), 3);
    let rows = s.tick_rows();
    // 交易所字典序(DCE < SHFE),再合约名
    assert_eq!(rows[0].symbol, "a2709");
    assert_eq!(rows[1].symbol, "au2706");
    assert_eq!(rows[2].symbol, "cu2709");
    assert_eq!(rows[2].last_price, 107670.0, "覆盖生效");
    let groups = s.tick_groups();
    assert_eq!(groups.len(), 2);
    assert_eq!(groups[0].0, "DCE");
    assert_eq!(groups[1].0, "SHFE");
    assert_eq!(groups[1].1.len(), 2);
    // 跨所同码不撞(审计修正:键 = en_symbol)
    s.update_tick(&tick(Exchange::DCE, "cu2709", 1.0));
    assert_eq!(s.tick_count(), 4);
}

#[test]
fn r7_order_form_to_request_bits_and_errors() {
    let f = OrderForm {
        exchange: "SHFE".into(),
        symbol: "cu2709".into(),
        price: "107660".into(),
        volume: "2".into(),
        ..Default::default()
    };
    let req = f.to_request().unwrap();
    assert_eq!(req.symbol, "cu2709");
    assert_eq!(req.exchange, Exchange::SHFE);
    assert_eq!(req.direction, Direction::LONG);
    assert_eq!(req.offset, Offset::OPEN);
    assert_eq!(req.order_type, OrderType::LIMIT);
    assert_eq!(req.price, 107660.0);
    assert_eq!(req.volume, 2.0);
    // 错误面(红字就地,不进总线)
    assert!(
        OrderForm {
            symbol: "".into(),
            ..f.clone()
        }
        .to_request()
        .unwrap_err()
        .contains("代码为空")
    );
    assert!(
        OrderForm {
            symbol: "SHFE.cu2709".into(),
            ..f.clone()
        }
        .to_request()
        .unwrap_err()
        .contains("前缀")
    );
    assert!(
        OrderForm {
            exchange: "XXX".into(),
            ..f.clone()
        }
        .to_request()
        .unwrap_err()
        .contains("Exchange")
    );
    assert!(
        OrderForm {
            price: "abc".into(),
            ..f.clone()
        }
        .to_request()
        .unwrap_err()
        .contains("价格")
    );
    assert!(
        OrderForm {
            price: "-1".into(),
            ..f.clone()
        }
        .to_request()
        .unwrap_err()
        .contains("为正")
    );
    assert!(
        OrderForm {
            volume: "0".into(),
            ..f.clone()
        }
        .to_request()
        .unwrap_err()
        .contains("为正")
    );
    // 市价单零价合法(与票据面同语义)
    let mk = OrderForm {
        order_type: OrderType::MARKET,
        price: "0".into(),
        ..f.clone()
    };
    assert_eq!(mk.to_request().unwrap().price, 0.0);
}

#[test]
fn r8_group_tabs_defaults_and_switch() {
    let s = UiShared::new();
    assert_eq!(s.topleft_tab(), TopLeftTab::Orders);
    assert_eq!(s.bottomright_tab(), BottomRightTab::Account);
    s.set_topleft_tab(TopLeftTab::Active);
    s.set_bottomright_tab(BottomRightTab::Logs);
    assert_eq!(s.topleft_tab(), TopLeftTab::Active);
    assert_eq!(s.bottomright_tab(), BottomRightTab::Logs);
    assert_eq!(TopLeftTab::Active.label(), "未成交");
    assert_eq!(BottomRightTab::Logs.label(), "日志");
}

fn contract(ex: 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,
    }
}

#[test]
fn r9_contract_store_query_and_selection() {
    let s = UiShared::new();
    assert_eq!(s.contract_count(), 0);
    s.update_contract(&contract(Exchange::SHFE, "rb2605", "螺纹钢2605"));
    s.update_contract(&contract(Exchange::DCE, "a2709", "黄玉米2709"));
    s.update_contract(&contract(Exchange::SHFE, "au2706", "黄金2706"));
    assert_eq!(s.contract_count(), 3);
    // 覆盖(en_symbol 键;同所同码重写不增格)
    s.update_contract(&contract(Exchange::SHFE, "rb2605", "螺纹钢2605"));
    assert_eq!(s.contract_count(), 3);
    // 查询口径(Python ContractManager:代码/交易所/名称模糊,留空 = 全部)
    assert_eq!(s.contracts("").len(), 3);
    assert_eq!(s.contracts("rb").len(), 1);
    assert_eq!(s.contracts("DCE").len(), 1);
    assert_eq!(s.contracts("玉米").len(), 1);
    assert_eq!(s.contracts("不存在").len(), 0);
    // 勾选翻转
    s.toggle_contract_selection("SHFE.rb2605");
    s.toggle_contract_selection("DCE.a2709");
    assert_eq!(
        s.selected_contracts(),
        vec!["SHFE.rb2605".to_string(), "DCE.a2709".to_string()]
    );
    s.toggle_contract_selection("SHFE.rb2605");
    assert_eq!(s.selected_contracts(), vec!["DCE.a2709".to_string()]);
}

#[test]
fn r10_window_flags_and_kline_multiopen() {
    let s = UiShared::new();
    // 默认全关
    assert!(!s.ops_window_open());
    assert!(!s.accounts_window_open());
    assert!(!s.contracts_window_open());
    assert!(s.kline_symbols().is_empty());
    // 开关翻转
    s.set_ops_window_open(true);
    s.set_accounts_window_open(true);
    s.set_contracts_window_open(true);
    assert!(s.ops_window_open() && s.accounts_window_open() && s.contracts_window_open());
    // K线窗多开 + 去重 + 关闭出表
    s.open_kline("rb2605");
    s.open_kline("au2706");
    s.open_kline("rb2605");
    assert_eq!(
        s.kline_symbols(),
        vec!["rb2605".to_string(), "au2706".to_string()]
    );
    s.close_kline("rb2605");
    assert_eq!(s.kline_symbols(), vec!["au2706".to_string()]);
    // open_kline 日志留痕(可断言面)
    assert!(
        s.logs_snapshot()
            .0
            .iter()
            .any(|l| l.contains("打开 K线窗 rb2605")),
        "kline open log missing"
    );
}

/// r8(BD-UI-31 扩展):行情带入逐列口径 —— 盘口四列带方向(买1价/买1量 →
/// 卖,卖1价/卖1量 → 买,价格列顺带填价);其余列只带入,方向/价格保持默认。
#[test]
fn r8_import_tick_columns() {
    let s = UiShared::new();
    let tick = ek_data::tick::TickData {
        gateway_name: "ctp".to_string(),
        symbol: "sn2709".to_string(),
        exchange: Exchange::SHFE,
        datetime: 1,
        last_price: 380.0,
        bid_price_1: 379.0,
        bid_volume_1: 3.0,
        ask_price_1: 381.0,
        ask_volume_1: 4.0,
        ..ek_data::tick::TickData::default()
    };
    // 默认表单(方向默认 LONG,价格空)。
    let base = OrderForm {
        symbol: "cu2709".to_string(),
        ..OrderForm::default()
    };
    s.set_order_form(base.clone());

    // 买1价列(8):带入 + 方向卖 + 价格 = 买1价。
    ek_ui::render::import_tick(&s, &tick, "SHFE", 8);
    let f = s.order_form();
    assert_eq!(f.symbol, "sn2709");
    assert_eq!(f.exchange, "SHFE");
    assert_eq!(f.direction, Direction::SHORT);
    assert_eq!(f.price, "379");

    // 买1量列(9):方向卖,价格不动(保持上一拍 379.0 不被清空)。
    ek_ui::render::import_tick(&s, &tick, "SHFE", 9);
    let f = s.order_form();
    assert_eq!(f.direction, Direction::SHORT);
    assert_eq!(f.price, "379");

    // 卖1价列(10):方向买 + 价格 = 卖1价。
    ek_ui::render::import_tick(&s, &tick, "SHFE", 10);
    let f = s.order_form();
    assert_eq!(f.direction, Direction::LONG);
    assert_eq!(f.price, "381");

    // 卖1量列(11):方向买,价格不动。
    ek_ui::render::import_tick(&s, &tick, "SHFE", 11);
    assert_eq!(s.order_form().direction, Direction::LONG);

    // 普通列(2 最新价等):只带入合约+交易所,方向/价格保持表单现值。
    s.set_order_form(OrderForm {
        price: "999".to_string(),
        direction: Direction::SHORT,
        ..base.clone()
    });
    ek_ui::render::import_tick(&s, &tick, "SHFE", 2);
    let f = s.order_form();
    assert_eq!(f.symbol, "sn2709");
    assert_eq!(f.exchange, "SHFE");
    assert_eq!(f.direction, Direction::SHORT);
    assert_eq!(f.price, "999");
}

// ---- 0.2.30(BD-UI-46)主题面收缩后的判据 ----
//
// 原来这一节有三条钉:r9(五套主题一键循环 + label)、r9c(`Theme as u8` 编码稳定 +
// AtomicU8 往返)、r9d(其余四套底色不被新主题带跑)。裁定 8 把主题减成一套、
// `Theme` 枚举与 `theme()/set_theme()/cycle_theme()` 全删 —— 三条钉的判据对象
// 都不存在了,**删除**,不留空转断言。下面留的是收缩后仍然有牙的两条。

/// U1 色值钉(原 r9b 原样保留,只换入口):唯一主题的视觉值 = shadcn/ui Nova 深色。
/// 值源 = `egui-shadcn 0.5.0`(= FerrisMind/shadcn-rs)`ColorPalette::shadcn_dark(
/// ShadcnBaseColor::Neutral)`,本机把库装下来跑探针把 26 支色读出来核对(DESIGN BD-UI-57
/// 有整张对照表)。值写死在这里是故意的 —— 将来谁手改色板、或 egui 升级换了缺省,这条立刻红。
/// 旧值(QDarkStyle 3.2.3 实测色板)逐支留在 CHANGELOG 0.4.1 四期。
#[test]
fn u1_visuals_match_shadcn_nova_palette() {
    use egui::Color32;
    let ctx = egui::Context::default();
    ek_ui::render::apply_ui_style(&ctx);
    let rgb = |r: u8, g: u8, b: u8| Color32::from_rgb(r, g, b);
    let (bg1, bg2, bg4, bg5, bg6) = (
        rgb(0x0A, 0x0A, 0x0A), // background
        rgb(0x17, 0x17, 0x17), // card
        rgb(0x26, 0x26, 0x26), // secondary = muted
        rgb(0x40, 0x40, 0x40), // accent
        rgb(0x52, 0x52, 0x52), // 本仓合成:pressed 沿中性阶再上一档
    );
    let (text1, text3, accent2, accent4) = (
        rgb(0xFA, 0xFA, 0xFA), // foreground
        rgb(0xA1, 0xA1, 0xA1), // muted-foreground
        rgb(0x52, 0x52, 0x52), // 选中底 = 比 hover(accent #404040)再亮一档
        rgb(0xE5, 0xE5, 0xE5), // primary(下划线 / 链接)
    );
    // `border` = 白 10%。egui 的 Color32 是预乘存储 → 白 10% 存成 (26,26,26,26);
    // 这里用预乘写法钉死,顺带把「库实测值 #1A1A1A_1A 与我们这支相等」也钉住。
    let border = Color32::from_rgba_premultiplied(26, 26, 26, 26);
    let v = ctx.style().visuals.clone(); // egui 0.33:style() -> Arc<Style>,style_mut 不回值
    assert!(v.dark_mode, "Nova 是深色主题");
    assert_eq!(v.window_fill, bg1, "页面底 background");
    assert_eq!(v.panel_fill, bg1, "shadcn 容器同底,靠描边区分");
    assert_eq!(v.extreme_bg_color, bg1, "输入框底 = 页面底");
    assert_eq!(v.faint_bg_color, bg2, "card");
    assert_eq!(v.window_stroke.color, border, "一切描边 = border(白 10%)");
    assert_eq!(
        v.hyperlink_color, accent4,
        "primary(选中 tab 下划线那支;旧 Blue.B70 蓝档退役)"
    );
    assert_eq!(v.weak_text_color, Some(text3), "次级文本 muted-foreground");
    // 控件三态 = secondary → accent(hover) → 合成 pressed
    assert_eq!(v.widgets.inactive.bg_fill, bg4);
    assert_eq!(v.widgets.inactive.weak_bg_fill, bg4);
    assert_eq!(v.widgets.hovered.bg_fill, bg5);
    assert_eq!(v.widgets.active.bg_fill, bg6);
    assert_eq!(v.widgets.open.bg_fill, bg6);
    // 文字一律 TEXT1,且 hover/pressed 不加重(shadcn 不加重)
    for (name, w) in [
        ("noninteractive", &v.widgets.noninteractive),
        ("inactive", &v.widgets.inactive),
        ("hovered", &v.widgets.hovered),
        ("active", &v.widgets.active),
        ("open", &v.widgets.open),
    ] {
        assert_eq!(w.fg_stroke.color, text1, "{name} 文字色");
        assert_eq!(w.fg_stroke.width, 1.0, "{name} hover/pressed 不加重文字");
        assert_eq!(w.expansion, 0.0, "{name} hover 不涨块(egui 缺省是 1.0)");
        assert_eq!(
            w.corner_radius,
            egui::CornerRadius::same(8),
            "{name} 控件圆角 = calc(var(--radius) - 2px) = 8px"
        );
    }
    assert_eq!(
        v.widgets.noninteractive.bg_stroke.color, border,
        "分隔线/窗框描边 = border(旧口径这一支兼做表头带底,已拆)"
    );
    // 选中:Nova 没有彩色强调 → 由「比 hover 再亮一档的底」表达;文字色 = TEXT1
    assert_eq!(v.selection.bg_fill, accent2);
    assert_eq!(v.selection.stroke.color, text1, "egui 把它当选中项文字色用");
    // 底色阶梯(0.4.1 四期 BD-UI-57 定死):页面底 < 控件底 < hover < 选中/pressed。
    // 挡的是「把选中调成和 hover 同值 → 选中的行看不出来」这类改法。
    let lum = |c: Color32| -> u32 { (c[0] as u32) + (c[1] as u32) + (c[2] as u32) };
    assert!(
        lum(bg1) < lum(bg4) && lum(bg4) < lum(bg5) && lum(bg5) < lum(accent2),
        "底色阶梯必须单调:页面 {bg1:?} < 控件 {bg4:?} < hover {bg5:?} < 选中 {accent2:?}"
    );
    assert_eq!(
        v.widgets.active.bg_fill, v.selection.bg_fill,
        "pressed/checked 与选中同档是有意的设计(两者不会落在同一类控件上)"
    );
    // 圆角两档:卡片/窗/菜单 = --radius 10px,控件 = 8px
    assert_eq!(v.window_corner_radius, egui::CornerRadius::same(10));
    assert_eq!(v.menu_corner_radius, egui::CornerRadius::same(10));
    // 跨主题恒定的东西不该被这套主题动掉
    assert_eq!(
        v.disabled_alpha, 0.5,
        "禁用仍走 alpha 近似(与旧主题同值,语义面不动)"
    );
    assert!(
        !v.striped,
        "shadcn 表没有斑马纹(旧 qss alternate-background 同底也不条纹)"
    );
}

/// U2 单一色源钉(BD-UI-46):`src/` 里**构造颜色的字面量**只允许出现在 `tokens.rs`。
/// 0.2.29 之前这条挡不住的东西有 38 处(渲染代码里各写各的语义色)。
/// 0.4.1 四期(BD-UI-57)把针扩到 alpha 构造器:`BORDER` 用的是
/// `Color32::from_rgba_unmultiplied_const`,只数 `from_rgb(` 会放跑这一支。
#[test]
fn u2_colors_have_a_single_source() {
    let root = concat!(env!("CARGO_MANIFEST_DIR"), "/src");
    const NEEDLES: [&str; 3] = [
        "Color32::from_rgb(",
        "Color32::from_rgba_unmultiplied",
        "Color32::from_rgba_premultiplied",
    ];
    let mut offenders: Vec<String> = Vec::new();
    for entry in std::fs::read_dir(root).expect("src/ 必须可读") {
        let path = entry.expect("src/ 目录项").path();
        if path.extension().and_then(|s| s.to_str()) != Some("rs") {
            continue;
        }
        let name = path
            .file_name()
            .expect("文件名")
            .to_string_lossy()
            .to_string();
        let text = std::fs::read_to_string(&path).expect("读 src 文件");
        let hits: usize = NEEDLES.iter().map(|n| text.matches(*n).count()).sum();
        if hits > 0 && name != "tokens.rs" {
            offenders.push(format!("{name} 里 {hits} 处"));
        }
    }
    assert!(
        offenders.is_empty(),
        "硬编码色必须只在 tokens.rs,实测 {offenders:?}"
    );
}

/// U5 地基钉:字号阶梯必须**三档可分**(标题 / 正文 / 次级)。
/// 毛病 7「无视觉层级」的根因是同一档文字被用在不同语义上;这一条挡的是
/// 「哪天把三档写成同一个数,层级又塌回一档」。
#[test]
fn u5_type_scale_has_three_separable_tiers() {
    use ek_ui::tokens;
    let ctx = egui::Context::default();
    ek_ui::render::apply_ui_style(&ctx);
    let style = ctx.style();
    let size = |t: egui::TextStyle| style.text_styles[&t].size;
    let heading = size(egui::TextStyle::Heading);
    let body = size(egui::TextStyle::Body);
    let small = size(egui::TextStyle::Small);
    assert!(
        heading >= body + 4.0,
        "标题必须明显大于正文:heading {heading} vs body {body}"
    );
    assert!(
        body >= small + 2.0,
        "正文必须明显大于次级:body {body} vs small {small}"
    );
    assert_eq!(body, tokens::FONT_BODY);
    assert_eq!(small, tokens::FONT_SMALL);
    assert_eq!(heading, tokens::FONT_HEADING);
    // 次级必须真的更淡(字号降 + 色降,两档都降才算层级;只降字号会被同色抹平)。
    assert_eq!(
        style.visuals.weak_text_color,
        Some(tokens::shadcn::TEXT3),
        "次级文本走 TEXT3,不是正文色"
    );
    assert_ne!(tokens::shadcn::TEXT3, tokens::shadcn::TEXT1);
}

/// U3 涨跌色钉(BD-UI-46「涨跌色定死红涨绿跌」):语义入口只有一支,方向写死,
/// 且涨色与跌色必须是两支不同的颜色(抄成同一支 = 涨跌看不出来)。
/// 0 不染色 —— 平盘不该被染成任何一种方向色。
#[test]
fn u3_rise_fall_colors_are_fixed_and_distinct() {
    use ek_ui::tokens;
    assert_eq!(tokens::rise_fall(1.0), tokens::semantic::RISE, "涨必须红");
    assert_eq!(
        tokens::rise_fall(0.01),
        tokens::semantic::RISE,
        "微涨也是涨"
    );
    assert_eq!(tokens::rise_fall(-1.0), tokens::semantic::FALL, "跌必须绿");
    assert_eq!(
        tokens::rise_fall(-0.01),
        tokens::semantic::FALL,
        "微跌也是跌"
    );
    assert_eq!(tokens::rise_fall(0.0), tokens::shadcn::TEXT1, "平盘不染色");
    assert_ne!(
        tokens::semantic::RISE,
        tokens::semantic::FALL,
        "涨跌两色必须不同"
    );
    // 红涨绿跌(中国期货惯例),不是美股那套绿涨红跌:RISE 的红分量必须明显高。
    let r = tokens::semantic::RISE;
    let g = tokens::semantic::FALL;
    assert!(r.r() > r.g(), "RISE 必须是红,实测 {:?}", r);
    assert!(g.g() > g.r(), "FALL 必须是绿,实测 {:?}", g);
    // 语义色与「错误 / 成功」不得混同(把「跌」和「错」画成同一支 = 读表的人分不清)。
    assert_ne!(tokens::semantic::RISE, tokens::semantic::ERROR);
    assert_ne!(tokens::semantic::FALL, tokens::semantic::OK);
}

/// U4 UiExt 钉(BD-UI-47):五支助手都必须**真在被调用**,不是写着一个没人用的模块。
/// 判据形状 = 机械扫描调用点计数(定义处 `pub fn x(` 不计,只数 `::x(`)。
#[test]
fn u4_ui_ext_helpers_are_actually_used() {
    let root = concat!(env!("CARGO_MANIFEST_DIR"), "/src");
    let mut corpus = String::new();
    for entry in std::fs::read_dir(root).expect("src/ 必须可读") {
        let path = entry.expect("src/ 目录项").path();
        if path.extension().and_then(|s| s.to_str()) == Some("rs") {
            corpus.push_str(&std::fs::read_to_string(&path).expect("读 src 文件"));
        }
    }
    for name in [
        "section_title",
        "muted_label",
        "space_group",
        "primary_button",
        "num_cell",
    ] {
        let calls = corpus.matches(&format!("::ui_ext::{name}(")).count();
        assert!(
            calls >= 1,
            "UiExt::{name} 必须至少有一处调用点,实测 {calls} 处"
        );
    }
}

/// E6 告警归属钉(0.3.2 Wave E,BD-UI-50):告警只由**状态轮询**产生,不由任何窗口产生。
///
/// 判据形状 = 纯状态层(全程不建构界面、不开任何窗):
/// ① 轮询一拍问出告警态 → 待灌告警 +1;② 同一状态再来一拍 → 不重复弹(去重);
/// ③ 状态转好 → 不弹;④ 状态换一档(掉线)→ 算新告警再弹一条。
/// 外加一条机械扫描:`push_alert(` 在 src/ 里只许有一个调用点,而且必须在 `state.rs`
/// —— 原来它挂在账户面板的渲染里(窗没开 = 没有告警),那是 Python `AlertEngine`
/// 没有的毛病:告警是「状态口问出来的事实」,不是「某张窗画出来的东西」。
#[test]
fn e6_alerts_come_from_the_status_poll_not_from_a_window() {
    let shared = Arc::new(UiShared::new());
    // 阈值收紧到「1 次 / 0 秒」:没有真柜台也要让断供判据显形(与 s49 同一手法)。
    shared.set_status_thresholds(1, 0);
    let form = ek_ui::state::CtpForm {
        broker_id: "9999".to_string(),
        td_user: "278153".to_string(),
        td_addr: "tcp://127.0.0.1:30001".to_string(),
        md_addr: "tcp://127.0.0.1:30011".to_string(),
        ..ek_ui::state::CtpForm::default()
    };
    let acct = ek_ui::state::AccountRow::derive(&form).expect("夹具账户必须可派生");
    let gw = acct.gateway.clone();
    shared.add_account(acct).expect("夹具账户必须入列");

    let now = ek_ctp::map::now_epoch_ms();
    fn st(
        gateway: &str,
        now: i64,
        f: impl FnOnce(&mut ek_ctp::plugin::GatewayStatus),
    ) -> ek_ctp::plugin::GatewayStatus {
        let mut s = ek_ctp::plugin::GatewayStatus {
            gateway: gateway.to_string(),
            sampled_ms: now,
            ..Default::default()
        };
        f(&mut s);
        s
    }

    // ① 断供(连续被拒达阈)→ 一条告警。全程没有 harness、没有开任何窗。
    shared.note_gateway_status(
        &gw,
        &st(&gw, now, |s| {
            s.td_handle_up = true;
            s.td_logged_in = true;
            s.td_query_reject_streak = 1;
            s.td_last_account_ms = Some(now);
        }),
    );
    assert_eq!(
        shared.pending_alert_count(),
        1,
        "E6①:轮询问出「查询断供」必须攒一条告警(不开窗也攒)"
    );

    // ② 同一状态再来一拍:不得重复弹(否则 8 秒自动关与同屏上限 3 都没有意义)。
    shared.note_gateway_status(
        &gw,
        &st(&gw, now, |s| {
            s.td_handle_up = true;
            s.td_logged_in = true;
            s.td_query_reject_streak = 2;
            s.td_last_account_ms = Some(now);
        }),
    );
    assert_eq!(
        shared.pending_alert_count(),
        1,
        "E6②:同一登录态不得重复弹(去重键 = 网关 + 登录态)"
    );
    assert_eq!(shared.alerted_count(), 1, "E6②:去重账本只记了一条");

    // ③ 转好(在线)→ 不弹。
    shared.note_gateway_status(
        &gw,
        &st(&gw, now, |s| {
            s.td_handle_up = true;
            s.td_logged_in = true;
            s.td_last_account_ms = Some(now);
            s.td_last_login_ok_ms = Some(now);
        }),
    );
    assert_eq!(shared.pending_alert_count(), 1, "E6③:在线不是告警");

    // ④ 换一档(掉线)→ 新告警(状态变了才算新的)。
    shared.note_gateway_status(
        &gw,
        &st(&gw, now, |s| {
            s.td_handle_up = true;
            s.td_logged_in = false;
            s.td_disconnects = 1;
        }),
    );
    assert_eq!(
        shared.pending_alert_count(),
        2,
        "E6④:登录态换档必须算新告警"
    );

    // 机械扫描:产生口只有一处,且在 state.rs。
    let root = concat!(env!("CARGO_MANIFEST_DIR"), "/src");
    let mut calls: Vec<String> = Vec::new();
    for entry in std::fs::read_dir(root).expect("src/ 必须可读") {
        let path = entry.expect("src/ 目录项").path();
        if path.extension().and_then(|s| s.to_str()) != Some("rs") {
            continue;
        }
        let name = path
            .file_name()
            .and_then(|s| s.to_str())
            .unwrap_or("?")
            .to_string();
        let text = std::fs::read_to_string(&path).expect("读 src 文件");
        for (idx, _) in text.match_indices("push_alert(") {
            let line_start = text[..idx].rfind('\n').map(|i| i + 1).unwrap_or(0);
            let line = text[line_start..idx].trim_start();
            // 定义行(`pub fn push_alert(`)不算调用点:匹配点之前的行尾就是 `pub fn`。
            if !line.trim_end().ends_with("pub fn") {
                calls.push(format!("{name}:{}push_alert(", line.trim_start()));
            }
        }
    }
    assert_eq!(
        calls.len(),
        1,
        "E6:告警产生口只许一处(state.rs 的轮询回写),实测 {calls:?}"
    );
    assert!(
        calls[0].starts_with("state.rs"),
        "E6:产生口必须在 state.rs,实测 {}",
        calls[0]
    );
}