use std::sync::Arc;
use egui_kittest::{
Harness,
kittest::{NodeT, Queryable},
};
use ek_station::wire::PlugRow;
use ek_ui::state::UiCmd;
use ek_ui::{BusSnap, UiShared, render_body};
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,
}
}
fn row_with(name: &str, state: &str, deps: Vec<String>) -> PlugRow {
PlugRow {
name: name.to_string(),
version: "0.1.0".to_string(),
state: state.to_string(),
deps,
spilled: 0,
panics: 0,
}
}
fn golden_bar(dt_s: i64) -> ek_data::bar::BarData {
ek_data::bar::BarData {
symbol: "rb2605".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
datetime: dt_s * 1_000_000_000,
interval: Some(ek_data::constant::Interval::MINUTE),
volume: 18.0,
open_price: 100.0,
high_price: 103.0,
low_price: 99.5,
close_price: 99.5,
..ek_data::bar::BarData::default()
}
}
fn feed_market_frame(shared: &UiShared, frames_expect: u64, updates_expect: u64) {
let frame = ek_dm::wire::BarsFrame {
name: "dm".to_string(),
interval: 60,
bars: vec![golden_bar(36_000)],
};
shared.market().on_frame(&frame);
let frame2 = ek_dm::wire::BarsFrame {
name: "dm".to_string(),
interval: 60,
bars: vec![golden_bar(36_060)],
};
shared.market().on_frame(&frame2);
for _ in 0..4 {
shared.market().on_update(&golden_bar(36_060));
}
shared.market().note_reload();
let (f, u, r) = shared.market().counts();
assert_eq!(f, frames_expect);
assert_eq!(u, updates_expect);
assert_eq!(r, 1);
}
fn tick(
ex: ek_data::constant::Exchange,
symbol: &str,
name: &str,
last: f64,
pct: f64,
) -> ek_data::tick::TickData {
ek_data::tick::TickData {
gateway_name: "ctp".to_string(),
symbol: symbol.to_string(),
exchange: ex,
datetime: 36_000 * 1_000_000_000,
name: name.to_string(),
last_price: last,
pct_change: pct,
volume: 1234.0,
open_price: 100.0,
high_price: 103.0,
low_price: 99.0,
bid_price_1: last - 0.5,
bid_volume_1: 3.0,
ask_price_1: last + 0.5,
ask_volume_1: 4.0,
update_time: "10:00:00".to_string(),
..ek_data::tick::TickData::default()
}
}
fn contract(
ex: ek_data::constant::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,
}
}
fn demo_rows() -> Vec<PlugRow> {
vec![row("svctl"), row("dm"), row("oms"), row("ui")]
}
fn seed_md_source(shared: &UiShared, broker: &str, account: &str) -> String {
let form = ek_ui::state::CtpForm {
broker_id: broker.to_string(),
td_user: account.to_string(),
..Default::default()
};
let row = ek_ui::state::AccountRow::derive(&form).expect("派生注册名");
shared.add_account(row.clone()).expect("添加账户");
shared.set_md_source(&row.gateway).expect("指定行情源");
row.gateway
}
fn mark_assembled(shared: &UiShared, gateway: &str) {
shared.sync_accounts_connected(&[row(gateway)]);
}
fn snapshot_lock() -> &'static std::sync::Mutex<()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
}
fn feed_snapshot(shared: &UiShared, services: Vec<(String, Vec<String>)>) {
shared.set_snapshot(BusSnap {
rows: demo_rows(),
services,
});
}
fn harness_for(shared: &UiShared) -> Harness<'_> {
Harness::builder()
.with_size([1280.0, 860.0])
.build(|ctx| render_body(ctx, shared))
}
static SNAP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn snap(harness: &mut Harness<'_>, name: &str) {
static WGPU_ENV: std::sync::OnceLock<()> = std::sync::OnceLock::new();
WGPU_ENV.get_or_init(|| {
#[cfg(windows)]
if std::env::var_os("WGPU_BACKEND").is_none() {
unsafe { std::env::set_var("WGPU_BACKEND", "VULKAN") };
}
});
let _g = SNAP_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
harness.snapshot(name);
}
#[test]
fn s0_rows_and_service_directory_visible() {
let shared = Arc::new(UiShared::new());
feed_snapshot(
&shared,
vec![
(
"svctl".to_string(),
vec![
"svctl.list".to_string(),
"svctl.start".to_string(),
"svctl.remove".to_string(),
],
),
("dm".to_string(), vec!["dm.reload".to_string()]),
("oms".to_string(), vec!["oms.query".to_string()]),
("ui".to_string(), vec![]),
],
);
shared.set_ops_window_open(true);
shared.set_bottomright_tab(ek_ui::state::BottomRightTab::Logs);
let mut harness = harness_for(&shared);
assert!(harness.query_by_label("dm").is_some());
assert!(harness.query_by_label("oms").is_some());
assert!(harness.query_by_label("ui").is_some());
assert!(harness.query_by_label("ghost").is_none());
assert_eq!(harness.get_all_by_label("Running").count(), 4);
assert_eq!(harness.get_all_by_label("svctl 服务").count(), 3);
assert!(harness.query_by_label("svctl.list").is_some());
assert!(harness.query_by_label("svctl.start").is_some());
assert!(harness.query_by_label("svctl.remove").is_some());
assert!(harness.query_by_label("dm 服务").is_some());
assert!(harness.query_by_label("dm.reload").is_some());
assert!(harness.query_by_label("oms.query").is_some());
assert!(harness.query_by_label("ui 服务").is_some());
assert!(
harness.query_by_label("清空日志").is_some(),
"就地操作按钮在场"
);
harness.run(); }
#[test]
fn s1_clear_logs_button_local_action() {
let shared = Arc::new(UiShared::new());
feed_snapshot(&shared, vec![]);
shared.set_bottomright_tab(ek_ui::state::BottomRightTab::Logs);
shared.log_line("[INFO][demo] 样例 001");
shared.log_line("[WARN][demo] 样例 002");
shared.log_line("[INFO][demo] 样例 003");
let mut harness = harness_for(&shared);
assert_eq!(shared.logs_len(), 3);
harness.get_by_label("清空日志").click_accesskit();
harness.run();
assert_eq!(shared.logs_len(), 0, "清空按钮就地生效");
harness.get_by_label("清空日志").click_accesskit();
harness.run();
assert_eq!(shared.logs_len(), 0);
}
#[test]
fn s2_log_lines_rendered_with_text() {
let shared = Arc::new(UiShared::new());
feed_snapshot(&shared, vec![]);
shared.set_bottomright_tab(ek_ui::state::BottomRightTab::Logs);
shared.log_line("[INFO][demo] 固定样例行 甲");
shared.log_line("[INFO][demo] 固定样例行 乙");
let harness = harness_for(&shared);
assert!(harness.query_by_label_contains("固定样例行 甲").is_some());
assert!(harness.query_by_label_contains("固定样例行 乙").is_some());
}
#[test]
fn s3_static_layout_snapshot() {
let _g = snapshot_lock().lock().unwrap();
let shared = Arc::new(UiShared::new());
feed_snapshot(
&shared,
vec![
(
"svctl".to_string(),
vec![
"svctl.list".to_string(),
"svctl.start".to_string(),
"svctl.remove".to_string(),
],
),
("dm".to_string(), vec!["dm.reload".to_string()]),
("oms".to_string(), vec![]),
("ui".to_string(), vec![]),
],
);
shared.set_bottomright_tab(ek_ui::state::BottomRightTab::Logs);
shared.log_line("[INFO][demo] 固定样例行 001");
shared.log_line("[INFO][demo] 固定样例行 002");
shared.log_line("[INFO][demo] 固定样例行 003");
let mut harness = harness_for(&shared);
harness.run();
let rect_of = |label: &str| {
harness
.query_by_label(label)
.unwrap_or_else(|| panic!("结构钉:{label} 必须存在"))
.rect()
};
let unfilled = rect_of("未成交"); let trades = rect_of("成交"); let pos = rect_of("持仓"); let funds = rect_of("资金"); let cancel_all = rect_of("全撤"); assert!(cancel_all.min.x < 250.0, "下单侧栏在左(SidePanel 缺省 250)");
assert!(
unfilled.min.x > 250.0 && unfilled.min.y < 500.0,
"委托组在中央面板左上"
);
assert!(trades.min.x > 700.0, "成交组在右列(响应式第二列存在)");
assert!(
funds.min.x > 700.0 && funds.max.x < 1280.0,
"资金组在右列且贴边不裁切"
);
assert!(
pos.min.y > 500.0 && funds.min.y > 500.0,
"底行沉到中央面板下部(响应式吃满高度,无死区;定高回归时此处 <500);实测 pos.y={:.0} funds.y={:.0}",
pos.min.y,
funds.min.y
);
assert!(
pos.min.y < 760.0 && funds.min.y < 760.0,
"底行在窗内(不溢出窗口)"
);
let col_w = funds.min.x - pos.min.x;
assert!(
(450.0..=540.0).contains(&col_w),
"两列等宽且随窗口分配(实测列距 {col_w:.0},期望 ≈ (可用宽-间距)/2)"
);
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx); if hit.is_none() {
return;
}
snap(&mut harness, "s3_static_layout");
}
fn feed_rows(shared: &UiShared, rows: Vec<PlugRow>) {
shared.set_snapshot(BusSnap {
rows,
services: vec![],
});
}
#[test]
fn s4_remove_two_step_confirm_flow() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, vec![row("svctl"), row("dm")]);
shared.set_ops_window_open(true);
let mut harness = harness_for(&shared);
harness.get_by_label("移除 dm").click_accesskit();
harness.run();
assert_eq!(shared.pending_confirm().as_deref(), Some("dm"));
assert!(shared.drain_cmds().is_empty(), "armed must not enqueue");
assert!(harness.query_by_label_contains("待二次确认").is_some());
assert!(harness.query_by_label("确认移除 dm ?").is_some());
harness.get_by_label("确认移除 dm ?").click_accesskit();
harness.run();
assert_eq!(shared.pending_confirm(), None);
assert_eq!(
shared.drain_cmds(),
vec![UiCmd::Remove {
name: "dm".to_string(),
}]
);
assert!(harness.query_by_label_contains("待二次确认").is_none());
}
#[test]
fn s5_restart_direct_enqueue() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, vec![row("oms")]);
shared.set_ops_window_open(true);
let mut harness = harness_for(&shared);
harness.get_by_label("重注入 oms").click_accesskit();
harness.run();
assert_eq!(
shared.drain_cmds(),
vec![UiCmd::Start {
factory: "oms".to_string(),
name: String::new(),
}]
);
}
#[test]
fn s6_cancel_confirm_quarantine_deps() {
let shared = Arc::new(UiShared::new());
feed_rows(
&shared,
vec![
row_with("dice", "Quarantined", vec![]),
row_with("p2", "Running", vec!["p1".to_string()]),
],
);
shared.set_ops_window_open(true);
let mut harness = harness_for(&shared);
harness.get_by_label("移除 dice").click_accesskit();
harness.run();
assert_eq!(shared.pending_confirm().as_deref(), Some("dice"));
harness.get_by_label("取消确认").click_accesskit();
harness.run();
assert_eq!(shared.pending_confirm(), None);
assert!(harness.query_by_label("移除 dice").is_some());
assert!(harness.query_by_label("Quarantined").is_some());
assert!(harness.query_by_label_contains("p2 → p1").is_some());
assert!(harness.query_by_label_contains("全部无依赖").is_none());
}
#[test]
fn s8_market_band_tick_rows() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.update_tick(&tick(
ek_data::constant::Exchange::SHFE,
"rb2605",
"螺纹钢2605",
101.0,
1.25,
));
shared.update_tick(&tick(
ek_data::constant::Exchange::SHFE,
"au2706",
"黄金2706",
777.0,
-0.5,
));
shared.update_tick(&tick(
ek_data::constant::Exchange::DCE,
"a2709",
"黄玉米2709",
2222.0,
0.0,
));
let mut harness = harness_for(&shared);
assert!(
harness.query_by_label_contains("tick 3 格").is_none(),
"状态行已撤,不得回归"
);
assert!(harness.query_by_label("DCE").is_some());
assert!(harness.query_by_label("SHFE").is_some());
assert!(harness.query_by_label("a2709").is_some());
assert!(harness.query_by_label_contains("黄玉米2709").is_some());
assert_eq!(harness.get_all_by_label("2222.0").count(), 1, "最新价列");
assert_eq!(harness.get_all_by_label("1234").count(), 1, "成交量列");
assert_eq!(harness.get_all_by_label("10:00:00").count(), 1, "时间列");
harness.get_by_label("SHFE").click_accesskit();
harness.run();
assert!(harness.query_by_label("rb2605").is_some());
assert!(harness.query_by_label_contains("螺纹钢2605").is_some());
assert_eq!(harness.get_all_by_label("101.0").count(), 1);
assert!(harness.query_by_label("a2709").is_none(), "非当前组不上屏");
harness.get_by_label("rb2605").click_accesskit();
harness.run();
let f = shared.order_form();
assert_eq!(f.symbol, "rb2605", "单击带入代码");
assert_eq!(f.exchange, "SHFE", "单击带入交易所");
let shared2 = Arc::new(UiShared::new());
feed_rows(&shared2, demo_rows());
let harness2 = harness_for(&shared2);
assert!(
harness2
.query_by_label_contains("等待 eTick 订阅")
.is_some()
);
}
#[test]
fn s10_kline_window_snapshot() {
let _g = snapshot_lock().lock().unwrap();
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
feed_market_frame(&shared, 2, 4);
shared.open_kline("rb2605");
let mut harness = harness_for(&shared);
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx); assert!(
harness
.query_by_label_contains("K线 rb2605@60s(2 根)")
.is_some(),
"kline window header missing"
);
harness.run();
if hit.is_none() {
return; }
snap(&mut harness, "s10_kline_first_frame");
}
#[test]
fn s11_trade_monitor_rows() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let mut active = ek_data::order::OrderData {
gateway_name: "ctp".to_string(),
symbol: "rb2605".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
orderid: "CTP_0_1".to_string(),
direction: Some(ek_data::constant::Direction::LONG),
offset: ek_data::constant::Offset::OPEN,
price: 102.5,
volume: 10.0,
status: ek_data::constant::Status::SUBMITTING,
..ek_data::order::OrderData::default()
};
let done = ek_data::order::OrderData {
orderid: "CTP_0_2".to_string(),
traded: 8.0,
traded_price: 102.5,
status: ek_data::constant::Status::ALLTRADED,
..active.clone()
};
active.traded = 4.0;
active.status = ek_data::constant::Status::PARTTRADED;
shared.set_trade_snap(ek_ui::state::TradeSnap {
orders: vec![active, done],
trades: vec![ek_data::trade::TradeData {
gateway_name: "ctp".to_string(),
symbol: "rb2605".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
orderid: "CTP_0_2".to_string(),
tradeid: "T1".to_string(),
direction: Some(ek_data::constant::Direction::LONG),
offset: ek_data::constant::Offset::OPEN,
price: 102.5,
volume: 8.0,
datetime: Some(36_000_000_000_000),
..ek_data::trade::TradeData::default()
}],
positions: vec![ek_data::position::PositionData {
gateway_name: "ctp".to_string(),
symbol: "rb2605".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
direction: ek_data::constant::Direction::LONG,
volume: 18.0,
avg_price: 102.5,
..ek_data::position::PositionData::default()
}],
accounts: vec![],
});
let mut harness = Harness::builder()
.with_size([1280.0, 860.0])
.with_step_dt(0.1)
.build(|ctx| render_body(ctx, &shared));
assert_eq!(harness.get_all_by_label("CTP_0_1").count(), 1, "委托号列");
assert_eq!(
harness.get_all_by_label("CTP_0_2").count(),
2,
"委托表委托号 + 成交表委托号(T1 挂 CTP_0_2)"
);
assert!(harness.query_by_label_contains("PARTTRADED").is_some());
assert!(harness.query_by_label_contains("ALLTRADED").is_some());
assert!(harness.query_by_label_contains("SUBMITTING").is_none());
assert_eq!(
harness.get_all_by_label("102.5").count(),
4,
"委托价×2 + 成交均价×1 + 成交价×1(持仓均价 102.50 非精确串)"
);
assert!(harness.query_by_label_contains("T1").is_some());
let cancel_label = format!("{} CTP_0_1", ek_ui::render::BTN_CANCEL_ORDER);
harness.get_by_label(&cancel_label).click_accesskit();
harness.run();
let cmds = shared.drain_cmds();
assert_eq!(cmds.len(), 1);
assert!(
matches!(
&cmds[0],
ek_ui::state::UiCmd::CancelOrder { orderid, .. } if orderid == "CTP_0_1"
),
"cancel cmd mismatch: {cmds:?}"
);
let pos = {
let row = harness.get_all_by_label("rb2605").next().unwrap();
row.rect().center()
};
harness.hover_at(pos);
harness.step();
for pressed in [true, false] {
harness.event(egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::default(),
});
}
harness.step();
assert!(
shared.drain_cmds().is_empty(),
"单击委托行不得入列(Python 同口径)"
);
for pressed in [true, false] {
harness.event(egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::default(),
});
}
harness.step();
let cmds2 = shared.drain_cmds();
assert!(
matches!(&cmds2[..], [ek_ui::state::UiCmd::CancelOrder { orderid, .. }] if orderid == "CTP_0_1"),
"双击撤单入列失败: {cmds2:?}"
);
}
#[test]
fn s12_order_panel_submit() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.set_order_form(ek_ui::state::OrderForm {
exchange: "SHFE".to_string(),
symbol: "rb2605".to_string(),
direction: ek_data::constant::Direction::LONG,
offset: ek_data::constant::Offset::OPEN,
order_type: ek_data::constant::OrderType::LIMIT,
price: "102.5".to_string(),
volume: "10".to_string(),
gateway: "ctp".to_string(),
follow_price: false,
auto_split: true,
});
let mut harness = harness_for(&shared);
let order_nodes: Vec<_> = harness
.get_all_by_label(ek_ui::render::BTN_SEND_ORDER)
.collect();
assert_eq!(order_nodes.len(), 2, "委托钮 + 委托 tab");
order_nodes[0].click_accesskit();
harness.run();
let cmds = shared.drain_cmds();
assert_eq!(cmds.len(), 1);
match &cmds[0] {
ek_ui::state::UiCmd::SendOrder { req, gateway, mode } => {
assert_eq!(req.symbol, "rb2605");
assert_eq!(req.exchange, ek_data::constant::Exchange::SHFE);
assert_eq!(req.price, 102.5);
assert_eq!(req.volume, 10.0);
assert_eq!(req.direction, ek_data::constant::Direction::LONG);
assert_eq!(gateway, "ctp", "网关选择子随单入列(S6)");
assert_eq!(
*mode,
ek_oms::LockMode::Auto,
"自动开平默认勾选 = Auto(s22 钉勾选翻转)"
);
}
other => panic!("expected SendOrder, got {other:?}"),
}
let mut bad = shared.order_form();
bad.price = "abc".to_string();
shared.set_order_form(bad);
harness.run();
let bad_nodes: Vec<_> = harness
.get_all_by_label(ek_ui::render::BTN_SEND_ORDER)
.collect();
bad_nodes[0].click_accesskit();
harness.step(); assert!(shared.drain_cmds().is_empty(), "bad form must not enqueue");
assert!(harness.query_by_label_contains("价格无效").is_some());
assert!(
shared
.logs_snapshot()
.0
.iter()
.any(|l| l.contains("价格无效")),
"order parse error log missing"
);
}
#[test]
fn s22_auto_split_checkbox_mode() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.set_order_form(ek_ui::state::OrderForm {
exchange: "SHFE".to_string(),
symbol: "rb2605".to_string(),
price: "102.5".to_string(),
volume: "2".to_string(),
gateway: "ctp".to_string(),
follow_price: false,
..Default::default()
});
let mut harness = harness_for(&shared);
harness.get_by_label("自动开平").click_accesskit();
harness.run();
let order_nodes: Vec<_> = harness
.get_all_by_label(ek_ui::render::BTN_SEND_ORDER)
.collect();
order_nodes[0].click_accesskit();
harness.step();
let cmds = shared.drain_cmds();
assert!(
matches!(
&cmds[..],
[ek_ui::state::UiCmd::SendOrder {
mode: ek_oms::LockMode::None,
..
}]
),
"取消勾选后 mode 应为 None: {cmds:?}"
);
harness.get_by_label("自动开平").click_accesskit();
harness.run();
let order_nodes: Vec<_> = harness
.get_all_by_label(ek_ui::render::BTN_SEND_ORDER)
.collect();
order_nodes[0].click_accesskit();
harness.step();
let cmds = shared.drain_cmds();
assert!(
matches!(
&cmds[..],
[ek_ui::state::UiCmd::SendOrder {
mode: ek_oms::LockMode::Auto,
..
}]
),
"再勾选后 mode 应为 Auto: {cmds:?}"
);
}
#[test]
fn s23_row_selection() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.set_trade_snap(ek_ui::state::TradeSnap {
orders: vec![
ek_data::order::OrderData {
gateway_name: "ctp".to_string(),
symbol: "rb2605".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
orderid: "CTP_0_1".to_string(),
order_type: ek_data::constant::OrderType::LIMIT,
direction: Some(ek_data::constant::Direction::LONG),
offset: ek_data::constant::Offset::OPEN,
price: 101.0,
volume: 2.0,
traded: 0.0,
traded_price: 0.0,
status: ek_data::constant::Status::SUBMITTING,
status_msg: String::new(),
name: String::new(),
reference: "P1".to_string(),
datetime: Some(36_000_000_000_000),
},
ek_data::order::OrderData {
gateway_name: "ctp".to_string(),
symbol: "rb2605".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
orderid: "CTP_1_1".to_string(),
order_type: ek_data::constant::OrderType::LIMIT,
direction: Some(ek_data::constant::Direction::SHORT),
offset: ek_data::constant::Offset::CLOSE,
price: 102.0,
volume: 1.0,
traded: 0.0,
traded_price: 0.0,
status: ek_data::constant::Status::SUBMITTING,
status_msg: String::new(),
name: String::new(),
reference: "P2".to_string(),
datetime: Some(37_000_000_000_000),
},
],
trades: vec![ek_data::trade::TradeData {
gateway_name: "ctp".to_string(),
symbol: "rb2605".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
tradeid: "T1".to_string(),
orderid: "CTP_0_1".to_string(),
direction: Some(ek_data::constant::Direction::LONG),
offset: ek_data::constant::Offset::OPEN,
price: 101.0,
volume: 2.0,
traded_amount: 202.0,
name: String::new(),
datetime: Some(36_500_000_000_000),
commission: 0.0,
slippage: 0.0,
trade_pnl: 0.0,
}],
positions: Vec::new(),
accounts: Vec::new(),
});
let mut harness = harness_for(&shared);
let rows: Vec<_> = harness.get_all_by_label("rb2605").collect();
rows[0].click_accesskit();
harness.run();
assert!(
shared.row_selected("orders", "ctp.CTP_0_1"),
"委托行首格单击应选中该行"
);
let row2: Vec<_> = harness.get_all_by_label("rb2605").collect();
row2[1].click_accesskit();
harness.run();
assert!(shared.row_selected("orders", "ctp.CTP_1_1"));
assert!(!shared.row_selected("orders", "ctp.CTP_0_1"));
harness.get_by_label("T1").click_accesskit();
harness.run();
assert!(shared.row_selected("trades", "ctp.T1"));
assert!(shared.row_selected("orders", "ctp.CTP_1_1"));
}
#[test]
fn s13_account_add_enqueue() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
assert!(
harness
.query_by_label(ek_ui::render::BTN_ACCOUNT_ADD)
.is_some(),
"账户面板添加钮在场"
);
assert!(!shared.account_edit_open(), "初始:配置弹窗未开");
assert!(
harness
.query_by_label_contains("订阅不在本窗定死")
.is_none(),
"Commit B:订阅口径句挪进**配置弹窗**,账户面板不再内嵌表单"
);
harness
.get_by_label(ek_ui::render::BTN_ACCOUNT_ADD)
.click_accesskit();
harness.run();
assert!(
shared.account_edit_open(),
"『添加账户』= 开配置弹窗(Commit B 口径)"
);
assert!(
harness.query_by_label("保存").is_some() && harness.query_by_label("保存并连接").is_some(),
"弹窗须给『保存』与『保存并连接』两个出口(规划 §3.3 四态)"
);
assert!(
harness
.query_by_label_contains("订阅不在本窗定死")
.is_some(),
"订阅撤出配置窗的口径句在场(弹窗内)"
);
assert!(shared.accounts().is_empty(), "开弹窗本身不写清册");
assert!(shared.drain_cmds().is_empty(), "开弹窗不下发任何指令");
shared.set_ctp_form(ek_ui::state::CtpForm {
broker_id: "9999".to_string(),
td_addr: "tcp://182.254.243.31:30001".to_string(),
td_user: "278153".to_string(),
td_password: "pw-secret".to_string(),
td_auth_code: "0000000000000000".to_string(),
td_app_id: "app".to_string(),
md_addr: "tcp://182.254.243.31:30011".to_string(),
md_user: String::new(),
md_password: String::new(),
symbols: String::new(),
..Default::default() });
harness.run();
click_dialog(&mut harness, "保存");
let cmds = shared.drain_cmds();
assert!(
cmds.is_empty(),
"保存只写清册,不得下发装配/凭据两令: {cmds:?}"
);
assert_eq!(shared.accounts().len(), 1, "账户清册落一行");
assert_eq!(shared.accounts()[0].gateway, "ctp_9999_278153");
assert_eq!(
shared.accounts()[0].stage,
ek_ui::state::AccountStage::NotAssembled,
"添加后 = 未装配(不是装配中,更不是已装配)"
);
assert_eq!(
shared.md_source(),
"ctp_9999_278153",
"无源 + 无在场账户 → 新账户即元账号(§5.C 在添加这一刻定源)"
);
assert!(shared.accounts()[0].md_source, "行内元账号标记同步落位");
assert_eq!(
shared.accounts()[0].form.md_addr,
"tcp://182.254.243.31:30011",
"首个账户 = 元账号 → md 前置必须留档(清掉它 = 它自己也永远收不到 tick)"
);
assert!(
harness.query_by_label("连接 278153").is_some(),
"未装配行须有『连接』钮(四态分离的入口)"
);
harness.get_by_label("连接 278153").click_accesskit();
harness.run();
let cmds = shared.drain_cmds();
assert_eq!(cmds.len(), 1, "『连接』= 一条 Connect 令: {cmds:?}");
match &cmds[0] {
UiCmd::Connect { gateway, form } => {
assert_eq!(gateway, "ctp_9999_278153", "连接随派生名寻址");
assert_eq!(form.broker_id, "9999");
assert_eq!(form.td_addr, "tcp://182.254.243.31:30001");
assert_eq!(form.td_user, "278153");
assert_eq!(
form.td_password, "pw-secret",
"密码原值随表单入列(不回显在界面)"
);
assert_eq!(
form.md_addr, "tcp://182.254.243.31:30011",
"元账号连接时 md 前置保留(§5.C)"
);
let req = form.to_configure_req();
assert_eq!(req.td_app_id, "app");
assert!(req.md_user.is_empty(), "回落归插件侧裁决,表单不预处理");
}
other => panic!("expected Connect, got {other:?}"),
}
click_account_add(&mut harness);
harness.run();
shared.set_ctp_form(ek_ui::state::CtpForm {
td_user: "220380".to_string(),
td_addr: "tcp://182.254.243.31:30001".to_string(),
td_password: "pw-secret-2".to_string(),
..shared.ctp_form()
});
harness.run();
click_dialog(&mut harness, "保存");
let cmds2 = shared.drain_cmds();
assert!(cmds2.is_empty(), "第二账户同样『保存不下发』: {cmds2:?}");
assert_eq!(
shared.accounts()[1].form.md_addr,
"",
"非源账户 md 前置在**留档**里就清空(旧口径只清下发不清留档 → 编辑页会带一根用不上的行情前置)"
);
harness.get_by_label("连接 220380").click_accesskit();
harness.run();
let cmds2 = shared.drain_cmds();
assert_eq!(cmds2.len(), 1, "『连接』一条令: {cmds2:?}");
match &cmds2[0] {
UiCmd::Connect { gateway, form } => {
assert_eq!(
gateway, "ctp_9999_220380",
"同 broker 不同账号 = 不同派生名"
);
assert_eq!(form.td_user, "220380", "TD 会话各账号独立");
assert!(
form.md_addr.is_empty(),
"非源账户 md 前置一律留空(单源纪律 §5.C)"
);
}
other => panic!("expected Connect, got {other:?}"),
}
assert_eq!(
shared.md_source(),
"ctp_9999_278153",
"元账号不被第二账户抢走"
);
assert_eq!(shared.accounts().len(), 2, "清册两行");
}
#[test]
fn s14_accounts_rows() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.set_trade_snap(ek_ui::state::TradeSnap {
accounts: vec![ek_data::account::AccountData {
gateway_name: "ctp".to_string(),
accountid: "278153".to_string(),
balance: 1_000_000.0,
frozen: 12_345.0,
}],
..ek_ui::state::TradeSnap::default()
});
let harness = harness_for(&shared);
assert_eq!(harness.get_all_by_label("278153").count(), 1, "账户列");
assert!(
harness.query_by_label_contains("1000000.00").is_some(),
"权益"
);
assert!(
harness.query_by_label_contains("12345.00").is_some(),
"冻结"
);
assert!(
harness.query_by_label_contains("987655.00").is_some(),
"可用 = balance-frozen"
);
let shared2 = Arc::new(UiShared::new());
let h2 = harness_for(&shared2);
assert!(h2.query_by_label_contains("等网关 eAccount").is_some());
}
#[test]
fn s17_menu_opens_windows() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let mut harness = harness_for(&shared);
assert!(!shared.ops_window_open());
assert!(!shared.accounts_window_open());
assert!(!shared.contracts_window_open());
harness
.get_by_label(ek_ui::render::MENU_OPS)
.click_accesskit();
harness.run();
assert!(shared.ops_window_open());
assert!(harness.query_by_label_contains("插件").is_some());
harness
.get_by_label(ek_ui::render::MENU_ACCOUNTS)
.click_accesskit();
harness.run();
assert!(shared.accounts_window_open());
assert!(
harness
.query_by_label(ek_ui::render::BTN_ACCOUNT_ADD)
.is_some(),
"账户面板表单在场"
);
harness
.get_by_label(ek_ui::render::MENU_CONTRACTS)
.click_accesskit();
harness.run();
assert!(shared.contracts_window_open());
assert!(harness.query_by_label_contains("代码或交易所").is_some());
}
#[test]
fn s18_contracts_window_query_select_subscribe() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let gw = seed_md_source(&shared, "9999", "278153");
shared.set_contracts_window_open(true);
shared.update_contract(&contract(
ek_data::constant::Exchange::SHFE,
"rb2605",
"螺纹钢2605",
));
shared.update_contract(&contract(
ek_data::constant::Exchange::DCE,
"a2709",
"黄玉米2709",
));
let mut harness = harness_for(&shared);
assert!(harness.query_by_label("DCE.a2709").is_some());
assert!(harness.query_by_label("SHFE.rb2605").is_some());
harness.get_by_label("SHFE.rb2605").click_accesskit();
harness.run();
assert_eq!(shared.selected_contracts(), vec!["SHFE.rb2605".to_string()]);
harness
.get_by_label(ek_ui::render::BTN_SUBSCRIBE_SELECTED)
.click_accesskit();
harness.run();
let cmds = shared.drain_cmds();
assert_eq!(cmds.len(), 1);
match &cmds[0] {
UiCmd::CtpSubscribe { gateway, symbols } => {
assert_eq!(gateway, &gw, "订阅一律路由到行情元账号");
assert_eq!(symbols, &vec!["rb2605".to_string()], "en_symbol → 裸代码");
}
other => panic!("expected CtpSubscribe, got {other:?}"),
}
shared.set_contract_query("DCE");
harness.run();
assert!(harness.query_by_label("DCE.a2709").is_some());
assert!(
harness.query_by_label("SHFE.rb2605").is_none(),
"过滤后不上屏"
);
shared.set_contract_query(String::new());
harness.run();
harness.get_by_label("SHFE.rb2605").click_accesskit(); harness.run();
assert!(shared.selected_contracts().is_empty());
harness
.get_by_label(ek_ui::render::BTN_SUBSCRIBE_SELECTED)
.click_accesskit();
harness.run();
assert!(shared.drain_cmds().is_empty(), "空勾选不得入列");
assert!(
shared
.logs_snapshot()
.0
.iter()
.any(|l| l.contains("未勾选任何合约")),
"空勾选日志口径缺位"
);
}
#[test]
fn s19_cancel_all_fanout() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let mk = |id: &str, status: ek_data::constant::Status| ek_data::order::OrderData {
gateway_name: "ctp".to_string(),
symbol: "rb2605".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
orderid: id.to_string(),
status,
..ek_data::order::OrderData::default()
};
shared.set_trade_snap(ek_ui::state::TradeSnap {
orders: vec![
mk("A1", ek_data::constant::Status::NOTTRADED),
mk("A2", ek_data::constant::Status::PARTTRADED),
mk("T1", ek_data::constant::Status::ALLTRADED),
mk("T2", ek_data::constant::Status::CANCELLED),
],
..ek_ui::state::TradeSnap::default()
});
let mut harness = harness_for(&shared);
harness
.get_by_label(ek_ui::render::BTN_CANCEL_ALL)
.click_accesskit();
harness.run();
let cmds = shared.drain_cmds();
assert_eq!(cmds.len(), 2, "只活动委托扇出");
let ids: Vec<String> = cmds
.iter()
.map(|c| match c {
ek_ui::state::UiCmd::CancelOrder { orderid, .. } => orderid.clone(),
other => panic!("expected CancelOrder, got {other:?}"),
})
.collect();
assert_eq!(ids, vec!["A1".to_string(), "A2".to_string()]);
assert!(
shared
.logs_snapshot()
.0
.iter()
.any(|l| l.contains("全撤入列 2 笔")),
"全撤日志留痕"
);
}
#[test]
fn s16_cjk_font_snapshot() {
let _g = snapshot_lock().lock().unwrap();
let shared = Arc::new(UiShared::new());
feed_snapshot(
&shared,
vec![("svctl".to_string(), vec!["svctl.list".to_string()])],
);
feed_rows(
&shared,
vec![ek_station::wire::PlugRow {
name: "观测".to_string(),
version: "0.1.0".to_string(),
state: "运行中".to_string(),
deps: vec!["依赖甲".to_string()],
spilled: 0,
panics: 0,
}],
);
shared.set_ops_window_open(true);
shared.set_bottomright_tab(ek_ui::state::BottomRightTab::Logs);
shared.update_tick(&tick(
ek_data::constant::Exchange::SHFE,
"rb2605",
"螺纹钢2605",
101.0,
1.25,
));
shared.log_line(
"[INFO][字体] 观测台插件状态版本依赖行情委托成交持仓资金冻结订阅授权密码查询合约运维撤单",
);
let mut harness = harness_for(&shared);
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
assert!(
hit.is_some(),
"验收环境必须有 CJK 候选字体(乱码环境不得跑本钉)"
);
harness.run();
snap(&mut harness, "s16_cjk_font");
}
#[test]
fn s20_enter_subscribes_symbol() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let gw = seed_md_source(&shared, "9999", "278153");
let mut harness = harness_for(&shared);
harness.run();
let target = {
let sym = harness
.get_all_by_role(egui::accesskit::Role::TextInput)
.next()
.expect("代码框 TextInput 在场");
sym.accesskit_node().id()
};
harness.event(egui::Event::AccessKitActionRequest(
egui::accesskit::ActionRequest {
action: egui::accesskit::Action::Focus,
target,
data: None,
},
));
harness.step();
harness.event(egui::Event::Text("rb2605".to_string()));
harness.step();
harness.key_press(egui::Key::Enter);
harness.step();
let cmds = shared.drain_cmds();
assert_eq!(cmds.len(), 1, "回车订阅恰一次入列: {cmds:?}");
match &cmds[0] {
ek_ui::state::UiCmd::CtpSubscribe { gateway, symbols } => {
assert_eq!(gateway, &gw, "订阅一律路由到行情元账号");
assert_eq!(symbols, &vec!["rb2605".to_string()], "裸代码订阅");
}
other => panic!("expected CtpSubscribe, got {other:?}"),
}
assert!(
shared
.logs_snapshot()
.0
.iter()
.any(|l| l.contains("代码回车订阅")),
"回车订阅日志留痕"
);
shared.set_order_form(ek_ui::state::OrderForm::default());
harness.run();
harness.key_press(egui::Key::Enter);
harness.step();
assert!(shared.drain_cmds().is_empty(), "空代码回车不得入列");
}
#[test]
fn s21_full_tables_snapshot() {
let _g = snapshot_lock().lock().unwrap();
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.update_tick(&tick(
ek_data::constant::Exchange::SHFE,
"rb2605",
"螺纹钢2605",
101.0,
1.25,
));
shared.set_trade_snap(ek_ui::state::TradeSnap {
orders: vec![
ek_data::order::OrderData {
gateway_name: "ctp".to_string(),
symbol: "rb2605".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
orderid: "CTP_7701".to_string(),
order_type: ek_data::constant::OrderType::LIMIT,
direction: Some(ek_data::constant::Direction::LONG),
offset: ek_data::constant::Offset::OPEN,
price: 101.0,
volume: 2.0,
traded: 1.0,
traded_price: 101.0,
status: ek_data::constant::Status::PARTTRADED,
status_msg: "部分成交".to_string(),
name: "螺纹钢2605".to_string(),
reference: "LOCAL-1".to_string(),
datetime: Some(36_000_000_000_000),
},
ek_data::order::OrderData {
gateway_name: "ctp".to_string(),
symbol: "rb2605".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
orderid: "CTP_7702".to_string(),
order_type: ek_data::constant::OrderType::LIMIT,
direction: Some(ek_data::constant::Direction::SHORT),
offset: ek_data::constant::Offset::CLOSE,
price: 102.0,
volume: 1.0,
traded: 0.0,
traded_price: 0.0,
status: ek_data::constant::Status::REJECTED,
status_msg: "资金不足".to_string(),
name: "螺纹钢2605".to_string(),
datetime: Some(36_000_000_000_000),
..ek_data::order::OrderData::default()
},
],
trades: vec![ek_data::trade::TradeData {
gateway_name: "ctp".to_string(),
symbol: "rb2605".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
orderid: "CTP_7701".to_string(),
tradeid: "T-8801".to_string(),
direction: Some(ek_data::constant::Direction::LONG),
offset: ek_data::constant::Offset::OPEN,
price: 101.0,
volume: 1.0,
traded_amount: 101.0,
name: "螺纹钢2605".to_string(),
datetime: Some(36_000_000_000_000),
..ek_data::trade::TradeData::default()
}],
positions: vec![ek_data::position::PositionData {
gateway_name: "ctp".to_string(),
symbol: "rb2605".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
direction: ek_data::constant::Direction::LONG,
volume: 1.0,
yd_volume: 0.0,
can_use_volume: 1.0,
frozen: 0.0,
on_road_volume: 0.0,
market_value: 101.0,
price: 101.0,
avg_price: 101.0,
last_price: 101.0,
pnl: 0.0,
profit_rate: 0.0,
name: "螺纹钢2605".to_string(),
}],
accounts: vec![],
});
let mut harness = harness_for(&shared);
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx); assert!(hit.is_some(), "验收环境必须有 CJK 候选字体");
harness.run();
snap(&mut harness, "s21_full_tables");
}
#[test]
fn s24_contracts_viewport_guard_immediate_path() {
use std::cell::RefCell;
use std::rc::Rc;
let seen: Rc<RefCell<Vec<String>>> = Rc::new(RefCell::new(Vec::new()));
let sink = Rc::clone(&seen);
egui::Context::set_immediate_viewport_renderer(move |_ctx, vp| {
sink.borrow_mut()
.push(vp.builder.title.clone().unwrap_or_default());
let tmp = egui::Context::default();
let mut vp = vp;
let _out = tmp.run(egui::RawInput::default(), |c| {
(vp.viewport_ui_cb)(c);
});
});
let shared = Arc::new(UiShared::new());
let n0 = seen.borrow().len();
let _h0 = Harness::builder().with_size([1280.0, 860.0]).build(|ctx| {
ctx.set_embed_viewports(false); render_body(ctx, &shared);
});
assert_eq!(
seen.borrow().len(),
n0,
"状态位关却调了 show_viewport_immediate = 窗永不被 GC(关不掉根因)"
);
shared.set_contracts_window_open(true);
let n1 = seen.borrow().len();
let _h1 = Harness::builder().with_size([1280.0, 860.0]).build(|ctx| {
ctx.set_embed_viewports(false);
render_body(ctx, &shared);
});
assert!(seen.borrow().len() > n1, "状态位开必须 show(否则窗出不来)");
assert!(
seen.borrow()[n1..].iter().all(|t| t == "查询合约窗"),
"Immediate 面 show 的窗必须是查询合约窗"
);
shared.set_contracts_window_open(false);
let n2 = seen.borrow().len();
let _h2 = Harness::builder().with_size([1280.0, 860.0]).build(|ctx| {
ctx.set_embed_viewports(false);
render_body(ctx, &shared);
});
assert_eq!(
seen.borrow().len(),
n2,
"关闭后不得再 show(再 show = 窗永不销毁)"
);
}
#[test]
fn s25_kline_esc_close_and_maximized() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
feed_market_frame(&shared, 2, 4);
shared.open_kline("rb2605");
let mut harness = harness_for(&shared);
harness.run();
assert!(shared.kline_symbols().contains(&"rb2605".to_string()));
harness.input_mut().events.push(egui::Event::Key {
key: egui::Key::Escape,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::NONE,
});
harness.run();
assert!(
shared.kline_symbols().is_empty(),
"ESC 应关闭 K线窗(Embedded 面;Immediate 面同构由 body 返回 true 走同一 close_kline)"
);
use std::cell::RefCell;
use std::rc::Rc;
type Seen = Rc<RefCell<Vec<(String, Option<bool>)>>>;
let seen: Seen = Rc::new(RefCell::new(Vec::new()));
let sink = Rc::clone(&seen);
egui::Context::set_immediate_viewport_renderer(move |_ctx, vp| {
sink.borrow_mut().push((
vp.builder.title.clone().unwrap_or_default(),
vp.builder.maximized,
));
let tmp = egui::Context::default();
let mut vp = vp;
let _out = tmp.run(egui::RawInput::default(), |c| {
(vp.viewport_ui_cb)(c);
});
});
let shared2 = Arc::new(UiShared::new());
shared2.open_kline("rb2605");
let _h = Harness::builder().with_size([1280.0, 860.0]).build(|ctx| {
ctx.set_embed_viewports(false);
render_body(ctx, &shared2);
});
let hits: Vec<(String, Option<bool>)> = seen
.borrow()
.iter()
.filter(|(t, _)| t.contains("K线"))
.cloned()
.collect();
assert!(!hits.is_empty(), "Immediate 面 K线窗必须 show");
assert!(
hits.iter().all(|(_, m)| *m == Some(true)),
"K线窗 builder 必须默认最大化(BD-UI-34 用户裁定)"
);
}
#[test]
fn s26_empty_tables_keep_headers() {
let shared = Arc::new(UiShared::new());
let mut harness = harness_for(&shared);
harness.run();
for probe in [
"(无委托:等网关 send_order)",
"(无成交)",
"(无持仓)",
"成交号",
"昨仓",
] {
assert!(
harness.query_by_label_contains(probe).is_some(),
"空态结构钉:{probe} 必须存在(表头/提示都在)"
);
}
}
#[test]
fn s27_scrolled_orders_no_peek_above_header() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let orders: Vec<ek_data::order::OrderData> = (0..12)
.map(|i| ek_data::order::OrderData {
gateway_name: "ctp".to_string(),
symbol: format!("rb{i}"),
exchange: ek_data::constant::Exchange::SHFE,
orderid: format!("SCROLL_{i}"),
order_type: ek_data::constant::OrderType::LIMIT,
direction: Some(ek_data::constant::Direction::LONG),
offset: ek_data::constant::Offset::OPEN,
price: 100.0 + i as f64,
volume: 1.0,
traded: 0.0,
traded_price: 0.0,
status: ek_data::constant::Status::SUBMITTING,
status_msg: String::new(),
name: String::new(),
reference: String::new(),
datetime: Some(36_000_000_000_000),
})
.collect();
shared.set_trade_snap(ek_ui::state::TradeSnap {
orders,
..Default::default()
});
let mut harness = harness_for(&shared);
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx); assert!(hit.is_some(), "验收环境必须有 CJK 候选字体(s16 同款闸)");
harness.run();
harness
.input_mut()
.events
.push(egui::Event::PointerMoved(egui::pos2(500.0, 450.0)));
harness.input_mut().events.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, -150.0),
modifiers: egui::Modifiers::NONE,
});
harness.run();
snap(&mut harness, "s27_scrolled_orders");
}
#[test]
fn s28_account_md_lock_and_remove_guard() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let gw1 = seed_md_source(&shared, "9999", "278153");
let row2 = ek_ui::state::AccountRow::derive(&ek_ui::state::CtpForm {
broker_id: "9999".to_string(),
td_user: "220380".to_string(),
..Default::default()
})
.expect("派生注册名");
let gw2 = row2.gateway.clone();
shared.add_account(row2).expect("第二账户");
assert_eq!(gw2, "ctp_9999_220380", "同 broker 不同账号 = 不同派生名");
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
assert!(harness.query_by_label(&gw1).is_some(), "账户行 1");
assert!(harness.query_by_label(&gw2).is_some(), "账户行 2");
assert!(!shared.accounts_locked(), "无账户在线 = 可指定行情源");
let newly = shared.sync_accounts_connected(&[row("svctl"), row(&gw1), row("dm")]);
assert_eq!(newly, vec![gw1.clone()], "新上线检测只回本轮新连上的");
assert!(shared.accounts_locked());
let err = shared.set_md_source(&gw2).unwrap_err();
assert!(err.contains("锁定"), "连接后切换必须被拒:{err}");
harness.run();
assert!(
harness.query_by_label_contains("行情源已锁定").is_some(),
"锁定口径句上屏"
);
shared.set_trade_snap(ek_ui::state::TradeSnap {
orders: vec![ek_data::order::OrderData {
gateway_name: gw1.clone(),
symbol: "rb2605".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
orderid: format!("{gw1}.CTP_1_1"),
status: ek_data::constant::Status::NOTTRADED,
..ek_data::order::OrderData::default()
}],
..ek_ui::state::TradeSnap::default()
});
harness.run();
assert!(
harness.query_by_label_contains("先撤单才能移除").is_some(),
"挂单守卫提示上屏"
);
assert!(
harness.query_by_label("先撤 1 笔").is_some(),
"守卫不能只堵门:须给『先撤 N 笔』出口(规划 U10)"
);
assert!(
harness.query_by_label("移除 278153").is_none(),
"有挂单的账户不给移除钮(柜台还在跑单而没人能撤 = 观测黑洞)"
);
shared.set_trade_snap(ek_ui::state::TradeSnap::default());
harness.run();
assert!(
harness.query_by_label("移除 278153").is_some(),
"守卫解除后移除钮回到场(显示名口径)"
);
assert!(
harness.query_by_label(&format!("移除 {gw1}")).is_none(),
"移除钮不再写派生名(同一行『编辑 278153』与『移除 278153』口径必须一致)"
);
}
fn two_accounts(shared: &UiShared) -> (String, String) {
let gw1 = seed_md_source(shared, "9999", "278153");
let row2 = ek_ui::state::AccountRow::derive(&ek_ui::state::CtpForm {
broker_id: "9999".to_string(),
td_user: "220380".to_string(),
..Default::default()
})
.expect("派生注册名");
let gw2 = row2.gateway.clone();
shared.add_account(row2).expect("第二账户");
let newly = shared.sync_accounts_connected(&[
row("svctl"),
row("dm"),
row("oms"),
row(&gw1),
row(&gw2),
]);
assert_eq!(newly.len(), 2, "两账户都该被检出新上线");
assert!(shared.accounts_locked(), "有账户在线 = 元账号锁定(D3)");
(gw1, gw2)
}
#[test]
fn s29_two_accounts_order_follows_selected_account() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let (gw1, gw2) = two_accounts(&shared);
shared.set_contracts_window_open(true);
shared.update_contract(&contract(
ek_data::constant::Exchange::SHFE,
"rb2605",
"螺纹钢2605",
));
shared.set_order_form(ek_ui::state::OrderForm {
exchange: "SHFE".to_string(),
symbol: "rb2605".to_string(),
direction: ek_data::constant::Direction::LONG,
offset: ek_data::constant::Offset::OPEN,
order_type: ek_data::constant::OrderType::LIMIT,
price: "102.5".to_string(),
volume: "1".to_string(),
gateway: gw2.clone(),
follow_price: false,
auto_split: true,
});
let ladder = {
let mut f = shared.order_form();
f.gateway = String::new();
shared.set_order_form(f);
shared.default_gateway()
};
assert_eq!(
ladder.as_deref(),
Some(gw1.as_str()),
"阶梯 = 第一个已连接账户"
);
let mut harness = harness_for(&shared);
harness.run();
let mut f = shared.order_form();
f.gateway = gw2.clone();
shared.set_order_form(f);
harness.run();
let nodes: Vec<_> = harness
.get_all_by_label(ek_ui::render::BTN_SEND_ORDER)
.collect();
assert_eq!(nodes.len(), 2, "委托钮 + 委托 tab");
nodes[0].click_accesskit();
harness.run();
let cmds = shared.drain_cmds();
assert_eq!(cmds.len(), 1, "下单一条入列: {cmds:?}");
match &cmds[0] {
UiCmd::SendOrder { req, gateway, .. } => {
assert_eq!(gateway, &gw2, "下单随所选账户(非行情源也发得出去)");
assert_eq!(req.symbol, "rb2605");
assert_eq!(req.price, 102.5);
}
other => panic!("expected SendOrder, got {other:?}"),
}
harness.get_by_label("SHFE.rb2605").click_accesskit();
harness.run();
harness
.get_by_label(ek_ui::render::BTN_SUBSCRIBE_SELECTED)
.click_accesskit();
harness.run();
let cmds = shared.drain_cmds();
assert_eq!(cmds.len(), 1, "订阅一条入列: {cmds:?}");
match &cmds[0] {
UiCmd::CtpSubscribe { gateway, symbols } => {
assert_eq!(gateway, &gw1, "订阅一律路由到行情元账号(下单选的是 {gw2})");
assert_eq!(symbols, &vec!["rb2605".to_string()]);
}
other => panic!("expected CtpSubscribe, got {other:?}"),
}
}
#[test]
fn s30_cancel_all_follows_each_orders_gateway() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let (gw1, gw2) = two_accounts(&shared);
let mk = |gw: &str, id: &str, status: ek_data::constant::Status| ek_data::order::OrderData {
gateway_name: gw.to_string(),
symbol: "rb2605".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
orderid: id.to_string(),
status,
..ek_data::order::OrderData::default()
};
shared.set_trade_snap(ek_ui::state::TradeSnap {
orders: vec![
mk(&gw1, "A1", ek_data::constant::Status::NOTTRADED),
mk(&gw1, "A2", ek_data::constant::Status::PARTTRADED),
mk(&gw2, "B1", ek_data::constant::Status::NOTTRADED),
mk(&gw2, "B2", ek_data::constant::Status::PARTTRADED),
mk(&gw1, "T1", ek_data::constant::Status::ALLTRADED),
mk(&gw2, "T2", ek_data::constant::Status::CANCELLED),
],
..ek_ui::state::TradeSnap::default()
});
let mut harness = harness_for(&shared);
harness.run();
harness
.get_by_label(ek_ui::render::BTN_CANCEL_ALL)
.click_accesskit();
harness.run();
let cmds = shared.drain_cmds();
assert_eq!(cmds.len(), 4, "只活动委托扇出(终态不发): {cmds:?}");
let pairs: Vec<(String, String)> = cmds
.iter()
.map(|c| match c {
UiCmd::CancelOrder { orderid, gateway } => (orderid.clone(), gateway.clone()),
other => panic!("expected CancelOrder, got {other:?}"),
})
.collect();
let to_gw1 = pairs.iter().filter(|(_, g)| g == &gw1).count();
let to_gw2 = pairs.iter().filter(|(_, g)| g == &gw2).count();
assert_eq!(to_gw1, 2, "元账号的两笔撤单目标 = 元账号");
assert_eq!(to_gw2, 2, "纯 TD 账号的两笔撤单目标 = 它自己(撤单随单走)");
assert!(
pairs.iter().all(|(id, g)| {
let own = if id.starts_with('A') { &gw1 } else { &gw2 };
g == own
}),
"逐笔核对:撤单 ask 目标必须等于该单自己的网关:{pairs:?}"
);
assert!(
shared
.logs_snapshot()
.0
.iter()
.any(|l| l.contains("全撤入列 4 笔")),
"全撤日志留痕"
);
}
#[test]
fn s31_remove_connected_account_unlocks_md_source() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let (gw1, gw2) = two_accounts(&shared);
shared.note_subscriptions(&["rb2605".to_string()]);
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
harness.get_by_label("移除 278153").click_accesskit();
harness.run();
assert_eq!(
shared.pending_confirm().as_deref(),
Some(gw1.as_str()),
"首点 = 待二次确认(内部仍按派生名记槽)"
);
assert!(shared.drain_cmds().is_empty(), "armed 不入列");
harness.get_by_label("确认移除 278153 ?").click_accesskit();
harness.run();
let cmds = shared.drain_cmds();
assert!(
cmds.iter()
.any(|c| matches!(c, UiCmd::Remove { name } if name == &gw1)),
"摘除走 svctl.remove: {cmds:?}"
);
assert_eq!(shared.accounts().len(), 1, "清册只剩纯 TD 账号");
assert!(
shared.md_source().is_empty(),
"元账号被摘 → 源位清空(等重新指定)"
);
assert_eq!(
shared.subscriptions(),
vec!["rb2605".to_string()],
"订阅集保留(规划 §5.C)"
);
let err = shared.set_md_source(&gw2).unwrap_err();
assert!(err.contains("锁定"), "有账户在线仍须拒:{err}");
harness.run();
assert!(
harness.query_by_label_contains("行情源已锁定").is_some(),
"锁定口径句仍在场"
);
harness.get_by_label("移除 220380").click_accesskit();
harness.run();
harness.get_by_label("确认移除 220380 ?").click_accesskit();
harness.run();
let _ = shared.drain_cmds();
assert!(shared.accounts().is_empty(), "清册空");
assert!(!shared.accounts_locked(), "无账户在线 = 解锁");
let row3 = ek_ui::state::AccountRow::derive(&ek_ui::state::CtpForm {
broker_id: "9999".to_string(),
td_user: "220380".to_string(),
..Default::default()
})
.expect("派生注册名");
assert_eq!(row3.gateway, gw2, "重加同名账户 = 同一派生名");
add_via_dialog(
&mut harness,
&shared,
acct_form(
"9999",
"220380",
"tcp://182.254.243.31:30001",
"tcp://182.254.243.31:30011",
),
);
let cmds = shared.drain_cmds();
assert!(
cmds.is_empty(),
"重加 = 只写清册(0.2.18 四态:『保存』不下发装配/凭据两令): {cmds:?}"
);
assert_eq!(
shared.md_source(),
gw2,
"无源 + 无在场账户 → 新添加的账户自动成为元账号(添加即定源)"
);
harness.run();
assert!(harness.query_by_label(&gw2).is_some(), "重加后账户行回到场");
assert!(
harness.query_by_label("连接 220380").is_some(),
"重加后须点『连接』才装配(四态分离入口在场)"
);
}
#[test]
fn s32_no_account_disables_send_button() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.set_order_form(ek_ui::state::OrderForm {
exchange: "SHFE".to_string(),
symbol: "rb2605".to_string(),
direction: ek_data::constant::Direction::LONG,
offset: ek_data::constant::Offset::OPEN,
order_type: ek_data::constant::OrderType::LIMIT,
price: "102.5".to_string(),
volume: "1".to_string(),
gateway: String::new(),
follow_price: false,
auto_split: true,
});
let mut harness = harness_for(&shared);
harness.run();
assert_eq!(shared.default_gateway(), None, "无账户 = 无默认网关");
assert!(
harness.query_by_label_contains("委托钮已禁用").is_some(),
"禁用口径句在场(不是静默禁用)"
);
let nodes: Vec<_> = harness
.get_all_by_label(ek_ui::render::BTN_SEND_ORDER)
.collect();
nodes[0].click_accesskit();
harness.run();
assert!(shared.drain_cmds().is_empty(), "禁用态点击不得入列");
let gw = seed_md_source(&shared, "9999", "278153");
assert_eq!(
shared.default_gateway(),
None,
"只添加未装配 = 仍无默认网关(阶梯不再退到第一个账户)"
);
harness.run();
assert!(
harness.query_by_label_contains("委托钮已禁用").is_some(),
"未装配账户不得把下单钮点亮"
);
mark_assembled(&shared, &gw);
harness.run();
assert_eq!(shared.default_gateway().as_deref(), Some(gw.as_str()));
assert!(
harness.query_by_label_contains("委托钮已禁用").is_none(),
"有账户后禁用句撤场"
);
let nodes: Vec<_> = harness
.get_all_by_label(ek_ui::render::BTN_SEND_ORDER)
.collect();
nodes[0].click_accesskit();
harness.run();
let cmds = shared.drain_cmds();
assert_eq!(cmds.len(), 1, "有账户后下单入列: {cmds:?}");
match &cmds[0] {
UiCmd::SendOrder { gateway, .. } => {
assert_eq!(gateway, &gw, "网关位取账户派生名(旧字面量 ctp 已退役)");
}
other => panic!("expected SendOrder, got {other:?}"),
}
}
fn acct_form(broker: &str, account: &str, td: &str, md: &str) -> ek_ui::state::CtpForm {
ek_ui::state::CtpForm {
broker_id: broker.to_string(),
td_addr: td.to_string(),
td_user: account.to_string(),
td_password: "fake-pw-1234".to_string(),
td_auth_code: "fake-auth".to_string(),
td_app_id: "fake-app".to_string(),
md_addr: md.to_string(),
md_user: String::new(),
md_password: String::new(),
symbols: String::new(),
accept_futures: true,
accept_options: false,
}
}
fn click_account_add(harness: &mut Harness<'_>) {
let nodes: Vec<_> = harness
.get_all_by_label(ek_ui::render::BTN_ACCOUNT_ADD)
.collect();
assert!(!nodes.is_empty(), "『添加账户』钮应在场");
nodes[0].click_accesskit();
}
fn click_dialog(harness: &mut Harness<'_>, label: &str) {
let nodes: Vec<_> = harness.get_all_by_label(label).collect();
assert!(!nodes.is_empty(), "配置弹窗钮 {label:?} 应在场");
nodes[0].click_accesskit();
harness.run();
}
fn add_via_dialog(harness: &mut Harness<'_>, shared: &UiShared, form: ek_ui::state::CtpForm) {
click_account_add(harness);
harness.run();
assert!(
shared.account_edit_open(),
"『添加账户』= 开配置弹窗(Commit B 口径)"
);
shared.set_ctp_form(form);
harness.run();
click_dialog(harness, "保存");
assert!(
!shared.account_edit_open(),
"『保存』成功 = 弹窗关闭(被拦才留着显形)"
);
}
#[test]
fn s33_failed_start_leaves_row_and_allows_retry() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
add_via_dialog(
&mut harness,
&shared,
acct_form(
"9999",
"278153",
"tcp://10.0.0.1:40001",
"tcp://10.0.0.1:40011",
),
);
assert!(
shared.drain_cmds().is_empty(),
"『保存』只写清册,不下发装配令"
);
assert_eq!(shared.accounts().len(), 1, "清册一行");
harness.run();
harness.get_by_label("连接 278153").click_accesskit();
harness.run();
let cmds = shared.drain_cmds();
assert_eq!(cmds.len(), 1, "『连接』入列一条令: {cmds:?}");
assert!(matches!(cmds[0], UiCmd::Connect { .. }));
shared.note_connect_sent("ctp_9999_278153");
harness.run();
assert!(
harness.query_by_label_contains("装配中").is_some(),
"装配中须在状态列显形"
);
shared.note_connect_result(
"ctp_9999_278153",
Err("svctl.start 被拒:工厂 ctp 不在册".to_string()),
);
harness.run();
let acc = shared
.account("ctp_9999_278153")
.expect("期望口径:失败行不回滚,留在清册给重试");
assert_eq!(acc.stage, ek_ui::state::AccountStage::AssembleFailed);
assert_eq!(
acc.fail_note.as_deref(),
Some("svctl.start 被拒:工厂 ctp 不在册"),
"失败原因须留档"
);
assert!(
harness.query_by_label_contains("装配失败").is_some(),
"装配失败必须在界面上显形(旧口径:零显形)"
);
assert!(
harness.query_by_label("重试 278153").is_some(),
"失败行须给『重试』入口"
);
harness.get_by_label("重试 278153").click_accesskit();
harness.run();
let cmds = shared.drain_cmds();
assert_eq!(
cmds.len(),
1,
"重试 = 一条 Connect 令(不需先移除): {cmds:?}"
);
assert!(matches!(cmds[0], UiCmd::Connect { .. }));
assert_eq!(shared.accounts().len(), 1, "失败行没被回滚,也没多建行");
}
#[test]
fn s34_account_form_is_per_account() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
add_via_dialog(
&mut harness,
&shared,
acct_form(
"9999",
"278153",
"tcp://10.0.0.1:40001",
"tcp://10.0.0.1:40011",
),
);
let _ = shared.drain_cmds();
let draft = shared.ctp_form();
assert!(draft.td_user.is_empty(), "期望口径:保存后账号位清空");
assert!(
draft.td_password.is_empty(),
"期望口径:上一家密码不得留在草稿位"
);
assert!(draft.td_addr.is_empty(), "期望口径:前置也清空");
assert_eq!(draft.broker_id, "9999", "非凭据字段沿用(Q20 缓解)");
assert_eq!(draft.td_app_id, "fake-app", "AppID 沿用");
assert_eq!(draft.td_auth_code, "fake-auth", "AuthCode 沿用");
let acc1 = shared.account("ctp_9999_278153").expect("第一账户");
assert_eq!(
acc1.form.td_password, "fake-pw-1234",
"留档保留自己的凭据(清空只作用于草稿位)"
);
click_account_add(&mut harness);
harness.run();
shared.set_ctp_form(ek_ui::state::CtpForm {
td_user: "220380".to_string(),
td_addr: "tcp://10.0.0.1:40001".to_string(),
td_password: "pw-of-220380".to_string(),
..shared.ctp_form()
});
harness.run();
click_dialog(&mut harness, "保存");
let _ = shared.drain_cmds();
let acc2 = shared.account("ctp_9999_220380").expect("第二账户已入清册");
assert_eq!(
acc2.form.td_password, "pw-of-220380",
"期望口径:新账户只带自己的密码,不继承上一家"
);
assert_eq!(
acc2.form.md_addr, "",
"期望口径:纯 TD 账户的 md 位在**留档**里就清空(旧缺陷:只清下发不清留档)"
);
assert_eq!(
acc1.form.md_addr, "tcp://10.0.0.1:40011",
"元账号的 md 留档不被第二账户的单源清空波及"
);
}
#[test]
fn s35_cancel_edit_clears_credentials() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let gw1 = seed_md_source(&shared, "9999", "278153");
shared.update_account_form(
&gw1,
acct_form(
"9999",
"278153",
"tcp://10.0.0.1:40001",
"tcp://10.0.0.1:40011",
),
);
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
harness.get_by_label("编辑 278153").click_accesskit();
harness.run();
assert!(shared.account_edit_open(), "『编辑』= 开配置弹窗(Commit B)");
assert_eq!(
shared.editing_account().as_deref(),
Some(gw1.as_str()),
"弹窗模式 = 编辑该账户"
);
assert_eq!(
shared.ctp_form().td_password,
"fake-pw-1234",
"编辑载入该账户自己的表单(含密码)"
);
assert!(
harness
.query_by_label_contains("要改这两格请『移除后重加』")
.is_some(),
"编辑模式 broker/账号只读的口径句上屏(规划 Q25)"
);
assert!(
harness.query_by_label("保存并连接").is_some(),
"编辑模式给『保存并连接』出口"
);
click_dialog(&mut harness, "取消");
assert!(!shared.account_edit_open(), "『取消』= 关弹窗");
assert!(shared.editing_account().is_none(), "编辑模式解除");
let draft = shared.ctp_form();
assert!(
draft.td_password.is_empty(),
"期望口径:取消清空凭据(旧口径:原封不动留在共享位)"
);
assert!(draft.td_user.is_empty(), "期望口径:账号位也清空");
assert!(draft.td_addr.is_empty(), "期望口径:前置也清空");
assert_eq!(draft.broker_id, "9999", "非凭据字段沿用");
click_account_add(&mut harness);
harness.run();
click_dialog(&mut harness, "保存");
assert!(shared.account_edit_open(), "被拦不得关弹窗(失败要能就地改)");
assert_eq!(
shared.accounts().len(),
1,
"清册仍一行(没建出凭据错配的账户)"
);
assert!(shared.drain_cmds().is_empty(), "空凭据草稿不得入列");
assert!(shared.edit_error().is_some(), "拦截原因须在弹窗里显形");
let (lines, _) = shared.logs_snapshot();
assert!(
lines.iter().any(|l| l.contains("保存被拦")),
"拦截须在日志里显形: {lines:?}"
);
}
#[test]
fn s36_empty_front_address_is_rejected() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
click_account_add(&mut harness);
harness.run();
assert!(shared.account_edit_open(), "『添加账户』= 开配置弹窗");
shared.set_ctp_form(acct_form("9999", "278153", "", ""));
harness.run();
click_dialog(&mut harness, "保存");
assert!(
shared.drain_cmds().is_empty(),
"期望口径:前置为空不得下发装配/凭据两令"
);
assert!(
shared.account("ctp_9999_278153").is_none(),
"期望口径:空前置的账户不入清册(旧口径:原样入库)"
);
assert!(shared.account_edit_open(), "被拦不得关弹窗(失败要能就地改)");
assert!(shared.edit_error().is_some(), "弹窗里须显形拦截原因");
let (lines, _) = shared.logs_snapshot();
assert!(
lines
.iter()
.any(|l| l.contains("保存被拦") && l.contains("td 前置")),
"拦截原因须在日志显形: {lines:?}"
);
shared.set_ctp_form(acct_form("9999", "278153", "tcp://10.0.0.1", ""));
harness.run();
click_dialog(&mut harness, "保存");
assert!(shared.account("ctp_9999_278153").is_none(), "缺端口须被拦");
shared.set_ctp_form(acct_form("9999", "278153", "tcp://10.0.0.1:abc", ""));
harness.run();
click_dialog(&mut harness, "保存");
assert!(
shared.account("ctp_9999_278153").is_none(),
"端口非数字须被拦"
);
shared.set_ctp_form(acct_form("9999", "278153", "tcp://10.0.0.1:40001", ""));
harness.run();
click_dialog(&mut harness, "保存");
assert!(!shared.account_edit_open(), "保存成功 = 弹窗关闭");
let acc = shared.account("ctp_9999_278153").expect("合规表单须入清册");
assert_eq!(acc.form.td_addr, "tcp://10.0.0.1:40001");
assert!(acc.form.md_addr.is_empty(), "md 前置留空 = 纯 TD 账户,合法");
}
#[test]
fn s37_account_panel_shows_stage_and_login_state() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let (gw1, gw2) = two_accounts(&shared);
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
assert!(harness.query_by_label(&gw1).is_some(), "账户行 1");
assert!(harness.query_by_label(&gw2).is_some(), "账户行 2");
let seen = harness.query_all_by_label_contains("已装配").count();
assert_eq!(
seen, 3,
"两行账户的状态列(「已装配·登录态未知」)+ 一句『行情源已锁定(有账户已装配)』口径句 = 3 处『已装配』"
);
assert!(
harness.query_by_label_contains("未装配").is_none(),
"两账户都在场 → 没有『未装配』行"
);
assert_eq!(
harness
.query_all_by_label_contains("已装配·登录态未知")
.count(),
2,
"两行都必须带「登录态未知」(装配 ≠ 在线,规划 U5 口径)"
);
assert_eq!(
harness.query_all_by_label_contains("已装配·在线").count(),
0,
"没有任何登录证据时不得出现「在线」"
);
assert!(
harness.query_by_label_contains("网关状态口").is_some(),
"面板底部须说明状态列的两个来源,以及『断开』/『移除』的分工"
);
for needle in ["掉线", "断供"] {
assert_eq!(
harness.query_all_by_label_contains(needle).count(),
0,
"{needle} 只在真读到该状态时才上屏;两账户现在都是「未知」,一处都不该有"
);
}
for account in ["278153", "220380"] {
assert!(
harness.query_by_label(&format!("断开 {account}")).is_some(),
"已装配账户 {account} 应有『断开』钮"
);
assert!(
harness.query_by_label(&format!("连接 {account}")).is_none(),
"已装配且会话未停的行不给『连接』钮"
);
}
}
#[test]
fn s38_removed_account_ledger_rows_stay_visible() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let (gw1, gw2) = two_accounts(&shared);
shared.set_trade_snap(ek_ui::state::TradeSnap {
orders: vec![ek_data::order::OrderData {
gateway_name: gw2.clone(),
symbol: "rb2605".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
orderid: format!("{gw2}.CTP_9_9"),
status: ek_data::constant::Status::NOTTRADED,
..ek_data::order::OrderData::default()
}],
positions: vec![ek_data::position::PositionData {
gateway_name: gw2.clone(),
symbol: "rb2605".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
volume: 2.0,
..ek_data::position::PositionData::default()
}],
..ek_ui::state::TradeSnap::default()
});
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
assert!(
harness.query_by_label_contains("先撤单才能移除").is_some(),
"有活跃委托 → 移除钮不给"
);
shared.set_trade_snap(ek_ui::state::TradeSnap {
positions: vec![ek_data::position::PositionData {
gateway_name: gw2.clone(),
symbol: "rb2605".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
volume: 2.0,
..ek_data::position::PositionData::default()
}],
..ek_ui::state::TradeSnap::default()
});
harness.run();
harness.get_by_label("移除 220380").click_accesskit();
harness.run();
harness.get_by_label("确认移除 220380 ?").click_accesskit();
harness.run();
let cmds = shared.drain_cmds();
assert!(
cmds.iter()
.any(|c| matches!(c, UiCmd::Remove { name } if name == &gw2)),
"摘除令入列: {cmds:?}"
);
assert!(shared.account(&gw2).is_none(), "清册行已摘");
assert!(
harness.query_by_label("移除 220380").is_none(),
"账户面板里这个账户的移除钮撤场"
);
assert!(
harness.query_by_label("编辑 220380").is_none(),
"账户面板里这个账户的编辑钮撤场"
);
shared.set_trade_snap(ek_ui::state::TradeSnap {
orders: vec![ek_data::order::OrderData {
gateway_name: gw2.clone(),
symbol: "rb2605".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
orderid: format!("{gw2}.CTP_9_9"),
status: ek_data::constant::Status::NOTTRADED,
..ek_data::order::OrderData::default()
}],
positions: vec![ek_data::position::PositionData {
gateway_name: gw2.clone(),
symbol: "rb2605".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
volume: 2.0,
..ek_data::position::PositionData::default()
}],
..ek_ui::state::TradeSnap::default()
});
harness.run();
let snap = shared.trade_snap();
assert!(
snap.orders.iter().any(|o| o.gateway_name == gw2),
"现状:摘除后委托行仍在账本"
);
assert!(
snap.positions.iter().any(|p| p.gateway_name == gw2),
"现状:摘除后持仓行仍在账本"
);
assert!(
harness.query_all_by_label_contains("已摘除").count() >= 2,
"0.2.18 Commit B 修 U8:残留行须带『已摘除』标注(委托行 + 持仓行各一处)——\
摘除不清账本(F5),但界面必须说清这一行属于谁"
);
assert!(shared.account(&gw1).is_some(), "元账号 {gw1} 不受影响");
}
#[test]
fn s39_account_panel_layout_snapshot() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let (gw1, gw2) = two_accounts(&shared);
shared.update_account_form(
&gw1,
acct_form(
"9999",
"278153",
"tcp://182.254.242.81:30001",
"tcp://182.254.242.81:30011",
),
);
shared.update_account_form(
&gw2,
acct_form("9999", "220380", "tcp://182.254.242.81:30001", ""),
);
shared.set_ctp_form(acct_form(
"9999",
"278153",
"tcp://182.254.242.81:30001",
"tcp://182.254.242.81:30011",
));
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx); if hit.is_none() {
return; }
harness.run();
snap(&mut harness, "s39_account_panel_layout");
assert!(harness.query_by_label(&gw1).is_some(), "账户行 1 在场");
assert!(harness.query_by_label(&gw2).is_some(), "账户行 2 在场");
assert!(
harness.query_by_label_contains("当前行情源 =").is_some(),
"列表上方须有行情源一览"
);
assert!(
harness.query_by_label_contains("账户 2/8").is_some(),
"计数含软上限(达限即禁用『添加账户』的可见依据)"
);
assert!(
harness.query_by_label_contains("收录(勾→实)").is_some(),
"收录列:勾选意图与网关回带事实并排(规划 U9)"
);
}
#[test]
fn s40_account_add_dialog_snapshot() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
click_account_add(&mut harness);
harness.run();
shared.set_ctp_form(acct_form(
"9999",
"278153",
"tcp://182.254.242.81:30001",
"tcp://182.254.242.81:30011",
));
shared.set_edit_alias("主账户");
harness.run();
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
if hit.is_none() {
return;
}
harness.run();
snap(&mut harness, "s40_account_add_dialog");
assert!(
harness.query_by_label("保存并连接").is_some(),
"添加模式给『保存并连接』"
);
assert!(
harness.query_by_label_contains("有未保存修改").is_some(),
"dirty 提示在场(关闭 = 放弃)"
);
}
#[test]
fn s41_account_edit_dialog_snapshot() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let (_gw1, gw2) = two_accounts(&shared);
shared.update_account_form(
&gw2,
acct_form("9999", "220380", "tcp://182.254.242.81:30001", ""),
);
shared.set_account_alias(&gw2, "从账户");
shared.note_effective_product_classes(&gw2, &["1".to_string(), "2".to_string()]);
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
harness.get_by_label("编辑 从账户").click_accesskit();
harness.run();
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
if hit.is_none() {
return;
}
harness.run();
snap(&mut harness, "s41_account_edit_dialog");
assert_eq!(shared.editing_account().as_deref(), Some(gw2.as_str()));
assert!(
harness
.query_by_label_contains("要改这两格请『移除后重加』")
.is_some(),
"编辑模式 broker/账号只读口径句(规划 Q25)"
);
assert!(
harness
.query_by_label_contains("非行情源账户:md 位留空")
.is_some(),
"非源 + 已锁定 → md 位置灰口径句(规划 Q12)"
);
assert_eq!(
harness.query_all_by_label_contains("期货+期权").count(),
2,
"网关实际收录回带两处上屏:列表 gw2 行的『实:期货+期权』+ 弹窗只读回带(规划 U9)"
);
}
#[test]
fn s42_alias_shown_but_not_addressable() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
click_account_add(&mut harness);
harness.run();
shared.set_ctp_form(acct_form(
"9999",
"278153",
"tcp://10.0.0.1:40001",
"tcp://10.0.0.1:40011",
));
shared.set_edit_alias("主账户");
harness.run();
click_dialog(&mut harness, "保存");
let acc = shared
.account("ctp_9999_278153")
.expect("带别名保存须入清册");
assert_eq!(acc.alias, "主账户", "别名随行入册");
harness.run();
assert!(
harness.query_by_label("主账户").is_some(),
"别名上列表(别名列)"
);
assert!(
harness.query_by_label("移除 主账户").is_some(),
"移除钮文案取别名(与『编辑 主账户』同一口径)"
);
assert!(
harness.query_by_label("编辑 主账户").is_some(),
"编辑钮文案取别名"
);
assert!(
harness.query_by_label("连接 主账户").is_some(),
"连接钮文案也取显示名(同一行四个钮口径必须一致)"
);
harness.get_by_label("连接 主账户").click_accesskit();
harness.run();
let cmds = shared.drain_cmds();
assert_eq!(cmds.len(), 1, "连接仍是一条令: {cmds:?}");
match &cmds[0] {
UiCmd::Connect { gateway, .. } => assert_eq!(
gateway, "ctp_9999_278153",
"别名不参与寻址(网关位仍是派生注册名)"
),
other => panic!("expected Connect, got {other:?}"),
}
click_account_add(&mut harness);
harness.run();
shared.set_ctp_form(ek_ui::state::CtpForm {
td_user: "220380".to_string(),
td_addr: "tcp://10.0.0.1:40001".to_string(),
td_password: "pw-2".to_string(),
..shared.ctp_form()
});
shared.set_edit_alias("主账户");
harness.run();
click_dialog(&mut harness, "保存");
assert!(
shared.account("ctp_9999_220380").is_none(),
"重名别名须被拦(别名唯一)"
);
assert!(shared.account_edit_open(), "被拦不得关弹窗");
assert!(
shared
.edit_error()
.map(|e| e.contains("别名"))
.unwrap_or(false),
"拦截原因须点名别名: {:?}",
shared.edit_error()
);
assert_eq!(shared.accounts().len(), 1, "清册不增行");
}
#[test]
fn s43_soft_cap_hides_add_button() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
for i in 0..ek_ui::state::MAX_ACCOUNTS {
let row = ek_ui::state::AccountRow::derive(&ek_ui::state::CtpForm {
broker_id: "9999".to_string(),
td_addr: "tcp://10.0.0.1:40001".to_string(),
td_user: format!("17{i:04}"),
td_password: "fake-pw-1234".to_string(),
td_app_id: "fake-app".to_string(),
td_auth_code: "fake-auth".to_string(),
..Default::default()
})
.expect("派生注册名");
shared.add_account(row).expect("账户入册");
}
assert!(!shared.can_add_account(), "达软上限");
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
assert!(
harness
.query_by_label(ek_ui::render::BTN_ACCOUNT_ADD)
.is_none(),
"达限后『添加账户』钮撤场(不是灰着让人白点)"
);
assert!(
harness.query_by_label_contains("账户已达软上限").is_some(),
"达限提示句在场"
);
assert_eq!(
harness.query_all_by_label_contains("未装配").count(),
ek_ui::state::MAX_ACCOUNTS,
"八行账户状态列都是『未装配』"
);
}
#[test]
fn s44_save_and_connect_single_command() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
click_account_add(&mut harness);
harness.run();
shared.set_ctp_form(acct_form(
"9999",
"278153",
"tcp://10.0.0.1:40001",
"tcp://10.0.0.1:40011",
));
harness.run();
click_dialog(&mut harness, "保存并连接");
assert!(!shared.account_edit_open(), "成功 = 弹窗关闭");
assert_eq!(shared.accounts().len(), 1, "先写清册");
let cmds = shared.drain_cmds();
assert_eq!(cmds.len(), 1, "『保存并连接』= 一条 Connect 令: {cmds:?}");
assert!(matches!(cmds[0], UiCmd::Connect { .. }));
assert!(
harness.query_by_label("连接 278153").is_none(),
"已入列连接的行不再给『连接』钮(避免重复入列)"
);
}
#[test]
fn s45_cancel_all_before_remove_entry() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let (_, gw2) = two_accounts(&shared);
shared.set_trade_snap(ek_ui::state::TradeSnap {
orders: vec![
ek_data::order::OrderData {
gateway_name: gw2.clone(),
symbol: "rb2605".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
orderid: format!("{gw2}.CTP_9_1"),
status: ek_data::constant::Status::NOTTRADED,
..ek_data::order::OrderData::default()
},
ek_data::order::OrderData {
gateway_name: gw2.clone(),
symbol: "cu2709".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
orderid: format!("{gw2}.CTP_9_2"),
status: ek_data::constant::Status::NOTTRADED,
..ek_data::order::OrderData::default()
},
],
..ek_ui::state::TradeSnap::default()
});
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
harness.get_by_label("先撤 2 笔").click_accesskit();
harness.run();
let cmds = shared.drain_cmds();
assert_eq!(cmds.len(), 2, "两笔活跃委托逐笔入列: {cmds:?}");
for c in &cmds {
match c {
UiCmd::CancelOrder { gateway, .. } => {
assert_eq!(gateway, &gw2, "撤单随单走(该账户自己的派生名)")
}
other => panic!("expected CancelOrder, got {other:?}"),
}
}
let (lines, _) = shared.logs_snapshot();
assert!(
lines
.iter()
.any(|l| l.contains("先撤") && l.contains("撤完才可移除")),
"出口须留痕: {lines:?}"
);
}
#[test]
fn s46_remove_while_editing_closes_dialog() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let (_gw1, gw2) = two_accounts(&shared);
shared.update_account_form(
&gw2,
acct_form("9999", "220380", "tcp://10.0.0.1:40001", ""),
);
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
harness.get_by_label("编辑 220380").click_accesskit();
harness.run();
assert!(shared.account_edit_open(), "编辑弹窗已开");
assert_eq!(
shared.editing_account().as_deref(),
Some(gw2.as_str()),
"编辑模式 = 该行"
);
harness.get_by_label("移除 220380").click_accesskit();
harness.run();
harness.get_by_label("确认移除 220380 ?").click_accesskit();
harness.run();
let cmds = shared.drain_cmds();
assert!(
cmds.iter()
.any(|c| matches!(c, UiCmd::Remove { name } if name == &gw2)),
"摘除令入列: {cmds:?}"
);
assert!(shared.account(&gw2).is_none(), "行已摘");
assert!(
!shared.account_edit_open(),
"摘除须连带关弹窗(不得留下退化成『添加模式』的编辑窗)"
);
assert!(shared.editing_account().is_none(), "编辑模式解除");
assert!(
shared.ctp_form().td_password.is_empty(),
"连带放弃:草稿位凭据清空(旧凭据不得留在共享位)"
);
let before = shared.accounts().len();
assert!(
!shared.begin_account_edit(Some(&gw2)),
"编辑不在清册的账户须被拒"
);
assert!(!shared.account_edit_open(), "不开窗");
assert_eq!(shared.accounts().len(), before, "清册不变");
}
#[test]
fn s47_removed_gateway_leaves_order_dropdown() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let (gw1, gw2) = two_accounts(&shared);
let mut f = shared.order_form();
f.gateway = gw2.clone();
shared.set_order_form(f);
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
assert!(
harness.query_by_label(&gw2).is_some(),
"摘除前:下单下拉选中 gw2"
);
harness.get_by_label("移除 220380").click_accesskit();
harness.run();
harness.get_by_label("确认移除 220380 ?").click_accesskit();
harness.run();
shared.drain_cmds();
harness.run();
assert!(
harness.query_by_label(&gw1).is_some(),
"在场账户仍是下单下拉的选中项(默认阶梯)"
);
assert!(
harness.query_by_label(&gw2).is_none(),
"已摘除网关不得留在下单下拉(选它 = unknown:gateway)"
);
assert_eq!(
shared.order_form().gateway,
gw1,
"表单里的陈旧选择须就地回落并落盘"
);
assert_eq!(
shared.default_gateway().as_deref(),
Some(gw1.as_str()),
"默认阶梯只认已装配账户"
);
}
fn status_of(
gateway: &str,
f: impl FnOnce(&mut ek_ctp::plugin::GatewayStatus),
) -> ek_ctp::plugin::GatewayStatus {
let mut st = ek_ctp::plugin::GatewayStatus {
gateway: gateway.to_string(),
sampled_ms: ek_ctp::map::now_epoch_ms(),
..Default::default()
};
f(&mut st);
st
}
#[test]
fn s48_login_badge_follows_gateway_status_readings() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let (gw1, _gw2) = two_accounts(&shared);
shared.update_account_form(
&gw1,
acct_form(
"9999",
"278153",
"tcp://127.0.0.1:30001",
"tcp://127.0.0.1:30011",
),
);
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
assert_eq!(
harness
.query_all_by_label_contains("已装配·登录态未知")
.count(),
2,
"起点:两行都还没有读数"
);
let now = ek_ctp::map::now_epoch_ms();
shared.note_gateway_status(
&gw1,
&status_of(&gw1, |st| {
st.td_handle_up = true;
st.td_logged_in = true;
st.td_last_login_ok_ms = Some(now);
st.td_last_account_ms = Some(now);
}),
);
harness.run();
assert!(
harness.query_by_label("已装配·在线").is_some(),
"读到 td_logged_in → 「已装配·在线」"
);
assert_eq!(
harness
.query_all_by_label_contains("已装配·登录态未知")
.count(),
1,
"另一行不受影响,仍是未知"
);
shared.note_gateway_status(
&gw1,
&status_of(&gw1, |st| {
st.td_handle_up = true;
st.td_logged_in = false;
st.td_disconnects = 2;
st.td_last_error = Some("柜台连接断开".to_string());
}),
);
harness.run();
assert!(
harness.query_by_label("已装配·掉线").is_some(),
"handle_up 真而 logged_in 假 → 掉线(不得显示在线)"
);
assert_eq!(
harness.query_all_by_label_contains("已装配·在线").count(),
0,
"旧读数不得留档"
);
assert!(
harness.query_by_label_contains("掉线 2 次").is_some(),
"细节行须带次数(F2 那种反复断供的取证入口)"
);
shared.note_gateway_status(
&gw1,
&status_of(&gw1, |st| {
st.td_handle_up = true;
st.td_login_failed = true;
st.td_last_error = Some("登录被拒:账号或密码错误".to_string());
}),
);
harness.run();
assert!(
harness.query_by_label("已装配·登录失败").is_some(),
"登录失败独立显形(与掉线不混成一档)"
);
assert!(
harness
.query_by_label_contains("登录被拒:账号或密码错误")
.is_some(),
"网关的原始错误文本须上屏,不是只给一个档名"
);
}
#[test]
fn s49_query_starved_two_criteria_and_thresholds() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let (gw1, _gw2) = two_accounts(&shared);
shared.update_account_form(
&gw1,
acct_form(
"9999",
"278153",
"tcp://127.0.0.1:30001",
"tcp://127.0.0.1:30011",
),
);
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
let now = ek_ctp::map::now_epoch_ms();
shared.note_gateway_status(
&gw1,
&status_of(&gw1, |st| {
st.td_handle_up = true;
st.td_logged_in = true;
st.td_last_account_ms = Some(now);
st.td_query_rejects = 2;
st.td_query_reject_streak = 2;
}),
);
harness.run();
assert!(
harness.query_by_label("已装配·在线").is_some(),
"2 次被拒没达阈值 3 → 不算断供"
);
shared.note_gateway_status(
&gw1,
&status_of(&gw1, |st| {
st.td_handle_up = true;
st.td_logged_in = true;
st.td_last_account_ms = Some(now);
st.td_query_rejects = 3;
st.td_query_reject_streak = 3;
}),
);
harness.run();
assert!(
harness.query_by_label("已装配·查询断供").is_some(),
"连续被拒达阈 → 查询断供"
);
assert!(
harness
.query_by_label_contains("查询连续被拒 3 次")
.is_some(),
"细节须说清是哪一判据命中的"
);
shared.note_gateway_status(
&gw1,
&status_of(&gw1, |st| {
st.td_handle_up = true;
st.td_logged_in = true;
st.td_last_account_ms = Some(now - 60_000);
}),
);
harness.run();
assert!(
harness.query_by_label("已装配·查询断供").is_some(),
"登录着但资金回报超时 → 同样断供(被拒 0 次不豁免)"
);
assert!(
harness
.query_by_label_contains("资金回报已 60s 未更新")
.is_some(),
"细节须带超时秒数"
);
shared.note_gateway_status(
&gw1,
&status_of(&gw1, |st| {
st.td_handle_up = true;
st.td_logged_in = true;
st.td_last_login_ok_ms = Some(now);
}),
);
harness.run();
assert!(
harness.query_by_label("已装配·在线").is_some(),
"「从未回报过」≠「断供」(None 不当 0 用)"
);
shared.set_status_thresholds(1, 30);
assert_eq!(shared.status_thresholds(), (1, 30), "覆盖位须可读回");
shared.note_gateway_status(
&gw1,
&status_of(&gw1, |st| {
st.td_handle_up = true;
st.td_logged_in = true;
st.td_last_account_ms = Some(ek_ctp::map::now_epoch_ms());
st.td_query_reject_streak = 1;
}),
);
harness.run();
assert!(
harness.query_by_label("已装配·查询断供").is_some(),
"阈值覆盖生效:1 次被拒即断供"
);
}
#[test]
fn s50_unreachable_status_falls_back_to_unknown() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let (gw1, _gw2) = two_accounts(&shared);
shared.update_account_form(
&gw1,
acct_form(
"9999",
"278153",
"tcp://127.0.0.1:30001",
"tcp://127.0.0.1:30011",
),
);
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
let now = ek_ctp::map::now_epoch_ms();
shared.note_gateway_status(
&gw1,
&status_of(&gw1, |st| {
st.td_handle_up = true;
st.td_logged_in = true;
st.td_last_account_ms = Some(now);
}),
);
harness.run();
assert!(
harness.query_by_label("已装配·在线").is_some(),
"先读到在线"
);
shared.note_status_unreachable(&gw1, "ask 超时");
harness.run();
assert_eq!(
harness.query_all_by_label_contains("已装配·在线").count(),
0,
"问不到时旧「在线」必须消失"
);
assert_eq!(
harness
.query_all_by_label_contains("已装配·登录态未知")
.count(),
2,
"两行都回到未知(问不到 = 没有证据)"
);
assert!(
harness.query_by_label_contains("状态口问不到").is_some(),
"须写明为什么未知,而不是静默变灰"
);
}
#[test]
fn s51_disconnect_button_enqueues_and_badge_follows() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let (gw1, _gw2) = two_accounts(&shared);
shared.update_account_form(
&gw1,
acct_form(
"9999",
"278153",
"tcp://127.0.0.1:30001",
"tcp://127.0.0.1:30011",
),
);
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
harness.get_by_label("断开 278153").click_accesskit();
harness.run();
let cmds = shared.drain_cmds();
assert!(
cmds.iter()
.any(|c| matches!(c, UiCmd::Disconnect { gateway } if gateway == &gw1)),
"『断开』须入列 Disconnect(不是 Remove:那是停实例,另一颗钮)"
);
assert!(
!cmds.iter().any(|c| matches!(c, UiCmd::Remove { .. })),
"『断开』不得顺手发移除令"
);
assert!(
shared
.logs_snapshot()
.0
.iter()
.any(|l| l.contains("断开") && l.contains("已入列")),
"按了钮须有一条入列日志"
);
shared.mark_account_stopped(&gw1);
harness.run();
assert!(
harness.query_by_label("已装配·已断开").is_some(),
"回执后徽标 = 已断开(装配态不变:实例还在)"
);
assert!(
harness.query_by_label("断开 278153").is_none(),
"会话已停的行不再给『断开』钮(没有会话可停)"
);
assert!(
harness.query_by_label("连接 278153").is_some(),
"恢复入口仍是『连接』(插件侧走「已在场 → 重投凭据」)"
);
assert!(
harness.query_by_label("移除 278153").is_some(),
"『移除』与『断开』并存:两个动作两条口,不共用"
);
}
#[test]
fn s52_not_assembled_row_has_no_disconnect_button() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let (gw1, gw2) = two_accounts(&shared);
let gw3 =
ek_ui::state::AccountRow::derive(&acct_form("9999", "777001", "tcp://127.0.0.1:30001", ""))
.expect("派生注册名")
.gateway;
assert_ne!(gw3, gw1);
assert_ne!(gw3, gw2);
shared
.add_account(
ek_ui::state::AccountRow::derive(&acct_form(
"9999",
"777001",
"tcp://127.0.0.1:30001",
"",
))
.expect("派生"),
)
.expect("添加第三账户");
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
assert!(harness.query_by_label(&gw3).is_some(), "第三行在场");
assert!(
harness.query_by_label("断开 777001").is_none(),
"未装配 = 没有会话可停,不留死钮(Q21)"
);
assert!(
harness.query_by_label("连接 777001").is_some(),
"未装配给『连接』"
);
assert!(
harness.query_by_label("断开 278153").is_some(),
"已装配那行仍给『断开』"
);
}
#[test]
fn s53_md_only_row_reads_the_md_side() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let (gw1, _gw2) = two_accounts(&shared);
shared.update_account_form(
&gw1,
acct_form("9999", "278153", "", "tcp://127.0.0.1:30011"),
);
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
let now = ek_ctp::map::now_epoch_ms();
shared.note_gateway_status(
&gw1,
&status_of(&gw1, |st| {
st.md_handle_up = true;
st.md_logged_in = true;
st.md_last_login_ok_ms = Some(now);
st.md_last_tick_ms = Some(now);
st.md_subscribed = 1;
}),
);
harness.run();
assert!(
harness.query_by_label("已装配·在线").is_some(),
"纯行情账户的在线证据在 MD 半边"
);
assert!(
harness.query_by_label_contains("纯行情账户").is_some(),
"细节须写明这一行没有 TD 会话(否则「在线」会被读成账户都能下单)"
);
shared.note_gateway_status(
&gw1,
&status_of(&gw1, |st| {
st.md_handle_up = true;
st.md_disconnects = 3;
}),
);
harness.run();
assert!(
harness.query_by_label("已装配·掉线").is_some(),
"MD 掉线同样显形"
);
assert!(harness.query_by_label_contains("掉线 3 次").is_some());
assert_eq!(
harness.query_all_by_label_contains("查询断供").count(),
0,
"TD 判据不得套在纯行情账户上"
);
}
#[test]
fn s54_disconnect_respects_active_order_guard() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let (gw1, gw2) = two_accounts(&shared);
shared.set_trade_snap(ek_ui::state::TradeSnap {
orders: vec![ek_data::order::OrderData {
gateway_name: gw2.clone(),
symbol: "rb2605".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
orderid: format!("{gw2}.CTP_9_1"),
status: ek_data::constant::Status::NOTTRADED,
..ek_data::order::OrderData::default()
}],
..ek_ui::state::TradeSnap::default()
});
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
assert!(
harness.query_by_label("断开 220380").is_none(),
"有活跃委托时不画『断开』(会话一停就撤不了单)"
);
assert!(
harness.query_by_label_contains("有 1 笔活跃委托").is_some(),
"守卫句须显形"
);
assert!(
harness.query_by_label("先撤 1 笔").is_some(),
"守卫须给出口"
);
assert!(
harness.query_by_label("断开 278153").is_some(),
"无活跃委托的已装配账户仍可断开"
);
shared.set_trade_snap(ek_ui::state::TradeSnap::default());
harness.run();
assert!(
harness.query_by_label("断开 220380").is_some(),
"委托撤完『断开』即恢复"
);
assert!(
harness.query_by_label("移除 220380").is_some(),
"同一行『移除』也在场(两个动作两条口)"
);
assert_eq!(gw1, "ctp_9999_278153", "寻址仍是派生名");
}
#[test]
fn s55_removed_instance_voids_its_status_readings() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let (gw1, gw2) = two_accounts(&shared);
shared.update_account_form(
&gw1,
acct_form(
"9999",
"278153",
"tcp://127.0.0.1:30001",
"tcp://127.0.0.1:30011",
),
);
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
shared.note_gateway_status(
&gw1,
&status_of(&gw1, |st| {
st.td_handle_up = true;
st.td_disconnects = 2;
}),
);
harness.run();
assert!(
harness.query_by_label("已装配·掉线").is_some(),
"先读到掉线"
);
assert!(
harness.query_by_label_contains("状态细节").is_some(),
"细节行在场"
);
shared.sync_accounts_connected(&[row("svctl"), row("dm"), row("oms")]);
harness.run();
assert_eq!(
harness.query_all_by_label_contains("未装配").count(),
2,
"两行都回「未装配」"
);
assert_eq!(
harness.query_all_by_label_contains("已装配·掉线").count(),
0,
"实例不在场了还印「掉线」= 替不存在的会话说话"
);
assert_eq!(
harness.query_all_by_label_contains("状态细节").count(),
0,
"状态细节同样不得留着(只给已装配的行)"
);
let rows = shared.accounts();
let a = rows.iter().find(|r| r.gateway == gw1).expect("行还在");
assert_eq!(a.stage, ek_ui::state::AccountStage::NotAssembled);
assert_eq!(a.login, ek_ui::state::LoginState::Unknown, "读数作废");
assert!(a.status_note.is_none(), "细节作废");
assert!(
harness.query_by_label("连接 278153").is_some(),
"回「未装配」即重新给『连接』钮"
);
assert!(
harness.query_by_label("断开 278153").is_none(),
"没有实例就没有会话可停"
);
assert_eq!(gw2, "ctp_9999_220380");
}
#[test]
fn s56_ops_window_leaves_account_instances_to_the_account_panel() {
let shared = Arc::new(UiShared::new());
feed_rows(
&shared,
vec![
row("svctl"),
row("dm"),
row("oms"),
row("ui"),
row("ctp_9999_278153"),
],
);
let (gw1, _gw2) = two_accounts(&shared);
shared.set_ops_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
assert!(
harness.query_by_label("移除 dm").is_some(),
"工厂插件仍可在运维窗移除"
);
assert!(
harness.query_by_label("重注入 oms").is_some(),
"工厂插件仍可重注入"
);
assert!(
harness.query_by_label_contains("ctp_9999_278153").is_some(),
"账户实例仍列在运维表里"
);
assert!(
harness.query_by_label("移除 ctp_9999_278153").is_none(),
"清册里的实例不在运维窗移除(Q17 边界收口)"
);
assert!(
harness.query_by_label("重注入 ctp_9999_278153").is_none(),
"同上:重注入对命名实例本来就是 unknown factory"
);
assert!(
harness
.query_by_label_contains("起停归『账户』面板")
.is_some(),
"给出的是去处,不是死钮"
);
assert_eq!(gw1, "ctp_9999_278153");
}
#[test]
fn s57_product_class_scope_is_stated_in_panel_and_dialog() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let (_gw1, _gw2) = two_accounts(&shared);
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
assert!(
harness
.query_by_label_contains("公用合约表 = 在场各网关的并集(合约与行情公用,规划 D3)")
.is_some(),
"面板底部口径句在场(F4)"
);
harness.get_by_label("编辑 278153").click_accesskit();
harness.run();
assert_eq!(
harness
.query_all_by_label_contains("收录勾选只管该网关自己收录并上抛的合约")
.count(),
2,
"面板底部 + 配置弹窗各一处"
);
assert_eq!(
harness
.query_all_by_label_contains("改勾选须『连接/重试』才下发到网关")
.count(),
2,
"生效条件同样两处都在"
);
}
#[test]
fn s58_instrument_query_rejection_is_named_in_status_detail() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let (gw1, _gw2) = two_accounts(&shared);
shared.update_account_form(
&gw1,
acct_form(
"9999",
"278153",
"tcp://127.0.0.1:30001",
"tcp://127.0.0.1:30011",
),
);
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
let now = ek_ctp::map::now_epoch_ms();
shared.note_gateway_status(
&gw1,
&status_of(&gw1, |st| {
st.td_handle_up = true;
st.td_logged_in = true;
st.td_last_login_ok_ms = Some(now);
st.td_last_account_ms = Some(now);
st.td_query_rejects = 1;
st.td_query_reject_streak = 1;
st.td_instrument_rejects = 1;
}),
);
harness.run();
assert!(
harness.query_by_label("已装配·在线").is_some(),
"连续串 1 未达阈 3:不算断供"
);
assert!(
harness
.query_by_label_contains("合约查询被拒 1 次")
.is_some(),
"0.9.1 分项须在状态细节里显形"
);
shared.note_gateway_status(
&gw1,
&status_of(&gw1, |st| {
st.td_handle_up = true;
st.td_logged_in = true;
st.td_last_login_ok_ms = Some(now);
st.td_last_account_ms = Some(now);
st.td_query_rejects = 3;
st.td_query_reject_streak = 3;
st.td_instrument_rejects = 2;
}),
);
harness.run();
assert!(
harness.query_by_label("已装配·查询断供").is_some(),
"连续串达阈 → 断供"
);
let rows = shared.accounts();
let a = rows.iter().find(|r| r.gateway == gw1).expect("行还在");
let note = a.status_note.clone().unwrap_or_default();
assert!(
note.contains("查询连续被拒 3 次") && note.contains("合约查询被拒 2 次"),
"两句并存,分路看得见: {note}"
);
assert_eq!(
note.matches("合约查询被拒").count(),
1,
"同一件事不重复点名: {note}"
);
shared.note_gateway_status(
&gw1,
&status_of(&gw1, |st| {
st.td_handle_up = true;
st.td_logged_in = true;
st.td_last_login_ok_ms = Some(now);
st.td_last_account_ms = Some(now);
}),
);
harness.run();
let rows = shared.accounts();
let a = rows.iter().find(|r| r.gateway == gw1).expect("行还在");
assert!(
a.status_note.is_none(),
"没有事实就不写句子: {:?}",
a.status_note
);
}
#[test]
fn s59_log_window_says_it_is_a_window_not_history() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.set_bottomright_tab(ek_ui::state::BottomRightTab::Logs);
for i in 0..(ek_ui::state::RING_CAP + 3) {
shared.log_line(format!("[INFO][demo] 状态推送 {i:04}"));
}
let mut harness = harness_for(&shared);
harness.run();
assert!(
harness
.query_by_label_contains("日志窗是窗口不是历史")
.is_some(),
"溢出时必须把口径说出来(光有『已丢弃 N 条』不够)"
);
assert!(
harness.query_by_label_contains("别回扫这里").is_some(),
"并给出该去哪儿"
);
assert!(
harness.query_by_label_contains("已丢弃 3 条").is_some(),
"溢出计数显形"
);
}
#[test]
fn s60_never_received_account_report_becomes_starved() {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let (gw1, _gw2) = two_accounts(&shared);
shared.update_account_form(
&gw1,
acct_form(
"9999",
"278153",
"tcp://127.0.0.1:30001",
"tcp://127.0.0.1:30011",
),
);
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
let now = ek_ctp::map::now_epoch_ms();
shared.note_gateway_status(
&gw1,
&status_of(&gw1, |st| {
st.td_handle_up = true;
st.td_logged_in = true;
st.td_last_login_ok_ms = Some(now - 5_000);
}),
);
harness.run();
assert!(
harness.query_by_label("已装配·在线").is_some(),
"刚登录几秒不算断供"
);
assert!(
harness
.query_by_label_contains("资金回报尚未到手")
.is_some(),
"但这句话要在场"
);
shared.note_gateway_status(
&gw1,
&status_of(&gw1, |st| {
st.td_handle_up = true;
st.td_logged_in = true;
st.td_last_login_ok_ms = Some(now - 40_000);
}),
);
harness.run();
assert!(
harness.query_by_label("已装配·查询断供").is_some(),
"从未到手超时即断供(不得永远在线)"
);
let rows = shared.accounts();
let a = rows.iter().find(|r| r.gateway == gw1).expect("行还在");
let note = a.status_note.clone().unwrap_or_default();
assert!(
note.contains("资金回报从未到手(登录已 40s)"),
"细节要带时长: {note}"
);
shared.note_gateway_status(
&gw1,
&status_of(&gw1, |st| {
st.td_handle_up = true;
st.td_logged_in = true;
st.td_last_login_ok_ms = Some(now - 40_000);
st.td_last_account_ms = Some(now);
}),
);
harness.run();
assert!(
harness.query_by_label("已装配·在线").is_some(),
"到手即回在线"
);
}