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;
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",
));
}
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
}
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();
}
}
#[test]
fn m1_mount_follows_assembly() {
pin_cfg_root();
let shared = Arc::new(UiShared::new());
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} 的实例表必须为空(窗口体显示未装配)"
);
}
}
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: 点了就开着");
let mut h = harness_for(&shared);
h.run();
assert!(
h.query_all_by_label_contains("数据管理").count() >= 1,
"m2: 菜单项与窗标题都在(一模块一菜单一窗)"
);
}
#[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: 关窗不销毁 —— 再开回执还在(实现了「每次重建实例」这条会红)"
);
}
#[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
);
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: 菜单项数不变");
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(一模块一窗)"
);
assert!(matches!(Shape::PerInstance, Shape::PerInstance));
}
#[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, "夹具:两行档案");
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() {
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);
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)"
);
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}"),
}
}
#[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: 窗口必须来自注册表"
);
}
#[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: 保存成功");
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: 不许按标题建窗键 —— 标题可改、可重名"
);
}
#[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();
assert!(
h.query_all_by_label("交易中枢").count() >= 1,
"m9: oms 模块窗可开"
);
assert!(
h.query_all_by_label("天勤桥").count() >= 1,
"m9: tq 模块窗可开"
);
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() {
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://` 引用分流(说明它拿到的不是明文)"
);
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:// 引用)"
);
}
#[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);
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 是读数口,不是命令口)"
);
}
#[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`)"
);
}
#[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 = 重复)"
);
}
assert_eq!(
h.query_all_by_label("交易网关").count(),
1,
"两个 ctp 实例不许变成两个菜单项"
);
}