ek-ui 0.9.46

ek 生态常驻主界面(egui/eframe):插件即界面——观测页 + 日志页,车头与 UI 同插件
//! 账户档案复原判据(0.9.0;BD-APP-1~4)。
//!
//! 口径边界:**全在 crate 内 target/tmp 的临时根** —— 绝不触碰
//! `%APPDATA%\ek\cfg`(真库;受理面是唯一持久面,判据面只摆临时档)。
//! 勾选落盘走 `save_auto_connect_flags_into`(显式 Store 注入,真写面在
//! ui eTimer 拍用 eco 根,测试用临时根,同一函数一字不差)。

use std::path::{Path, PathBuf};
use std::sync::Arc;

use ek_core::{Field, SectionMap};
use ek_ui::plugin::{THEME_NS, save_account_aliases_into, save_auto_connect_flags_into};
use ek_ui::state::{AccountRow, CtpForm, LoginState, UiShared};

fn tmp_root(tag: &str) -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("target")
        .join("tmp")
        .join(format!("ekui-restore-{tag}-{}", std::process::id()))
}

/// 摆一档:`ctp.ctp_9_11`(broker 9 / 账号 11 / 密码 = 密库引用形态)+
/// ui 段勾选表。段形与实档(比如 `ctp.ctp_9999_278153.ini`)完全同构:
/// 明文键 + `td_password = secret://…`(值本身永不进文件)。
fn seed(root: &Path, auto: Option<(&str, &str)>) -> ek_cfg::Store {
    let store = ek_cfg::Store::with_root(root.to_path_buf());
    let acct = SectionMap::new(1)
        .set("broker_id", Field::plain("9"))
        .set("td_addr", Field::plain("tcp://1.2.3.4:40001"))
        .set("td_user", Field::plain("11"))
        .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("1,2"))
        .set("td_password", Field::secret("gw_td_password"));
    ek_cfg::cfg_save_at(store.root(), "ctp.ctp_9_11", &acct).unwrap();
    let mut ui = SectionMap::new(1).set("theme", Field::plain("dark"));
    if let Some((k, v)) = auto {
        ui = ui.set(format!("auto_connect.{k}"), Field::plain(v));
    }
    ek_cfg::cfg_save_at(store.root(), THEME_NS, &ui).unwrap();
    store
}

/// 清点复原:行到位 + 密码不进表单 + 密库引用徽标 + 收录面收窄(0.8.8 裁定:
/// 期权已全线退役;旧档里的 "1,2" 复原为**只期货**,呈现层宁可收窄)。
#[test]
fn u_restored_row_comes_from_archive_without_password() {
    ek_cfg::set_default_root_for_tests(std::path::PathBuf::from(
        "C:/dev/fuke/ek-ui/target/cfg-test-root",
    ));
    ek_cfg::secret::force_fake_for_tests(); // 秘面走内存表:夹具不碰真库、不需要主密钥
    let root = tmp_root("a");
    let _ = std::fs::remove_dir_all(&root);
    let store = seed(&root, None);
    let shared = Arc::new(UiShared::new());
    let restored = shared.restore_saved_accounts_from(&store);
    assert_eq!(restored, vec!["ctp_9_11".to_string()]);
    let row = shared.account("ctp_9_11").expect("复原行应入册");
    let AccountRow {
        restored: is_restored,
        password_ref,
        auto_connect,
        form,
        stage,
        login,
        ..
    } = row.clone();
    assert!(is_restored, "档案标必须立");
    assert!(password_ref, "密库引用徽标来自存档 secret 行(值不外泄)");
    assert!(!auto_connect, "键缺 = 关(不善意);勾面在 ui 段");
    assert_eq!(form.broker_id, "9");
    assert_eq!(form.td_addr, "tcp://1.2.3.4:40001");
    assert_eq!(form.td_user, "11");
    assert_eq!(form.md_addr, "tcp://1.2.3.4:40011");
    assert!(
        form.td_password.is_empty(),
        "密码绝不进界面表单(密库只在实例 on_start 解)"
    );
    assert!(
        form.accept_futures && !form.accept_options,
        "旧档 1,2 → 只期货(呈现层收窄,不得悄悄重开期权)"
    );
    assert_eq!(stage, ek_ui::state::AccountStage::NotAssembled);
    assert_eq!(login, LoginState::Unknown, "没有证据不装在线");
    let _ = std::fs::remove_dir_all(&root);
}

/// 勾选落盘:手点(置位+清 slew-flag)→ 存档键就位、主题键保全;摘户行清键。
#[test]
fn u_auto_connect_flags_persist_and_prune() {
    ek_cfg::set_default_root_for_tests(std::path::PathBuf::from(
        "C:/dev/fuke/ek-ui/target/cfg-test-root",
    ));
    ek_cfg::secret::force_fake_for_tests(); // 秘面走内存表:夹具不碰真库、不需要主密钥
    let root = tmp_root("b");
    let _ = std::fs::remove_dir_all(&root);
    let store = seed(&root, None);
    let shared = Arc::new(UiShared::new());
    // 先入册(与复原同路径的手工侧)。
    shared.restore_saved_accounts_from(&store);
    shared.set_auto_connect("ctp_9_11", true).expect("勾选置位");
    assert!(
        shared.take_auto_connect_persist_due(),
        "手点必须立落盘标记(同主题纪律)"
    );
    let flags: Vec<(String, bool)> = shared
        .accounts()
        .iter()
        .map(|a| (a.gateway.clone(), a.auto_connect))
        .collect();
    save_auto_connect_flags_into(&store, &flags).expect("存档");
    let ui = match store.load(THEME_NS).unwrap() {
        ek_cfg::LoadOutcome::Loaded { map, .. } => map,
        ek_cfg::LoadOutcome::Fresh => panic!("ui 段应存在"),
    };
    assert_eq!(ui.get_plain("auto_connect.ctp_9_11"), Some("1"));
    assert_eq!(
        ui.get_plain("theme"),
        Some("dark"),
        "同段邻居键(主题)必须保全"
    );
    // 摘户:勾选表里没有户口的键都要清(不留魂)。
    save_auto_connect_flags_into(&store, &[]).expect("清用");
    let ui = match store.load(THEME_NS).unwrap() {
        ek_cfg::LoadOutcome::Loaded { map, .. } => map,
        ek_cfg::LoadOutcome::Fresh => panic!("主题键仍在,段不消失"),
    };
    assert!(
        ui.get_plain("auto_connect.ctp_9_11").is_none(),
        "摘户后旧键必须清(不留魂)"
    );
    assert_eq!(ui.get_plain("theme"), Some("dark"));
    let _ = std::fs::remove_dir_all(&root);
}

/// 同名守卫:已入册(手输先至)的同名行不让清点覆盖 —— 清册行为唯一事实源。
#[test]
fn u_restore_does_not_clobber_manual_rows() {
    ek_cfg::set_default_root_for_tests(std::path::PathBuf::from(
        "C:/dev/fuke/ek-ui/target/cfg-test-root",
    ));
    ek_cfg::secret::force_fake_for_tests(); // 秘面走内存表:夹具不碰真库、不需要主密钥
    let root = tmp_root("c");
    let _ = std::fs::remove_dir_all(&root);
    let store = seed(&root, None);
    let shared = Arc::new(UiShared::new());
    let form = CtpForm {
        broker_id: "9".to_string(),
        td_addr: "tcp://5.6.7.8:40001".to_string(),
        td_user: "11".to_string(),
        td_password: "手输密码".to_string(),
        td_auth_code: "0123456789abcdef".to_string(),
        td_app_id: "simnow_client_test".to_string(),
        md_addr: "tcp://5.6.7.8:40011".to_string(),
        md_user: String::new(),
        md_password: String::new(),
        symbols: String::new(),
        accept_futures: true,
        accept_options: false,
    };
    let row = AccountRow::derive(&form).unwrap();
    shared.add_account(row).expect("手输行入册");
    let restored = shared.restore_saved_accounts_from(&store);
    assert!(restored.is_empty(), "同名行已手输,清点不覆盖: {restored:?}");
    let row = shared.account("ctp_9_11").expect("手输行仍在");
    assert!(
        !row.restored && row.form.td_addr == "tcp://5.6.7.8:40001",
        "手输行的参数与档案标必须原样保留"
    );
    let _ = std::fs::remove_dir_all(&root);
}

/// 别名标签(0.9.10 用户裁定:仿真/24小时 标签要落盘、要复原、要能选):
/// ① 改别名立落盘标记 → `alias.<网关名>` 进 ui 段(主题/勾选邻居键保全);
/// ② 重启复原贴标签;③ 摘户清键不留魂;④ 别名撞名 → 行照复原、标签丢弃并显形。
#[test]
fn u_alias_label_persists_and_restores() {
    ek_cfg::set_default_root_for_tests(std::path::PathBuf::from(
        "C:/dev/fuke/ek-ui/target/cfg-test-root",
    ));
    ek_cfg::secret::force_fake_for_tests(); // 秘面走内存表:夹具不碰真库、不需要主密钥
    let root = tmp_root("d");
    let _ = std::fs::remove_dir_all(&root);
    let store = seed(&root, None);
    let shared = Arc::new(UiShared::new());
    shared.restore_saved_accounts_from(&store);
    // ① 改别名 → 立标记 → 落盘。
    assert!(shared.set_account_alias("ctp_9_11", "仿真"), "别名入行");
    assert!(
        shared.take_alias_persist_due(),
        "改别名必须立落盘标记(同自动连接纪律)"
    );
    let aliases: Vec<(String, String)> = shared
        .accounts()
        .iter()
        .map(|a| (a.gateway.clone(), a.alias.clone()))
        .collect();
    save_account_aliases_into(&store, &aliases).expect("别名存档");
    let ui = match store.load(THEME_NS).unwrap() {
        ek_cfg::LoadOutcome::Loaded { map, .. } => map,
        ek_cfg::LoadOutcome::Fresh => panic!("ui 段应存在"),
    };
    assert_eq!(ui.get_plain("alias.ctp_9_11"), Some("仿真"));
    assert_eq!(ui.get_plain("theme"), Some("dark"), "邻居键(主题)保全");
    // ② 重启复原:标签贴回行上(面板按钮将显「连接 仿真」)。
    let shared2 = Arc::new(UiShared::new());
    shared2.restore_saved_accounts_from(&store);
    assert_eq!(
        shared2.account("ctp_9_11").expect("复原行").alias,
        "仿真",
        "重启后标签必须复原(用户口径:选择时可按标签挑)"
    );
    // ③ 摘户 → 立标记;按空表落盘 → 别名键清掉,主题键仍活。
    shared2.remove_account("ctp_9_11");
    assert!(
        shared2.take_alias_persist_due(),
        "摘行必须立别名落盘标记(清键)"
    );
    save_account_aliases_into(&store, &[]).expect("清键");
    let ui = match store.load(THEME_NS).unwrap() {
        ek_cfg::LoadOutcome::Loaded { map, .. } => map,
        ek_cfg::LoadOutcome::Fresh => panic!("主题键仍在,段不消失"),
    };
    assert!(
        ui.get_plain("alias.ctp_9_11").is_none(),
        "摘户后别名键必须清(不留魂)"
    );
    assert_eq!(ui.get_plain("theme"), Some("dark"));
    // ④ 撞名显形:ui 段给档案行挂了别名,而该别名已被手输行占用 →
    //    行照复原、标签丢弃(清册唯一性守卫优先),不得把行挡在门外。
    let root4 = tmp_root("e");
    let _ = std::fs::remove_dir_all(&root4);
    let store4 = seed(&root4, None);
    let aliases4 = vec![("ctp_9_11".to_string(), "仿真".to_string())];
    save_account_aliases_into(&store4, &aliases4).expect("存档别名");
    let shared4 = Arc::new(UiShared::new());
    let form4 = CtpForm {
        broker_id: "9".to_string(),
        td_addr: "tcp://5.6.7.8:40001".to_string(),
        td_user: "22".to_string(),
        td_password: "手输密码".to_string(),
        td_auth_code: "0123456789abcdef".to_string(),
        td_app_id: "simnow_client_test".to_string(),
        md_addr: "tcp://5.6.7.8:40011".to_string(),
        md_user: String::new(),
        md_password: String::new(),
        symbols: String::new(),
        accept_futures: true,
        accept_options: false,
    };
    let mut row4 = AccountRow::derive(&form4).unwrap();
    row4.alias = "仿真".to_string();
    shared4.add_account(row4).expect("手输带别名行入册");
    let restored4 = shared4.restore_saved_accounts_from(&store4);
    assert_eq!(restored4, vec!["ctp_9_11".to_string()], "行照复原");
    assert!(
        shared4
            .account("ctp_9_11")
            .expect("复原行")
            .alias
            .is_empty(),
        "别名撞名:标签丢弃,行不许被挡"
    );
    let _ = std::fs::remove_dir_all(&root);
    let _ = std::fs::remove_dir_all(&root4);
}

/// 0.9.10 多前置档案钉:同账号两套前置 = 两份档案(`ctp_9_11` + `ctp_9_11_2`)。
/// 复原必须**跟着档案名走** —— 若拿派生基名当注册名,两行必撞成一行,
/// 第二套前置就永远起不来(用户裁定的共存面在复原侧的收口)。
#[test]
fn u_restore_multi_front_rows_keep_archive_names() {
    ek_cfg::set_default_root_for_tests(std::path::PathBuf::from(
        "C:/dev/fuke/ek-ui/target/cfg-test-root",
    ));
    ek_cfg::secret::force_fake_for_tests(); // 秘面走内存表:夹具不碰真库、不需要主密钥
    let root = tmp_root("mf");
    let _ = std::fs::remove_dir_all(&root);
    let store = seed(&root, None);
    let acct2 = SectionMap::new(1)
        .set("broker_id", Field::plain("9"))
        .set("td_addr", Field::plain("tcp://1.2.3.4:41001"))
        .set("td_user", Field::plain("11"))
        .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:41011"))
        .set("product_classes", Field::plain("1"))
        .set("td_password", Field::secret("gw_td_password"));
    ek_cfg::cfg_save_at(store.root(), "ctp.ctp_9_11_2", &acct2).unwrap();
    let shared = Arc::new(UiShared::new());
    let restored = shared.restore_saved_accounts_from(&store);
    assert_eq!(
        restored,
        vec!["ctp_9_11".to_string(), "ctp_9_11_2".to_string()],
        "两套前置 = 两行,注册名跟档案名"
    );
    assert_eq!(
        shared.account("ctp_9_11_2").expect("_2 行").form.td_addr,
        "tcp://1.2.3.4:41001",
        "_2 行吃自己的前置"
    );
    assert!(
        shared.account("ctp_9_11_2").expect("_2 行").password_ref,
        "_2 行的密库引用徽标照落"
    );
    let _ = std::fs::remove_dir_all(&root);
}

/// 0.9.10 用户实锤钉:「界面删了的账户,下次启动又显示」= 摘行只摘内存清册、
/// 档案段还在 → 启动清点扫 `ctp.*` 扫回来。判据链 = 摘行 → 待办入队 →
/// `purge_account_archive` 抹段 → **重新起一个清册走清点 = 删掉的行不复活**
/// (档案文件真没 + 在册行真少,两段都钉死)。
#[test]
fn u_remove_account_purges_archive_and_no_ghost_revives() {
    ek_cfg::set_default_root_for_tests(std::path::PathBuf::from(
        "C:/dev/fuke/ek-ui/target/cfg-test-root",
    ));
    ek_cfg::secret::force_fake_for_tests(); // 秘面走内存表:夹具不碰真库、不需要主密钥
    let root = tmp_root("purge");
    let _ = std::fs::remove_dir_all(&root);
    let store = seed(&root, None);
    let acct2 = SectionMap::new(1)
        .set("broker_id", Field::plain("9"))
        .set("td_addr", Field::plain("tcp://1.2.3.4:41001"))
        .set("td_user", Field::plain("11"))
        .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:41011"))
        .set("product_classes", Field::plain("1"))
        .set("td_password", Field::secret("gw_td_password"));
    ek_cfg::cfg_save_at(store.root(), "ctp.ctp_9_11_2", &acct2).unwrap();
    let shared = Arc::new(UiShared::new());
    assert_eq!(shared.restore_saved_accounts_from(&store).len(), 2);

    // 摘行:内存清册摘除 + 档案抹除待办入队(渲染侧只入队,零 IO)。
    assert!(shared.remove_account("ctp_9_11_2"));
    assert_eq!(shared.take_archive_purges(), vec!["ctp_9_11_2".to_string()]);
    // 落盘拍消费 = 抹档案段。
    ek_ui::appconfig::purge_account_archive(&store, "ctp_9_11_2").expect("抹段成功");
    assert!(
        matches!(
            store.load("ctp.ctp_9_11_2").expect("抹后读"),
            ek_cfg::LoadOutcome::Fresh
        ),
        "抹段后该段消失(单模:载入回 Fresh;库文件禁删)"
    );
    // 重开清册再清点(= 下次启动):删掉的行不复活,留下的行照在。
    let shared2 = Arc::new(UiShared::new());
    assert_eq!(
        shared2.restore_saved_accounts_from(&store),
        vec!["ctp_9_11".to_string()],
        "档案已抹 = 清点无鬼户口"
    );
    // 幂等:重复抹(段已无)= 成功不报错。
    ek_ui::appconfig::purge_account_archive(&store, "ctp_9_11_2").expect("幂等");
    let _ = std::fs::remove_dir_all(&root);
}