ek-ui 0.9.43

ek 生态常驻主界面(egui/eframe):插件即界面——观测页 + 日志页,车头与 UI 同插件
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
//! 模块挂载判据(0.9.34「插件界面 = 一模块一菜单一窗」波)。
//!
//! 对应开发计划 `docs/ek-plugin-ui-devplan-2026-11-01.md` 的判据矩阵,**m1–m20 全覆盖**
//! (19 个测试函数:m13/m14 合成一个,其余一一对应):
//! m1/m2(装配驱动・点开幂等)、m3(改键即落档往返)、m4(关窗不销毁)、
//! m5/m6(一模块一菜单・生命周期)、m7(窗键按 id)、m8(Shape 缺省 Single)、
//! m9/m10/m11/m12(oms・tq 接入・分节・不串段・秘面只显引用)、
//! m13/m14(无 ctp 也成表・12 列)、m15(按行原子写只碰该段)、
//! m16(身份键单点)、m17(异常列有源)、m18(操作只投命令)、
//! m19(单一渲染路径)、m20(宿主自有窗零回退)。
//!
//! 这一组判据的存在理由:2026-10-29 插件窗被连根铲时**没有任何判据变红**。
//! 所以这里钉的是"结构"(一模块一菜单、菜单项数不随实例数变、按行原子写),
//! 不是"某个像素在不在"。

use std::sync::Arc;

use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;

use ek_core::Plugin;
use ek_core::main_engine::MainEngine;
use ek_ui::mount::Shape;
use ek_ui::state::UiShared;

// ---------- 夹具 ----------

/// 假插件:只提供 `name()` / `kind()`,用来测"归组"这条框架规则
/// (真实 CtpPlugin 需要一整套 CTP 配置,构造代价与这条判据无关)。
struct Fake {
    name: String,
    kind: &'static str,
}

impl Plugin for Fake {
    fn name(&self) -> &str {
        &self.name
    }
    fn kind(&self) -> &str {
        self.kind
    }
    fn on_mail(&self, _event: &ek_event::Event) {}
}

fn engine_with(names: &[(&'static str, &'static str)]) -> Arc<MainEngine> {
    let engine = Arc::new(MainEngine::new(None));
    for (name, kind) in names {
        engine
            .add_plugin(Arc::new(Fake {
                name: (*name).to_string(),
                kind,
            }))
            .expect("注册假插件");
    }
    engine
}

fn harness_for(shared: &UiShared) -> Harness<'_> {
    Harness::builder()
        .with_size([1280.0, 860.0])
        .build(|ctx| ek_ui::render_body(ctx, shared))
}

fn pin_cfg_root() {
    ek_cfg::set_default_root_for_tests(std::path::PathBuf::from(
        "C:/dev/fuke/ek-ui/target/cfg-test-root",
    ));
}

/// 摆两档账户档案(`ctp.ctp_9999_278153` / `ctp.ctp_9999_220380`)。
fn seed_two_archives(tag: &str) -> ek_cfg::Store {
    let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("target")
        .join("tmp")
        .join(format!("ekui-mount-{tag}-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&root);
    let store = ek_cfg::Store::with_root(root);
    seed_into(&store);
    store
}

/// 往给定存储根摆两档(m15 用:写口吃的是**默认根**,夹具必须同根)。
fn seed_into(store: &ek_cfg::Store) {
    use ek_core::{Field, SectionMap};
    for (acct, classes) in [("278153", "1"), ("220380", "1,2")] {
        let map = SectionMap::new(1)
            .set("broker_id", Field::plain("9999"))
            .set("td_addr", Field::plain("tcp://1.2.3.4:40001"))
            .set("td_user", Field::plain(acct))
            .set("td_app_id", Field::plain("simnow_client_test"))
            .set("td_auth_code", Field::plain("0123456789abcdef"))
            .set("md_addr", Field::plain("tcp://1.2.3.4:40011"))
            .set("product_classes", Field::plain(classes))
            .set("td_password", Field::secret("gw_td_password"));
        ek_cfg::cfg_save_at(store.root(), &format!("ctp.ctp_9999_{acct}"), &map).unwrap();
    }
}

// ---------- m1 / m2:装配驱动 + 点开成窗幂等 ----------

#[test]
fn m1_mount_follows_assembly() {
    pin_cfg_root();
    let shared = Arc::new(UiShared::new());
    // 空引擎 = 一个插件都没装(0.9.37 用户裁定后的口径):
    // **有界面的模块照旧在菜单里**,只是实例表为空 —— 窗口本体显示「未装配」。
    // 旧口径是「插件没装 = 菜单项不存在」,用户实测反馈"找不到入口",已改。
    let engine = engine_with(&[]);
    shared.install_engine(&engine);
    shared.remount_modules();
    assert!(
        shared.modules().list.iter().all(|w| w.always),
        "m1: 全部模块都该是常驻的(always)"
    );
    {
        let reg = shared.modules();
        for kind in ["dm", "oms", "tq"] {
            let w = reg
                .list
                .iter()
                .find(|w| w.kind == kind)
                .unwrap_or_else(|| panic!("m1: 没装插件时 {kind} 也必须常驻菜单"));
            assert!(
                w.instances.is_empty(),
                "m1: 没装插件时 {kind} 的实例表必须为空(窗口体显示未装配)"
            );
        }
    }

    // 装一个 dm(kind 缺省 = name):菜单多出「数据管理」。
    let engine = engine_with(&[("dm", "dm")]);
    shared.install_engine(&engine);
    shared.remount_modules();
    let reg = shared.modules();
    let dm = reg
        .list
        .iter()
        .find(|w| w.kind == "dm")
        .expect("m1: dm 模块窗");
    assert_eq!(dm.instances, vec!["dm".to_string()], "m1: dm 实例归组");
    assert!(dm.always, "m1: dm 常驻菜单(0.9.37);实例表由装配填");
}

#[test]
fn m2_open_window_is_idempotent() {
    pin_cfg_root();
    let shared = Arc::new(UiShared::new());
    let engine = engine_with(&[("dm", "dm")]);
    shared.install_engine(&engine);
    shared.remount_modules();

    shared.modules().set_open("dm", true);
    shared.modules().set_open("dm", true);
    assert_eq!(
        shared
            .modules()
            .list
            .iter()
            .filter(|w| w.kind == "dm")
            .count(),
        1,
        "m2: 重复点开不能长出第二扇窗"
    );
    assert!(shared.modules().is_open("dm"), "m2: 点了就开着");

    // 真渲染一帧:窗标题 = 模块标题,窗内容在场(dm 未装配实例 → 提示文案)。
    let mut h = harness_for(&shared);
    h.run();
    assert!(
        h.query_all_by_label_contains("数据管理").count() >= 1,
        "m2: 菜单项与窗标题都在(一模块一菜单一窗)"
    );
}

// ---------- m4:关窗不销毁(编辑中的内容还在) ----------

#[test]
fn m4_closing_keeps_window_state() {
    pin_cfg_root();
    let shared = Arc::new(UiShared::new());
    // 交易网关窗的态:写一条回执,关窗再开必须还在。
    {
        let mut reg = shared.modules();
        reg.set_open("ctp", true);
        let w = reg
            .list
            .iter_mut()
            .find(|w| w.kind == "ctp")
            .expect("网关窗");
        if let ek_ui::mount::Body::Gateway(st) = &mut w.body {
            st.toast = Some("m4 回执在场".to_string());
        } else {
            panic!("m4: ctp 必须是 Gateway 体");
        }
    }
    let mut h = harness_for(&shared);
    h.run();
    assert!(
        h.query_by_label_contains("m4 回执在场").is_some(),
        "m4: 打开时应看到回执"
    );
    shared.modules().set_open("ctp", false);
    h.run();
    shared.modules().set_open("ctp", true);
    h.run();
    assert!(
        h.query_by_label_contains("m4 回执在场").is_some(),
        "m4: 关窗不销毁 —— 再开回执还在(实现了「每次重建实例」这条会红)"
    );
}

// ---------- m5 / m6 / m8:一模块一菜单、实例增删、逃生门 ----------

#[test]
fn m5_one_menu_item_per_module() {
    pin_cfg_root();
    let shared = Arc::new(UiShared::new());
    let engine = engine_with(&[("ctp_278153", "ctp"), ("ctp_220380", "ctp"), ("dm", "dm")]);
    shared.install_engine(&engine);
    shared.remount_modules();

    let reg = shared.modules();
    let ctp: Vec<_> = reg.list.iter().filter(|w| w.kind == "ctp").collect();
    assert_eq!(ctp.len(), 1, "m5: 两个 ctp 实例只能有**一扇**交易网关窗");
    assert_eq!(ctp[0].instances.len(), 2, "m5: 但两个实例都登记在案");
    // 菜单项数 = 模块数(与实例数无关)。
    let expected = reg.list.len();
    drop(reg);
    assert_eq!(
        shared.modules().list.len(),
        expected,
        "m5: 菜单项数只随模块数变"
    );
}

#[test]
fn m6_instance_lifecycle_updates_without_new_menu() {
    pin_cfg_root();
    let shared = Arc::new(UiShared::new());
    let engine = engine_with(&[("ctp_278153", "ctp")]);
    shared.install_engine(&engine);
    shared.remount_modules();
    let before = shared.modules().list.len();
    assert_eq!(
        shared
            .modules()
            .list
            .iter()
            .find(|w| w.kind == "ctp")
            .unwrap()
            .instances
            .len(),
        1
    );

    // 新起一个实例(等价 svctl.start 回执后 remount)。
    let engine = engine_with(&[("ctp_278153", "ctp"), ("ctp_220380", "ctp")]);
    shared.install_engine(&engine);
    shared.remount_modules();
    assert_eq!(
        shared
            .modules()
            .list
            .iter()
            .find(|w| w.kind == "ctp")
            .unwrap()
            .instances
            .len(),
        2,
        "m6: 起了新实例 → 归组多一个"
    );
    assert_eq!(shared.modules().list.len(), before, "m6: 菜单项数不变");

    // 摘掉实例(等价 svctl.remove 回执后 remount)。
    let engine = engine_with(&[]);
    shared.install_engine(&engine);
    shared.remount_modules();
    assert!(
        shared
            .modules()
            .list
            .iter()
            .find(|w| w.kind == "ctp")
            .unwrap()
            .instances
            .is_empty(),
        "m6: 摘光实例后 instances 清零"
    );
    assert!(
        shared.modules().list.iter().any(|w| w.kind == "ctp"),
        "m6: 交易网关是 always 模块 —— 实例清零也不下架(还要能管档案)"
    );
}

#[test]
fn m8_shape_defaults_to_single() {
    pin_cfg_root();
    let shared = Arc::new(UiShared::new());
    assert!(
        shared
            .modules()
            .list
            .iter()
            .all(|w| w.shape == Shape::Single),
        "m8: 默认形状 = Single(一模块一窗)"
    );
    // 逃生门在册但本期没有任何模块使用(改默认值/给某模块开 PerInstance → 这条红)。
    assert!(matches!(Shape::PerInstance, Shape::PerInstance));
}

// ---------- m13 / m14 / m15:交易网关表 ----------

#[test]
fn m13_and_m14_gateway_table_without_ctp_plugin() {
    pin_cfg_root();
    ek_cfg::secret::force_fake_for_tests();
    let store = seed_two_archives("table");
    let shared = Arc::new(UiShared::new());
    shared.restore_saved_accounts_from(&store);
    assert_eq!(shared.accounts().len(), 2, "夹具:两行档案");

    // **没有** ctp 插件(未装配):窗仍要在菜单里,且能管档案。
    let engine = engine_with(&[]);
    shared.install_engine(&engine);
    shared.remount_modules();
    shared.modules().set_open("ctp", true);

    let mut h = harness_for(&shared);
    h.run();
    assert!(
        h.query_all_by_label("交易网关").count() >= 1,
        "m13: 菜单项与窗标题都在(一模块一菜单一窗)"
    );
    assert!(
        h.query_by_label(ek_ui::render::BTN_ACCOUNT_ADD).is_some(),
        "m14: 未装配 ctp 也能新增档案(表在、添加钮在)"
    );
    assert_eq!(
        h.query_all_by_label_contains("ctp_9999_").count(),
        2,
        "m13: 两个 ctp 实例 → 表里两行(不是两扇窗)"
    );
    for head in [
        "网关",
        "别名",
        "账号",
        "经纪商",
        "td 前置",
        "md 前置",
        "收录",
        "连接",
        "阶段",
        "行情源",
        "异常",
        "操作",
    ] {
        assert!(
            h.query_all_by_label(head).count() >= 1,
            "m13: 列「{head}」在场"
        );
    }
}

#[test]
fn m15_row_save_touches_only_that_namespace() {
    // 写口(无引擎 → `ui_cfg_store()`)吃**默认根**:夹具必须摆在同一根上。
    let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("target")
        .join("tmp")
        .join(format!("ekui-mount-row-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&root);
    ek_cfg::set_default_root_for_tests(root);
    ek_cfg::secret::force_fake_for_tests();
    let store = {
        let s = ek_cfg::Store::with_root(
            std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
                .join("target")
                .join("tmp")
                .join(format!("ekui-mount-row-{}", std::process::id())),
        );
        seed_into(&s);
        s
    };
    let shared = Arc::new(UiShared::new());
    shared.restore_saved_accounts_from(&store);

    // 改 A 行的收录(只写 A 段)。
    let bridge = ek_ui::mount::CfgBridge::with_store(&shared, store.clone());
    ek_ui::gateway::save_product_classes(
        &shared,
        &bridge,
        "ctp_9999_278153",
        &["1".into(), "2".into()],
    )
    .expect("m15: 按行保存成功");

    let a = load_map(&store, "ctp.ctp_9999_278153");
    let b = load_map(&store, "ctp.ctp_9999_220380");
    let a_classes = a
        .get("product_classes")
        .and_then(|f| f.plain_value().map(str::to_string));
    let b_classes = b
        .get("product_classes")
        .and_then(|f| f.plain_value().map(str::to_string));
    assert_eq!(a_classes.as_deref(), Some("1,2"), "m15: A 行已改");
    assert_eq!(
        b_classes.as_deref(),
        Some("1,2"),
        "m15: B 行原值不动(1,2 → 1,2)"
    );
    // 反证:A 段里其它键仍在(整段覆盖 ≠ 只写一个键 → 必须读-改-写)。
    assert!(a.get("td_user").is_some(), "m15: 身份键没被覆盖掉");
}

fn load_map(store: &ek_cfg::Store, ns: &str) -> ek_core::SectionMap {
    match store.load(ns).expect("读段") {
        ek_cfg::LoadOutcome::Loaded { map, .. } => map,
        ek_cfg::LoadOutcome::Fresh => panic!("段不存在: {ns}"),
    }
}

// ---------- m16 / m19:结构扫描(防"又一个编辑面 / 又一条渲染路径") ----------

#[test]
fn m16_identity_keys_have_a_single_write_surface() {
    let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/gateway.rs"))
        .expect("读 src/gateway.rs")
        .replace("\r\n", "\n");
    for key in ["td_user", "td_addr", "td_auth_code", "td_app_id"] {
        assert!(
            !src.contains(&format!(".set(\"{key}\"")),
            "m16: 交易网关表不许直接写身份键 `{key}` —— 身份只走「编辑行 → 保存并连接」(唯一编辑面)"
        );
    }
}

#[test]
fn m19_single_render_path() {
    let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/render.rs"))
        .expect("读 src/render.rs")
        .replace("\r\n", "\n");
    for hard in [
        "egui::Window::new(\"运维窗\")",
        "egui::Window::new(\"查询合约窗\")",
        "Account_HEADERS",
    ] {
        assert!(
            !src.contains(hard),
            "m19: 宿主自有窗不许再有硬编码渲染({hard})—— 一律走 mount 注册表"
        );
    }
    assert!(
        src.contains("shared.modules().menu(ui, shared)"),
        "m19: 菜单必须来自注册表"
    );
    assert!(
        src.contains("shared.modules().windows(ctx, shared)"),
        "m19: 窗口必须来自注册表"
    );
}

// ---------- m3 / m7:落档往返 + 窗键按 id ----------

#[test]
fn m3_section_save_roundtrips_through_the_store() {
    use ek_core::cfg_face::CfgHost;
    use ek_core::{Field, SectionMap};

    let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("target")
        .join("tmp")
        .join(format!("ekui-mount-m3-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&root);
    ek_cfg::secret::force_fake_for_tests();

    let shared = Arc::new(UiShared::new());
    let bridge =
        ek_ui::mount::CfgBridge::with_store(&shared, ek_cfg::Store::with_root(root.clone()));
    let map = SectionMap::new(1).set("theme", Field::plain("dark"));
    bridge.cfg_save("ui", &map).expect("m3: 保存成功");

    // 换一个 Store 实例、同一个根读回 = 真的写下去了(不是内存假象)。
    let again = ek_cfg::Store::with_root(root);
    let back = match again.load("ui").expect("m3: 读回") {
        ek_cfg::LoadOutcome::Loaded { map, .. } => map,
        ek_cfg::LoadOutcome::Fresh => panic!("m3: 段没落盘"),
    };
    assert_eq!(
        back.get("theme").and_then(|f| f.plain_value()),
        Some("dark"),
        "m3: 改一个键 → 立刻能在同一存储根读回来"
    );
}

#[test]
fn m7_window_key_is_id_not_title() {
    let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/mount.rs"))
        .expect("读 src/mount.rs")
        .replace("\r\n", "\n");
    assert!(
        src.contains(r#"egui::ViewportId::from_hash_of(("module_win", &id))"#),
        "m7: 窗键必须按模块 id 生成(同标题不同模块不许互相顶掉)"
    );
    assert!(
        src.contains(r#"egui::Id::new(("module_win", &id))"#),
        "m7: 内嵌后端(不支持多窗)也要按 id 建窗"
    );
    assert!(
        !src.contains(r#"from_hash_of(("module_win", w.title"#),
        "m7: 不许按标题建窗键 —— 标题可改、可重名"
    );
}

// ---------- m9 / m10 / m11 / m12:oms、tq 接入与分节、不串段、秘面 ----------

#[test]
fn m9_oms_and_tq_windows_open_and_store() {
    use ek_core::cfg_face::CfgHost;
    use ek_core::{Field, SectionMap};

    pin_cfg_root();
    let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("target")
        .join("tmp")
        .join(format!("ekui-mount-m9-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&root);
    ek_cfg::secret::force_fake_for_tests();

    let shared = Arc::new(UiShared::new());
    let engine = engine_with(&[("oms", "oms"), ("tq", "tq")]);
    shared.install_engine(&engine);
    shared.remount_modules();
    shared.modules().set_open("oms", true);
    shared.modules().set_open("tq", true);

    let mut h = harness_for(&shared);
    h.run();
    // 窗开了(标题在场);插件本体是假的 → 面里出「未装配」提示(不是 panic、不是空窗)。
    assert!(
        h.query_all_by_label("交易中枢").count() >= 1,
        "m9: oms 模块窗可开"
    );
    assert!(
        h.query_all_by_label("天勤桥").count() >= 1,
        "m9: tq 模块窗可开"
    );

    // 两窗各自落自己的段(写口按模块名分 ns)。
    let bridge =
        ek_ui::mount::CfgBridge::with_store(&shared, ek_cfg::Store::with_root(root.clone()));
    bridge
        .cfg_save(
            "oms",
            &SectionMap::new(1).set("rate_limit", Field::plain("5")),
        )
        .expect("m9: oms 段保存");
    bridge
        .cfg_save("tq", &SectionMap::new(1).set("bridge", Field::plain("0")))
        .expect("m9: tq 段保存");

    let store = ek_cfg::Store::with_root(root);
    assert!(
        load_map(&store, "oms").get("rate_limit").is_some(),
        "m9: oms 段读回"
    );
    assert!(
        load_map(&store, "tq").get("bridge").is_some(),
        "m9: tq 段读回"
    );
}

#[test]
fn m10_oms_schema_is_grouped() {
    use ek_core::Configurable;

    let schema = ek_oms::plugin::OmsPlugin::new().config_schema();
    let g = |k: &str| {
        schema
            .iter()
            .find(|f| f.key == k)
            .unwrap_or_else(|| panic!("m10: schema 里没有 `{k}`"))
            .group
            .clone()
    };
    assert_eq!(g("rate_limit"), "提交节流", "m10: 节流一档");
    for k in [
        "max_volume_per_order",
        "max_position_per_symbol",
        "max_notional",
        "price_band",
        "require_contract",
    ] {
        assert_eq!(g(k), "风控闸", "m10: 风控键 `{k}` 归「风控闸」");
    }
    // 分节不引入新键:六个键之外没有空组。
    assert!(
        schema.iter().all(|f| !f.group.is_empty()),
        "m10: oms 窗内每个键都该有归属节"
    );
}

#[test]
fn m11_modules_never_share_a_namespace() {
    use ek_core::cfg_face::CfgHost;
    use ek_core::{Field, SectionMap};

    let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("target")
        .join("tmp")
        .join(format!("ekui-mount-m11-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&root);
    ek_cfg::secret::force_fake_for_tests();

    let shared = Arc::new(UiShared::new());
    let bridge =
        ek_ui::mount::CfgBridge::with_store(&shared, ek_cfg::Store::with_root(root.clone()));
    bridge
        .cfg_save("dm", &SectionMap::new(1).set("k", Field::plain("dm")))
        .unwrap();
    bridge
        .cfg_save("oms", &SectionMap::new(1).set("k", Field::plain("oms")))
        .unwrap();

    let store = ek_cfg::Store::with_root(root);
    let dm = load_map(&store, "dm");
    let oms = load_map(&store, "oms");
    assert_eq!(
        dm.get("k").and_then(|f| f.plain_value()),
        Some("dm"),
        "m11: dm 段只有 dm 的值"
    );
    assert_eq!(
        oms.get("k").and_then(|f| f.plain_value()),
        Some("oms"),
        "m11: oms 段不被 dm 覆盖"
    );
}

#[test]
fn m12_plugin_faces_show_secret_refs_never_secret_values() {
    // tq 面:秘面值与普通值按 `secret://` 前缀分流,秘面只显示引用串。
    let tq = std::fs::read_to_string(concat!(
        env!("CARGO_MANIFEST_DIR"),
        "/../ek-tq/src/ui_face.rs"
    ))
    .expect("读 ek-tq/src/ui_face.rs")
    .replace("\r\n", "\n");
    assert!(
        tq.contains(r#"starts_with("secret://")"#),
        "m12: tq 面必须按 `secret://` 引用分流(说明它拿到的不是明文)"
    );
    // dm 面:零 `secret://` 引用 = 它没有秘面字段;若将来有,这条会提醒重审。
    let dm = std::fs::read_to_string(concat!(
        env!("CARGO_MANIFEST_DIR"),
        "/../ek-dm/src/ui_face.rs"
    ))
    .expect("读 ek-dm/src/ui_face.rs");
    assert!(
        !dm.contains("password"),
        "m12: dm 面无口令字段(有的话必须走 secret:// 引用)"
    );
}

// ---------- m17 / m18:状态列有源、操作只投命令 ----------

#[test]
fn m17_anomaly_column_reads_from_status() {
    ek_cfg::secret::force_fake_for_tests();
    let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("target")
        .join("tmp")
        .join(format!("ekui-mount-m17-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&root);
    let store = ek_cfg::Store::with_root(root);
    seed_into(&store);

    let shared = Arc::new(UiShared::new());
    shared.restore_saved_accounts_from(&store);
    shared.remount_modules();
    shared.modules().set_open("ctp", true);

    // 喂一份「3 条 td 异常、0 条 md 异常」的状态。
    let st = ek_ctp::plugin::GatewayStatus {
        gateway: "ctp_9999_278153".to_string(),
        td_anomalies: ek_ctp::driver::AnomalyCounts {
            total: 3,
            ..Default::default()
        },
        ..Default::default()
    };
    shared.note_gateway_status("ctp_9999_278153", &st);

    let row = shared
        .accounts()
        .into_iter()
        .find(|a| a.gateway == "ctp_9999_278153")
        .expect("m17: 账户行在");
    assert_eq!(row.td_anomaly, 3, "m17: td 异常数来自 status");

    let mut h = harness_for(&shared);
    h.run();
    assert!(
        h.query_all_by_label_contains("td 3").count() >= 1,
        "m17: 异常列显示 `td 3 / md 0`(有源、不是写死的)"
    );
}

#[test]
fn m18_ops_column_only_publishes_commands() {
    let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/gateway.rs"))
        .expect("读 src/gateway.rs");
    assert!(src.contains("push_cmd"), "m18: 操作列只投命令(push_cmd)");
    assert!(
        !src.contains("ask_service"),
        "m18: 操作列不许直接问插件(ask_service 是读数口,不是命令口)"
    );
}

// ---------- m20:宿主自有窗行为零回退 ----------

#[test]
fn m20_host_windows_keep_their_content() {
    pin_cfg_root();
    let shared = Arc::new(UiShared::new());
    let engine = engine_with(&[]);
    shared.install_engine(&engine);
    shared.remount_modules();

    shared.set_ops_window_open(true);
    let mut h = harness_for(&shared);
    h.run();
    assert!(
        h.query_all_by_label("插件").count() >= 1,
        "m20: 运维窗「插件」节仍在"
    );
    drop(h);

    shared.set_ops_window_open(false);
    shared.set_contracts_window_open(true);
    let mut h = harness_for(&shared);
    h.run();
    assert!(
        h.query_all_by_label("查询合约").count() >= 1,
        "m20: 查询合约窗仍能打开"
    );
    drop(h);

    shared.set_contracts_window_open(false);
    shared.open_kline("rb2601");
    let mut h = harness_for(&shared);
    h.run();
    assert!(
        h.query_all_by_label_contains("rb2601").count() >= 1,
        "m20: K线窗仍按合约开(`kline:rb2601`)"
    );
}

// ---------- m21:有界面的插件一个不漏地出现在菜单里 ----------

/// 用户口径(2026-11-01):「每一个有界面的插件,都要放到菜单上显示出来」。
///
/// 这条钉的是**清单完整性**,不是某一个标签:凡是有 `ui_face.rs` 的插件
/// (ek-dm / ek-oms / ek-tq)+ 宿主渲染的 ctp(交易网关)+ 三扇宿主窗,
/// 都要在菜单里各占一项。少一项 = 那个插件等于没有界面。
#[test]
fn m21_every_ui_plugin_has_one_menu_item() {
    pin_cfg_root();
    let shared = Arc::new(UiShared::new());
    let engine = engine_with(&[
        ("dm", "dm"),
        ("oms", "oms"),
        ("tq", "tq"),
        ("ctp_9999_278153", "ctp"),
        ("ctp_9999_220380", "ctp"),
    ]);
    shared.install_engine(&engine);
    shared.remount_modules();

    let mut h = harness_for(&shared);
    h.run();
    for label in [
        "交易网关",
        "数据管理",
        "交易中枢",
        "天勤桥",
        "运维",
        "查询合约",
        "K线",
    ] {
        assert_eq!(
            h.query_all_by_label(label).count(),
            1,
            "菜单里「{label}」必须**恰好一项**(0 = 有界面的插件没上菜单;>1 = 重复)"
        );
    }
    // 反证:两个 ctp 实例只多一行,不多一项菜单(一模块一菜单不破)。
    assert_eq!(
        h.query_all_by_label("交易网关").count(),
        1,
        "两个 ctp 实例不许变成两个菜单项"
    );
}