use std::sync::Arc;
use std::thread;
use std::time::Duration;
use ek_data::constant::{Direction, Exchange, Offset, OrderType};
use ek_station::wire::PlugRow;
use ek_ui::state::{BottomRightTab, OrderForm, TopLeftTab};
use ek_ui::{BusSnap, LogRing, UiShared};
fn row(name: &str) -> PlugRow {
PlugRow {
name: name.to_string(),
version: "0.1.0".to_string(),
state: "Running".to_string(),
deps: vec![],
spilled: 0,
panics: 0,
}
}
#[test]
fn r0_ring_cap_and_spill_count() {
let mut ring = LogRing::new(3);
for i in 0..5 {
ring.push(format!("L{i}"));
}
assert_eq!(ring.len(), 3);
assert_eq!(ring.spilled(), 2);
let snap = ring.snapshot();
assert_eq!(snap, vec!["L2", "L3", "L4"]);
}
#[test]
fn r1_ring_clear_keeps_spilled_fact() {
let mut ring = LogRing::new(2);
for i in 0..4 {
ring.push(format!("L{i}"));
}
assert_eq!(ring.spilled(), 2);
ring.clear();
assert!(ring.is_empty());
assert_eq!(ring.spilled(), 2, "溢出事实不抹");
}
#[test]
fn r2_shared_default_is_clean() {
let s = UiShared::new();
assert_eq!(s.rev(), 0);
assert!(!s.is_stop());
assert!(s.snapshot().rows.is_empty());
let (lines, spilled) = s.logs_snapshot();
assert!(lines.is_empty());
assert_eq!(spilled, 0);
}
#[test]
fn r3_shared_trace_and_revision() {
let s = UiShared::new();
s.log_line("a");
s.log_line("b");
let r1 = s.rev();
assert!(r1 >= 2);
s.clear_logs();
assert!(s.rev() > r1);
let (lines, _) = s.logs_snapshot();
assert!(lines.is_empty());
}
#[test]
fn r4_snapshot_roundtrip_shared_arc() {
let s = Arc::new(UiShared::new());
let mut snap = BusSnap::default();
snap.rows.push(row("dm"));
snap.services
.push(("dm".to_string(), vec!["dm.reload".to_string()]));
s.set_snapshot(snap);
let got = s.snapshot();
assert_eq!(got.rows.len(), 1);
assert_eq!(got.rows[0].name, "dm");
assert_eq!(got.services[0].1, vec!["dm.reload".to_string()]);
}
#[test]
fn r5_stop_flag_set_and_read() {
let s = Arc::new(UiShared::new());
let s2 = Arc::clone(&s);
let h = thread::spawn(move || {
thread::sleep(Duration::from_millis(50));
s2.request_stop();
});
let mut saw = false;
for _ in 0..50 {
if s.is_stop() {
saw = true;
break;
}
thread::sleep(Duration::from_millis(20));
}
h.join().unwrap();
assert!(saw, "stop 位跨线程可见");
}
fn tick(ex: Exchange, symbol: &str, last: f64) -> ek_data::tick::TickData {
ek_data::tick::TickData {
symbol: symbol.to_string(),
exchange: ex,
last_price: last,
..Default::default()
}
}
#[test]
fn r6_tick_store_en_symbol_key_and_groups() {
let s = UiShared::new();
s.update_tick(&tick(Exchange::SHFE, "cu2709", 107660.0));
s.update_tick(&tick(Exchange::DCE, "a2709", 3500.0));
s.update_tick(&tick(Exchange::SHFE, "au2706", 786.5));
s.update_tick(&tick(Exchange::SHFE, "cu2709", 107670.0));
assert_eq!(s.tick_count(), 3);
let rows = s.tick_rows();
assert_eq!(rows[0].symbol, "a2709");
assert_eq!(rows[1].symbol, "au2706");
assert_eq!(rows[2].symbol, "cu2709");
assert_eq!(rows[2].last_price, 107670.0, "覆盖生效");
let groups = s.tick_groups();
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].0, "DCE");
assert_eq!(groups[1].0, "SHFE");
assert_eq!(groups[1].1.len(), 2);
s.update_tick(&tick(Exchange::DCE, "cu2709", 1.0));
assert_eq!(s.tick_count(), 4);
}
#[test]
fn r7_order_form_to_request_bits_and_errors() {
let f = OrderForm {
exchange: "SHFE".into(),
symbol: "cu2709".into(),
price: "107660".into(),
volume: "2".into(),
..Default::default()
};
let req = f.to_request().unwrap();
assert_eq!(req.symbol, "cu2709");
assert_eq!(req.exchange, Exchange::SHFE);
assert_eq!(req.direction, Direction::LONG);
assert_eq!(req.offset, Offset::OPEN);
assert_eq!(req.order_type, OrderType::LIMIT);
assert_eq!(req.price, 107660.0);
assert_eq!(req.volume, 2.0);
assert!(
OrderForm {
symbol: "".into(),
..f.clone()
}
.to_request()
.unwrap_err()
.contains("代码为空")
);
assert!(
OrderForm {
symbol: "SHFE.cu2709".into(),
..f.clone()
}
.to_request()
.unwrap_err()
.contains("前缀")
);
assert!(
OrderForm {
exchange: "XXX".into(),
..f.clone()
}
.to_request()
.unwrap_err()
.contains("Exchange")
);
assert!(
OrderForm {
price: "abc".into(),
..f.clone()
}
.to_request()
.unwrap_err()
.contains("价格")
);
assert!(
OrderForm {
price: "-1".into(),
..f.clone()
}
.to_request()
.unwrap_err()
.contains("为正")
);
assert!(
OrderForm {
volume: "0".into(),
..f.clone()
}
.to_request()
.unwrap_err()
.contains("为正")
);
let mk = OrderForm {
order_type: OrderType::MARKET,
price: "0".into(),
..f.clone()
};
assert_eq!(mk.to_request().unwrap().price, 0.0);
}
#[test]
fn r8_group_tabs_defaults_and_switch() {
let s = UiShared::new();
assert_eq!(s.topleft_tab(), TopLeftTab::Orders);
assert_eq!(s.bottomright_tab(), BottomRightTab::Account);
s.set_topleft_tab(TopLeftTab::Active);
s.set_bottomright_tab(BottomRightTab::Logs);
assert_eq!(s.topleft_tab(), TopLeftTab::Active);
assert_eq!(s.bottomright_tab(), BottomRightTab::Logs);
assert_eq!(TopLeftTab::Active.label(), "未成交");
assert_eq!(BottomRightTab::Logs.label(), "日志");
}
fn contract(ex: Exchange, symbol: &str, name: &str) -> ek_data::contract::ContractData {
ek_data::contract::ContractData {
gateway_name: "ctp".to_string(),
symbol: symbol.to_string(),
exchange: ex,
name: name.to_string(),
asset: ek_data::constant::Asset::default(),
instrument_type: None,
size: 10.0,
price_tick: 1.0,
product_id: None,
max_limit_order_volume: None,
min_limit_order_volume: None,
max_market_order_volume: None,
min_market_order_volume: None,
delivery_year: None,
delivery_month: None,
open_date: None,
pre_close: None,
up_stop_price: None,
down_stop_price: None,
float_volume: None,
total_volume: None,
instrument_status: None,
is_trading: None,
}
}
#[test]
fn r9_contract_store_query_and_selection() {
let s = UiShared::new();
assert_eq!(s.contract_count(), 0);
s.update_contract(&contract(Exchange::SHFE, "rb2605", "螺纹钢2605"));
s.update_contract(&contract(Exchange::DCE, "a2709", "黄玉米2709"));
s.update_contract(&contract(Exchange::SHFE, "au2706", "黄金2706"));
assert_eq!(s.contract_count(), 3);
s.update_contract(&contract(Exchange::SHFE, "rb2605", "螺纹钢2605"));
assert_eq!(s.contract_count(), 3);
assert_eq!(s.contracts("").len(), 3);
assert_eq!(s.contracts("rb").len(), 1);
assert_eq!(s.contracts("DCE").len(), 1);
assert_eq!(s.contracts("玉米").len(), 1);
assert_eq!(s.contracts("不存在").len(), 0);
s.toggle_contract_selection("SHFE.rb2605");
s.toggle_contract_selection("DCE.a2709");
assert_eq!(
s.selected_contracts(),
vec!["SHFE.rb2605".to_string(), "DCE.a2709".to_string()]
);
s.toggle_contract_selection("SHFE.rb2605");
assert_eq!(s.selected_contracts(), vec!["DCE.a2709".to_string()]);
}
#[test]
fn r10_window_flags_and_kline_multiopen() {
let s = UiShared::new();
assert!(!s.ops_window_open());
assert!(!s.accounts_window_open());
assert!(!s.contracts_window_open());
assert!(s.kline_symbols().is_empty());
s.set_ops_window_open(true);
s.set_accounts_window_open(true);
s.set_contracts_window_open(true);
assert!(s.ops_window_open() && s.accounts_window_open() && s.contracts_window_open());
s.open_kline("rb2605");
s.open_kline("au2706");
s.open_kline("rb2605");
assert_eq!(
s.kline_symbols(),
vec!["rb2605".to_string(), "au2706".to_string()]
);
s.close_kline("rb2605");
assert_eq!(s.kline_symbols(), vec!["au2706".to_string()]);
assert!(
s.logs_snapshot()
.0
.iter()
.any(|l| l.contains("打开 K线窗 rb2605")),
"kline open log missing"
);
}
#[test]
fn r8_import_tick_columns() {
let s = UiShared::new();
let tick = ek_data::tick::TickData {
gateway_name: "ctp".to_string(),
symbol: "sn2709".to_string(),
exchange: Exchange::SHFE,
datetime: 1,
last_price: 380.0,
bid_price_1: 379.0,
bid_volume_1: 3.0,
ask_price_1: 381.0,
ask_volume_1: 4.0,
..ek_data::tick::TickData::default()
};
let base = OrderForm {
symbol: "cu2709".to_string(),
..OrderForm::default()
};
s.set_order_form(base.clone());
ek_ui::render::import_tick(&s, &tick, "SHFE", 8);
let f = s.order_form();
assert_eq!(f.symbol, "sn2709");
assert_eq!(f.exchange, "SHFE");
assert_eq!(f.direction, Direction::SHORT);
assert_eq!(f.price, "379");
ek_ui::render::import_tick(&s, &tick, "SHFE", 9);
let f = s.order_form();
assert_eq!(f.direction, Direction::SHORT);
assert_eq!(f.price, "379");
ek_ui::render::import_tick(&s, &tick, "SHFE", 10);
let f = s.order_form();
assert_eq!(f.direction, Direction::LONG);
assert_eq!(f.price, "381");
ek_ui::render::import_tick(&s, &tick, "SHFE", 11);
assert_eq!(s.order_form().direction, Direction::LONG);
s.set_order_form(OrderForm {
price: "999".to_string(),
direction: Direction::SHORT,
..base.clone()
});
ek_ui::render::import_tick(&s, &tick, "SHFE", 2);
let f = s.order_form();
assert_eq!(f.symbol, "sn2709");
assert_eq!(f.exchange, "SHFE");
assert_eq!(f.direction, Direction::SHORT);
assert_eq!(f.price, "999");
}
#[test]
fn u1_visuals_match_shadcn_nova_palette() {
use egui::Color32;
let ctx = egui::Context::default();
ek_ui::render::apply_ui_style(&ctx);
let rgb = |r: u8, g: u8, b: u8| Color32::from_rgb(r, g, b);
let (bg1, bg2, bg4, bg5, bg6) = (
rgb(0x0A, 0x0A, 0x0A), rgb(0x17, 0x17, 0x17), rgb(0x26, 0x26, 0x26), rgb(0x40, 0x40, 0x40), rgb(0x52, 0x52, 0x52), );
let (text1, text3, accent2, accent4) = (
rgb(0xFA, 0xFA, 0xFA), rgb(0xA1, 0xA1, 0xA1), rgb(0x52, 0x52, 0x52), rgb(0xE5, 0xE5, 0xE5), );
let border = Color32::from_rgba_premultiplied(26, 26, 26, 26);
let v = ctx.style().visuals.clone(); assert!(v.dark_mode, "Nova 是深色主题");
assert_eq!(v.window_fill, bg1, "页面底 background");
assert_eq!(v.panel_fill, bg1, "shadcn 容器同底,靠描边区分");
assert_eq!(v.extreme_bg_color, bg1, "输入框底 = 页面底");
assert_eq!(v.faint_bg_color, bg2, "card");
assert_eq!(v.window_stroke.color, border, "一切描边 = border(白 10%)");
assert_eq!(
v.hyperlink_color, accent4,
"primary(选中 tab 下划线那支;旧 Blue.B70 蓝档退役)"
);
assert_eq!(v.weak_text_color, Some(text3), "次级文本 muted-foreground");
assert_eq!(v.widgets.inactive.bg_fill, bg4);
assert_eq!(v.widgets.inactive.weak_bg_fill, bg4);
assert_eq!(v.widgets.hovered.bg_fill, bg5);
assert_eq!(v.widgets.active.bg_fill, bg6);
assert_eq!(v.widgets.open.bg_fill, bg6);
for (name, w) in [
("noninteractive", &v.widgets.noninteractive),
("inactive", &v.widgets.inactive),
("hovered", &v.widgets.hovered),
("active", &v.widgets.active),
("open", &v.widgets.open),
] {
assert_eq!(w.fg_stroke.color, text1, "{name} 文字色");
assert_eq!(w.fg_stroke.width, 1.0, "{name} hover/pressed 不加重文字");
assert_eq!(w.expansion, 0.0, "{name} hover 不涨块(egui 缺省是 1.0)");
assert_eq!(
w.corner_radius,
egui::CornerRadius::same(8),
"{name} 控件圆角 = calc(var(--radius) - 2px) = 8px"
);
}
assert_eq!(
v.widgets.noninteractive.bg_stroke.color, border,
"分隔线/窗框描边 = border(旧口径这一支兼做表头带底,已拆)"
);
assert_eq!(v.selection.bg_fill, accent2);
assert_eq!(v.selection.stroke.color, text1, "egui 把它当选中项文字色用");
let lum = |c: Color32| -> u32 { (c[0] as u32) + (c[1] as u32) + (c[2] as u32) };
assert!(
lum(bg1) < lum(bg4) && lum(bg4) < lum(bg5) && lum(bg5) < lum(accent2),
"底色阶梯必须单调:页面 {bg1:?} < 控件 {bg4:?} < hover {bg5:?} < 选中 {accent2:?}"
);
assert_eq!(
v.widgets.active.bg_fill, v.selection.bg_fill,
"pressed/checked 与选中同档是有意的设计(两者不会落在同一类控件上)"
);
assert_eq!(v.window_corner_radius, egui::CornerRadius::same(10));
assert_eq!(v.menu_corner_radius, egui::CornerRadius::same(10));
assert_eq!(
v.disabled_alpha, 0.5,
"禁用仍走 alpha 近似(与旧主题同值,语义面不动)"
);
assert!(
!v.striped,
"shadcn 表没有斑马纹(旧 qss alternate-background 同底也不条纹)"
);
}
#[test]
fn u2_colors_have_a_single_source() {
let root = concat!(env!("CARGO_MANIFEST_DIR"), "/src");
const NEEDLES: [&str; 3] = [
"Color32::from_rgb(",
"Color32::from_rgba_unmultiplied",
"Color32::from_rgba_premultiplied",
];
let mut offenders: Vec<String> = Vec::new();
for entry in std::fs::read_dir(root).expect("src/ 必须可读") {
let path = entry.expect("src/ 目录项").path();
if path.extension().and_then(|s| s.to_str()) != Some("rs") {
continue;
}
let name = path
.file_name()
.expect("文件名")
.to_string_lossy()
.to_string();
let text = std::fs::read_to_string(&path).expect("读 src 文件");
let hits: usize = NEEDLES.iter().map(|n| text.matches(*n).count()).sum();
if hits > 0 && name != "tokens.rs" {
offenders.push(format!("{name} 里 {hits} 处"));
}
}
assert!(
offenders.is_empty(),
"硬编码色必须只在 tokens.rs,实测 {offenders:?}"
);
}
#[test]
fn u5_type_scale_has_three_separable_tiers() {
use ek_ui::tokens;
let ctx = egui::Context::default();
ek_ui::render::apply_ui_style(&ctx);
let style = ctx.style();
let size = |t: egui::TextStyle| style.text_styles[&t].size;
let heading = size(egui::TextStyle::Heading);
let body = size(egui::TextStyle::Body);
let small = size(egui::TextStyle::Small);
assert!(
heading >= body + 4.0,
"标题必须明显大于正文:heading {heading} vs body {body}"
);
assert!(
body >= small + 2.0,
"正文必须明显大于次级:body {body} vs small {small}"
);
assert_eq!(body, tokens::FONT_BODY);
assert_eq!(small, tokens::FONT_SMALL);
assert_eq!(heading, tokens::FONT_HEADING);
assert_eq!(
style.visuals.weak_text_color,
Some(tokens::shadcn::TEXT3),
"次级文本走 TEXT3,不是正文色"
);
assert_ne!(tokens::shadcn::TEXT3, tokens::shadcn::TEXT1);
}
#[test]
fn u3_rise_fall_colors_are_fixed_and_distinct() {
use ek_ui::tokens;
assert_eq!(tokens::rise_fall(1.0), tokens::semantic::RISE, "涨必须红");
assert_eq!(
tokens::rise_fall(0.01),
tokens::semantic::RISE,
"微涨也是涨"
);
assert_eq!(tokens::rise_fall(-1.0), tokens::semantic::FALL, "跌必须绿");
assert_eq!(
tokens::rise_fall(-0.01),
tokens::semantic::FALL,
"微跌也是跌"
);
assert_eq!(tokens::rise_fall(0.0), tokens::shadcn::TEXT1, "平盘不染色");
assert_ne!(
tokens::semantic::RISE,
tokens::semantic::FALL,
"涨跌两色必须不同"
);
let r = tokens::semantic::RISE;
let g = tokens::semantic::FALL;
assert!(r.r() > r.g(), "RISE 必须是红,实测 {:?}", r);
assert!(g.g() > g.r(), "FALL 必须是绿,实测 {:?}", g);
assert_ne!(tokens::semantic::RISE, tokens::semantic::ERROR);
assert_ne!(tokens::semantic::FALL, tokens::semantic::OK);
}
#[test]
fn u4_ui_ext_helpers_are_actually_used() {
let root = concat!(env!("CARGO_MANIFEST_DIR"), "/src");
let mut corpus = String::new();
for entry in std::fs::read_dir(root).expect("src/ 必须可读") {
let path = entry.expect("src/ 目录项").path();
if path.extension().and_then(|s| s.to_str()) == Some("rs") {
corpus.push_str(&std::fs::read_to_string(&path).expect("读 src 文件"));
}
}
for name in [
"section_title",
"muted_label",
"space_group",
"primary_button",
"num_cell",
] {
let calls = corpus.matches(&format!("::ui_ext::{name}(")).count();
assert!(
calls >= 1,
"UiExt::{name} 必须至少有一处调用点,实测 {calls} 处"
);
}
}
#[test]
fn e6_alerts_come_from_the_status_poll_not_from_a_window() {
let shared = Arc::new(UiShared::new());
shared.set_status_thresholds(1, 0);
let form = ek_ui::state::CtpForm {
broker_id: "9999".to_string(),
td_user: "278153".to_string(),
td_addr: "tcp://127.0.0.1:30001".to_string(),
md_addr: "tcp://127.0.0.1:30011".to_string(),
..ek_ui::state::CtpForm::default()
};
let acct = ek_ui::state::AccountRow::derive(&form).expect("夹具账户必须可派生");
let gw = acct.gateway.clone();
shared.add_account(acct).expect("夹具账户必须入列");
let now = ek_ctp::map::now_epoch_ms();
fn st(
gateway: &str,
now: i64,
f: impl FnOnce(&mut ek_ctp::plugin::GatewayStatus),
) -> ek_ctp::plugin::GatewayStatus {
let mut s = ek_ctp::plugin::GatewayStatus {
gateway: gateway.to_string(),
sampled_ms: now,
..Default::default()
};
f(&mut s);
s
}
shared.note_gateway_status(
&gw,
&st(&gw, now, |s| {
s.td_handle_up = true;
s.td_logged_in = true;
s.td_query_reject_streak = 1;
s.td_last_account_ms = Some(now);
}),
);
assert_eq!(
shared.pending_alert_count(),
1,
"E6①:轮询问出「查询断供」必须攒一条告警(不开窗也攒)"
);
shared.note_gateway_status(
&gw,
&st(&gw, now, |s| {
s.td_handle_up = true;
s.td_logged_in = true;
s.td_query_reject_streak = 2;
s.td_last_account_ms = Some(now);
}),
);
assert_eq!(
shared.pending_alert_count(),
1,
"E6②:同一登录态不得重复弹(去重键 = 网关 + 登录态)"
);
assert_eq!(shared.alerted_count(), 1, "E6②:去重账本只记了一条");
shared.note_gateway_status(
&gw,
&st(&gw, now, |s| {
s.td_handle_up = true;
s.td_logged_in = true;
s.td_last_account_ms = Some(now);
s.td_last_login_ok_ms = Some(now);
}),
);
assert_eq!(shared.pending_alert_count(), 1, "E6③:在线不是告警");
shared.note_gateway_status(
&gw,
&st(&gw, now, |s| {
s.td_handle_up = true;
s.td_logged_in = false;
s.td_disconnects = 1;
}),
);
assert_eq!(
shared.pending_alert_count(),
2,
"E6④:登录态换档必须算新告警"
);
let root = concat!(env!("CARGO_MANIFEST_DIR"), "/src");
let mut calls: Vec<String> = Vec::new();
for entry in std::fs::read_dir(root).expect("src/ 必须可读") {
let path = entry.expect("src/ 目录项").path();
if path.extension().and_then(|s| s.to_str()) != Some("rs") {
continue;
}
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("?")
.to_string();
let text = std::fs::read_to_string(&path).expect("读 src 文件");
for (idx, _) in text.match_indices("push_alert(") {
let line_start = text[..idx].rfind('\n').map(|i| i + 1).unwrap_or(0);
let line = text[line_start..idx].trim_start();
if !line.trim_end().ends_with("pub fn") {
calls.push(format!("{name}:{}push_alert(", line.trim_start()));
}
}
}
const ALERT_PRODUCER_CALLS: usize = 4;
assert_eq!(
calls.len(),
ALERT_PRODUCER_CALLS,
"E6:告警调用点必须是 {ALERT_PRODUCER_CALLS} 处且全在状态层(note_gateway_status 1 + \
note_trade 1 + note_plugin_spill 2),实测 {calls:?}"
);
assert!(
calls.iter().all(|c| c.starts_with("state.rs")),
"E6:产生口必须在 state.rs,实测 {calls:?}"
);
}