ek-ui 0.2.4

ek 生态常驻主界面(egui/eframe):插件即界面——观测页 + 日志页,车头与 UI 同插件
Documentation
//! 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.ctp_window_open());
    assert!(!s.contracts_window_open());
    assert!(s.kline_symbols().is_empty());
    // 开关翻转
    s.set_ops_window_open(true);
    s.set_ctp_window_open(true);
    s.set_contracts_window_open(true);
    assert!(s.ops_window_open() && s.ctp_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.0");

    // 买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.0");

    // 卖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.0");

    // 卖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");
}