use std::sync::Arc;
use egui_kittest::{
Harness,
kittest::{By, 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,
pending: 0,
peak: 0,
soft_cap: ek_core::plugin::MAILBOX_SOFT_CAP,
}
}
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,
pending: 0,
peak: 0,
soft_cap: ek_core::plugin::MAILBOX_SOFT_CAP,
}
}
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 shapes_of(harness: &Harness<'_>) -> Vec<egui::Shape> {
harness
.output()
.shapes
.iter()
.map(|c| c.shape.clone())
.collect()
}
fn clipped_shapes_of(harness: &Harness<'_>) -> Vec<egui::epaint::ClippedShape> {
harness.output().shapes.clone()
}
fn text_shapes_of_color(shapes: &[egui::Shape], color: egui::Color32) -> Vec<String> {
shapes
.iter()
.filter_map(|s| match s {
egui::Shape::Text(t) if text_color_of(t) == color => Some(t.galley.text().to_string()),
_ => None,
})
.collect()
}
fn any_shapes_of_color(shapes: &[egui::Shape], color: egui::Color32) -> Vec<egui::Shape> {
shapes
.iter()
.filter_map(|s| match s {
egui::Shape::LineSegment { stroke, .. } if stroke.color == color => Some(s.clone()),
egui::Shape::Rect(r) if r.fill == color || r.stroke.color == color => Some(s.clone()),
egui::Shape::Text(t) if text_color_of(t) == color => Some(s.clone()),
_ => None,
})
.collect()
}
fn filled_rects(shapes: &[egui::Shape], color: egui::Color32) -> Vec<egui::Rect> {
shapes
.iter()
.filter_map(|s| match s {
egui::Shape::Rect(r) if r.fill == color && r.stroke.is_empty() => Some(r.rect),
_ => None,
})
.collect()
}
fn hlines(shapes: &[egui::Shape], color: egui::Color32) -> Vec<(f32, egui::Rangef)> {
shapes
.iter()
.filter_map(|s| match s {
egui::Shape::LineSegment { points, stroke }
if stroke.color == color
&& !stroke.is_empty()
&& (points[0].y - points[1].y).abs() < 0.5 =>
{
Some((points[0].y, egui::Rangef::new(points[0].x, points[1].x)))
}
_ => None,
})
.collect()
}
fn vlines_any(shapes: &[egui::Shape]) -> Vec<(f32, egui::Rangef, egui::Color32)> {
shapes
.iter()
.filter_map(|s| match s {
egui::Shape::LineSegment { points, stroke }
if !stroke.is_empty() && (points[0].x - points[1].x).abs() < 0.5 =>
{
Some((
points[0].x,
egui::Rangef::new(points[0].y, points[1].y),
stroke.color,
))
}
_ => None,
})
.collect()
}
fn texts_containing(
shapes: &[egui::Shape],
needle: &str,
) -> Vec<(String, egui::Rect, egui::Color32)> {
shapes
.iter()
.filter_map(|s| match s {
egui::Shape::Text(t) => {
let text = t.galley.text();
text.contains(needle)
.then(|| (text.to_string(), t.visual_bounding_rect(), text_color_of(t)))
}
_ => None,
})
.collect()
}
fn text_color_of(t: &egui::epaint::TextShape) -> egui::Color32 {
if let Some(c) = t.override_text_color {
return c;
}
for section in &t.galley.job.sections {
if section.format.color != egui::Color32::PLACEHOLDER {
return section.format.color;
}
}
t.fallback_color
}
fn monospace_at(harness: &Harness<'_>, needle: &str, at: &egui::Rect) -> Option<bool> {
let probe = at.shrink(2.0);
shapes_of(harness)
.iter()
.filter_map(|sh| match sh {
egui::Shape::Text(t) if t.galley.text() == needle => {
t.visual_bounding_rect().intersects(probe).then(|| {
t.galley
.job
.sections
.iter()
.all(|sec| matches!(sec.format.font_id.family, egui::FontFamily::Monospace))
})
}
_ => None,
})
.next()
}
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))
}
fn harness_wide_for(shared: &UiShared) -> Harness<'_> {
Harness::builder()
.with_size([2600.0, 900.0])
.build(|ctx| render_body(ctx, shared))
}
#[test]
fn s0_rows_and_service_directory_visible() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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_all_by_label_contains("清空日志").count() == 0,
"日志面不得再有「清空日志」钮(0.4.1 用户裁定 2026-10-22:这个钮撤除)"
);
harness.run(); }
#[test]
fn s1_log_surface_has_no_clear_button() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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);
harness.run();
assert_eq!(shared.logs_len(), 3);
assert_eq!(
harness.query_all_by_label_contains("清空日志").count(),
0,
"「清空日志」钮必须已从界面撤除"
);
for needle in ["样例 001", "样例 002", "样例 003"] {
assert!(
harness.query_all_by_label_contains(needle).next().is_some(),
"日志行「{needle}」必须还在表里"
);
}
for needle in ["时间", "信息"] {
assert!(
harness.query_all_by_label_contains(needle).next().is_some(),
"日志表头「{needle}」必须还在"
);
}
}
#[test]
fn s2_log_lines_rendered_with_text() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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_frame() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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.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 cancel_all = rect_of("全撤");
let market = harness
.query_all_by_label("代码")
.map(|n| n.rect())
.find(|r| r.min.y < 120.0)
.unwrap_or_else(|| panic!("结构钉:行情带首列表头「代码」必须存在"));
let mut codes: Vec<egui::Rect> = harness
.query_all_by_label("代码")
.map(|n| n.rect())
.filter(|r| r.min.x > 300.0)
.collect();
codes.sort_by(|a, b| {
(a.min.y, a.min.x)
.partial_cmp(&(b.min.y, b.min.x))
.expect("可比")
});
let order_hdr = codes
.iter()
.copied()
.find(|r| r.min.x < 700.0)
.expect("结构钉:委托表头必须存在");
let trades_hdr = codes
.iter()
.copied()
.find(|r| r.min.x > 700.0)
.expect("结构钉:成交表头必须存在");
let trades = rect_of("成交金额");
let pos = rect_of("均价");
let funds = rect_of("权益");
assert!(
(cancel_all.min.x - 168.0).abs() < 40.0 && cancel_all.min.y > 400.0,
"下单格在下带的左列(≈300 px 宽,下带里),实测 {:.0},{:.0}",
cancel_all.min.x,
cancel_all.min.y
);
assert!(
market.min.x < 40.0 && market.min.y < 120.0,
"行情带横跨整个窗宽(贴窗口左沿、菜单条之下),实测 {:.0},{:.0} —— \
这里 x 变大就说明左边又被「下单通高」吃掉了",
market.min.x,
market.min.y
);
assert!(
cancel_all.min.y > market.min.y + 150.0,
"下单格必须在行情带**之下**(Python 框架:行情带横跨全宽,下带里才有下单),\
实测 全撤 y={:.0} 行情 y={:.0}",
cancel_all.min.y,
market.min.y
);
assert!(
order_hdr.min.x > 280.0 && order_hdr.min.y < 500.0,
"委托格在数据区左上"
);
assert!(
order_hdr.min.y > market.min.y,
"行情格压在数据区之上(上 32% / 下 68%)"
);
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 = trades_hdr.min.x - order_hdr.min.x;
assert!(
(450.0..=540.0).contains(&col_w),
"两列等宽且随窗口分配(实测列距 {col_w:.0},期望 ≈ (可用宽-间距)/2)"
);
let shapes = shapes_of(&harness);
let bands = filled_rects(&shapes, ek_ui::tokens::shadcn::BG4);
assert!(
bands.iter().any(|r| r.width() > 100.0),
"首帧必须真的画出整行表头带(填充色 = BG4),实测 {bands:?}"
);
shared.set_bottomright_tab(ek_ui::state::BottomRightTab::Logs);
harness.run();
for line in ["固定样例行 001", "固定样例行 002", "固定样例行 003"] {
assert!(
harness.query_by_label_contains(line).is_some(),
"切到日志格后 {line} 必须上屏"
);
}
assert!(
harness.query_by_label("权益").is_none(),
"资金格让位后它的表头「权益」不得还在屏上(同叶只有一个前台 tab;0.4.1 六期 BD-UI-59 起空态没有提示行,判据改用表头列名;0.8.5 起「余额」改名「权益」)"
);
}
fn feed_rows(shared: &UiShared, rows: Vec<PlugRow>) {
shared.set_snapshot(BusSnap {
rows,
services: vec![],
});
}
#[test]
fn s4_remove_two_step_confirm_flow() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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);
click_row_action(&mut harness, "dm", "移除");
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!(row_action_exists(&harness, "dm", "确认移除?"));
click_row_action(&mut harness, "dm", "确认移除?");
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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, vec![row("oms")]);
shared.set_ops_window_open(true);
let mut harness = harness_for(&shared);
click_row_action(&mut harness, "oms", "重注入");
assert_eq!(
shared.drain_cmds(),
vec![UiCmd::Start {
factory: "oms".to_string(),
name: String::new(),
}]
);
}
#[test]
fn s6_cancel_confirm_quarantine_deps() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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);
click_row_action(&mut harness, "dice", "移除");
assert_eq!(shared.pending_confirm().as_deref(), Some("dice"));
harness.get_by_label("取消确认").click_accesskit();
harness.run();
assert_eq!(shared.pending_confirm(), None);
assert!(row_action_exists(&harness, "dice", "移除"));
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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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_eq!(shared.selected_exchange(), "SHFE", "默认前台 = 第一所");
assert!(harness.query_by_label("rb2605").is_some(), "SHFE 格上屏");
assert_eq!(harness.get_all_by_label("101").count(), 1, "最新价列");
assert_eq!(harness.get_all_by_label("1234").count(), 2, "成交量列");
assert_eq!(
harness.get_all_by_label_contains("10:00:00").count(),
2,
"时间列"
);
shared.set_selected_exchange("DCE");
harness.run();
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").count(), 1, "最新价列");
assert!(harness.query_by_label("rb2605").is_none(), "非前台格不上屏");
harness.get_by_label("a2709").click_accesskit();
harness.run();
let f = shared.order_form();
assert_eq!(f.symbol, "a2709", "单击带入代码");
assert_eq!(f.exchange, "DCE", "单击带入交易所");
shared.set_selected_exchange("CZCE");
harness.run();
assert!(
harness.query_all_by_label_contains("最新价").count() >= 1,
"空态不得吞表头:CZCE 行情格的表头必须在"
);
assert_eq!(
harness
.query_all_by_label_contains("等待该所 eTick 订阅")
.count(),
0,
"空态提示词已退役(0.4.1 六期 BD-UI-59):这一拍 CZCE 表体必须是空的"
);
}
#[test]
fn s10_kline_window_opens_with_header() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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);
assert!(
harness
.query_by_label_contains("K线 rb2605@60s(2 根)")
.is_some(),
"kline window header missing"
);
harness.run();
assert!(
shared.kline_symbols().iter().any(|s| s == "rb2605"),
"K线窗必须在状态层里真开着(kline_symbols),实测 {:?}",
shared.kline_symbols()
);
let render_src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/render.rs"))
.expect("render.rs 必须可读");
for needle in [
"bullish_color: crate::tokens::rise(ui.ctx())",
"bearish_color: crate::tokens::fall(ui.ctx())",
"let pal = crate::tokens::palette(ui.ctx());",
"background_color: pal.bg1",
"grid_color: pal.border",
"text_color: pal.text1",
] {
assert!(
render_src.contains(needle),
"K线图表色板必须显式覆盖成**当前档**的令牌(缺「{needle}」= 又用回自带色板,或把深档常量写死了)"
);
}
for banned in [
"background_color: crate::tokens::shadcn::BG1",
"grid_color: crate::tokens::shadcn::BORDER",
"text_color: crate::tokens::shadcn::TEXT1",
] {
assert!(
!render_src.contains(banned),
"K线不许把深档常量写死(浅档就不跟档),实测还挂着「{banned}」"
);
}
}
#[test]
fn s10b_kline_prefix_key_lookup_and_inflight_hint() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.open_kline("rb2605");
let mut harness = harness_for(&shared);
harness.run();
assert!(
harness
.query_by_label_contains("tq 历史补数在途")
.is_some(),
"开窗无格必须显形『补数在途』(不许静默空图)"
);
let mut b = golden_bar(36_000);
b.symbol = "SHFE.rb2605".to_string();
let mut b2 = golden_bar(36_060);
b2.symbol = "SHFE.rb2605".to_string();
shared.market().note_history("SHFE.rb2605", 60, &[b, b2]);
shared.bump();
let mut harness = harness_for(&shared);
harness.run();
assert!(
harness
.query_by_label_contains("K线 SHFE.rb2605@60s(2 根)")
.is_some(),
"裸符号窗必须经剥前缀命中带前缀格(旧病:存键带前缀/查键裸 → 永远空图)"
);
}
#[test]
fn s11_trade_monitor_rows() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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.seed_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([2600.0, 900.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(),
5,
"委托价×2 + 成交均价×1 + 成交价×1 + 持仓成本×1(0.3.3 起持仓成本走 format::price,不再是 102.50)"
);
assert!(
harness
.query_by_label_contains(ek_ui::render::BTN_CANCEL_ORDER)
.is_none(),
"委托表行内「撤单」钮必须不存在(2026-10-28 裁定撤列;若它在屏上 = 列又回来了)"
);
assert!(harness.query_by_label_contains("T1").is_some());
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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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");
assert_eq!(
shared.topleft_tab(),
ek_ui::state::TopLeftTab::Orders,
"委托 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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.seed_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: "螺纹钢2605一号".to_string(),
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: "螺纹钢2605二号".to_string(),
reference: "P2".to_string(),
datetime: Some(36_001_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: "螺纹钢2605成交".to_string(),
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_wide_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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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!(
row_action_exists(&harness, "ctp_9999_278153", "连接"),
"未装配行须有『连接』钮(四态分离的入口)"
);
click_row_action(&mut harness, "ctp_9999_278153", "连接");
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 前置在**留档**里就清空(旧口径只清下发不清留档 → 编辑页会带一根用不上的行情前置)"
);
click_row_action(&mut harness, "ctp_9999_220380", "连接");
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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.seed_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").is_some(),
"权益(0.3.3 起资金表走 format::price:整数不带 .00)"
);
assert!(harness.query_by_label_contains("12345").is_some(), "冻结");
assert!(
harness.query_by_label_contains("987655").is_some(),
"可用 = balance-frozen"
);
let shared2 = Arc::new(UiShared::new());
let h2 = harness_for(&shared2);
assert!(
h2.query_by_label("权益").is_some(),
"空帧资金格表头必须在(0.8.5 起「余额」改名「权益」)"
);
assert_eq!(
h2.query_all_by_label_contains("等网关 eAccount").count(),
0,
"空态提示词已退役(BD-UI-59)"
);
}
#[test]
fn s17_menu_opens_windows() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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_none()
&& harness.query_by_label("SHFE.rb2605").is_none(),
"合约表的 en_symbol 勾选框标签必须不存在(0.8.5 撤列;若在 = 勾选框回来了)"
);
harness.get_by_label("螺纹钢2605").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("黄玉米2709").is_some());
assert!(
harness.query_by_label("螺纹钢2605").is_none(),
"过滤后不上屏"
);
shared.set_contract_query(String::new());
harness.run();
harness.get_by_label("螺纹钢2605").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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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.seed_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_is_installed_and_lays_out() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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,
pending: 0,
peak: 0,
soft_cap: ek_core::plugin::MAILBOX_SOFT_CAP,
}],
);
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();
let families = harness.ctx.fonts(|f| f.definitions().families.clone());
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
let list = families
.get(&family)
.unwrap_or_else(|| panic!("{family} 族必须在字体表里"))
.clone();
assert!(
list.iter().any(|n| n == ek_ui::fonts::CJK_FONT_NAME),
"{family} 族必须挂上 CJK 登记名「{}」,实测 {list:?}",
ek_ui::fonts::CJK_FONT_NAME
);
}
assert!(
harness
.query_by_label_contains("查询合约运维撤单")
.is_some(),
"日志表里的长中文行必须在语义树里"
);
let probe = texts_containing(&shapes_of(&harness), "查询合约运维撤单");
let widths: Vec<f32> = probe.iter().map(|(_, r, _)| r.width()).collect();
assert!(
widths.iter().any(|w| *w > 300.0),
"中文行必须排出真实宽度(>300 pt),实测 {widths:?}"
);
}
#[test]
fn s20_enter_subscribes_symbol() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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_columns_and_rows() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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.seed_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_wide_for(&shared);
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx); assert!(hit.is_some(), "验收环境必须有 CJK 候选字体");
harness.run();
for want in [
"CTP_7701",
"CTP_7702",
"T-8801",
"螺纹钢2605",
"部分成交",
"资金不足",
] {
assert!(
harness.query_all_by_label_contains(want).next().is_some(),
"整面板三张表里必须有「{want}」这一格"
);
}
assert_eq!(
harness.query_all_by_label("CTP_7701").count(),
2,
"委托行 CTP_7701 + 成交行的委托号格,共 2 处"
);
assert_eq!(
harness.query_all_by_label("CTP_7702").count(),
1,
"委托行 CTP_7702 必须只有一格(重复 = 表重复建构)"
);
assert_eq!(
harness.query_all_by_label("T-8801").count(),
1,
"成交表必须正好一行(成交号 T-8801)"
);
for (col, at_least) in [
("委托号", 2),
("接口", 3),
("代码", 3),
("名称", 3),
("交易所", 3),
("方向", 3),
("开平", 3),
("价格", 3),
("时间", 3),
("总量", 1),
("已成", 1),
("成交均价", 1),
("状态", 1),
("状态描述", 1),
("来源", 1),
("成交号", 1),
("数量", 1),
("成交金额", 1),
("成本", 1),
("均价", 1),
("最新价", 1),
("盈亏", 1),
("成交额", 1), ("持仓量", 1), ] {
let n = harness.query_all_by_label(col).count();
assert!(
n >= at_least,
"列「{col}」必须至少 {at_least} 处,实测 {n} 处"
);
}
}
#[test]
fn s42_theme_lands_on_style_and_frame() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.seed_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),
}],
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();
assert_eq!(
harness.ctx.style().visuals.clone(),
ek_ui::tokens::visuals(),
"shadcn Nova 深色必须整套装进 egui 样式 —— 样式与令牌源不一致"
);
let shapes = shapes_of(&harness);
let bg1 = filled_rects(&shapes, ek_ui::tokens::shadcn::BG1);
assert!(
bg1.iter().any(|r| r.area() > 1_000.0),
"整面板必须真画出 BG1(#0A0A0A)底,实测 {bg1:?}"
);
let border_lines = hlines(&shapes, ek_ui::tokens::shadcn::BORDER);
assert!(
border_lines.iter().any(|(_, x)| x.span() > 200.0),
"窗框/分隔线必须是 `border`(白 10%)整行横线,实测最长 {:?}",
border_lines
.iter()
.map(|(_, x)| x.span())
.fold(0.0, f32::max)
);
}
#[test]
fn u1_menu_has_one_theme_button_and_both_looks_land_on_screen() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let mut harness = harness_for(&shared);
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
assert!(hit.is_some(), "验收环境必须有 CJK 候选字体");
harness.run();
let theme_btn: Vec<_> = harness.query_all_by_label_contains("主题:").collect();
assert_eq!(
theme_btn.len(),
1,
"菜单里必须恰有一颗「主题:」钮(单点入口),实测 {n} 个节点",
n = theme_btn.len()
);
for label in ["运维", "账户", "查询合约", "帮助"] {
assert!(
harness.query_all_by_label_contains(label).next().is_some(),
"菜单钮「{label}」必须还在"
);
}
assert_eq!(
harness.ctx.style().visuals.window_fill,
ek_ui::tokens::PALETTE_DARK.bg1,
"缺省档(深色)的底色没落到画面上"
);
harness
.query_by_label_contains("主题:")
.expect("菜单必须有主题钮")
.click_accesskit();
harness.run();
assert_eq!(
shared.theme(),
ek_ui::tokens::Theme::Light,
"点一下钮状态位必须换档"
);
assert_eq!(
harness.ctx.style().visuals.window_fill,
ek_ui::tokens::PALETTE_LIGHT.bg1,
"浅色档的底色没落到画面上(状态换了画面没换)"
);
harness
.query_by_label_contains("主题:")
.expect("菜单必须有主题钮")
.click_accesskit();
harness.run();
assert_eq!(
shared.theme(),
ek_ui::tokens::Theme::Dark,
"第二下必须切回深色"
);
assert_eq!(
harness.ctx.style().visuals.window_fill,
ek_ui::tokens::PALETTE_DARK.bg1,
"切回深色后底色必须复原"
);
assert!(
shared.take_theme_persist_due(),
"用户手点之后必须有落盘请求(命令线程写 ui.theme)"
);
}
#[test]
fn s24_contracts_viewport_guard_immediate_path() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
let mut harness = harness_for(&shared);
harness.run();
for probe in ["成交号", "昨仓", "状态描述", "委托号"] {
assert!(
harness.query_all_by_label_contains(probe).count() >= 1,
"空态结构钉:{probe} 表头必须存在"
);
}
for gone in [
"(无委托:等网关 send_order)",
"(无未成交)",
"(无成交)",
"(无持仓)",
"(无资金:等网关 eAccount.)",
"(无日志)",
"(无合约:先连接 CTP,合约随 eContract. 自动回流)",
"(等待该所 eTick 订阅)",
] {
assert_eq!(
harness.query_all_by_label_contains(gone).count(),
0,
"空态提示词已退役(BD-UI-59):{gone} 不许还在屏上"
);
}
}
#[test]
fn s27_scrolled_orders_no_peek_above_header() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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.seed_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();
let head_before = harness
.query_by_label("状态描述")
.expect("委托表必须有「状态描述」表头")
.rect();
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();
let header = harness
.query_by_label("状态描述")
.expect("委托表必须有「状态描述」表头")
.rect();
assert!(
(header.min.y - head_before.min.y).abs() < 0.5,
"滚动后表头必须还在原处(前 {:.1} → 后 {:.1})",
head_before.min.y,
header.min.y
);
let band: Vec<egui::Rect> = filled_rects(&shapes_of(&harness), ek_ui::tokens::shadcn::BG4)
.into_iter()
.filter(|r| r.width() > 300.0 && (r.min.y - header.min.y).abs() < 40.0)
.collect();
assert!(
!band.is_empty(),
"滚动后表头带必须还画着(表头 y={:.1})",
header.min.y
);
let band_right = band.iter().map(|r| r.max.x).fold(0.0f32, f32::max);
let body_right = clipped_shapes_of(&harness)
.iter()
.filter_map(|cs| {
let egui::Shape::Text(t) = &cs.shape else {
return None;
};
if !t.galley.text().contains("SCROLL_") {
return None;
}
let visible = t.visual_bounding_rect().intersect(cs.clip_rect);
visible.is_positive().then_some(visible.max.x)
})
.fold(0.0f32, f32::max);
assert!(
band_right + 90.0 >= body_right,
"表头带盖住体表最宽一行的缺口必须 ≤ 90 px(带右 {band_right:.1} / 体表右 {body_right:.1})"
);
let band_bottom = band.iter().map(|r| r.max.y).fold(0.0f32, f32::max);
for cs in clipped_shapes_of(&harness) {
let egui::Shape::Text(t) = &cs.shape else {
continue;
};
if !t.galley.text().contains("SCROLL_") {
continue;
}
let visible = t.visual_bounding_rect().intersect(cs.clip_rect);
if !visible.is_positive() {
continue; }
assert!(
visible.min.y >= band_bottom - 1.0,
"数据行「{}」从表头带上方露出(带底 {:.1},露出顶 {:.1})",
t.galley.text(),
band_bottom,
visible.min.y
);
}
}
#[test]
fn s28_account_md_lock_and_remove_guard() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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!(row_action_exists(&harness, &gw1, "编辑"), "账户行 1");
assert!(row_action_exists(&harness, &gw2, "编辑"), "账户行 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_all_by_label_contains("● 行情源")
.next()
.is_some(),
"元账号行显形「● 行情源」"
);
shared.seed_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!(
row_action_exists(&harness, &gw1, "先撤 1 笔"),
"守卫不能只堵门:须给『先撤 N 笔』出口(规划 U10)"
);
assert!(
!row_action_exists(&harness, &gw1, "移除"),
"有挂单的账户不给移除钮(柜台还在跑单而没人能撤 = 观测黑洞)"
);
shared.seed_trade_snap(ek_ui::state::TradeSnap::default());
harness.run();
assert!(
row_action_exists(&harness, &gw1, "移除"),
"守卫解除后该行移除钮回到场"
);
assert!(
harness.query_by_label(&format!("移除 {gw1}")).is_none()
&& harness.query_by_label("移除 278153").is_none(),
"移除钮不带账户名(0.9.10 光杆口径;寻址恒走派生名,由指令面钉)"
);
}
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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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("螺纹钢2605").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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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.seed_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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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();
click_row_action(&mut harness, &gw1, "移除");
assert_eq!(
shared.pending_confirm().as_deref(),
Some(gw1.as_str()),
"首点 = 待二次确认(内部仍按派生名记槽)"
);
assert!(shared.drain_cmds().is_empty(), "armed 不入列");
click_row_action(&mut harness, &gw1, "确认移除?");
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();
click_row_action(&mut harness, &gw2, "移除");
click_row_action(&mut harness, &gw2, "确认移除?");
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!(row_action_exists(&harness, &gw2, "编辑"), "重加后账户行回到场");
assert!(
row_action_exists(&harness, &gw2, "连接"),
"重加后须点『连接』才装配(四态分离入口在场)"
);
}
#[test]
fn s32_no_account_disables_send_button() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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 row_action_exists(harness: &Harness<'_>, row_cell: &str, action: &str) -> bool {
let ys: Vec<f32> = harness
.query_all(By::new().label(row_cell))
.map(|n| n.rect().center().y)
.collect();
harness
.query_all(By::new().label(action))
.any(|n| ys.iter().any(|y| (n.rect().center().y - y).abs() < 14.0))
}
fn click_row_action(harness: &mut Harness<'_>, row_cell: &str, action: &str) {
let ys: Vec<f32> = harness
.query_all(By::new().label(row_cell))
.map(|n| n.rect().center().y)
.collect();
assert!(!ys.is_empty(), "面板须有行 {row_cell:?}");
let nd = harness
.query_all(By::new().label(action))
.find(|n| ys.iter().any(|y| (n.rect().center().y - y).abs() < 14.0))
.unwrap_or_else(|| panic!("行 {row_cell:?} 带里须有 {action:?} 钮"));
nd.click_accesskit();
harness.run();
}
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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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();
click_row_action(&mut harness, "ctp_9999_278153", "连接");
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!(
row_action_exists(&harness, "ctp_9999_278153", "重试"),
"失败行须给『重试』入口"
);
click_row_action(&mut harness, "ctp_9999_278153", "重试");
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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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();
click_row_action(&mut harness, &gw1, "编辑");
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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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, 2,
"两行账户的状态列各一处「已装配」(0.9.10:面板底部口径句已删,不再有三处)"
);
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,
"没有任何登录证据时不得出现「在线」"
);
for needle in ["掉线", "断供"] {
assert_eq!(
harness.query_all_by_label_contains(needle).count(),
0,
"{needle} 只在真读到该状态时才上屏;两账户现在都是「未知」,一处都不该有"
);
}
for gw in [&gw1, &gw2] {
assert!(
row_action_exists(&harness, gw, "断开"),
"已装配账户 {gw} 应有『断开』钮"
);
assert!(
!row_action_exists(&harness, gw, "连接"),
"已装配且会话未停的行不给『连接』钮"
);
}
}
#[test]
fn s38_removed_account_ledger_rows_stay_visible() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let (gw1, gw2) = two_accounts(&shared);
shared.seed_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_wide_for(&shared);
harness.run();
assert!(
!row_action_exists(&harness, &gw2, "移除") && row_action_exists(&harness, &gw2, "先撤 1 笔"),
"有活跃委托 → 该行不给移除钮,只给『先撤 N 笔』出口(0.9.10 光杆口径)"
);
shared.seed_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();
click_row_action(&mut harness, &gw2, "移除");
click_row_action(&mut harness, &gw2, "确认移除?");
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!(
!row_action_exists(&harness, &gw2, "编辑"),
"账户面板里这个账户整行撤场(行带里再无任何钮)"
);
assert!(
!row_action_exists(&harness, &gw2, "移除"),
"账户面板里这个账户的移除钮撤场"
);
shared.seed_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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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();
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 个").is_some(),
"计数在场(0.9.10:软上限口径撤除,只数个数 —— 用户裁定「从来没有限制」)"
);
assert!(
harness.query_by_label("td 前置").is_some(),
"td 前置列在场(用户裁定:配了什么 IP/端口就要显示)"
);
assert!(
harness
.query_all_by_label_contains("tcp://182.254.242.81:30001")
.count()
>= 2,
"两行的 td 前置整串直显(行即数据,不是藏进弹窗才看得见)"
);
assert!(
harness.query_by_label_contains("收录").is_some(),
"收录列在场(0.9.10:只显勾选意图,勾/实并排口径退役)"
);
}
#[test]
fn s39b_account_panel_chain_stage_column() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let (gw1, gw2) = two_accounts(&shared);
shared.note_chain(&gw1, "结算单已确认");
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
assert!(
harness.query_by_label("阶段").is_some(),
"账户表须有「阶段」列(0.5.0 加栏;整串精确匹配,不撞窗下方那句口径)"
);
assert_eq!(
harness.query_all_by_label_contains("结算单已确认").count(),
1,
"gw1 的阶段格 = 最近一条 `[链] ` 状态行正文(不带前缀),且只此一处"
);
assert_eq!(
shared.account(&gw2).unwrap().chain_note,
None,
"没推过状态行的账户不凭空有阶段(那一格显示「—」)"
);
assert!(
!shared
.account(&gw1)
.unwrap()
.status_label()
.contains("结算单已确认"),
"状态列文案实测 {}",
shared.account(&gw1).unwrap().status_label()
);
}
#[test]
fn s40_account_add_dialog() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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();
assert!(
harness.query_by_label("保存并连接").is_some(),
"添加模式给『保存并连接』"
);
assert!(
harness.query_by_label_contains("有未保存修改").is_some(),
"dirty 提示在场(关闭 = 放弃)"
);
}
#[test]
fn s41_account_edit_dialog() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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();
click_row_action(&mut harness, &gw2, "编辑");
harness.run();
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(),
1,
"网关实际收录回带只上弹窗只读区(0.9.10:列表收录列只显勾选意图,「实:」回带撤除)",
);
}
#[test]
fn s42_alias_shown_but_not_addressable() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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!(
row_action_exists(&harness, "ctp_9999_278153", "移除"),
"该行须有『移除』钮(钮不再取别名)"
);
assert!(
row_action_exists(&harness, "ctp_9999_278153", "编辑"),
"该行须有『编辑』钮"
);
assert!(
harness.query_by_label("移除 主账户").is_none()
&& harness.query_by_label("编辑 主账户").is_none()
&& harness.query_by_label("连接 主账户").is_none(),
"钮文案一律光杆(0.9.10 用户裁定:编辑就编辑,移除就移除)"
);
assert!(
row_action_exists(&harness, "ctp_9999_278153", "连接"),
"连接钮在场(同一行各钮口径一致)"
);
click_row_action(&mut harness, "ctp_9999_278153", "连接");
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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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("账户 64 个").is_some(),
"0.9.10 口径:达防御顶只数个数,不再挂软上限提示句(用户裁定「从来没有限制」)"
);
assert_eq!(
harness.query_all_by_label_contains("未装配").count(),
ek_ui::state::MAX_ACCOUNTS,
"六十四行账户状态列都是『未装配』"
);
}
#[test]
fn s44_save_and_connect_single_command() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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!(
!row_action_exists(&harness, "ctp_9999_278153", "连接"),
"已入列连接的行不再给『连接』钮(避免重复入列)"
);
}
#[test]
fn s45_cancel_all_before_remove_entry() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let (_, gw2) = two_accounts(&shared);
shared.seed_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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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();
click_row_action(&mut harness, &gw2, "编辑");
harness.run();
assert!(shared.account_edit_open(), "编辑弹窗已开");
assert_eq!(
shared.editing_account().as_deref(),
Some(gw2.as_str()),
"编辑模式 = 该行"
);
click_row_action(&mut harness, &gw2, "移除");
click_row_action(&mut harness, &gw2, "确认移除?");
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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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"
);
click_row_action(&mut harness, &gw2, "移除");
click_row_action(&mut harness, &gw2, "确认移除?");
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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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_steps(4);
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_steps(4);
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_steps(4);
assert!(
harness
.query_all_by_label_contains("已装配·掉线")
.collect::<Vec<_>>()
.iter()
.any(|n| n.rect().width() > 0.0),
"handle_up 真而 logged_in 假 → 掉线(不得显示在线;告警带柜台短因 = 文本比档名长)"
);
assert_eq!(
harness.query_all_by_label_contains("已装配·在线").count(),
0,
"旧读数不得留档"
);
assert!(
shared
.account(&gw1)
.unwrap()
.status_note
.clone()
.unwrap_or_default()
.contains("掉线 2 次"),
"细节(F2 取证入口)住在 status_note(0.9.10:面板底部细节段退役,全量在事件日志)"
);
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_steps(4);
assert!(
harness.query_by_label_contains("已装配·登录失败").is_some(),
"登录失败独立显形(与掉线不混成一档)"
);
assert!(
shared
.account(&gw1)
.unwrap()
.status_note
.clone()
.unwrap_or_default()
.contains("登录被拒:账号或密码错误"),
"网关的原始错误文本须进 status_note(0.9.10:上屏位 = 状态列短因 + 事件日志全句)"
);
}
#[test]
fn s49_query_starved_two_criteria_and_thresholds() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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_steps(4);
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_steps(4);
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_steps(4);
assert!(
harness.query_by_label_contains("已装配·查询断供").is_some(),
"连续被拒达阈 → 查询断供"
);
assert!(
shared
.account(&gw1)
.unwrap()
.status_note
.clone()
.unwrap_or_default()
.contains("查询连续被拒 3 次"),
"细节须说清是哪一判据命中的(status_note 数据面)"
);
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_steps(4);
assert!(
harness.query_by_label_contains("已装配·查询断供").is_some(),
"登录着但资金回报超时 → 同样断供(被拒 0 次不豁免)"
);
assert!(
shared
.account(&gw1)
.unwrap()
.status_note
.clone()
.unwrap_or_default()
.contains("资金回报已 60s 未更新"),
"细节须带超时秒数(status_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);
}),
);
harness.run_steps(4);
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_steps(4);
assert!(
harness.query_by_label_contains("已装配·查询断供").is_some(),
"阈值覆盖生效:1 次被拒即断供"
);
}
#[test]
fn s50_unreachable_status_falls_back_to_unknown() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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!(
shared
.account(&gw1)
.unwrap()
.status_note
.clone()
.unwrap_or_default()
.contains("状态口问不到"),
"须写明为什么未知(status_note 数据面 + 事件日志;0.9.10 面板细节段退役)"
);
}
#[test]
fn s51_disconnect_button_enqueues_and_badge_follows() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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();
click_row_action(&mut harness, &gw1, "断开");
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!(
!row_action_exists(&harness, &gw1, "断开"),
"会话已停的行不再给『断开』钮(没有会话可停)"
);
assert!(
row_action_exists(&harness, &gw1, "连接"),
"恢复入口仍是『连接』(插件侧走「已在场 → 重投凭据」)"
);
assert!(
row_action_exists(&harness, &gw1, "移除"),
"『移除』与『断开』并存:两个动作两条口,不共用"
);
}
#[test]
fn s52_not_assembled_row_has_no_disconnect_button() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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!(
!row_action_exists(&harness, &gw3, "断开"),
"未装配 = 没有会话可停,不留死钮(Q21)"
);
assert!(
row_action_exists(&harness, &gw3, "连接"),
"未装配给『连接』"
);
assert!(
row_action_exists(&harness, &gw1, "断开"),
"已装配那行仍给『断开』"
);
}
#[test]
fn s53_md_only_row_reads_the_md_side() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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!(
shared
.account(&gw1)
.unwrap()
.status_note
.clone()
.unwrap_or_default()
.contains("纯行情账户"),
"细节须写明这一行没有 TD 会话(status_note 数据面;0.9.10 面板细节段退役)"
);
shared.note_gateway_status(
&gw1,
&status_of(&gw1, |st| {
st.md_handle_up = true;
st.md_disconnects = 3;
}),
);
harness.run_steps(4);
assert!(
harness.query_by_label_contains("已装配·掉线").is_some(),
"MD 掉线同样显形"
);
assert!(
shared
.account(&gw1)
.unwrap()
.status_note
.clone()
.unwrap_or_default()
.contains("掉线 3 次"),
"细节带次数(status_note 数据面)"
);
assert_eq!(
harness.query_all_by_label_contains("查询断供").count(),
0,
"TD 判据不得套在纯行情账户上"
);
}
#[test]
fn s54_disconnect_respects_active_order_guard() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let (gw1, gw2) = two_accounts(&shared);
shared.seed_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!(
!row_action_exists(&harness, &gw2, "断开"),
"有活跃委托时不画『断开』(会话一停就撤不了单)"
);
assert!(
!row_action_exists(&harness, &gw2, "移除"),
"有活跃委托时不画『移除』(同一个观测黑洞)"
);
assert!(
row_action_exists(&harness, &gw2, "先撤 1 笔"),
"守卫须给出口"
);
assert!(
row_action_exists(&harness, &gw1, "断开"),
"无活跃委托的已装配账户仍可断开"
);
shared.seed_trade_snap(ek_ui::state::TradeSnap::default());
harness.run();
assert!(
row_action_exists(&harness, &gw2, "断开"),
"委托撤完『断开』即恢复"
);
assert!(
row_action_exists(&harness, &gw2, "移除"),
"同一行『移除』也在场(两个动作两条口)"
);
assert_eq!(gw1, "ctp_9999_278153", "寻址仍是派生名");
}
#[test]
fn s55_removed_instance_voids_its_status_readings() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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_steps(4);
shared.note_gateway_status(
&gw1,
&status_of(&gw1, |st| {
st.td_handle_up = true;
st.td_disconnects = 2;
}),
);
harness.run_steps(4);
assert!(
harness.query_by_label_contains("已装配·掉线").is_some(),
"先读到掉线"
);
assert!(
shared
.account(&gw1)
.unwrap()
.status_note
.is_some(),
"细节数据在场(status_note;0.9.10 面板细节段退役,取证走事件日志)"
);
shared.sync_accounts_connected(&[row("svctl"), row("dm"), row("oms")]);
harness.run_steps(4);
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!(
row_action_exists(&harness, &gw1, "连接"),
"回「未装配」即重新给『连接』钮"
);
assert!(
!row_action_exists(&harness, &gw1, "断开"),
"没有实例就没有会话可停"
);
assert_eq!(gw2, "ctp_9999_220380");
}
#[test]
fn s56_ops_window_leaves_account_instances_to_the_account_panel() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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!(
row_action_exists(&harness, "dm", "移除"),
"工厂插件仍可在运维窗移除"
);
assert!(
row_action_exists(&harness, "oms", "重注入"),
"工厂插件仍可重注入"
);
assert!(
harness.query_by_label_contains("ctp_9999_278153").is_some(),
"账户实例仍列在运维表里"
);
assert!(
!row_action_exists(&harness, "ctp_9999_278153", "移除"),
"清册里的实例不在运维窗移除(Q17 边界收口)"
);
assert!(
!row_action_exists(&harness, "ctp_9999_278153", "重注入"),
"同上:重注入对命名实例本来就是 unknown factory"
);
assert!(
row_action_exists(&harness, "ctp_9999_278153", "归账户面板"),
"给出的是去处,不是死钮"
);
assert_eq!(gw1, "ctp_9999_278153");
}
#[test]
fn s56b_ops_window_refuses_removing_the_control_and_observation_faces() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(
&shared,
vec![row("svctl"), row("dm"), row("oms"), row("ui")],
);
shared.set_ops_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
assert!(
!row_action_exists(&harness, "svctl", "移除"),
"管制面自身不得给移除钮(摘掉即单向门)"
);
assert!(
!row_action_exists(&harness, "ui", "移除"),
"观测面自身不得给移除钮(摘掉即自己等自己)"
);
assert_eq!(
harness.query_all_by_label_contains("自身不摘").count(),
2,
"两条自身行都要给徽章(说明在 tooltip,不在行内长句)"
);
assert!(
row_action_exists(&harness, "svctl", "重注入")
&& row_action_exists(&harness, "ui", "重注入"),
"重注入不砍:在场时撞宿主 Duplicate → svctl 回拒,无害"
);
assert!(
row_action_exists(&harness, "dm", "移除"),
"工厂插件仍可在运维窗移除"
);
}
#[test]
fn s57_product_class_scope_is_stated_in_panel_and_dialog() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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_eq!(
harness
.query_all_by_label_contains("公用合约表 = 在场各网关的并集(合约与行情公用,规划 D3)")
.count(),
0,
"面板底部不再有 F4 长句"
);
click_row_action(&mut harness, "ctp_9999_278153", "编辑");
harness.run();
assert_eq!(
harness
.query_all_by_label_contains("收录勾选只管该网关自己收录并上抛的合约")
.count(),
1,
"配置弹窗一处(F4 口径唯一在场位)"
);
assert_eq!(
harness
.query_all_by_label_contains("改勾选须『连接/重试』才下发到网关")
.count(),
1,
"生效条件同位在场"
);
}
#[test]
fn s58_instrument_query_rejection_is_named_in_status_detail() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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_steps(4);
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_steps(4);
assert!(
harness.query_by_label("已装配·在线").is_some(),
"连续串 1 未达阈 3:不算断供"
);
assert!(
shared
.account(&gw1)
.unwrap()
.status_note
.clone()
.unwrap_or_default()
.contains("合约查询被拒 1 次"),
"0.9.1 分项须在状态数据里(status_note;0.9.10 面板细节段退役)"
);
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_steps(4);
assert!(
harness.query_by_label_contains("已装配·查询断供").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_steps(4);
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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
unsafe {
std::env::set_var(
"EK_UI_LOG_DIR",
std::env::temp_dir().join(format!("ek-ui-s59-{}", std::process::id())),
);
}
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("全量在盘:%APPDATA%\\ek\\logs\\")
.is_some(),
"全量取证文件路径必须显形,否则落盘能力在界面上隐形"
);
assert!(
harness.query_by_label_contains("已丢弃 3 条").is_some(),
"溢出计数显形"
);
}
#[test]
fn s60_never_received_account_report_becomes_starved() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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_steps(4);
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_steps(4);
assert!(
harness.query_by_label("已装配·在线").is_some(),
"刚登录几秒不算断供"
);
assert!(
shared
.account(&gw1)
.unwrap()
.status_note
.clone()
.unwrap_or_default()
.contains("资金回报尚未到手"),
"这句话在 status_note(0.9.10 面板细节段退役)"
);
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_steps(4);
assert!(
harness.query_by_label_contains("已装配·查询断供").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_steps(4);
assert!(
harness.query_by_label("已装配·在线").is_some(),
"到手即回在线"
);
}
#[test]
fn s63_logs_tab_is_a_table_with_headers() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.log_line("[ops] 日志表格化第一行");
shared.log_line("[订阅] 日志表格化第二行");
shared.set_bottomright_tab(ek_ui::state::BottomRightTab::Logs);
let mut harness = harness_for(&shared);
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
assert!(hit.is_some(), "验收环境必须有 CJK 候选字体");
harness.run();
let t = harness.query_all_by_label("时间").count();
assert!(
t >= 2,
"日志表必须有『时间』表头(行情带也有一列,实测 {t} 处)"
);
assert!(
harness.query_by_label("信息").is_some(),
"日志表必须有『信息』表头(全画面唯一)"
);
assert!(
harness
.query_by_label_contains("日志表格化第一行")
.is_some()
);
assert!(
harness
.query_by_label_contains("日志表格化第二行")
.is_some()
);
}
#[test]
fn s64_trades_and_positions_cells_have_tab_heads() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let mut harness = harness_for(&shared);
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
assert!(hit.is_some(), "验收环境必须有 CJK 候选字体");
harness.run();
for ex in ek_ui::state::MARKET_ORDER {
let name = ek_ui::render::exchange_tab_name(ex);
assert!(
harness.query_all_by_label(name).count() >= 1,
"{name}({}) 行情 tab 头缺失",
ex.as_str()
);
}
for tab_head in ["委托", "未成交", "资金", "日志", "成交", "持仓"] {
assert!(
harness.query_all_by_label(tab_head).count() >= 1,
"{tab_head} tab 头缺失"
);
}
for head in ["成交金额", "均价"] {
assert!(
harness.query_by_label(head).is_some(),
"{head} 表头缺失:前台格内容没画出来"
);
}
assert!(
harness.query_by_label("权益").is_some(),
"资金格表头「权益」缺失(前台格内容没画出来;0.8.5 起「余额」改名「权益」)"
);
assert!(
harness.query_all_by_label("委托号").count() >= 2,
"委托号表头必须同时出现在委托表与成交表"
);
}
#[test]
fn s65_service_directory_table_has_headers() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_snapshot(
&shared,
vec![
("dm".to_string(), vec!["dm.1".to_string()]),
("oms".to_string(), vec![]),
],
);
shared.set_ops_window_open(true);
let mut harness = harness_for(&shared);
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
assert!(hit.is_some(), "验收环境必须有 CJK 候选字体");
harness.run();
let s = harness.query_all_by_label("服务").count();
assert!(
s >= 4,
"服务目录表必须有 3 处『服务』表头 + 1 处标题,实测 {s}"
);
let i = harness.query_all_by_label("实例").count();
assert_eq!(
i, 3,
"服务目录表必须有 3 处『实例』表头(与三对列同数),实测 {i}"
);
}
fn header_bands(shapes: &[egui::Shape], color: egui::Color32, min_w: f32) -> Vec<egui::Rect> {
shapes
.iter()
.filter_map(|s| match s {
egui::Shape::Rect(r)
if r.fill == color
&& r.stroke.is_empty()
&& r.corner_radius == egui::CornerRadius::ZERO
&& r.rect.width() >= min_w
&& r.rect.height() >= 8.0 =>
{
Some(r.rect)
}
_ => None,
})
.collect()
}
fn text_span_in(shapes: &[egui::epaint::ClippedShape], region: egui::Rect) -> f32 {
let mut lo = f32::INFINITY;
let mut hi = f32::NEG_INFINITY;
for cs in shapes {
let egui::Shape::Text(t) = &cs.shape else {
continue;
};
let visible = t.visual_bounding_rect().intersect(cs.clip_rect);
if !visible.is_positive() || !region.contains_rect(visible) {
continue;
}
lo = lo.min(visible.min.x);
hi = hi.max(visible.max.x);
}
if hi < lo { 0.0 } else { hi - lo }
}
#[test]
fn s67_chrome_is_actually_painted() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let mut harness = harness_for(&shared);
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
assert!(hit.is_some(), "验收环境必须有 CJK 候选字体");
harness.run();
let shapes = shapes_of(&harness);
let bands = header_bands(&shapes, ek_ui::tokens::shadcn::BG4, 200.0);
let band_area: f32 = bands.iter().map(|r| r.area()).sum();
assert!(
!bands.is_empty() && band_area > 4_000.0,
"表头带(BG4 实心、够宽够高)必须成片出现,实测 {} 根 / 面积 {band_area:.0}",
bands.len()
);
let underline = hlines(&shapes, ek_ui::tokens::shadcn::ACCENT4);
assert!(
!underline.is_empty(),
"选中 tab 的下划线(ACCENT4)必须出现在绘制指令里,实测 {} 根",
underline.len()
);
let tab_fill = filled_rects(&shapes, ek_ui::tokens::shadcn::BG5);
assert!(
!tab_fill.is_empty(),
"选中 tab 的底(BG5)必须出现在绘制指令里,实测 {} 块",
tab_fill.len()
);
}
#[test]
fn s69_ops_window_header_band_covers_the_row() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_snapshot(
&shared,
vec![
("dm".to_string(), vec!["dm.1".to_string()]),
("oms".to_string(), vec![]),
],
);
shared.set_ops_window_open(true);
let mut harness = harness_for(&shared);
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
assert!(hit.is_some(), "验收环境必须有 CJK 候选字体");
harness.run();
let clipped = clipped_shapes_of(&harness);
let shapes: Vec<egui::Shape> = clipped.iter().map(|c| c.shape.clone()).collect();
let heads: Vec<egui::Rect> = ["统计", "实例"]
.iter()
.flat_map(|h| harness.query_all_by_label(h).map(|n| n.rect()))
.collect();
assert!(
heads.len() >= 4,
"运维窗必须有插件表「统计」+ 服务目录 3 处「实例」表头,实测 {}",
heads.len()
);
let all_bands = header_bands(&shapes, ek_ui::tokens::shadcn::BG4, 100.0);
let bands: Vec<egui::Rect> = all_bands
.iter()
.filter(|b| heads.iter().any(|h| b.contains_rect(*h)))
.copied()
.collect();
assert!(
!bands.is_empty(),
"运维窗的表头带必须真的盖住表头格(BG4 实心矩形),实测本帧带 {all_bands:?}"
);
assert!(
bands.iter().any(|b| b.width() > 400.0),
"运维窗里必须有一根整行表头带(宽 > 400),实测 {:?}",
bands.iter().map(|b| b.width()).collect::<Vec<_>>()
);
for band in &bands {
let body_region = egui::Rect::from_min_max(
egui::pos2(band.min.x, band.max.y + 1.0),
egui::pos2(band.max.x, band.max.y + 160.0),
);
let body = text_span_in(&clipped, body_region);
assert!(
band.width() + 90.0 >= body,
"表头带盖住体表最宽一行的缺口必须 ≤ 90 px(带 {:.1} px / 体表 {:.1} px,带 {band:?})",
band.width(),
body
);
}
}
#[test]
fn s70_funds_table_band_covers_the_row() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
shared.seed_trade_snap(ek_ui::state::TradeSnap {
accounts: vec![
ek_data::account::AccountData {
gateway_name: "ctp_9999_278153".to_string(),
accountid: "278153".to_string(),
balance: 19_929_826.41,
frozen: 12_345.67,
},
ek_data::account::AccountData {
gateway_name: "ctp_9999_220380".to_string(),
accountid: "220380".to_string(),
balance: 1_000.0,
frozen: 0.0,
},
],
..ek_ui::state::TradeSnap::default()
});
shared.set_bottomright_tab(ek_ui::state::BottomRightTab::Account);
let mut harness = harness_for(&shared);
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
assert!(hit.is_some(), "验收环境必须有 CJK 候选字体");
harness.run();
assert!(
harness.query_by_label("账号").is_some(),
"资金表表头必须在(账号列)"
);
let clipped = clipped_shapes_of(&harness);
let shapes: Vec<egui::Shape> = clipped.iter().map(|c| c.shape.clone()).collect();
let head = harness
.query_by_label("账号")
.expect("资金表必须有「账号」表头")
.rect();
let bands: Vec<egui::Rect> = header_bands(&shapes, ek_ui::tokens::shadcn::BG4, 100.0)
.into_iter()
.filter(|b| b.contains_rect(head))
.collect();
assert!(
!bands.is_empty(),
"资金表头格必须被表头带(BG4 实心矩形)盖住,表头格 {head:?} / 本帧带 {:?}",
header_bands(&shapes, ek_ui::tokens::shadcn::BG4, 100.0)
);
let band = bands
.iter()
.max_by(|a, b| a.width().total_cmp(&b.width()))
.expect("上面已保证非空");
let body_region = egui::Rect::from_min_max(
egui::pos2(band.min.x, band.max.y + 1.0),
egui::pos2(band.max.x, band.max.y + 200.0),
);
let body = text_span_in(&clipped, body_region);
assert!(
band.width() >= body,
"资金表头带必须盖住体表最宽一行(带 {:.1} px < 体表 {:.1} px = 断开,带 {band:?})",
band.width(),
body
);
}
#[test]
fn s73_empty_state_keeps_table_headers() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new()); shared.set_ops_window_open(true);
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
for (head, why) in [
("涨跌幅", "行情格(等 eTick 订阅时)"),
("状态描述", "委托格"),
("成交金额", "成交格"),
(
"盈亏",
"持仓格(0.8.5 撤死列:s73 改钉「盈亏」,这句判据仍防空态吞头)",
),
("权益", "资金格(0.8.5 起「余额」改名「权益」)"),
("实例", "运维窗服务目录(无服务时)"),
("账号", "账户面板(无账户时)"),
] {
assert!(
harness.query_all_by_label_contains(head).count() >= 1,
"空态不得吞掉表头:{why}的表头「{head}」必须在(这就是用户看到的「表头都没有」)"
);
}
shared.set_bottomright_tab(ek_ui::state::BottomRightTab::Logs);
harness.run();
assert!(
harness.query_all_by_label_contains("信息").count() >= 1,
"空态不得吞掉表头:日志格(无日志时)的表头「信息」必须在"
);
}
#[test]
fn s74_empty_tables_fill_their_header_band() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new()); let mut harness = harness_for(&shared);
for _ in 0..4 {
harness.run();
}
let shapes = shapes_of(&harness);
let bands = header_bands(&shapes, ek_ui::tokens::shadcn::BG4, 100.0);
for (head, why) in [("涨跌幅", "行情带(14 列)"), ("账号", "资金格(5 列)")] {
let anchor = texts_containing(&shapes, head)
.into_iter()
.find(|(t, _, _)| t == head)
.map(|(_, r, _)| r)
.unwrap_or_else(|| panic!("s74:{why}的表头「{head}」必须在场(s73 已钉)"));
let Some(band) = bands.iter().find(|b| b.contains_rect(anchor)) else {
panic!("s74:{why}必须有一根盖住表头「{head}」的表头带,实测带 {bands:?}");
};
let rightmost = shapes
.iter()
.filter_map(|s| match s {
egui::Shape::Text(t) => {
let r = t.visual_bounding_rect();
(r.center().y >= band.min.y
&& r.center().y <= band.max.y
&& r.min.x >= band.min.x
&& r.min.x <= band.max.x)
.then_some(r.max.x)
}
_ => None,
})
.fold(f32::NEG_INFINITY, f32::max);
let fill = (rightmost - band.min.x) / band.width();
println!(
"s74 {why}:带 {:.1}..{:.1}(宽 {:.1})最右表头右沿 {rightmost:.1} → 填满 {:.1}%",
band.min.x,
band.max.x,
band.width(),
fill * 100.0
);
assert!(
fill >= 0.85,
"s74:{why}空态必须按表头比例把带宽填满,实测只铺到 {:.1}%(带右沿前还空 {:.1} px)—— 这就是用户说的「表头全挤在一起」",
fill * 100.0,
band.max.x - rightmost
);
}
}
const PINNED_GRID_TARGETS: [(&str, usize); 5] = [
("行情带(14 列)", 10),
("委托表(17 列)", 7),
("成交表(12 列)", 7),
("持仓表(16 列)", 9),
("资金格(5 列)", 4),
];
fn pinned_band_identity(b: &egui::Rect) -> &'static str {
if b.min.x < 100.0 && b.min.y < 100.0 {
"行情带(14 列)"
} else if b.min.y < 450.0 && b.min.x < 700.0 {
"委托表(17 列)"
} else if b.min.y < 450.0 {
"成交表(12 列)"
} else if b.min.x < 700.0 {
"持仓表(16 列)"
} else {
"资金格(5 列)"
}
}
fn pinned_grid_line_counts(shared: &UiShared) -> Vec<(String, usize, usize)> {
let mut harness = harness_for(shared);
for _ in 0..4 {
harness.run();
}
let shapes = shapes_of(&harness);
let border = ek_ui::tokens::shadcn::BORDER_SOFT;
let bands = header_bands(&shapes, ek_ui::tokens::shadcn::BG4, 100.0);
let hl = hlines(&shapes, border);
let vl = vlines_any(&shapes);
let in_tables = |r: &egui::Rect| r.min.x > 300.0 || r.min.y < 100.0;
let mut anchors: Vec<egui::Rect> = harness
.query_all_by_label("代码")
.map(|n| n.rect())
.filter(in_tables)
.collect();
anchors.extend(
harness
.query_all_by_label("账号")
.map(|n| n.rect())
.filter(in_tables),
);
let mut out: Vec<(String, usize, usize)> = Vec::new();
for a in &anchors {
let Some(band) = bands.iter().find(|b| {
b.min.x <= a.min.x + 2.0
&& b.max.x >= a.max.x - 2.0
&& b.min.y <= a.min.y + 2.0
&& b.max.y >= a.max.y - 2.0
}) else {
continue;
};
let rows = hl
.iter()
.filter(|(y, x)| {
*y > band.max.y + 3.0
&& *y <= band.max.y + 3.0 + 2.5 * 26.0
&& x.span() >= band.width() * 0.7
})
.count();
let cols = vl
.iter()
.filter(|(x, y, _)| {
*x > band.min.x + 2.0
&& *x < band.max.x - 2.0
&& y.min >= band.min.y - 2.0
&& y.min <= band.max.y + 2.0
&& y.max > band.max.y + 20.0
})
.count();
out.push((pinned_band_identity(band).to_string(), rows, cols));
}
out
}
#[test]
fn s75_every_pinned_table_paints_grid_lines() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
shared.update_tick(&tick(
ek_data::constant::Exchange::SHFE,
"rb2605",
"螺纹钢",
3700.0,
1.2,
));
shared.update_tick(&tick(
ek_data::constant::Exchange::SHFE,
"rb2606",
"螺纹钢2606",
3710.0,
-0.4,
));
let order = ek_data::order::OrderData {
gateway_name: "ctp".to_string(),
symbol: "rb2605".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
orderid: "GRID_1".to_string(),
direction: Some(ek_data::constant::Direction::LONG),
offset: ek_data::constant::Offset::OPEN,
price: 3700.0,
volume: 3.0,
traded: 1.0,
traded_price: 3700.0,
status: ek_data::constant::Status::PARTTRADED,
..ek_data::order::OrderData::default()
};
let trade = ek_data::trade::TradeData {
gateway_name: "ctp".to_string(),
symbol: "rb2605".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
orderid: "GRID_1".to_string(),
tradeid: "GRID_T1".to_string(),
direction: Some(ek_data::constant::Direction::LONG),
offset: ek_data::constant::Offset::OPEN,
price: 3700.0,
volume: 1.0,
datetime: Some(36_000_000_000_000),
..ek_data::trade::TradeData::default()
};
let pos = 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: 3.0,
avg_price: 3700.0,
..ek_data::position::PositionData::default()
};
shared.seed_trade_snap(ek_ui::state::TradeSnap {
orders: vec![
order.clone(),
ek_data::order::OrderData {
orderid: "GRID_2".to_string(),
..order.clone()
},
],
trades: vec![
trade.clone(),
ek_data::trade::TradeData {
tradeid: "GRID_T2".to_string(),
price: 3701.0,
volume: 2.0,
..trade.clone()
},
],
positions: vec![
pos.clone(),
ek_data::position::PositionData {
symbol: "rb2606".to_string(),
direction: ek_data::constant::Direction::SHORT,
volume: 2.0,
avg_price: 3710.0,
..pos.clone()
},
],
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_data::account::AccountData {
gateway_name: "ctp2".to_string(),
accountid: "278154".to_string(),
balance: 2_000_000.0,
frozen: 543.0,
},
],
});
let counts = pinned_grid_line_counts(&shared);
for (why, _) in PINNED_GRID_TARGETS {
let (_, rows, cols) = counts
.iter()
.find(|(w, _, _)| w == why)
.unwrap_or_else(|| panic!("s75:{why} 没被找到(带分类:{counts:?})"))
.clone();
println!("s75 {why}:行线 {rows} 根 / 列线 {cols} 根");
assert!(
rows >= 2,
"s75:{why} 喂了两行数据就必须有行线,实测 {rows} 根"
);
assert!(
cols == 0,
"s75:{why} 表内不该再有列线,实测 {cols} 根 —— 0.4.1 七期 BD-UI-60 裁定「所有列分割线都去掉,只要行的分割线」(反向钉,任意色计数,换颜色不算过)",
);
}
}
#[test]
fn s76_short_tables_get_the_same_grid() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
for (open_ops, open_accounts, head, head_count, mate, why, _) in [
(
true,
false,
"版本",
1,
Some("统计"),
"运维窗插件表(6 列)",
5,
),
(true, false, "实例", 3, None, "运维窗服务目录(6 列)", 5),
(
false,
true,
"别名",
1,
Some("行情源"),
"账户面板账户清单(7 列)",
6,
),
] {
let shared = Arc::new(UiShared::new());
shared.set_ops_window_open(open_ops);
shared.set_accounts_window_open(open_accounts);
if open_ops {
feed_snapshot(
&shared,
vec![
(
"svctl".to_string(),
vec!["s1".to_string(), "s2".to_string()],
),
("dm".to_string(), vec!["d1".to_string(), "d2".to_string()]),
],
);
} else {
seed_md_source(&shared, "9999", "220380");
seed_md_source(&shared, "9998", "220381");
}
let mut harness = harness_for(&shared);
for _ in 0..4 {
harness.run();
}
let shapes = shapes_of(&harness);
let border = ek_ui::tokens::shadcn::BORDER_SOFT;
let bands = header_bands(&shapes, ek_ui::tokens::shadcn::BG4, 60.0);
let hl = hlines(&shapes, border);
let vl = vlines_any(&shapes);
let rects_of = |label: &str| -> Vec<egui::Rect> {
harness
.query_all_by_label(label)
.map(|n| n.rect())
.collect()
};
let head_rects = rects_of(head);
assert!(
head_rects.len() >= head_count,
"s76:{why}的表头「{head}」应有 {head_count} 个,实测 {} 个(s73 同口径)",
head_rects.len()
);
let mate_rects = mate.map(rects_of).unwrap_or_default();
let inside = |b: &egui::Rect, r: &egui::Rect| {
b.min.x <= r.min.x + 2.0
&& b.max.x >= r.max.x - 2.0
&& b.min.y <= r.min.y + 2.0
&& b.max.y >= r.max.y - 2.0
};
let Some(band) = bands.iter().find(|b| {
head_rects.iter().filter(|r| inside(b, r)).count() >= head_count
&& (mate_rects.is_empty() || mate_rects.iter().any(|r| inside(b, r)))
}) else {
panic!(
"s76:{why}必须有一条带同时包住 {head_count} 个「{head}」{head_rects:?}(与 {:?} 同带),候选带 {bands:?}",
mate.unwrap_or(head)
);
};
let rows = hl
.iter()
.filter(|(y, x)| {
*y > band.max.y + 1.0 && *y <= band.max.y + 130.0 && x.span() >= band.width() * 0.7
})
.count();
let cols = vl
.iter()
.filter(|(x, y, _)| {
*x > band.min.x + 2.0
&& *x < band.max.x - 2.0
&& y.min >= band.min.y - 2.0
&& y.min <= band.min.y + 2.0
&& y.max > band.max.y + 10.0
})
.count();
println!(
"s76 {why}:行线 {rows} 根 / 列线 {cols} 根(带 {:.1}..{:.1})",
band.min.x, band.max.x
);
assert!(
rows >= 2,
"s76:{why} 喂了两行以上就必须有行线,实测 {rows} 根"
);
assert!(
cols == 0,
"s76:{why} 表内不该再有列线,实测 {cols} 根 —— BD-UI-60 裁定撤列线(反向钉,任意色)"
);
}
}
#[test]
fn s77_empty_tables_have_no_column_lines() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let counts = pinned_grid_line_counts(&UiShared::new());
for (why, _) in PINNED_GRID_TARGETS {
let (_, _rows, cols) = counts
.iter()
.find(|(w, _, _)| w == why)
.unwrap_or_else(|| panic!("s77:{why} 没被找到(带分类:{counts:?})"))
.clone();
println!("s77 空态 {why}:列线 {cols} 根");
assert!(
cols == 0,
"s77:{why} 空态(0 行)表内不该有列线,实测 {cols} 根 —— BD-UI-60 裁定撤列线",
);
}
}
#[test]
fn s78_region_fill_thin_scrollbars_and_centered_text() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
shared.update_tick(&tick(
ek_data::constant::Exchange::SHFE,
"rb2605",
"螺纹钢",
3700.0,
1.2,
));
shared.seed_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_7801".to_string(),
direction: Some(ek_data::constant::Direction::LONG),
offset: ek_data::constant::Offset::OPEN,
price: 3700.0,
volume: 3.0,
traded: 1.0,
traded_price: 3700.0,
status: ek_data::constant::Status::PARTTRADED,
..ek_data::order::OrderData::default()
}],
trades: vec![trade_row("7802", 3701.0, "螺纹钢")],
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: 3.0,
avg_price: 3700.0,
..ek_data::position::PositionData::default()
}],
accounts: vec![ek_data::account::AccountData {
gateway_name: "ctp".to_string(),
accountid: "278153".to_string(),
balance: 1_000_000.0,
frozen: 12_345.0,
}],
});
let mut harness = harness_for(&shared);
for _ in 0..4 {
harness.run();
}
for why in ["order_grid", "trade_grid", "pos_grid"] {
let (stored, target) = ek_ui::render::table_fill_probe(&harness.ctx, why);
assert!(
target > 100.0,
"s78:{why} 补宽目标宽没记上(实测 {target:.1})—— `table_fill` 状态钉没写?"
);
assert!(
stored >= target * 0.9,
"s78:{why} 列宽总和 {stored:.0} 应填到目标宽 {target:.0} 的 9 成(BD-UI-60:表宽 = 所在区域 100%,剩余补给最后一列)",
);
}
let sc = harness.ctx.style().spacing.scroll;
assert!(
(sc.bar_width - ek_ui::tokens::SCROLL_BAR_WIDTH).abs() < 0.01
&& (sc.bar_width - 5.0).abs() < 0.01,
"s78 滚动条粗细应是 5 px(BD-UI-60「尽可能细一点」),实测 {:.1}",
sc.bar_width
);
assert!(
!sc.foreground_color,
"s78 滚动条把手不该取 `fg_stroke`(TEXT1 `#FAFAFA`,全调色板最亮的一支)—— BD-UI-60「暗一些」"
);
assert!(
!sc.floating,
"s78 滚动条要占位不悬浮(Qt QScrollBar 同口径),实测 floating = {:?}",
sc.floating
);
let shapes = shapes_of(&harness);
let bands = header_bands(&shapes, ek_ui::tokens::shadcn::BG4, 100.0);
let heads = texts_containing(&shapes, "代码");
let mut pinned = 0usize;
for (_, rect, _) in &heads {
let Some(band) = bands.iter().find(|b| b.contains_rect(*rect)) else {
continue;
};
pinned += 1;
let off = rect.center().y - band.center().y;
assert!(
off.abs() <= 2.0,
"s78 表格层表头文字必须在带里上下居中,实测上隙 {:.1} / 下隙 {:.1}(带 {band:?} 文字 {rect:?})",
rect.min.y - band.min.y,
band.max.y - rect.max.y,
);
}
assert!(
pinned >= 3,
"s78 至少要有 3 张表格层的表能测表头居中,实测 {pinned}"
);
let ops = Arc::new(UiShared::new());
ops.set_ops_window_open(true);
feed_snapshot(&ops, vec![("svctl".to_string(), vec!["s1".to_string()])]);
let mut h2 = harness_for(&ops);
for _ in 0..4 {
h2.run();
}
let shapes2 = shapes_of(&h2);
let bands2 = header_bands(&shapes2, ek_ui::tokens::shadcn::BG4, 60.0);
let mut short = 0usize;
for (_, rect, _) in texts_containing(&shapes2, "版本") {
let Some(band) = bands2.iter().find(|b| b.contains_rect(rect)) else {
continue;
};
short += 1;
let off = rect.center().y - band.center().y;
assert!(
off.abs() <= 2.0,
"s78 短表层表头文字必须在带里上下居中,实测上隙 {:.1} / 下隙 {:.1}(带 {band:?} 文字 {rect:?})",
rect.min.y - band.min.y,
band.max.y - rect.max.y,
);
}
assert!(
short >= 1,
"s78 运维窗插件表的表头「版本」没找到,测不到短表层居中"
);
}
#[test]
fn s79_all_table_headers_same_height_and_font() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
shared.update_tick(&tick(
ek_data::constant::Exchange::SHFE,
"rb2605",
"螺纹钢",
3700.0,
1.2,
));
shared.seed_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_7901".to_string(),
direction: Some(ek_data::constant::Direction::LONG),
offset: ek_data::constant::Offset::OPEN,
price: 3700.0,
volume: 3.0,
traded: 1.0,
traded_price: 3700.0,
status: ek_data::constant::Status::PARTTRADED,
..ek_data::order::OrderData::default()
}],
trades: vec![trade_row("7902", 3701.0, "螺纹钢")],
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: 3.0,
avg_price: 3700.0,
..ek_data::position::PositionData::default()
}],
accounts: vec![ek_data::account::AccountData {
gateway_name: "ctp".to_string(),
accountid: "278153".to_string(),
balance: 1_000_000.0,
frozen: 12_345.0,
}],
});
shared.update_contract(&contract(
ek_data::constant::Exchange::SHFE,
"rb2605",
"螺纹钢2605",
));
shared.set_ops_window_open(true);
shared.set_accounts_window_open(true);
shared.set_contracts_window_open(true);
let mut harness = harness_for(&shared);
for _ in 0..4 {
harness.run();
}
let shapes = shapes_of(&harness);
let small = harness.ctx.style().text_styles[&egui::TextStyle::Small].size;
let mut bands = header_bands(&shapes, ek_ui::tokens::shadcn::BG4, 60.0)
.into_iter()
.filter(|r| r.height() >= 20.0)
.collect::<Vec<_>>();
bands.sort_by(|a, c| (a.min.y, a.min.x).partial_cmp(&(c.min.y, c.min.x)).unwrap());
assert!(
bands.len() >= 9,
"s79 至少要数到 9 块表头带(主窗 5 + 合约窗 1 + 短表 3),实测 {} 块:{:?}",
bands.len(),
bands
);
let hs: Vec<f32> = bands.iter().map(|b| b.height()).collect();
let lo = hs.iter().copied().fold(f32::INFINITY, |a, b| a.min(b));
let hi = hs.iter().copied().fold(f32::NEG_INFINITY, |a, b| a.max(b));
let spread = hi - lo;
println!("s79 带高:{hs:?}");
assert!(
spread <= 1.0,
"s79 所有表头带高必须同一档,实测极差 {spread:.1} px(最矮 {lo:.1} 最高 {hi:.1})—— BD-UI-61 之前短表层 22.0 / 表格层 29.0",
);
assert!(
(hi - 29.0).abs() <= 1.0,
"s79 表头带高应是「表头行高 26 + clip_rect_margin 3」= 29,实测最高 {hi:.1}"
);
for label in [
"今开",
"涨跌幅",
"昨仓",
"最小变动",
"合约分类",
"版本",
"依赖",
"统计",
"实例",
"别名",
"行情源",
] {
let mut found = 0usize;
for cs in shapes.iter() {
let egui::Shape::Text(t) = cs else {
continue;
};
if !t.galley.text().starts_with(label) {
continue;
}
let vr = t.visual_bounding_rect();
let mut band: Option<&egui::Rect> = None;
let mut best = f32::MAX;
for b in bands.iter().filter(|b| b.contains_rect(vr)) {
let d = (b.center().y - vr.center().y).abs();
if d < best {
best = d;
band = Some(b);
}
}
let Some(band) = band else {
continue;
};
found += 1;
let size = t
.galley
.job
.sections
.first()
.map(|s| s.format.font_id.size)
.unwrap_or(0.0);
assert!(
(size - small).abs() < 0.01,
"s79 表头「{label}」字号 {size:.1} 应等于 Small {small:.1}(全表同一字号)",
);
let off = vr.center().y - band.center().y;
assert!(
off.abs() <= 2.0,
"s79 表头「{label}」必须在带里上下居中,实测上隙 {:.1} / 下隙 {:.1}(带 {band:?})",
vr.min.y - band.min.y,
band.max.y - vr.max.y,
);
}
assert!(
found >= 1,
"s79 表头「{label}」没在任何表头带里找到,测不到字号/高度"
);
}
}
#[test]
fn s80_market_band_tab_strip_names_and_stays_in_band() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let mut harness = harness_for(&shared);
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
assert!(hit.is_some(), "s80 验收环境必须有 CJK 候选字体");
for _ in 0..4 {
harness.run();
}
let shapes = shapes_of(&harness);
let mut tabs: Vec<(String, egui::Rect)> = Vec::new();
for ex in ek_ui::state::MARKET_ORDER {
let name = ek_ui::render::exchange_tab_name(ex);
assert!(
!matches!(name, "SHFE" | "DCE" | "CZCE" | "CFFEX" | "GFEX" | "INE"),
"s80 行情带 tab 名应是中文名,实测 {name:?}({})",
ex.as_str()
);
let mut got: Vec<egui::Rect> = harness.query_all_by_label(name).map(|n| n.rect()).collect();
assert!(
!got.is_empty(),
"s80 行情带 tab「{name}」({})缺失",
ex.as_str()
);
got.sort_by_key(|r| (r.min.y as i32, r.min.x as i32));
tabs.push((name.to_string(), got[0]));
}
let mut market_bands = header_bands(&shapes, ek_ui::tokens::shadcn::BG4, 600.0)
.into_iter()
.filter(|r| r.min.y < 120.0)
.collect::<Vec<_>>();
market_bands.sort_by_key(|r| r.min.y as i32);
assert!(
!market_bands.is_empty(),
"s80 找不到行情带表头带(BG4 实心、宽 ≥ 600、顶 y < 120)"
);
let mb = market_bands[0];
for (name, r) in &tabs {
assert!(
r.min.y > mb.max.y,
"s80 tab「{name}」应在表下:tab 顶 {:.1} 应 > 行情表头带底 {:.1}(带 {mb:?})",
r.min.y,
mb.max.y
);
}
let heads: Vec<egui::Rect> = harness
.query_all_by_label("委托")
.map(|n| n.rect())
.filter(|r| r.min.x > 320.0)
.collect();
assert!(
!heads.is_empty(),
"s80 数据区「委托」tab 头缺失(拿它当数据区上沿的锚)"
);
for (name, r) in &tabs {
for h in &heads {
assert!(
!r.intersects(*h),
"s80 tab「{name}」({r:?})压到数据区「委托」tab 头({h:?})—— 行情带把 tab 条挤到面板外了",
);
}
}
let target = tabs
.iter()
.find(|(n, _)| n == "大商所")
.map(|(_, r)| *r)
.expect("s80 缺「大商所」tab 矩形");
assert_ne!(
shared.selected_exchange(),
"DCE",
"s80 起点就不该已经在 DCE 上(否则这条钉测不出东西)"
);
click_at(
&mut harness,
target.center(),
egui::PointerButton::Primary,
1,
);
harness.run();
assert_eq!(
shared.selected_exchange(),
"DCE",
"s80 点「大商所」tab 应把当前所切到 DCE(标签改名后接线断了)"
);
}
#[test]
fn s81_row_highlight_covers_full_row_width() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.update_tick(&ek_data::tick::TickData {
gateway_name: "ctp".to_string(),
symbol: "rb2710".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
datetime: 7 * 3600 * 1_000_000_000,
name: "螺纹钢2710".to_string(),
last_price: 3120.0,
..ek_data::tick::TickData::default()
});
let mut harness = harness_for(&shared);
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
assert!(hit.is_some(), "s81 验收环境必须有 CJK 候选字体");
for _ in 0..4 {
harness.run();
}
let shapes = shapes_of(&harness);
let mut market_bands = header_bands(&shapes, ek_ui::tokens::shadcn::BG4, 600.0)
.into_iter()
.filter(|r| r.min.y < 120.0)
.collect::<Vec<_>>();
market_bands.sort_by_key(|r| r.min.y as i32);
assert!(!market_bands.is_empty(), "s81 找不到行情带表头带(高亮锚)");
let mb = market_bands[0];
let probe = egui::pos2(mb.max.x - 5.0, mb.max.y + 14.0);
harness.hover_at(probe);
harness.run();
let shapes2 = shapes_of(&harness);
let hov: Vec<egui::Rect> = filled_rects(&shapes2, ek_ui::tokens::shadcn::BG5)
.into_iter()
.filter(|r| r.height() > 12.0 && r.height() < 60.0 && r.min.y >= mb.max.y - 1.0)
.collect();
assert!(
!hov.is_empty(),
"s81 悬停点 {probe:?} 之后没有任何行高亮块(悬停底色 = `widgets.hovered.bg_fill`)",
);
let widest = hov.iter().copied().fold(0.0f32, |a, r| a.max(r.width()));
assert!(
widest >= mb.width() - 2.0,
"s81 整行高亮应铺满整行宽:实测最宽 {widest:.1} px,行情带宽 {:.1} px(带 {mb:?})",
mb.width()
);
}
#[test]
fn s82_data_area_vertical_splitter_is_one_continuous_line() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let mut harness = harness_for(&shared);
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
assert!(hit.is_some(), "s82 验收环境必须有 CJK 候选字体");
for _ in 0..4 {
harness.run();
}
let shapes = shapes_of(&harness);
let mut data_bands = header_bands(&shapes, ek_ui::tokens::shadcn::BG4, 200.0)
.into_iter()
.filter(|r| r.min.y > 200.0)
.collect::<Vec<_>>();
data_bands.sort_by(|a, b| (a.min.y, a.min.x).partial_cmp(&(b.min.y, b.min.x)).unwrap());
assert!(
data_bands.len() >= 4,
"s82 数据区应有 4 块表头带(委托/成交/持仓/资金),实测 {} 块:{:?}",
data_bands.len(),
data_bands
);
let top_y = data_bands[0].min.y;
let bottom_y = data_bands
.iter()
.copied()
.fold(0.0f32, |a, r| a.max(r.max.y));
let seam = (data_bands[0].max.x + data_bands[1].min.x) * 0.5;
for (why, y) in [
("上排(委托|成交)", data_bands[0].center().y),
("下排(持仓|资金)", data_bands[3].center().y),
] {
harness.hover_at(egui::pos2(seam, y));
harness.run();
let sh = shapes_of(&harness);
let v: Vec<(f32, egui::Rangef, egui::Color32)> = vlines_any(&sh)
.into_iter()
.filter(|(_, _, c)| *c == ek_ui::tokens::shadcn::ACCENT4)
.collect();
assert!(
v.iter()
.any(|(_, r, _)| r.min <= top_y + 1.0 && r.max >= bottom_y - 1.0),
"s82 悬在{why}的竖线上(x {seam:.1}):ACCENT4 竖线应是一根贯穿线(覆盖 y {top_y:.1}..{bottom_y:.1}),实测 {v:?}",
);
}
}
#[test]
fn s83_account_row_shows_counter_reject_reason() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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_steps(4);
shared.note_gateway_status(
&gw1,
&status_of(&gw1, |st| {
st.td_handle_up = true;
st.td_login_failed = true;
st.td_logged_in = false;
st.td_last_error =
Some("交易服务器登录失败,代码:3,信息:CTP:不合法的登录".to_string());
}),
);
harness.run_steps(4);
assert!(
harness
.query_by_label_contains("已装配·登录失败·不合法的登录(3)")
.is_some(),
"s83 账户行状态列没带上柜台原因(应显形「已装配·登录失败·不合法的登录(3)」)"
);
let note = shared
.account(&gw1)
.unwrap()
.status_note
.clone()
.unwrap_or_default();
assert!(
note.contains("交易服务器登录失败,代码:3"),
"s83 柜台原句(带代码)要在状态数据里给全: {note}"
);
let rows = shared.accounts();
let a = rows.iter().find(|r| r.gateway == gw1).expect("行还在");
assert_eq!(
a.login,
ek_ui::state::LoginState::LoginFailed,
"s83 登录态应是「登录失败」"
);
assert_eq!(
a.login_brief.as_deref(),
Some("不合法的登录(3)"),
"s83 短标签形状不对(应剥掉 `CTP:` 前缀并把代码括在后面)"
);
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);
st.td_last_account_ms = Some(now);
}),
);
harness.run_steps(4);
let rows = shared.accounts();
let a = rows.iter().find(|r| r.gateway == gw1).expect("行还在");
assert!(
a.login_brief.is_none(),
"s83 在线行不该带柜台原因: {:?}",
a.login_brief
);
assert!(
harness.query_by_label("已装配·在线").is_some(),
"s83 恢复后状态列应回到「已装配·在线」(不带原因)"
);
}
#[test]
fn s71_contract_table_gridlines_land_on_column_bounds() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
shared.update_contract(&contract(
ek_data::constant::Exchange::SHFE,
"rb2605",
"螺纹钢2605",
));
shared.update_contract(&contract(
ek_data::constant::Exchange::DCE,
"a2709",
"黄玉米2709",
));
shared.update_contract(&contract(
ek_data::constant::Exchange::CZCE,
"TA709",
"精对苯二甲酸709",
));
shared.set_contracts_window_open(true);
let mut harness = harness_for(&shared);
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
assert!(hit.is_some(), "验收环境必须有 CJK 候选字体");
harness.run();
for name in ["螺纹钢2605", "黄玉米2709", "精对苯二甲酸709"] {
assert!(
harness.query_by_label(name).is_some(),
"合约表必须有真行「{name}」"
);
}
let anchor = harness
.query_by_label("最小变动")
.expect("合约表必须有「最小变动」表头")
.rect();
let head_of = |label: &str| -> egui::Rect {
harness
.query_all_by_label(label)
.map(|n| n.rect())
.find(|r| (r.min.y - anchor.min.y).abs() < 2.0 && (r.max.y - anchor.max.y).abs() < 2.0)
.unwrap_or_else(|| {
panic!(
"合约表必须有「{label}」表头(与「最小变动」同一行 y={:.1})",
anchor.min.y
)
})
};
let heads: Vec<egui::Rect> = [
"交易所",
"名称",
"合约分类",
"乘数",
"最小变动",
"最小委托量",
]
.iter()
.map(|h| head_of(h))
.collect();
let shapes = shapes_of(&harness);
let band = header_bands(&shapes, ek_ui::tokens::shadcn::BG4, 100.0)
.into_iter()
.find(|b| b.contains_rect(anchor))
.expect("合约表必须有表头带(BG4 实心矩形盖住表头行)");
let name_col = head_of("名称");
let rows: Vec<egui::Rect> = ["螺纹钢2605", "黄玉米2709", "精对苯二甲酸709"]
.iter()
.flat_map(|n| harness.query_all_by_label(n).map(|x| x.rect()))
.filter(|r| {
r.min.y > band.max.y
&& r.min.x >= name_col.min.x - 2.0
&& r.min.x <= name_col.max.x + 2.0
})
.collect();
assert_eq!(
rows.len(),
3,
"合约表必须正好三行真数据(都在「名称」列里),实测 {rows:?}"
);
let top = heads.iter().map(|r| r.min.y).fold(f32::INFINITY, f32::min);
let bottom = rows
.iter()
.map(|r| r.max.y)
.fold(f32::NEG_INFINITY, f32::max);
let vlines: Vec<(f32, egui::Rangef, egui::Color32)> = shapes
.iter()
.filter_map(|s| match s {
egui::Shape::LineSegment { points, stroke }
if !stroke.is_empty() && (points[0].x - points[1].x).abs() < 0.5 =>
{
Some((
points[0].x,
egui::Rangef::new(points[0].y, points[1].y),
stroke.color,
))
}
_ => None,
})
.filter(|(x, y, _)| {
y.span() > 10.0
&& y.min >= band.min.y - 2.0
&& y.min < bottom
&& y.max > top
&& *x >= band.min.x - 2.0
&& *x <= band.max.x + 2.0
})
.collect();
assert!(
vlines.is_empty(),
"六列合约表(0.8.5 起)表内不该有列线,实测 {} 根:{:?}",
vlines.len(),
vlines
);
let row_lines = shapes
.iter()
.filter(|s| {
matches!(s, egui::Shape::LineSegment { points, stroke }
if !stroke.is_empty() && (points[0].y - points[1].y).abs() < 0.5
&& points[0].y > band.max.y && points[0].y <= bottom + 6.0
&& points[0].x >= band.min.x - 2.0 && points[1].x >= band.max.x * 0.5)
})
.count();
assert!(
row_lines >= 2,
"合约表三行真数据必须有行线(BD-UI-60 起行线是唯一的网格线),实测 {row_lines} 根"
);
}
fn trade_row(id: &str, price: f64, name: &str) -> ek_data::trade::TradeData {
ek_data::trade::TradeData {
gateway_name: "ctp".to_string(),
symbol: "rb2605".to_string(),
name: name.to_string(),
exchange: ek_data::constant::Exchange::SHFE,
orderid: format!("CTP_{id}"),
tradeid: id.to_string(),
direction: Some(ek_data::constant::Direction::LONG),
offset: ek_data::constant::Offset::OPEN,
price,
volume: 1.0,
traded_amount: price * 2.0,
datetime: Some(36_000_000_000_000),
..ek_data::trade::TradeData::default()
}
}
fn trades_snap(trades: Vec<ek_data::trade::TradeData>) -> ek_ui::state::TradeSnap {
ek_ui::state::TradeSnap {
orders: Vec::new(),
trades,
positions: Vec::new(),
accounts: Vec::new(),
}
}
#[test]
fn t2_column_width_follows_content() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let probe = |name: &str| -> (f32, f32) {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.update_contract(&contract(ek_data::constant::Exchange::SHFE, "rb2605", name));
shared.set_contracts_window_open(true);
let mut harness = harness_for(&shared);
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
assert!(hit.is_some(), "验收环境必须有 CJK 候选字体");
harness.run();
let head = harness
.query_by_label("乘数")
.unwrap_or_else(|| panic!("合约表必须有「乘数」表头"))
.rect();
let cell = harness
.query_by_label(name)
.unwrap_or_else(|| panic!("合约表必须有名称格「{name}」"))
.rect();
(head.min.x, cell.max.x)
};
let (a_head, a_cell) = probe("螺纹");
let (b_head, b_cell) = probe("精对苯二甲酸709");
assert!(
a_cell <= a_head + 1.0,
"短名文字也不得越过下一列左边界:文字右 {a_cell:.1} vs 列线 {a_head:.1}"
);
assert!(
b_head > a_head + 20.0,
"名称列宽必须随内容变:短名下一列 x={a_head:.1} → 长名下一列 x={b_head:.1}"
);
assert!(
b_cell <= b_head + 1.0,
"长名文字不得越过下一列左边界(截断/重叠):文字右 {b_cell:.1} vs 列线 {b_head:.1}"
);
}
#[test]
fn t3_numeric_columns_are_left_aligned_and_monospace() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.seed_trade_snap(trades_snap(vec![
trade_row("P1", 1.25, "螺纹"),
trade_row("P2", 12345.678, "螺纹"),
trade_row("P3", 99.7, "螺纹"),
]));
shared.update_tick(&tick(
ek_data::constant::Exchange::SHFE,
"rb2605",
"螺纹钢2605",
5.0,
1.0,
));
shared.update_tick(&tick(
ek_data::constant::Exchange::SHFE,
"cu2709",
"铜2709",
12345.0,
2.0,
));
shared.update_tick(&tick(
ek_data::constant::Exchange::SHFE,
"au2612",
"黄金2612",
98.0,
3.0,
));
let mut harness = harness_for(&shared);
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
assert!(hit.is_some(), "验收环境必须有 CJK 候选字体");
harness.run();
let left_of = |s: &str| {
harness
.query_by_label(s)
.unwrap_or_else(|| panic!("必须有数字格「{s}」"))
.rect()
.min
.x
};
let spread = |vals: &[f32]| {
let lo = vals.iter().copied().fold(f32::INFINITY, f32::min);
let hi = vals.iter().copied().fold(f32::NEG_INFINITY, f32::max);
hi - lo
};
let trades = [left_of("1.25"), left_of("12345.678"), left_of("99.7")];
assert!(
spread(&trades) <= 1.0,
"成交表价格列每行**左边界**必须对齐(全左对齐),实测 {trades:?}"
);
let bid = [left_of("4.5"), left_of("12344.5"), left_of("97.5")];
let ask = [left_of("5.5"), left_of("12345.5"), left_of("98.5")];
assert!(
spread(&bid) <= 1.0,
"行情带「买1价」列必须左沿成列(毛病 6 由三列小表 + 等宽守),实测 {bid:?}"
);
assert!(
spread(&ask) <= 1.0,
"行情带「卖1价」列必须左沿成列(毛病 6 由三列小表 + 等宽守),实测 {ask:?}"
);
for s in [
"1.25",
"12345.678",
"99.7",
"4.5",
"12344.5",
"5.5",
"12345.5",
] {
let at = harness
.query_by_label(s)
.unwrap_or_else(|| panic!("必须有数字格「{s}」"))
.rect();
assert!(
monospace_at(&harness, s, &at)
.unwrap_or_else(|| { panic!("本帧必须有数字格「{s}」的文字形状") }),
"数字格「{s}」必须走等宽字体(tokens::num_text)"
);
}
}
#[test]
fn t4_trades_table_scrolls_horizontally() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.seed_trade_snap(trades_snap(
(1..=6)
.map(|i| trade_row(&format!("T{i}"), 102.5 + i as f64, "螺纹钢2605螺纹钢2605"))
.collect(),
));
let mut harness = harness_for(&shared);
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
assert!(hit.is_some(), "验收环境必须有 CJK 候选字体");
harness.run();
let before = harness
.query_by_label("成交金额")
.unwrap_or_else(|| panic!("成交表必须有「成交金额」表头"))
.rect();
let hover_x = harness
.query_all_by_label_contains("螺纹钢2605螺纹钢2605")
.next()
.expect("T4:成交表名称格必须查得到")
.rect()
.center()
.x;
let hover_pt = egui::pos2(hover_x, before.max.y + 40.0);
harness.hover_at(hover_pt);
harness.step();
for _ in 0..4 {
harness.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(-260.0, 0.0),
modifiers: egui::Modifiers::NONE,
});
harness.step();
}
let after = harness
.query_by_label("成交金额")
.unwrap_or_else(|| panic!("滚动后「成交金额」表头必须还在"))
.rect();
assert!(
(after.min.x - before.min.x).abs() > 100.0,
"成交表必须能横向滚动(列总宽 > 格宽,最后一列才可达):前 x={:.1} 后 x={:.1}",
before.min.x,
after.min.x
);
assert!(
(after.min.y - before.min.y).abs() < 1.0,
"横向滚动不得把表头推走(固定表头不回归):前 y={:.1} 后 y={:.1}",
before.min.y,
after.min.y
);
let last = harness
.query_all_by_label("接口")
.map(|n| n.rect())
.find(|r| (r.min.y - before.min.y).abs() < 2.0)
.unwrap_or_else(|| panic!("滚动后成交表必须有「接口」列表头"));
assert!(
last.max.x <= 1280.0,
"滚动后最后一列必须进可视区:接口列右缘 x={:.1}",
last.max.x
);
}
#[test]
fn t5_row_backgrounds_do_not_alternate() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.update_contract(&contract(
ek_data::constant::Exchange::CZCE,
"TA709",
"精对苯二甲酸709",
));
let shorts: Vec<String> = (1..=6).map(|i| format!("螺{i}")).collect();
for (i, n) in shorts.iter().enumerate() {
shared.update_contract(&contract(
ek_data::constant::Exchange::SHFE,
&format!("rb26{i:02}"),
n,
));
}
shared.set_contracts_window_open(true);
let mut harness = harness_for(&shared);
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
assert!(hit.is_some(), "验收环境必须有 CJK 候选字体");
harness.run();
assert!(
!harness.ctx.style().visuals.striped,
"唯一主题的 `visuals.striped` 必须是 false(Nova 表没有斑马纹)"
);
let render_src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/render.rs"))
.expect("render.rs 必须可读");
assert!(
render_src.contains("visuals().selection.bg_fill") && !render_src.contains(".striped("),
"行底色只许走主题(选中 `selection.bg_fill` / hover `widgets.hovered.bg_fill`),\
渲染侧不得残留 TableBuilder 的 `.striped(...)` 透传 —— egui_table 没有这条路",
);
for n in &shorts {
assert!(harness.query_by_label(n).is_some(), "合约表必须有行「{n}」");
}
let stripes = filled_rects(&shapes_of(&harness), ek_ui::tokens::shadcn::BG2);
assert!(
stripes.is_empty(),
"本帧不许出现斑马块(faint_bg_color = BG2 实心矩形),实测 {} 块:{:?}",
stripes.len(),
stripes
);
}
#[test]
fn t7_table_row_count_and_virtual_scroll() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let built = |n: usize, size: [f32; 2]| -> usize {
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.seed_trade_snap(trades_snap(
(1..=n)
.map(|i| trade_row(&format!("T{i}"), 100.0 + (i % 97) as f64, "螺纹"))
.collect(),
));
let mut harness = Harness::builder()
.with_size(size)
.build(|ctx| render_body(ctx, &shared));
harness.run();
harness.query_all_by_label("螺纹").count()
};
let tall = built(30, [1280.0, 2400.0]);
assert_eq!(
tall, 30,
"30 行成交表在装得下它的面板里必须把每一行都建构出来(行不丢)"
);
let (b50, b200, b1000) = (
built(50, [1280.0, 860.0]),
built(200, [1280.0, 860.0]),
built(1000, [1280.0, 860.0]),
);
println!("T7 1280×860 建构颗数:50 行 → {b50},200 行 → {b200},1000 行 → {b1000}");
assert!(b50 > 0, "成交表一颗粒都没有 = 表塌了");
assert!(
b50 < 50 && b1000 < 1000,
"1280×860 的成交格装不下 50 行,装不下就不许全建构(虚拟滚动):{b50} / {b1000}"
);
assert_eq!(
(b50, b200, b1000),
(b50, b50, b50),
"建构颗数必须只由视口决定,不随行数增长(一涨 = 虚拟滚动没了)"
);
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.seed_trade_snap(trades_snap(
(1..=1000)
.map(|i| trade_row(&format!("T{i}"), 100.0 + (i % 97) as f64, "螺纹"))
.collect(),
));
let mut harness = harness_for(&shared);
harness.run();
let mut samples: Vec<f64> = Vec::new();
for _ in 0..5 {
let t0 = std::time::Instant::now();
harness.run_steps(3);
samples.push(t0.elapsed().as_secs_f64() * 1000.0 / 3.0);
}
samples.sort_by(|a, b| a.total_cmp(b));
let c1000 = samples[0];
let budget = if cfg!(debug_assertions) {
3000.0
} else {
300.0
};
assert!(
c1000 < budget,
"1000 行单帧建构 {c1000:.1} ms ≥ {budget:.0} ms → 数量级退化(不是噪声能解释的量)"
);
println!("T7 最优单帧建构(ms):{c1000:.1}");
}
#[test]
fn s72_contract_row_lines_land_between_rows() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
const ROWS: usize = 13;
let mut names: Vec<String> = Vec::with_capacity(ROWS);
for i in 0..ROWS - 1 {
let name = format!("螺纹钢26{i:02}合约名称");
names.push(name.clone());
shared.update_contract(&contract(
ek_data::constant::Exchange::SHFE,
&format!("rb26{i:02}"),
&name,
));
}
names.push("精对苯二甲酸709".to_string());
shared.update_contract(&contract(
ek_data::constant::Exchange::CZCE,
"TA709",
"精对苯二甲酸709",
));
shared.set_contracts_window_open(true);
let mut harness = harness_for(&shared);
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
assert!(hit.is_some(), "验收环境必须有 CJK 候选字体");
harness.run();
let mut rows: Vec<egui::Rect> = Vec::with_capacity(ROWS);
for n in &names {
let node = harness
.query_by_label(n)
.unwrap_or_else(|| panic!("合约表必须有行「{n}」"));
rows.push(node.rect());
}
rows.sort_by(|a, b| a.min.y.total_cmp(&b.min.y));
let anchor = harness
.query_by_label("最小变动")
.expect("合约表必须有「最小变动」表头")
.rect();
let shapes = shapes_of(&harness);
let band = header_bands(&shapes, ek_ui::tokens::shadcn::BG4, 100.0)
.into_iter()
.find(|b| b.contains_rect(anchor))
.expect("合约表必须有表头带(BG4 实心矩形盖住表头行)");
let mut lines: Vec<f32> = hlines(&shapes, ek_ui::tokens::shadcn::BORDER_SOFT)
.into_iter()
.filter(|(_, x)| {
x.min >= band.min.x - 2.0 && x.max <= band.max.x + 2.0 && x.span() >= band.width() * 0.8
})
.map(|(y, _)| y)
.filter(|y| *y > rows[0].min.y && *y <= rows[ROWS - 1].min.y)
.collect();
lines.sort_by(f32::total_cmp);
lines.dedup();
assert!(
lines.len() >= 10,
"{ROWS} 行夹具里可见行线必须 ≥ 10 根,实测 {} 根:{:?}",
lines.len(),
lines
);
let gaps: Vec<f32> = lines.windows(2).map(|p| p[1] - p[0]).collect();
for g in &gaps {
assert!((g - gaps[0]).abs() <= 1.5, "行线间距必须均匀,实测 {gaps:?}");
}
for y in &lines {
assert!(
rows.windows(2)
.any(|p| *y + 0.5 >= p[0].max.y && *y - 0.5 <= p[1].min.y),
"行线 y={y:.1} 没落在任何相邻两行之间(行矩形 {rows:?})"
);
for (k, r) in rows.iter().enumerate() {
let center = (r.min.y + r.max.y) * 0.5;
assert!(
(y - center).abs() >= 5.0,
"行线 y={y:.1} 压在第 {} 行文字中心 {center:.1} 上(毛病 8 复发)",
k + 1
);
}
}
}
#[test]
fn t8_book_frame_price_and_volume_columns_are_aligned() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
let mut form = shared.order_form();
form.exchange = "SHFE".to_string();
form.symbol = "rb2605".to_string();
shared.set_order_form(form);
shared.update_tick(&ek_data::tick::TickData {
gateway_name: "ctp".to_string(),
symbol: "rb2605".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
name: "螺纹钢2605".to_string(),
datetime: 36_000 * 1_000_000_000,
last_price: 101.0,
pct_change: 1.0,
volume: 1234.0,
open_price: 100.0,
high_price: 103.0,
low_price: 99.0,
bid_price_1: 1.0,
bid_volume_1: 7.0,
bid_price_2: 12345.5,
bid_volume_2: 1234.0,
bid_price_3: 98.5,
bid_volume_3: 3.0,
bid_price_4: 0.5,
bid_volume_4: 8.0,
bid_price_5: 9.2,
bid_volume_5: 45.0,
ask_price_1: 101.5,
ask_volume_1: 6.0,
ask_price_2: 12346.5,
ask_volume_2: 2345.0,
ask_price_3: 102.5,
ask_volume_3: 2.0,
ask_price_4: 103.5,
ask_volume_4: 12.0,
ask_price_5: 104.5,
ask_volume_5: 123.0,
update_time: "10:00:00".to_string(),
..ek_data::tick::TickData::default()
});
let mut harness = harness_for(&shared);
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
assert!(hit.is_some(), "验收环境必须有 CJK 候选字体");
harness.run();
let spread = |vals: &[f32]| {
let lo = vals.iter().copied().fold(f32::INFINITY, f32::min);
let hi = vals.iter().copied().fold(f32::NEG_INFINITY, f32::max);
hi - lo
};
const BOOK_ROWS: [(&str, &str, &str); 10] = [
("卖5", "104.5", "123"),
("卖4", "103.5", "12"),
("卖3", "102.5", "2"),
("卖2", "12346.5", "2345"),
("卖1", "101.5", "6"),
("买1", "1", "7"),
("买2", "12345.5", "1234"),
("买3", "98.5", "3"),
("买4", "0.5", "8"),
("买5", "9.2", "45"),
];
let mut tag_lefts: Vec<f32> = Vec::new();
let mut price_lefts: Vec<f32> = Vec::new();
let mut vol_lefts: Vec<f32> = Vec::new();
let mut mono: Vec<(String, Option<bool>)> = Vec::new();
for (tag, price, vol) in BOOK_ROWS {
let anchor = harness
.query_all_by_label(tag)
.map(|n| n.rect())
.find(|r| r.min.x < 280.0)
.unwrap_or_else(|| panic!("盘口必须有档位标签「{tag}」这一格"));
tag_lefts.push(anchor.min.x);
let p = harness
.query_all_by_label(price)
.map(|n| n.rect())
.find(|r| {
(r.center().y - anchor.center().y).abs() < 3.0 && r.min.x > anchor.max.x - 2.0
})
.unwrap_or_else(|| {
panic!("盘口「{tag}」这一行必须有价格格「{price}」(与档位同高、在档位右边)")
});
price_lefts.push(p.min.x);
mono.push((price.to_string(), monospace_at(&harness, price, &p)));
let v = harness
.query_all_by_label(vol)
.map(|n| n.rect())
.find(|r| (r.center().y - anchor.center().y).abs() < 3.0 && r.min.x > p.max.x - 2.0)
.unwrap_or_else(|| {
panic!("盘口「{tag}」这一行必须有量格「{vol}」(与档位同高、在价格格右边)")
});
vol_lefts.push(v.min.x);
mono.push((vol.to_string(), monospace_at(&harness, vol, &v)));
}
assert!(
spread(&price_lefts) <= 1.0,
"盘口「价」列必须左沿成列(毛病 6 由三列小表 + 等宽守),实测 {price_lefts:?}"
);
assert!(
spread(&vol_lefts) <= 1.0,
"盘口「量」列必须左沿成列(毛病 6 由三列小表 + 等宽守),实测 {vol_lefts:?}"
);
assert!(
spread(&tag_lefts) <= 1.0,
"盘口档位标签必须同一列,实测 {tag_lefts:?}"
);
let present: Vec<(String, bool)> = mono
.into_iter()
.filter_map(|(s, m)| m.map(|ok| (s, ok)))
.collect();
assert!(
present.len() >= 12,
"盘口数字格形状在场数不足(判据没牙),实测 {n} 个(价 + 量共 20 格)",
n = present.len()
);
for (s, ok) in present {
assert!(
ok,
"盘口数字格「{s}」必须走等宽字体(撤右对齐后「成列」靠它)"
);
}
}
#[test]
fn u3b_market_band_change_column_colors_by_direction() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.update_tick(&tick(
ek_data::constant::Exchange::SHFE,
"rb2605",
"螺纹钢2605",
101.0,
2.5,
));
shared.update_tick(&tick(
ek_data::constant::Exchange::SHFE,
"cu2709",
"铜2709",
12345.0,
-3.5,
));
let mut harness = harness_for(&shared);
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
assert!(hit.is_some(), "验收环境必须有 CJK 候选字体");
harness.run();
assert!(
harness.query_all_by_label("+2.50").next().is_some(),
"必须有涨跌幅格「+2.50」"
);
assert!(
harness.query_all_by_label("-3.50").next().is_some(),
"必须有涨跌幅格「-3.50」"
);
let shapes = shapes_of(&harness);
let up = texts_containing(&shapes, "+2.50");
let down = texts_containing(&shapes, "-3.50");
assert!(
!up.is_empty() && !down.is_empty(),
"本帧必须画出「+2.50」与「-3.50」两串文字"
);
let colors = |v: &[(String, egui::Rect, egui::Color32)]| -> Vec<egui::Color32> {
v.iter().map(|(_, _, c)| *c).collect()
};
assert!(
up.iter()
.all(|(_, _, c)| *c == ek_ui::tokens::semantic::RISE),
"涨行「+2.50」必须是 RISE(红 #E05050),实测 {:?}",
colors(&up)
);
assert!(
down.iter()
.all(|(_, _, c)| *c == ek_ui::tokens::semantic::FALL),
"跌行「-3.50」必须是 FALL(绿 #2E9E2E),实测 {:?}",
colors(&down)
);
assert_eq!(
ek_ui::tokens::rise_fall(&harness.ctx, 2.5),
ek_ui::tokens::semantic::RISE
);
assert_eq!(
ek_ui::tokens::rise_fall(&harness.ctx, -3.5),
ek_ui::tokens::semantic::FALL
);
assert_eq!(
ek_ui::tokens::rise_fall(&harness.ctx, 0.0),
ek_ui::tokens::shadcn::TEXT1
);
assert_ne!(
ek_ui::tokens::semantic::RISE,
ek_ui::tokens::semantic::FALL,
"红涨绿跌不许同色"
);
let render_src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/render.rs"))
.expect("render.rs 必须可读");
assert!(
render_src.contains("crate::tokens::rise_fall("),
"涨跌幅那一列必须走 `tokens::rise_fall`(不许自己 `if v > 0 {{红}} else {{绿}}`)"
);
}
#[test]
fn v1_native_panels_are_the_frame() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/render.rs"))
.expect("render.rs 必须可读");
for need in [
"TopBottomPanel::top(\"menu_bar\")",
"TopBottomPanel::top(\"market_band\")",
"SidePanel::left(\"order_side\")",
"render_data_area",
] {
assert!(src.contains(need), "V1:原生容器必须有 {need:?}");
}
assert!(
!src.contains("egui_dock"),
"V1:render.rs 不得再碰 egui_dock"
);
assert!(
!src.contains("crate::dock::"),
"V1:render.rs 不得再引用 dock 模块"
);
assert!(
!std::path::Path::new(&concat!(env!("CARGO_MANIFEST_DIR"), "/src/dock.rs")).exists(),
"V1:src/dock.rs 必须删除"
);
assert!(
src.contains("crate::splitters::quad"),
"V1:数据区两刀必须走薄 QSplitter(0.4.1 九期 BD-UI-62 起 = 2×2 贯穿两刀 `quad`)"
);
}
#[test]
fn v2_data_split_defaults_to_python_fractions() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = ek_ui::state::UiShared::new();
let s = shared.data_split();
assert_eq!(s.col, 0.5, "V2:左右刀缺省 50:50");
assert_eq!(s.row, 0.5, "V2:上下刀缺省 50:50");
assert_eq!(
ek_ui::tokens::MARKET_BAND_HEIGHT,
240.0,
"V2:行情带缺省高 = Python setSizes([240,560]) 上带"
);
assert_eq!(
ek_ui::tokens::ORDER_SIDE_WIDTH,
300.0,
"V2:下单侧缺省宽 = Python setFixedWidth(300)"
);
assert_eq!(
ek_ui::state::MARKET_ORDER.len(),
6,
"V2:行情带六所固定 tab(用户裁定「每个交易所都要一个」)"
);
shared.set_data_split(ek_ui::state::DataSplit {
col: 0.9,
row: 0.05,
});
let s = shared.data_split();
assert!(
(s.col - 0.85).abs() <= 1e-6 && (s.row - 0.15).abs() <= 1e-6,
"V2:份额必须夹取到 [0.15, 0.85],实测 {s:?}"
);
shared.reset_layout();
let s = shared.data_split();
assert_eq!(
(s.col, s.row),
(0.5, 0.5),
"V2:恢复布局 = 回 Python right_splitter 口径"
);
}
#[test]
fn v3_dock_tabs_are_visible_and_layout_can_be_reset() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = UiShared::new();
let harness = harness_for(&shared);
for title in ["委托", "成交", "持仓", "资金"] {
assert!(
harness.query_all_by_label_contains(title).count() >= 1,
"V3:dock tab「{title}」必须可见"
);
}
assert!(
harness.query_all_by_label("恢复布局").count() == 1,
"V3:菜单里必须有且只有一个「恢复布局」钮"
);
}
#[test]
fn v4_monitor_groups_cannot_be_closed() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = UiShared::new();
let harness = harness_for(&shared);
let close_btns = harness.query_all_by_label_contains("×").count();
assert_eq!(
close_btns, 0,
"V4:数据区不能被关,实测查到 {close_btns} 个关闭钮"
);
}
#[test]
fn v5_container_style_derives_from_tokens_only() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let split = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/splitters.rs"))
.expect("splitters.rs 必须可读");
for needle in ["from_rgb", "Color32::"] {
assert!(
!split.contains(needle),
"V5:splitters.rs 里出现了字面量颜色 {needle:?} —— 分隔条必须只吃色板令牌"
);
}
assert!(
split.contains("crate::tokens::palette(ui.ctx())"),
"V5:分隔条必须从**当前档**色板取色(tokens::palette(ui.ctx())),实测 splitters.rs 里没有这一支"
);
for banned in ["shadcn::BG1", "shadcn::BORDER", "shadcn::ACCENT4"] {
assert!(
!split.contains(banned),
"V5:splitters.rs 不许再直写深档常量 {banned:?}(浅档下它就是那块唯一的深档)"
);
}
for needle in ["pal.border", "pal.accent4"] {
assert!(
split.contains(needle),
"V5:分隔条必须吃色板里的描边/高亮两支(与 chrome 同源),实测缺「{needle}」"
);
}
}
fn wave_d_shared() -> UiShared {
let shared = UiShared::new();
let trade = |id: &str, symbol: &str, price: f64, ns: i64| ek_data::trade::TradeData {
gateway_name: "ctp".to_string(),
symbol: symbol.to_string(),
exchange: ek_data::constant::Exchange::SHFE,
orderid: format!("CTP_{id}"),
tradeid: id.to_string(),
direction: Some(ek_data::constant::Direction::LONG),
offset: ek_data::constant::Offset::OPEN,
price,
volume: 1.0,
datetime: Some(ns),
name: "螺纹钢2605".to_string(),
..ek_data::trade::TradeData::default()
};
shared.seed_trade_snap(ek_ui::state::TradeSnap {
trades: vec![
trade("T-1", "rb2605", 101.0, 36_000_000_000_000),
trade("T-2", "cu2709", 3.5, 36_001_000_000_000),
trade("T-3", "au2712", 12.345, 36_002_000_000_000),
],
..ek_ui::state::TradeSnap::default()
});
shared
}
fn click_at(
harness: &mut egui_kittest::Harness<'_>,
pos: egui::Pos2,
button: egui::PointerButton,
times: usize,
) {
let input = harness.input_mut();
input.events.push(egui::Event::PointerMoved(pos));
for _ in 0..times {
for pressed in [true, false] {
input.events.push(egui::Event::PointerButton {
pos,
button,
pressed,
modifiers: egui::Modifiers::NONE,
});
}
}
}
fn trades_price_header_pos(harness: &mut egui_kittest::Harness<'_>) -> egui::Pos2 {
let head = harness
.query_all_by_label_contains("成交金额")
.next()
.expect("W1:成交表「成交金额」表头必须查得到")
.rect();
let name = harness
.query_all_by_label_contains("螺纹钢2605")
.next()
.expect("W1:成交表名称格必须查得到")
.rect();
harness
.query_all_by_label_contains("价格")
.find(|n| {
(n.rect().min.y - head.min.y).abs() < 2.0
&& n.rect().min.x > name.min.x - 2.0
&& n.rect().min.x < head.max.x + 2.0
})
.expect("W1:成交表那一行的「价格」表头必须查得到")
.rect()
.center()
}
#[test]
fn w1_clicking_the_price_header_sorts_the_trades_table() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = wave_d_shared();
let mut harness = harness_wide_for(&shared);
harness.run();
let y_of = |h: &mut egui_kittest::Harness<'_>, id: &str| {
h.query_all_by_label_contains(id)
.next()
.unwrap_or_else(|| panic!("W1:夹具成交「{id}」必须查得到"))
.rect()
.min
.y
};
let before = (y_of(&mut harness, "T-1"), y_of(&mut harness, "T-2"));
println!("W1 点表头前:T-1 y={:.1} T-2 y={:.1}", before.0, before.1);
assert!(before.0 < before.1, "W1 前置条件:未排序时夹具按插入序");
let pos = trades_price_header_pos(&mut harness);
println!("W1 诊断:点击位置 = {pos:?}");
click_at(&mut harness, pos, egui::PointerButton::Primary, 1);
harness.run();
println!(
"W1 诊断:点表头后 shared.table_sort(\"trade_grid\") = {:?}",
shared.table_sort("trade_grid")
);
let asc = (y_of(&mut harness, "T-1"), y_of(&mut harness, "T-2"));
println!("W1 第一次点后:T-1 y={:.1} T-2 y={:.1}", asc.0, asc.1);
assert!(
asc.1 < asc.0,
"W1:点「价格」表头后必须按价格升序(T-2=3.5 应到 T-1=101 之上),实测 T-1 y={:.1} T-2 y={:.1}",
asc.0,
asc.1
);
let pos2 = trades_price_header_pos(&mut harness);
click_at(&mut harness, pos2, egui::PointerButton::Primary, 1);
harness.run();
let desc = (y_of(&mut harness, "T-1"), y_of(&mut harness, "T-2"));
println!("W1 第二次点后:T-1 y={:.1} T-2 y={:.1}", desc.0, desc.1);
assert!(
desc.0 < desc.1,
"W1:再点一次必须翻成降序,实测 T-1 y={:.1} T-2 y={:.1}",
desc.0,
desc.1
);
}
#[test]
fn w2_sorting_applies_to_a_snapshot_copy_not_the_model() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let render = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/render.rs"))
.expect("render.rs 必须可读");
assert!(
render.contains("sorted_rows"),
"W2:渲染侧必须有一支「取快照副本再排序」的入口(sorted_rows),不许就地排模型"
);
let state = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/state.rs"))
.expect("state.rs 必须可读");
assert!(
state.contains("table_sorts"),
"W2:排序键必须住在界面状态(table_sorts),不混进数据模型"
);
}
#[test]
fn w3_double_clicking_a_trade_row_fills_the_order_form() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = wave_d_shared();
let mut harness = harness_for(&shared);
harness.run();
let pos = harness
.query_all_by_label_contains("cu2709")
.next()
.expect("W3:夹具成交「cu2709」必须查得到")
.rect()
.center();
click_at(&mut harness, pos, egui::PointerButton::Primary, 2);
harness.run();
let form = shared.order_form();
println!("W3 双击 T-2(cu2709)后下单代码 = {:?}", form.symbol);
assert_eq!(
form.symbol, "cu2709",
"W3:双击成交行必须把该合约填进下单面板"
);
}
#[test]
fn w5_right_click_on_a_table_row_opens_the_menu() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = wave_d_shared();
let mut harness = harness_for(&shared);
harness.run();
let pos = harness
.query_all_by_label_contains("rb2605")
.next()
.expect("W5:夹具成交「rb2605」必须查得到")
.rect()
.center();
click_at(&mut harness, pos, egui::PointerButton::Secondary, 1);
harness.run();
let n_save = harness.query_all_by_label("保存数据").count();
println!("W5 右键后「保存数据」节点数 = {n_save}");
assert!(n_save >= 1, "W5:右键菜单里必须有「保存数据」");
let gone = harness.query_all_by_label("调整列宽").count();
println!("W5 右键后「调整列宽」节点数 = {gone}(应为 0)");
assert_eq!(gone, 0, "W5:「调整列宽」已退役,菜单里不许还挂着这颗空壳钮");
let csv = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/ek-ui-trade_grid.csv");
let _ = std::fs::remove_file(&csv);
harness.get_by_label("保存数据").click_accesskit();
harness.run();
let text = std::fs::read_to_string(&csv)
.unwrap_or_else(|e| panic!("W5:点「保存数据」必须落地 {}: {e}", csv.display()));
let lines: Vec<&str> = text.lines().collect();
println!("W5 CSV 行数 = {},表头 = {:?}", lines.len(), lines.first());
assert_eq!(
lines.first().copied(),
Some("代码,名称,交易所,方向,开平,价格,数量,成交金额,时间,成交号,委托号,接口"),
"W5:CSV 表头必须与屏幕表头逐字同源"
);
assert_eq!(lines.len(), 1 + 3, "W5:三笔成交 → 表头 + 3 行");
for (i, l) in lines.iter().skip(1).enumerate() {
assert_eq!(
l.split(',').count(),
12,
"W5:CSV 第 {} 行必须 12 个字段(与表头列数同源),实测 {l:?}",
i + 1
);
}
}
#[test]
fn w6_number_and_time_formats_are_fixed() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
assert_eq!(ek_ui::format::price(101.0), "101");
assert_eq!(ek_ui::format::price(3.5), "3.5");
assert_eq!(ek_ui::format::price(12.345), "12.345");
assert_eq!(ek_ui::format::price(12.3456), "12.346");
assert_eq!(
ek_ui::format::time_hms_millis(Some(36_000_000_000_000)),
"18:00:00.000"
);
assert_eq!(ek_ui::format::time_hms_millis(None), "-");
assert_eq!(
ek_ui::format::date_ymd(Some(36_000_000_000_000)),
"1970-01-01"
);
let shared = wave_d_shared();
let mut harness = harness_wide_for(&shared);
harness.run();
let count_of = |h: &mut egui_kittest::Harness<'_>, needle: &str| -> usize {
h.query_all_by_label_contains(needle).count()
};
if count_of(&mut harness, "18:00:00.000") == 0 {
let hover_x = harness
.query_all_by_label("成交金额")
.next()
.expect("W6:成交表必须有「成交金额」表头(悬停锚点)")
.rect()
.center()
.x;
harness.hover_at(egui::pos2(hover_x, 320.0));
harness.step();
for _ in 0..4 {
harness.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(-260.0, 0.0),
modifiers: egui::Modifiers::NONE,
});
harness.step();
}
}
for t in ["18:00:00.000", "18:00:01.000", "18:00:02.000"] {
let n = count_of(&mut harness, t);
println!("W6 渲染里「{t}」标签数 = {n}");
assert!(n >= 1, "W6:成交表时间列必须出现 {t}(HH:MM:SS.mmm 口径)");
}
}
#[test]
fn w7_closing_the_window_asks_for_confirmation_first() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let read = |f: &str| {
std::fs::read_to_string(format!("{}/{}", env!("CARGO_MANIFEST_DIR"), f))
.unwrap_or_else(|_| panic!("{f} 必须可读"))
};
let render = read("src/render.rs");
let state = read("src/state.rs");
assert!(
render.contains("close_requested()") && render.contains("ViewportCommand::CancelClose"),
"W7:render.rs 必须拦下关闭请求(CancelClose),不许让窗口一声不响就退"
);
assert!(
render.contains("ViewportCommand::Close") && render.contains("确认退出"),
"W7:必须有一条放行路(确认窗里的「确认退出」重发 Close)"
);
assert!(
state.contains("exit_confirm_open") && state.contains("exit_confirmed"),
"W7:确认状态要住在 UiShared(确认窗开合 + 已确认放行位),不能是 render 里的局部变量"
);
}
fn w8_order(gw: &str, id: &str, symbol: &str, price: f64) -> ek_data::order::OrderData {
ek_data::order::OrderData {
gateway_name: gw.to_string(),
symbol: symbol.to_string(),
exchange: ek_data::constant::Exchange::SHFE,
orderid: id.to_string(),
direction: Some(ek_data::constant::Direction::LONG),
offset: ek_data::constant::Offset::OPEN,
price,
volume: 1.0,
status: ek_data::constant::Status::ALLTRADED,
datetime: Some(36_000_000_000_000),
..ek_data::order::OrderData::default()
}
}
#[test]
fn w8_clicking_the_price_header_sorts_the_orders_table() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.seed_trade_snap(ek_ui::state::TradeSnap {
orders: vec![
w8_order("ctp", "O-1", "rb2605", 101.0),
w8_order("ctp", "O-2", "cu2709", 3.5),
w8_order("ctp", "O-3", "au2712", 12.345),
],
..ek_ui::state::TradeSnap::default()
});
let mut harness = harness_wide_for(&shared);
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
assert!(hit.is_some(), "验收环境必须有 CJK 候选字体");
harness.run();
let y_of = |h: &mut egui_kittest::Harness<'_>, id: &str| {
h.query_all_by_label_contains(id)
.next()
.unwrap_or_else(|| panic!("W8:夹具委托「{id}」必须查得到"))
.rect()
.min
.y
};
let before = (y_of(&mut harness, "O-1"), y_of(&mut harness, "O-2"));
assert!(before.0 < before.1, "W8 前置条件:未排序时按插入序");
let orders_head_y = harness
.query_all_by_label("总量")
.next()
.expect("W8:委托表「总量」表头必须查得到")
.rect()
.min
.y;
let min_x_price = |h: &mut egui_kittest::Harness<'_>| -> egui::Pos2 {
h.query_all_by_label_contains("价格")
.filter(|n| (n.rect().min.y - orders_head_y).abs() < 2.0)
.min_by(|a, b| {
a.rect()
.min
.x
.partial_cmp(&b.rect().min.x)
.unwrap_or(std::cmp::Ordering::Equal)
})
.expect("W8:委托表那一行的「价格」表头必须查得到")
.rect()
.center()
};
let pos = min_x_price(&mut harness);
println!("W8 诊断:表头行 y={orders_head_y:.1},点击位置 = {pos:?}");
click_at(&mut harness, pos, egui::PointerButton::Primary, 1);
harness.run();
println!(
"W8 点表头后 shared.table_sort(\"order_grid\") = {:?}",
shared.table_sort("order_grid")
);
let asc = (y_of(&mut harness, "O-1"), y_of(&mut harness, "O-2"));
assert!(
asc.1 < asc.0,
"W8:点「价格」表头后委托表必须按价格升序(O-2=3.5 到 O-1=101 之上),实测 O-1 y={:.1} O-2 y={:.1}",
asc.0,
asc.1
);
let pos2 = min_x_price(&mut harness);
click_at(&mut harness, pos2, egui::PointerButton::Primary, 1);
harness.run();
let desc = (y_of(&mut harness, "O-1"), y_of(&mut harness, "O-2"));
assert!(
desc.0 < desc.1,
"W8:再点一次必须翻成降序,实测 O-1 y={:.1} O-2 y={:.1}",
desc.0,
desc.1
);
}
fn w9_position(id: &str, volume: f64) -> ek_data::position::PositionData {
ek_data::position::PositionData {
gateway_name: "ctp".to_string(),
symbol: id.to_string(),
exchange: ek_data::constant::Exchange::SHFE,
direction: ek_data::constant::Direction::LONG,
volume,
..ek_data::position::PositionData::default()
}
}
#[test]
fn w9_clicking_the_volume_header_sorts_the_positions_table() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.seed_trade_snap(ek_ui::state::TradeSnap {
positions: vec![
w9_position("P-1", 5.0),
w9_position("P-2", 1.0),
w9_position("P-3", 3.0),
],
..ek_ui::state::TradeSnap::default()
});
let mut harness = harness_for(&shared);
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
assert!(hit.is_some(), "验收环境必须有 CJK 候选字体");
harness.run();
let y_of = |h: &mut egui_kittest::Harness<'_>, id: &str| {
h.query_all_by_label_contains(id)
.next()
.unwrap_or_else(|| panic!("W9:夹具持仓「{id}」必须查得到"))
.rect()
.min
.y
};
let before = (y_of(&mut harness, "P-1"), y_of(&mut harness, "P-2"));
assert!(before.0 < before.1, "W9 前置条件:未排序时按插入序");
let anchor = harness
.query_all_by_label("昨仓")
.next()
.expect("W9:持仓表「昨仓」表头必须查得到")
.rect();
let pos = harness
.query_all_by_label("数量")
.rfind(|n| (n.rect().min.y - anchor.min.y).abs() < 2.0 && n.rect().min.x < anchor.min.x)
.expect("W9:持仓表那一行的「数量」表头必须查得到")
.rect()
.center();
click_at(&mut harness, pos, egui::PointerButton::Primary, 1);
harness.run();
println!(
"W9 点表头后 shared.table_sort(\"pos_grid\") = {:?}",
shared.table_sort("pos_grid")
);
let asc = (
y_of(&mut harness, "P-1"),
y_of(&mut harness, "P-2"),
y_of(&mut harness, "P-3"),
);
assert!(
asc.1 < asc.2 && asc.2 < asc.0,
"W9:点「数量」表头后持仓表必须按持仓量升序(P-2=1 < P-3=3 < P-1=5),实测 y={:?}",
asc
);
}
#[test]
fn w10_clicking_the_last_price_header_sorts_the_market_band() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.update_tick(&tick(
ek_data::constant::Exchange::SHFE,
"rb2605",
"螺纹钢2605",
5.0,
1.0,
));
shared.update_tick(&tick(
ek_data::constant::Exchange::SHFE,
"cu2709",
"铜2709",
12345.0,
2.0,
));
shared.update_tick(&tick(
ek_data::constant::Exchange::SHFE,
"au2612",
"黄金2612",
98.0,
3.0,
));
let mut harness = harness_for(&shared);
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
assert!(hit.is_some(), "验收环境必须有 CJK 候选字体");
harness.run();
let y_of = |h: &mut egui_kittest::Harness<'_>, id: &str| {
h.query_all_by_label_contains(id)
.next()
.unwrap_or_else(|| panic!("W10:夹具行情「{id}」必须查得到"))
.rect()
.min
.y
};
let before = (y_of(&mut harness, "rb2605"), y_of(&mut harness, "au2612"));
println!(
"W10 点表头前:rb2605 y={:.1} au2612 y={:.1}",
before.0, before.1
);
assert!(
before.1 < before.0,
"W10 前置条件:缺省序里 au2612 在 rb2605 之上(按 symbol 排),实测 rb2605 y={:.1} au2612 y={:.1}",
before.0,
before.1
);
let anchor = harness
.query_all_by_label("今开")
.next()
.expect("W10:行情带「今开」表头必须查得到")
.rect();
let pos = harness
.query_all_by_label_contains("最新价")
.rfind(|n| (n.rect().min.y - anchor.min.y).abs() < 2.0 && n.rect().min.x < anchor.min.x)
.expect("W10:行情带那一行的「最新价」表头必须查得到")
.rect()
.center();
click_at(&mut harness, pos, egui::PointerButton::Primary, 1);
harness.run();
println!(
"W10 点表头后 shared.table_sort(\"tick_grid\") = {:?}",
shared.table_sort("tick_grid")
);
let after = (y_of(&mut harness, "rb2605"), y_of(&mut harness, "au2612"));
let third = y_of(&mut harness, "cu2709");
assert!(
after.0 < after.1 && after.1 < third,
"W10:点「最新价」表头后行情带必须按最新价升序(rb2605=5 < au2612=98 < cu2709=12345),实测 rb2605 y={:.1} au2612 y={:.1} cu2709 y={third:.1}",
after.0,
after.1
);
}
fn toast_texts(shapes: &[egui::Shape], needle: &str) -> Vec<(String, egui::Rect, egui::Color32)> {
let mut v = texts_containing(shapes, needle)
.into_iter()
.filter(|(_, r, _)| r.min.x >= 900.0 && r.min.y >= 500.0)
.collect::<Vec<_>>();
v.sort_by(|a, b| a.1.min.y.total_cmp(&b.1.min.y));
v
}
fn toast_marks(shapes: &[egui::Shape]) -> Vec<(egui::Rect, egui::Color32)> {
let mut v = toast_texts(shapes, "▎")
.into_iter()
.map(|(_, r, c)| (r, c))
.collect::<Vec<_>>();
v.sort_by(|a, b| a.0.min.y.total_cmp(&b.0.min.y));
v
}
#[test]
fn e1_an_alert_shows_a_toast_in_the_bottom_right() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let base = {
let shared = Arc::new(UiShared::new());
let mut h = harness_for(&shared);
h.run_steps(6);
shapes_of(&h)
};
let shared = Arc::new(UiShared::new());
shared.push_alert(ek_ui::toast::AlertLevel::Error, "账户 ct01:掉线");
let mut harness = harness_for(&shared);
harness.run_steps(6);
let shapes = shapes_of(&harness);
let without = toast_texts(&base, "账户 ct01:掉线");
let with = toast_texts(&shapes, "账户 ct01:掉线");
assert!(
without.is_empty(),
"V1:没有告警的那一帧不该出现这条 toast,实测 {without:?}"
);
assert!(
!with.is_empty(),
"V1:告警必须在右下角画出 toast(正文文字形状),实测 {with:?}"
);
let marks = toast_marks(&shapes);
assert!(
marks.iter().any(|(_, c)| *c == ek_ui::tokens::alert::ERROR),
"V1:toast 的级别标记「▎」必须是 Python ERROR 色 #FF4D4F,实测 {:?}",
marks.iter().map(|(_, c)| *c).collect::<Vec<_>>()
);
}
#[test]
fn e2_the_three_levels_use_the_python_colors() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
shared.push_alert(ek_ui::toast::AlertLevel::Info, "甲");
shared.push_alert(ek_ui::toast::AlertLevel::Warning, "乙");
shared.push_alert(ek_ui::toast::AlertLevel::Error, "丙");
let mut harness = harness_for(&shared);
harness.run_steps(6);
let marks = toast_marks(&shapes_of(&harness));
let got: Vec<egui::Color32> = marks.iter().map(|(_, c)| *c).collect();
for (name, want) in [
("INFO #1890FF", ek_ui::tokens::alert::INFO),
("WARNING #FAAD14", ek_ui::tokens::alert::WARNING),
("ERROR #FF4D4F", ek_ui::tokens::alert::ERROR),
] {
assert!(
marks.iter().any(|(_, c)| *c == want),
"V2:{name} 必须有一条 toast 级别标记(右下角「▎」),实测 {got:?}"
);
}
assert_eq!(
marks.len(),
3,
"V2:右下角必须正好三条 toast 标记(实测 {marks:?})"
);
}
#[test]
fn e3_toasts_stack_upward_with_the_newest_at_the_bottom() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
shared.push_alert(ek_ui::toast::AlertLevel::Info, "最早");
shared.push_alert(ek_ui::toast::AlertLevel::Warning, "中间");
shared.push_alert(ek_ui::toast::AlertLevel::Error, "最新");
let mut harness = harness_for(&shared);
harness.run_steps(6);
let shapes = shapes_of(&harness);
let y_of = |needle: &str| {
toast_texts(&shapes, needle)
.into_iter()
.map(|(_, r, _)| r.min.y)
.reduce(f32::max)
.unwrap_or_else(|| panic!("V3:{needle} 那条必须在屏上"))
};
let info = y_of("最早");
let warn = y_of("中间");
let err = y_of("最新");
assert!(
err > warn && warn > info,
"V3:新的必须在最底 —— 实测 最早 y={info:.1} / 中间 y={warn:.1} / 最新 y={err:.1}"
);
}
#[test]
fn e4_no_more_than_three_toasts_on_screen() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
for i in 0..5 {
shared.push_alert(ek_ui::toast::AlertLevel::Error, format!("告警 {i}"));
}
let mut harness = harness_for(&shared);
harness.run_steps(10);
let seen = toast_texts(&shapes_of(&harness), "告警 ");
let on_screen: Vec<String> = seen.iter().map(|(t, _, _)| t.clone()).collect();
assert!(
!seen.is_empty() && seen.len() <= ek_ui::toast::SCREEN_CAP,
"V4:屏上 toast 必须 ≤ {} 条(实测 {} 条:{on_screen:?})",
ek_ui::toast::SCREEN_CAP,
seen.len()
);
}
#[test]
fn e5_the_toast_mechanism_keeps_the_python_numbers_and_the_real_trigger() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let read = |f: &str| {
std::fs::read_to_string(format!("{}/{}", env!("CARGO_MANIFEST_DIR"), f))
.unwrap_or_else(|_| panic!("{f} 必须可读"))
};
let toast_src = read("src/toast.rs");
assert!(
toast_src.contains("Duration::from_secs(8)"),
"V5:自动关必须是 Python 的 8 秒"
);
assert!(
toast_src.contains("Anchor::BottomRight") && toast_src.contains(".reverse(true)"),
"V5:必须是右下角 + 新的在最底(向上堆叠)"
);
assert!(
toast_src.contains("SCREEN_CAP: usize = 3") && toast_src.contains("dismiss_oldest_toast"),
"V5:同屏上限 3 与「超限关最旧」必须在这一层里"
);
let tokens_src = read("src/tokens.rs");
assert!(
tokens_src.contains("0x18, 0x90, 0xFF")
&& tokens_src.contains("0xFA, 0xAD, 0x14")
&& tokens_src.contains("0xFF, 0x4D, 0x4F"),
"V5:三级色必须是 Python 那三个,而且住在 tokens.rs(单一色源钉 U2)"
);
let render_src = read("src/render.rs");
assert!(
render_src.contains("crate::toast::drain_and_show"),
"V5:每帧必须收口 toast 队列(不是某个按钮的副作用)"
);
let state_src = read("src/state.rs");
assert!(
state_src.contains("self.push_alert(") && state_src.contains("self.alert_once("),
"V5:必须有一条真告警接在真告警态上(状态轮询回写 + 去重),不许只留一个没人调的 API"
);
assert!(
!render_src.contains("shared.push_alert"),
"V5(E6 同源):告警不得从某张窗的渲染里产生 —— 账户窗没开也必须知道掉线了"
);
let cargo = read("Cargo.toml");
assert!(
cargo.contains("egui-notify = \"0.21"),
"V5:egui-notify 必须锁在 0.21(0.22 起要 egui ^0.34,撞我们的 0.33.3)"
);
}
fn one_rect(harness: &egui_kittest::Harness<'_>, want: &str) -> egui::Rect {
let hits: Vec<egui::Rect> = harness.query_all_by_label(want).map(|n| n.rect()).collect();
assert!(
hits.len() == 1,
"F 组:「{want}」必须正好一处(0 处 = 那一格没建构,≥2 处 = 撞名或表重复),实测 {}",
hits.len()
);
hits[0]
}
fn rect_in_band(
harness: &egui_kittest::Harness<'_>,
want: &str,
band_y: f32,
left: f32,
right: f32,
) -> egui::Rect {
let hits: Vec<egui::Rect> = harness
.query_all_by_label(want)
.filter(|n| {
let r = n.rect();
(r.center().y - band_y).abs() < 3.0 && r.center().x >= left && r.center().x <= right
})
.map(|n| n.rect())
.collect();
assert!(
hits.len() == 1,
"F 组:行带 y={band_y:.1}、x∈[{left:.1},{right:.1}] 里「{want}」必须正好一处,实测 {}",
hits.len()
);
hits[0]
}
fn cell_in_row(harness: &egui_kittest::Harness<'_>, want: &str, row_y: f32) -> egui::Rect {
let hits: Vec<egui::Rect> = harness
.query_all_by_label(want)
.filter(|n| (n.rect().center().y - row_y).abs() < 3.0)
.map(|n| n.rect())
.collect();
assert!(
hits.len() == 1,
"F 组:行 y={row_y:.1} 里「{want}」必须正好一处(0 = 格没建构,≥2 = 行带撞名),实测 {}",
hits.len()
);
hits[0]
}
fn f1_trade_row() -> ek_data::trade::TradeData {
ek_data::trade::TradeData {
gateway_name: "f1gw".to_string(),
symbol: "F1SYM".to_string(),
name: "F1名称".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
orderid: "F1ORDER".to_string(),
tradeid: "F1TRADE".to_string(),
direction: Some(ek_data::constant::Direction::SHORT),
offset: ek_data::constant::Offset::CLOSETODAY,
price: 101.5,
volume: 7.0,
traded_amount: 141.25,
datetime: Some(36_000_000_000_000),
..ek_data::trade::TradeData::default()
}
}
#[test]
fn f1_trades_body_cells_line_up_with_their_headers() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
const WANT_HEADERS: [&str; 12] = [
"代码",
"名称",
"交易所",
"方向",
"开平",
"价格",
"数量",
"成交金额",
"时间",
"成交号",
"委托号",
"接口",
];
const WANT_CELLS: [&str; 12] = [
"F1SYM",
"F1名称",
"SHFE",
"SHORT",
"CLOSETODAY",
"101.5",
"7",
"141.25",
"18:00:00.000",
"F1TRADE",
"F1ORDER",
"f1gw",
];
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.seed_trade_snap(trades_snap(vec![f1_trade_row()]));
let mut harness = Harness::builder()
.with_size([2600.0, 900.0])
.build(|ctx| render_body(ctx, &shared));
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
assert!(hit.is_some(), "验收环境必须有 CJK 候选字体");
harness.run();
let row_y = one_rect(&harness, "F1TRADE").center().y;
let mut pairs: Vec<(f32, &str)> = WANT_CELLS
.iter()
.map(|w| {
let r = cell_in_row(&harness, w, row_y);
assert!(
(r.center().y - row_y).abs() < 3.0,
"F1:「{w}」必须与成交行同高(y={:.1} vs 行 y={row_y:.1})",
r.center().y
);
(r.center().x, *w)
})
.collect();
pairs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let got: Vec<&str> = pairs.iter().map(|(_, l)| *l).collect();
assert_eq!(
got,
WANT_CELLS.to_vec(),
"F1:成交体表体按 x 升序必须正好是这 12 串(错位 / 少格 / 多格都在这里显形)"
);
let head_y = one_rect(&harness, "成交金额").center().y;
let shapes_hdr = shapes_of(&harness);
let trade_band = header_bands(&shapes_hdr, ek_ui::tokens::shadcn::BG4, 100.0)
.into_iter()
.find(|b| b.min.y < head_y + 3.0 && b.max.y > head_y - 3.0 && b.min.x > 600.0)
.expect("成交表必须有表头带(BG4);未找到 = 表头带回归或布局退列");
let (left, right) = (trade_band.min.x + 2.0, trade_band.max.x - 2.0);
let mut head: Vec<(f32, &str)> = WANT_HEADERS
.iter()
.map(|h| {
(
rect_in_band(&harness, h, head_y, left, right).center().x,
*h,
)
})
.collect();
head.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let got_head: Vec<&str> = head.iter().map(|(_, l)| *l).collect();
assert_eq!(
got_head,
WANT_HEADERS.to_vec(),
"F1:成交表表头行带内按 x 升序必须正好是这 12 列(每一列都在窗内正好一处)"
);
}
#[test]
fn f2_position_cost_and_average_price_are_not_swapped() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.seed_trade_snap(ek_ui::state::TradeSnap {
positions: vec![ek_data::position::PositionData {
gateway_name: "ctp".to_string(),
symbol: "F2SYM".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
direction: ek_data::constant::Direction::LONG,
volume: 3.0,
yd_volume: 1.0,
can_use_volume: 2.0,
frozen: 1.0,
on_road_volume: 4.0,
market_value: 555.0,
price: 101.5,
avg_price: 98.25,
last_price: 77.125,
pnl: -6.5,
profit_rate: 1.5,
name: "F2名称".to_string(),
}],
..ek_ui::state::TradeSnap::default()
});
let mut harness = harness_wide_for(&shared);
let hit = ek_ui::fonts::apply_cjk_fonts(&harness.ctx);
assert!(hit.is_some(), "验收环境必须有 CJK 候选字体");
harness.run();
let body_y = one_rect(&harness, "F2SYM").center().y;
let cost_v = one_rect(&harness, "98.25");
let avg_v = one_rect(&harness, "101.5");
for (label, r) in [("98.25", &cost_v), ("101.5", &avg_v)] {
assert!(
(r.center().y - body_y).abs() < 3.0,
"F2:「{label}」必须与持仓行同高(y={:.1} vs 行 y={body_y:.1})",
r.center().y
);
}
let head_y = one_rect(&harness, "成本").center().y;
let band = |label: &str| {
rect_in_band(&harness, label, head_y, f32::MIN, f32::MAX)
.center()
.x
};
let (cost_h, avg_h, pnl_h) = (band("成本"), band("均价"), band("盈亏"));
assert!(
cost_h < avg_h && avg_h < pnl_h,
"F2:持仓表表头 x 序必须是 成本 < 均价 < 盈亏,实测 {cost_h:.1} / {avg_h:.1} / {pnl_h:.1}"
);
assert!(
cost_h < cost_v.center().x && cost_v.center().x < avg_h,
"F2:成本列必须装 `avg_price`(98.25):成本表头 x={cost_h:.1} 均价表头 x={avg_h:.1},值 x={:.1}",
cost_v.center().x
);
assert!(
avg_h < avg_v.center().x && avg_v.center().x < pnl_h,
"F2:均价列必须装 `price`(101.5):均价表头 x={avg_h:.1} 盈亏表头 x={pnl_h:.1},值 x={:.1}",
avg_v.center().x
);
let _ = one_rect(&harness, "-6.5");
}
#[test]
fn f3_trades_column_order_matches_the_python_blueprint() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let render = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/render.rs"))
.expect("render.rs 必须可读");
let want = [
"代码",
"名称",
"交易所",
"方向",
"开平",
"价格",
"数量",
"成交金额",
"时间",
"成交号",
"委托号",
"接口",
];
let anchor = "const TRADE_HEADERS: [&str; 12] = [";
let start = render.find(anchor).unwrap_or_else(|| {
panic!("F3:成交表列头必须是单源常量 `{anchor}`(12 列),不许两处各写一份")
});
let block = &render[start + anchor.len()..];
let end = block
.find("];")
.unwrap_or_else(|| panic!("F3:TRADE_HEADERS 字面量必须闭合(找不到 `];`)"));
let got: Vec<String> = block[..end]
.split('"')
.enumerate()
.filter(|(i, _)| i % 2 == 1)
.map(|(_, s)| s.to_string())
.collect();
assert_eq!(
got,
want.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
"F3:TRADE_HEADERS 的列序必须逐字等于 Python TradeMonitor.headers(widget.py:1270-1283)"
);
}
#[test]
fn f4_display_formats_live_only_in_format_rs() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let render = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/render.rs"))
.expect("render.rs 必须可读");
for needle in ["format!(\"{:", "format!(\"{ns:"] {
let hits: Vec<&str> = render
.lines()
.filter(|l| {
let t = l.trim_start();
!t.starts_with("//")
&& l.contains(needle)
&& !l.contains("020.4")
&& !l.contains("025}")
})
.map(str::trim)
.collect();
assert!(
hits.is_empty(),
"F4:render.rs 里不许有带精度的裸 format 展示格式(只许走 src/format.rs),实测 {hits:?}"
);
}
let mut offenders: Vec<String> = Vec::new();
for line in render.lines() {
if line.trim_start().starts_with("//") || !line.contains("format!(\"") {
continue;
}
for spec in line.split('{').skip(1) {
let Some(close) = spec.find('}') else {
continue;
};
let spec = &spec[..close];
let is_sort_key = spec == ":020.4" || spec == "ns:025" || spec == ":025";
let is_display = spec.contains('.') || spec.contains('+') || spec.contains('>');
if is_display && !is_sort_key {
offenders.push(format!("{{{spec}}} in: {}", line.trim()));
}
}
}
assert!(
offenders.is_empty(),
"F4:render.rs 里不许有带精度/符号/对齐的裸 format 展示格式(只许走 src/format.rs),实测 {offenders:?}"
);
let fmt = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/format.rs"))
.expect("format.rs 必须可读");
for name in [
"pub fn price(",
"pub fn volume(",
"pub fn pct(",
"pub fn price_tick(",
"pub fn time_hms_millis(",
"pub fn date_ymd(",
] {
assert!(
fmt.contains(name),
"F4:展示口径必须齐这六支(缺一支就会有人回到裸 format!),缺 {name}"
);
}
assert!(
fmt.contains("pub fn pct(") && fmt.contains("pub fn price_tick("),
"F4:两支偏离口径必须住在 format.rs 并带注释,不许散回 render.rs"
);
}
#[test]
fn s87_ops_window_shows_oms_pipeline_section_when_present() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(
&shared,
vec![row("svctl"), row("dm"), row("oms"), row("ui")],
);
shared.set_pipeline_lines(vec![
"stage:validate|split|risk|send|book".to_string(),
"floor:field|rate(5/s)|cancel-guard(3)".to_string(),
"splitter:ek-converter".to_string(),
"gate:ek-risk:contract".to_string(),
"gate:ek-risk:price-band".to_string(),
]);
shared.set_ops_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
assert!(
harness
.query_all_by_label_contains("oms 管线")
.next()
.is_some(),
"有读数必须有小节标题"
);
for needle in [
"stage:validate|split|risk|send|book",
"floor:field|rate(5/s)|cancel-guard(3)",
"gate:ek-risk:contract",
"gate:ek-risk:price-band",
] {
assert!(
harness.query_all_by_label_contains(needle).next().is_some(),
"装配行 {needle} 没上屏"
);
}
}
#[test]
fn s87b_ops_window_omits_oms_pipeline_section_when_absent() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, vec![row("svctl"), row("dm"), row("ui")]);
shared.set_pipeline_lines(Vec::new());
shared.set_ops_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
assert!(
harness
.query_all_by_label_contains("oms 管线")
.next()
.is_none(),
"没读数时整节不许画(空标题比没有更坏)"
);
assert!(
harness
.query_all_by_label_contains("stage:validate")
.next()
.is_none(),
"同上:内容也不许残留"
);
}
#[test]
fn s88_ops_window_has_a_wired_manual_pipeline_reask() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, vec![row("svctl"), row("dm"), row("ui")]);
shared.set_pipeline_lines(Vec::new());
shared.set_ops_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
let pos = {
let node = harness
.query_all_by_label_contains(ek_ui::render::BTN_PIPELINE_REFRESH)
.next()
.unwrap_or_else(|| {
panic!(
"s88:没读数时也要有『{}』钮(问不到才最需要重问)",
ek_ui::render::BTN_PIPELINE_REFRESH
)
});
node.rect().center()
};
click_at(&mut harness, pos, egui::PointerButton::Primary, 1);
harness.run();
assert!(
shared.take_pipeline_due(),
"点钮必须置「该问一次」标记(消费性标记,不是一档状态)"
);
assert!(
!shared.take_pipeline_due(),
"一次点击只标记一次,不许留成常驻"
);
}
#[test]
fn s89_both_palettes_land_on_screen_frame_wide() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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,
));
let dark = harness_wide_for(&shared);
assert!(
ek_ui::fonts::apply_cjk_fonts(&dark.ctx).is_some(),
"验收环境必须有 CJK 候选字体"
);
let dshapes = shapes_of(&dark);
assert!(
!filled_rects(&dshapes, ek_ui::tokens::PALETTE_DARK.bg4).is_empty(),
"深档帧里必须有表头带底"
);
assert!(
!hlines(&dshapes, ek_ui::tokens::PALETTE_DARK.border_soft).is_empty(),
"深档帧里必须画得出行线"
);
shared.set_theme(ek_ui::tokens::Theme::Light);
let light = harness_wide_for(&shared);
assert!(
ek_ui::fonts::apply_cjk_fonts(&light.ctx).is_some(),
"验收环境必须有 CJK 候选字体"
);
let lshapes = shapes_of(&light);
assert!(
!filled_rects(&lshapes, ek_ui::tokens::PALETTE_LIGHT.bg4).is_empty(),
"浅档帧里必须有浅档表头带底"
);
assert!(
!hlines(&lshapes, ek_ui::tokens::PALETTE_LIGHT.border_soft).is_empty(),
"浅档帧里必须画得出浅档行线"
);
let stray_band: Vec<egui::Rect> = filled_rects(&lshapes, ek_ui::tokens::PALETTE_DARK.bg4);
assert!(
stray_band.is_empty(),
"浅档帧里不许出现深档带底,实测 {n} 块",
n = stray_band.len()
);
let stray_bg: Vec<egui::Rect> = filled_rects(&lshapes, ek_ui::tokens::PALETTE_DARK.bg1);
println!("S89 探针 浅档帧里的深档底矩形 = {stray_bg:?}");
assert!(
stray_bg.is_empty(),
"浅档帧里不许出现深档页面底,实测 {n} 块",
n = stray_bg.len()
);
let rise_light = ek_ui::tokens::semantic_of_theme(ek_ui::tokens::Theme::Light).rise;
let rise_dark = ek_ui::tokens::semantic_of_theme(ek_ui::tokens::Theme::Dark).rise;
assert_ne!(rise_light, rise_dark, "夹具前提:两档涨色必须不同值");
let lit = text_shapes_of_color(&lshapes, rise_light);
let dk = text_shapes_of_color(&lshapes, rise_dark);
assert!(
!lit.is_empty() && dk.is_empty(),
"浅档帧里的涨色必须是浅档那支(浅档支 {n1} 处 / 深档支 {n2} 处)",
n1 = lit.len(),
n2 = dk.len()
);
}
#[test]
fn s90_band_fill_and_border_stay_two_colors_in_light_theme() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let pal = ek_ui::tokens::PALETTE_LIGHT;
assert_ne!(
pal.bg4, pal.border,
"浅档带底与描边同值 = 一支兼两职(带与行线糊在一起)"
);
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.set_theme(ek_ui::tokens::Theme::Light);
let harness = harness_wide_for(&shared);
assert!(
ek_ui::fonts::apply_cjk_fonts(&harness.ctx).is_some(),
"验收环境必须有 CJK 候选字体"
);
let shapes = shapes_of(&harness);
let bands = filled_rects(&shapes, pal.bg4);
let lines = hlines(&shapes, pal.border);
assert!(!bands.is_empty(), "浅档帧里必须画得出表头带底");
assert!(!lines.is_empty(), "浅档帧里必须画得出行线");
let border_as_fill: Vec<egui::Rect> = filled_rects(&shapes, pal.border);
assert!(
border_as_fill.is_empty(),
"浅档里描边那支不许当实心填充(一支兼两职),实测 {n} 块",
n = border_as_fill.len()
);
let band_as_line: Vec<(f32, egui::Rangef)> = hlines(&shapes, pal.bg4);
assert!(
band_as_line.is_empty(),
"浅档里带底那支不许当行线(一支兼两职),实测 {n} 条",
n = band_as_line.len()
);
}
#[test]
fn s91_header_and_body_cells_share_the_left_edge_per_column() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
let _ = seed_md_source(&shared, "9999", "278153");
shared.update_contract(&contract(
ek_data::constant::Exchange::SHFE,
"rb2605",
"螺纹钢2605",
));
shared.set_contracts_window_open(true);
let harness = harness_wide_for(&shared);
assert!(
ek_ui::fonts::apply_cjk_fonts(&harness.ctx).is_some(),
"验收环境必须有 CJK 候选字体"
);
for (header, body) in [("交易所", "SHFE"), ("乘数", "10"), ("最小变动", "1.0000")] {
let cells: Vec<egui::Rect> = harness.query_all_by_label(body).map(|n| n.rect()).collect();
assert_eq!(
cells.len(),
1,
"夹具前提:表体格「{body}」全帧必须恰好一颗(合约表只喂一行),实测 {n} 颗",
n = cells.len()
);
let cell = cells[0];
let heads: Vec<egui::Rect> = harness
.query_all_by_label(header)
.map(|n| n.rect())
.filter(|r| r.max.y <= cell.min.y)
.collect();
assert!(
!heads.is_empty(),
"「{body}」这一列上方必须有表头「{header}」"
);
let head = heads
.iter()
.max_by(|a, b| {
a.min
.y
.partial_cmp(&b.min.y)
.unwrap_or(std::cmp::Ordering::Equal)
})
.copied()
.expect("取最近的一颗表头");
assert!(
(head.min.x - cell.min.x).abs() <= 1.0,
"列「{header}」表头左沿 x={:.1} 与表体左沿 x={:.1} 不同一条竖线(差 {:.1}px)",
head.min.x,
cell.min.x,
(head.min.x - cell.min.x).abs()
);
}
}
#[test]
fn s92_numeric_cells_render_monospace_and_only_numbers() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
shared.seed_trade_snap(trades_snap(vec![trade_row("P1", 102.5, "螺纹钢2605")]));
shared.update_tick(&tick(
ek_data::constant::Exchange::SHFE,
"rb2605",
"螺纹钢2605",
101.0,
1.25,
));
let harness = harness_wide_for(&shared);
assert!(
ek_ui::fonts::apply_cjk_fonts(&harness.ctx).is_some(),
"验收环境必须有 CJK 候选字体"
);
for s in ["101", "102.5"] {
let at = harness
.query_all_by_label(s)
.map(|n| n.rect())
.next()
.unwrap_or_else(|| panic!("必须有数字格「{s}」"));
assert!(
monospace_at(&harness, s, &at)
.unwrap_or_else(|| panic!("必须有「{s}」的文字形状(数字格没上屏)")),
"数字格「{s}」必须走等宽字体(全左对齐后「成列」全靠它)"
);
}
let at = harness
.query_all_by_label("螺纹钢2605")
.map(|n| n.rect())
.next()
.expect("行情带必须有名称格");
assert!(
!monospace_at(&harness, "螺纹钢2605", &at).unwrap_or(true),
"名称格不许走等宽(等宽只给数字格)"
);
}
#[test]
fn s93_kline_chart_follows_the_current_theme() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
feed_rows(&shared, demo_rows());
feed_market_frame(&shared, 2, 4);
shared.open_kline("rb2605");
let dark = harness_wide_for(&shared);
assert!(
ek_ui::fonts::apply_cjk_fonts(&dark.ctx).is_some(),
"验收环境必须有 CJK 候选字体"
);
let dshapes = shapes_of(&dark);
assert!(
!filled_rects(&dshapes, ek_ui::tokens::PALETTE_DARK.bg1).is_empty(),
"深档 K 线帧必须画得出深档底"
);
shared.set_theme(ek_ui::tokens::Theme::Light);
let light = harness_wide_for(&shared);
assert!(
ek_ui::fonts::apply_cjk_fonts(&light.ctx).is_some(),
"验收环境必须有 CJK 候选字体"
);
let lshapes = shapes_of(&light);
assert!(
!filled_rects(&lshapes, ek_ui::tokens::PALETTE_LIGHT.bg1).is_empty(),
"浅档 K 线帧必须画得出浅档底(写死深档常量 = 界面里唯一一块黑底)"
);
let stray: Vec<egui::Rect> = filled_rects(&lshapes, ek_ui::tokens::PALETTE_DARK.bg1);
assert!(
stray.is_empty(),
"浅档帧不许留深档底(K 线区最容易漏),实测 {n} 块",
n = stray.len()
);
let rise_light = ek_ui::tokens::semantic_of_theme(ek_ui::tokens::Theme::Light).rise;
let rise_dark = ek_ui::tokens::semantic_of_theme(ek_ui::tokens::Theme::Dark).rise;
assert_ne!(rise_light, rise_dark, "夹具前提:两档涨色必须不同值");
let lit = any_shapes_of_color(&lshapes, rise_light);
let dk = any_shapes_of_color(&lshapes, rise_dark);
assert!(
!lit.is_empty() && dk.is_empty(),
"浅档帧的涨色必须是浅档那支(浅档支 {n1} 处 / 深档支 {n2} 处)",
n1 = lit.len(),
n2 = dk.len()
);
}
#[test]
fn s94_text_ladder_lands_on_screen_in_both_themes() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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,
));
let color_of_first = |h: &Harness<'_>, needle: &str| -> Option<egui::Color32> {
for shape in shapes_of(h) {
if let egui::Shape::Text(t) = shape
&& t.galley.text().contains(needle)
{
return Some(text_color_of(&t));
}
}
None
};
for theme in [ek_ui::tokens::Theme::Dark, ek_ui::tokens::Theme::Light] {
shared.set_theme(theme);
let h = harness_wide_for(&shared);
assert!(
ek_ui::fonts::apply_cjk_fonts(&h.ctx).is_some(),
"验收环境必须有 CJK 候选字体"
);
let pal = theme.palette();
let head = color_of_first(&h, "最新价").expect("行情带表头「最新价」必须落屏");
assert_eq!(
head, pal.text3,
"{theme:?} 档表头文字必须落 text3(参考 th = ink-muted),实测 {head:?}"
);
let body = color_of_first(&h, "1234").expect("行情带表体数字必须落屏");
assert_eq!(
body, pal.text2,
"{theme:?} 档表体数字必须落 text2(参考 td = ink-soft),实测 {body:?}"
);
let ink = h.ctx.style().visuals.strong_text_color();
assert_eq!(ink, pal.text1, "{theme:?} 档强文字层必须 = text1");
assert_ne!(head, body, "{theme:?} 档表头与表体同色 = 层级塌一档");
assert_ne!(head, ink, "{theme:?} 档表头与正文同色 = 层级塌一档");
assert_ne!(body, ink, "{theme:?} 档表体与正文同色 = 层级塌一档");
}
shared.set_theme(ek_ui::tokens::Theme::Dark);
}
#[test]
fn s58_restored_row_renders_archive_badge_and_auto_connect_toggle() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
let shared = Arc::new(UiShared::new());
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join("tmp")
.join(format!("ekui-kit-restore-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let store = ek_cfg::Store::with_root(root.clone());
let acct = ek_core::SectionMap::new(1)
.set("broker_id", ek_core::Field::plain("9"))
.set("td_addr", ek_core::Field::plain("tcp://1.2.3.4:40001"))
.set("td_user", ek_core::Field::plain("11"))
.set("td_app_id", ek_core::Field::plain("simnow_client_test"))
.set("td_auth_code", ek_core::Field::plain("0123456789abcdef"))
.set("product_classes", ek_core::Field::plain("1"))
.set("td_password", ek_core::Field::secret("gw_td_password"));
ek_cfg::cfg_save_at(store.root(), "ctp.ctp_9_11", &acct).expect("临时档案");
let restored = shared.restore_saved_accounts_from(&store);
assert_eq!(restored, vec!["ctp_9_11".to_string()]);
shared.set_accounts_window_open(true);
let mut harness = harness_for(&shared);
harness.run();
assert!(
harness.query_by_label("ctp_9_11").is_some(),
"复原行派生名必须落屏(账户 Grid 第一列)"
);
assert!(
harness
.query_all_by_label_contains("档案")
.next()
.is_some(),
"档案徽标(0.9.10 二字徽标,说明进悬停 tooltip)必须落屏 —— 密码值的替身只许有这一档"
);
assert!(
harness
.query_all_by_label_contains("tcp://1.2.3.4:40001")
.next()
.is_some(),
"td 前置整串直显(0.9.10 用户裁定:配了什么 IP/端口就要显示)"
);
assert!(
harness
.query_all_by_label_contains("自动连")
.next()
.is_some(),
"「自动连」勾选必须给档案行与手输行共用(启动清点面 = 守护/桌面的开关源)"
);
assert!(
harness
.query_all_by_label_contains("secret://")
.next()
.is_none(),
"引用串形态(secret://…)不得进语义树:密码值的替身是徽标,不是引用串"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn s90_multi_front_same_account_two_rows() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
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://182.254.243.31:30001", "tcp://182.254.243.31:30011"),
);
let _ = shared.drain_cmds();
click_account_add(&mut harness);
harness.run();
shared.set_ctp_form(ek_ui::state::CtpForm {
td_user: "278153".to_string(),
td_addr: "tcp://182.254.243.31:40001".to_string(),
td_password: "pw-2".to_string(),
md_addr: "tcp://182.254.243.31:40011".to_string(),
..shared.ctp_form()
});
harness.run();
click_dialog(&mut harness, "保存");
let _ = shared.drain_cmds();
assert_eq!(shared.accounts().len(), 2, "同账号两套前置 = 两行共存");
assert!(
shared
.accounts()
.iter()
.any(|a| a.gateway == "ctp_9999_278153_2"),
"第二行 = 后缀 _2(基名零迁移)"
);
harness.run();
assert!(
row_action_exists(&harness, "ctp_9999_278153_2", "连接"),
"_2 行独立给『连接』(用户挑一行登)"
);
assert_eq!(
harness
.query_all_by_label_contains("tcp://182.254.243.31:")
.count(),
2,
"td 前置列并排显形两根前置(哪套挂哪行一眼看穿)"
);
click_account_add(&mut harness);
harness.run();
shared.set_ctp_form(ek_ui::state::CtpForm {
td_user: "278153".to_string(),
td_addr: "tcp://182.254.243.31:40001".to_string(),
td_password: "pw-3".to_string(),
..shared.ctp_form()
});
harness.run();
click_dialog(&mut harness, "保存");
assert_eq!(
shared.accounts().len(),
2,
"三全同前置必须被拦(清册不增行)"
);
assert!(shared.account_edit_open(), "被拦不得关弹窗(就地改前置)");
click_dialog(&mut harness, "取消");
}