ek-ui 0.9.46

ek 生态常驻主界面(egui/eframe):插件即界面——观测页 + 日志页,车头与 UI 同插件
//! CLI 写档 ⇔ 网关受理 **等效判据**(0.9.0;BD-APP-4)。
//!
//! 这条钉防的是「两处形态漂移」:`ekapp add-account`(`appconfig::write_account`)
//! 写出的段,喂给**真受理方法** `apply_config`(ek-cta 的 boot/svctl.start 同用
//! 那把)必须 ① 全采纳、② 密码档位 = 引用已登记、③ 受理后再 dump 与 CLI 段键集
//! 相等(证明 CLI 不是"仿了一个形状",而是恰为受理面本体)。判据面只在
//! crate 内 target/tmp 走(真库零接触;秘键缺省 缺省 fake 后端,不碰凭据管理器)。

use std::path::PathBuf;

use ek_core::Configurable;
use ek_core::SectionMap;
use ek_ui::appconfig::{AcctSpec, write_account};

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

fn spec() -> AcctSpec {
    AcctSpec {
        broker_id: "9".to_string(),
        user: "11".to_string(),
        td_addr: "tcp://1.2.3.4:40001".to_string(),
        md_addr: "tcp://1.2.3.4:40011".to_string(),
        app_id: "simnow_client_test".to_string(),
        auth_code: "0123456789abcdef".to_string(),
        classes: "1".to_string(),
        md_user: String::new(),
        md_password: None,
    }
}

/// CLI 段 = 受理面本体:全采纳 + 档位引用 + dump 键集相等。
#[test]
fn u_cli_section_is_the_realm_of_apply_config() {
    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 = ek_cfg::Store::with_root(root.clone());
    let gw = write_account(&store, &spec(), "判据密码").expect("CLI 写档");
    assert_eq!(gw, "ctp_9_11");
    let ns = format!("ctp.{gw}");
    // ① 档案形态:明文键齐 + 密码是**引用行**(值不在档)。
    let map = match store.load(&ns).expect("载入") {
        ek_cfg::LoadOutcome::Loaded { map, .. } => map,
        ek_cfg::LoadOutcome::Fresh => panic!("档应存在"),
    };
    assert_eq!(map.get_plain("td_user"), Some("11"));
    assert_eq!(map.get_plain("product_classes"), Some("1"));
    assert_eq!(
        map.get_secret_key("td_password"),
        Some("gw_td_password"),
        "密码行 = 密库引用"
    );
    // ② 受理:真 apply_config 全采纳(acceptance-equivalent gate 的正面)。
    let plugin = ek_ctp::plugin::CtpPlugin::default();
    let rep = plugin.apply_config(&map).expect("CLI 段必被受理");
    for k in [
        "broker_id",
        "td_addr",
        "td_user",
        "td_app_id",
        "td_auth_code",
        "md_addr",
        "product_classes",
        "td_password",
    ] {
        assert!(
            rep.adopted.iter().any(|a| a == k),
            "键 {k} 必被采纳(实际 adopted={:?})",
            rep.adopted
        );
    }
    assert!(
        rep.deferred.is_empty(),
        "CLI 段不应有被搁置的键(实际 deferred={:?})",
        rep.deferred
    );
    // ③ 档位:引用已登记 + 引用键拼写与 CLI 单源一致。
    let creds = plugin.credentials_state();
    let td = creds
        .iter()
        .find(|c| c.field == "td_password")
        .expect("档位应含 td_password");
    assert_eq!(
        td.state, "REFERENCED",
        "受理面只登记引用(整合值在 on_start)"
    );
    assert_eq!(td.ref_key, "gw_td_password");
    // ④ 再 dump:键集相等(受理面表示与 CLI 段互为同一档案形态)。
    let dumped = plugin.dump_config();
    let keys_of = |m: &SectionMap| m.iter().map(|(k, _)| k.to_string()).collect::<Vec<_>>();
    let mut a = keys_of(&map);
    let mut b = keys_of(&dumped);
    a.sort();
    b.sort();
    assert_eq!(a, b, "CLI 段键集必须与受理后 dump 相等(CLI 即受理面)");
    let _ = std::fs::remove_dir_all(&root);
}

/// 缺必填/空密码/非法收录面 = 拒(与受理面校验同向;CLI 不是旁路)。
#[test]
fn u_cli_rejects_incomplete_specs() {
    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 = ek_cfg::Store::with_root(root.clone());
    let s = spec();
    let s = AcctSpec {
        td_addr: String::new(),
        ..s
    };
    assert!(write_account(&store, &s, "pw").is_err(), "缺 td 前置必拒");
    let _s = spec();
    assert!(
        write_account(&store, &_s, "  ").is_err(),
        "空/全空白密码必拒"
    );
    let s = spec();
    let s = AcctSpec {
        classes: "1,113".to_string(),
        ..s
    };
    assert!(
        write_account(&store, &s, "pw").is_err(),
        "非单字符收录面必拒(D-PC-FILTER)"
    );
    let _ = std::fs::remove_dir_all(&root);
}

/// 0.9.10 多前置钉(用户裁定「一账号多套前置,别名备注切换;IP 相同端口不同
/// 也算两套」):唯一键 = (broker, 账号, td 前置整串)。
/// - 三全同 = 拒;
/// - 同账号换前置 = 基名占用后自动后缀 `_2`(再换 = `_3`);
/// - CLI 路:写第二档 → 档案 ns = `ctp.ctp_9_11_2`(基名档不动,零迁移)。
#[test]
fn u_multi_front_same_account_gets_suffix() {
    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(); // 秘面走内存表:夹具不碰真库、不需要主密钥
    use ek_ui::appconfig::unique_gw_name;
    let taken: Vec<(String, String, String, String)> = vec![(
        "ctp_9_11".to_string(),
        "9".to_string(),
        "11".to_string(),
        "tcp://1.2.3.4:40001".to_string(),
    )];
    // 三全同 = 拒(同前置不重复登记)。
    assert!(
        unique_gw_name(&taken, "9", "11", "tcp://1.2.3.4:40001").is_err(),
        "同 broker+账号+前置须被拒"
    );
    // 同端口不同 IP = 两套。
    assert_eq!(
        unique_gw_name(&taken, "9", "11", "tcp://8.8.8.8:40001")
            .as_deref()
            .unwrap(),
        "ctp_9_11_2",
        "同账号换 IP = 后缀 _2"
    );
    // 同 IP 不同端口 = 两套(用户点名口径)。
    assert_eq!(
        unique_gw_name(&taken, "9", "11", "tcp://1.2.3.4:41001")
            .as_deref()
            .unwrap(),
        "ctp_9_11_2",
        "同账号换端口 = 后缀 _2"
    );
    // 第三个空位 = _3。
    let taken2: Vec<(String, String, String, String)> = vec![
        taken[0].clone(),
        (
            "ctp_9_11_2".to_string(),
            "9".to_string(),
            "11".to_string(),
            "tcp://8.8.8.8:40001".to_string(),
        ),
    ];
    assert_eq!(
        unique_gw_name(&taken2, "9", "11", "tcp://9.9.9.9:40001")
            .as_deref()
            .unwrap(),
        "ctp_9_11_3",
        "第三套前置 = _3"
    );
    // 别的账号不沾后缀(基名空 = 直用)。
    assert_eq!(
        unique_gw_name(&taken2, "9", "22", "tcp://1.2.3.4:40001")
            .as_deref()
            .unwrap(),
        "ctp_9_22",
        "换账号 = 各用各的基名"
    );

    // CLI 路:先写基名档,再写同账号新前置 → 落 `_2` 档;三全同重跑 = 拒。
    let root = tmp_root("mf");
    let _ = std::fs::remove_dir_all(&root);
    let store = ek_cfg::Store::with_root(root.clone());
    let gw1 = write_account(&store, &spec(), "判据密码").expect("基名档");
    assert_eq!(gw1, "ctp_9_11");
    let s2 = AcctSpec {
        td_addr: "tcp://1.2.3.4:41001".to_string(),
        md_addr: "tcp://1.2.3.4:41011".to_string(),
        ..spec()
    };
    let gw2 = write_account(&store, &s2, "判据密码2").expect("同账号第二套前置写档");
    assert_eq!(gw2, "ctp_9_11_2", "CLI 写档走同一后缀算法");
    assert!(
        write_account(&store, &s2, "判据密码2").is_err(),
        "同前置重复登记必拒(档案侧同样吃 (broker,账号,td) 三全同闸)"
    );
    let _ = std::fs::remove_dir_all(&root);
}