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, RING_CAP, 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,
pending: 0,
peak: 0,
soft_cap: ek_core::plugin::MAILBOX_SOFT_CAP,
}
}
#[test]
fn r0_ring_cap_and_spill_count() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); let 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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); let 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 r11_ring_cap_is_4096_display_window() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); assert_eq!(RING_CAP, 4096);
let mut ring = LogRing::new(RING_CAP);
for i in 0..(RING_CAP + 3) {
ring.push(format!("L{i}"));
}
assert_eq!(ring.len(), RING_CAP);
assert_eq!(ring.spilled(), 3);
let snap = ring.snapshot();
assert!(snap[0].contains("L3"), "窗内首条 = 第一次被挤掉之后的头: {}", snap[0]);
assert!(
snap.last().unwrap().contains(&format!("L{}", RING_CAP + 2)),
"窗内末条 = 最新一笔"
);
}
#[test]
fn r12_spilling_ring_writes_every_line_to_disk() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); let dir = std::env::temp_dir().join(format!(
"ek-ui-spill-{}-{}",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
));
std::fs::create_dir_all(&dir).expect("tmp log dir");
let mut ring = LogRing::new_spilling(RING_CAP, dir.clone());
for i in 0..(RING_CAP + 500) {
ring.push(format!("line {i}"));
}
assert_eq!(ring.len(), RING_CAP);
assert_eq!(ring.spilled(), 500);
let mut files = std::fs::read_dir(&dir)
.expect("log dir readable")
.filter_map(|e| e.ok().map(|e| e.path()))
.collect::<Vec<_>>();
files.sort();
assert_eq!(files.len(), 1, "一天一文件: {files:?}");
let text = std::fs::read_to_string(&files[0]).expect("spill log readable");
let lines = text.lines().collect::<Vec<_>>();
assert_eq!(lines.len(), RING_CAP + 500, "全量落盘(推入 {N} 行 也全在", N = RING_CAP + 500);
assert!(lines[0].contains("line 0"), "首行: {}", lines[0]);
assert!(
lines.last().unwrap().contains(&format!("line {}", RING_CAP + 499)),
"末行: {}",
lines.last().unwrap()
);
assert!(lines[0].len() >= "00:00:00 | line 0".len(), "{:?}", lines[0]);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn r13_log_dir_env_redirect_actually_routes_disk() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); let dir = std::env::temp_dir().join(format!(
"ek-ui-redirect-{}-{}",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
));
unsafe { std::env::set_var("EK_UI_LOG_DIR", &dir) };
let mut ring = LogRing::default();
ring.push("redirect-probe 001");
ring.push("redirect-probe 002");
assert_eq!(ring.len(), 2);
assert_eq!(ring.spilled(), 0, "窗内容量远未到,漏不算溢出");
let files = std::fs::read_dir(&dir)
.expect("redirect dir exists")
.filter_map(|e| e.ok().map(|e| e.path()))
.collect::<Vec<_>>();
assert_eq!(files.len(), 1, "一天一文件: {files:?}");
let text = std::fs::read_to_string(&files[0]).expect("log readable");
assert!(text.contains("redirect-probe 001"), "首笔: {text}");
assert!(text.contains("redirect-probe 002"), "最末笔: {text}");
std::fs::remove_dir_all(&dir).ok();
unsafe { std::env::remove_var("EK_UI_LOG_DIR") };
}
#[test]
fn r14_find_cell_strips_exchange_prefix_alias() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); let shared = UiShared::new();
let b = ek_data::bar::BarData {
gateway_name: "tq".to_string(),
symbol: "SHFE.rb2605".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
datetime: 36_000 * 1_000_000_000,
interval: Some(ek_data::constant::Interval::MINUTE),
volume: 18.0,
turnover: 0.0,
open_interest: 0.0,
open_price: 100.0,
high_price: 103.0,
low_price: 99.5,
close_price: 99.5,
pct_change: 0.0,
};
shared
.market()
.note_history("SHFE.rb2605", 60, &[b.clone(), b]);
assert!(
shared.market().find_cell("rb2605").is_some(),
"裸键必须经剥前缀命中带前缀格(旧病窗就断在这一查)"
);
assert!(shared.market().find_cell("SHFE.rb2605").is_some(), "精确键照常命中");
assert!(shared.market().find_cell("rb2606").is_none(), "无格不造格");
assert!(
shared.market().find_cell("DCE.rb2605").is_none(),
"跨所前缀不吞符(同码异所不张冠李戴)"
);
let dm_bars = vec![ek_dm::wire::BarsFrame {
name: "dm".to_string(),
interval: 60,
bars: vec![ek_data::bar::BarData {
gateway_name: "dm".to_string(),
symbol: "rb2605".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
datetime: 37_000 * 1_000_000_000,
interval: Some(ek_data::constant::Interval::MINUTE),
volume: 5.0,
turnover: 0.0,
open_interest: 0.0,
open_price: 100.0,
high_price: 100.2,
low_price: 99.9,
close_price: 100.2,
pct_change: 0.0,
}],
}];
for f in &dm_bars {
shared.market().on_frame(f);
}
let pick = shared
.market()
.find_cell("rb2605")
.expect("两格并存仍有解");
assert_eq!(
pick.symbol, "SHFE.rb2605",
"裸格+前缀格并存 → 前缀格(历史)胜出(BTreeMap 键序确定性)"
);
}
#[test]
fn r2_shared_default_is_clean() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); let 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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); let 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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); let 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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); let 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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); let 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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); let 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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); let 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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); let 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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); let 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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); let 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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); use egui::Color32;
let ctx = egui::Context::default();
ek_ui::render::apply_ui_style(&ctx, ek_ui::tokens::Theme::Dark);
let rgb = |r: u8, g: u8, b: u8| Color32::from_rgb(r, g, b);
let (bg1, bg2, bg4, bg5, bg6) = (
rgb(0x0E, 0x11, 0x13), rgb(0x16, 0x1A, 0x1D), rgb(0x1D, 0x22, 0x26), rgb(0x2A, 0x30, 0x36), rgb(0x34, 0x3B, 0x42), );
let (text1, text2, text3, accent2, accent4) = (
rgb(0xEE, 0xF1, 0xF3), rgb(0xC7, 0xCD, 0xD3), rgb(0x8E, 0x97, 0x9F), rgb(0x34, 0x3B, 0x42), rgb(0xE5, 0xE5, 0xE5), );
let border = rgb(0x2A, 0x30, 0x36);
let v = ctx.style().visuals.clone(); assert!(v.dark_mode, "深档必须标成 dark");
assert_eq!(v.window_fill, bg1, "页面底 background");
assert_eq!(v.panel_fill, bg1, "容器同底,靠描边区分");
assert_eq!(v.extreme_bg_color, bg1, "输入框底 = 页面底");
assert_eq!(v.faint_bg_color, bg2, "surface");
assert_eq!(v.window_stroke.color, border, "一切描边 = border(实色一支)");
assert_eq!(
v.hyperlink_color, accent4,
"primary(选中 tab 下划线那支;深档不动 = 用户裁定 E)"
);
assert_eq!(v.weak_text_color, Some(text3), "次级文本 ink-muted");
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);
assert_eq!(
v.widgets.noninteractive.fg_stroke.color, text2,
"普通 label(表体)文字 = TEXT2(参考 td = ink-soft)"
);
for (name, w) in [
("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.fg_stroke.width, 1.0,
"noninteractive 描边宽度不加重"
);
assert_eq!(
v.widgets.noninteractive.expansion, 0.0,
"noninteractive 不涨块"
);
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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); let root = 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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); use ek_ui::tokens;
let ctx = egui::Context::default();
ek_ui::render::apply_ui_style(&ctx, ek_ui::tokens::Theme::Dark);
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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); use ek_ui::tokens;
let ctx = egui::Context::default();
for theme in [tokens::Theme::Dark, tokens::Theme::Light] {
tokens::set_theme(&ctx, theme);
let s = tokens::semantic_of_theme(theme);
assert_eq!(tokens::rise_fall(&ctx, 1.0), s.rise, "{theme:?} 档涨必须红");
assert_eq!(
tokens::rise_fall(&ctx, 0.01),
s.rise,
"{theme:?} 档微涨也是涨"
);
assert_eq!(
tokens::rise_fall(&ctx, -1.0),
s.fall,
"{theme:?} 档跌必须绿"
);
assert_eq!(
tokens::rise_fall(&ctx, -0.01),
s.fall,
"{theme:?} 档微跌也是跌"
);
assert_eq!(
tokens::rise_fall(&ctx, 0.0),
theme.palette().text1,
"{theme:?} 档平盘不染色"
);
assert_ne!(s.rise, s.fall, "{theme:?} 档涨跌两色必须不同");
}
for theme in [tokens::Theme::Dark, tokens::Theme::Light] {
let s = tokens::semantic_of_theme(theme);
assert!(s.rise.r() > s.rise.g(), "{theme:?} 档 RISE 必须是红");
assert!(s.fall.g() > s.fall.r(), "{theme:?} 档 FALL 必须是绿");
assert_ne!(s.rise, s.error, "{theme:?} 档「涨」与「错」不得混同");
assert_ne!(s.fall, s.ok, "{theme:?} 档「跌」与「成」不得混同");
}
}
#[test]
fn u4_ui_ext_helpers_are_actually_used() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); let root = 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",
] {
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() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); let 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 = 6;
assert_eq!(
calls.len(),
ALERT_PRODUCER_CALLS,
"E6:告警调用点必须是 {ALERT_PRODUCER_CALLS} 处且全在状态层(note_gateway_status 1 + \
note_trade 1 + note_plugin_spill 3 + note_status_event 1),实测 {calls:?}"
);
assert!(
calls.iter().all(|c| c.starts_with("state.rs")),
"E6:产生口必须在 state.rs,实测 {calls:?}"
);
}
fn fixture_account(shared: &Arc<UiShared>) -> String {
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("夹具账户必须入列");
gw
}
fn status_ev(
gw: &str,
change: ek_ctp::GatewayChange,
f: impl FnOnce(&mut ek_ctp::plugin::GatewayStatus),
) -> ek_ctp::GatewayStatusEvent {
let mut s = ek_ctp::plugin::GatewayStatus {
gateway: gw.to_string(),
sampled_ms: ek_ctp::map::now_epoch_ms(),
td_handle_up: true,
td_logged_in: true,
..Default::default()
};
f(&mut s);
ek_ctp::GatewayStatusEvent {
gateway: gw.to_string(),
at_ms: s.sampled_ms,
change,
status: Box::new(s),
}
}
#[test]
fn c11_status_event_lands_text_state_and_alert_in_one_shot() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); let shared = Arc::new(UiShared::new());
shared.set_status_thresholds(1, 0);
let gw = fixture_account(&shared);
let ev = status_ev(&gw, ek_ctp::GatewayChange::TdDisconnected, |s| {
s.td_logged_in = false;
s.td_disconnects = 1;
s.td_last_error = Some("交易服务器连接断开,原因7".to_string());
});
let text = ev.describe();
shared.note_status_event(&ev);
let (lines, _) = shared.logs_snapshot();
assert!(
lines
.iter()
.any(|l| l.contains(&format!("[INFO][{gw}]")) && l.contains(&text)),
"C11:文案要逐字进日志环,实环尾 5 行 {:?}",
&lines[lines.len().saturating_sub(5)..]
);
let row = shared.account(&gw).expect("夹具行要在");
assert!(
row.chain_note
.as_deref()
.unwrap_or("")
.contains("交易前置断开"),
"C11:阶段栏要吃同一句(生产者文案),got {:?}",
row.chain_note
);
assert!(
row.login.is_alarm(),
"C11:状态列同拍翻成告警态,got {:?}",
row.login
);
assert!(
shared.pending_alert_count() >= 1,
"C11:告警必须攒下(不开窗也攒)"
);
}
#[test]
fn c12_status_event_alerts_dedupe_per_session_boundary() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); let shared = Arc::new(UiShared::new());
shared.set_status_thresholds(99, 3600);
let gw = fixture_account(&shared);
shared.note_status_event(&status_ev(
&gw,
ek_ctp::GatewayChange::TdDisconnected,
|s| {
s.td_logged_in = false;
s.td_disconnects = 1;
},
));
let first = shared.alerted_count();
shared.note_status_event(&status_ev(
&gw,
ek_ctp::GatewayChange::TdDisconnected,
|s| {
s.td_logged_in = false;
s.td_disconnects = 1;
},
));
assert_eq!(
shared.alerted_count(),
first,
"C12:同一会话同一款不得重复弹"
);
shared.note_status_event(&status_ev(
&gw,
ek_ctp::GatewayChange::TdDisconnected,
|s| {
s.td_logged_in = false;
s.td_disconnects = 2;
},
));
assert!(
shared.alerted_count() > first,
"C12:重连后的同款要算新事件(会话标记 = 断开计数)"
);
let before = shared.alerted_count();
shared.note_status_event(&status_ev(&gw, ek_ctp::GatewayChange::TdLoginOk, |s| {
s.td_logged_in = true;
s.td_disconnects = 2;
s.td_front_id = 8;
s.td_session_id = 5;
}));
assert_eq!(
shared.alerted_count(),
before,
"C12:成功类事件不得新增告警(文案与状态列已经说话)"
);
}
#[test]
fn c13_plugin_polls_no_status_only_a_one_shot_seed() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/plugin.rs"))
.expect("读 src/plugin.rs");
assert!(
!src.contains("fn poll_status"),
"C13:周期状态轮询必须退役(src/plugin.rs 里不得再有 poll_status)"
);
assert!(
!src.contains("status_batch"),
"C13:环形摊平取批(status_batch)不得再被插件调用"
);
assert!(src.contains("fn seed_status"), "C13:一次性播种必须在场");
assert!(
src.contains("request_status_seed"),
"C13:ePlugUp 要负责排播种(运行期每个网关至多这一发 ask)"
);
assert!(
src.contains("ek_ctp::EVENT_STATUS_PREFIX"),
"C13:插件必须订阅 eStatus."
);
}
#[test]
fn c14_two_faces_come_from_the_same_sentence() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); let shared = Arc::new(UiShared::new());
shared.set_status_thresholds(99, 3600);
let gw = fixture_account(&shared);
for change in [
ek_ctp::GatewayChange::TdAuthOk,
ek_ctp::GatewayChange::TdLoginOk,
ek_ctp::GatewayChange::SettlementConfirmed,
ek_ctp::GatewayChange::ContractArrived,
ek_ctp::GatewayChange::MdLoginOk,
ek_ctp::GatewayChange::QueryRejected,
] {
let ev = status_ev(&gw, change, |s| {
s.td_query_rejects = 3;
s.td_query_reject_streak = 2;
s.td_last_error = Some("查询被拒,错误代码:80".to_string());
s.md_subscribed = 4;
});
let text = ev.describe();
shared.note_status_event(&ev);
let (lines, _) = shared.logs_snapshot();
let want = format!("[INFO][{gw}] {text}");
assert!(
lines.iter().any(|l| l == &want),
"C14:{} 的日志行必须逐字等于 describe(),实测缺行",
change.kind()
);
let row = shared.account(&gw).expect("夹具行要在");
assert_eq!(
row.chain_note.as_deref().unwrap_or("").trim(),
text.strip_prefix(ek_ui::state::CHAIN_STATE_PREFIX)
.expect("生产者文案必带链前缀")
.trim(),
"C14:{}:阶段栏与日志行是同一句",
change.kind()
);
}
}
#[test]
fn c22_cancel_reject_reasons_are_translated_with_the_machine_code_kept() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); let cases: [(&str, &[&str]); 4] = [
("unknown:order", &["这台网关的账本", "unknown:order"]),
(
"mismatch:gateway 订单归属 ctp_9999_278153 ≠ 请求 ctp_9999_220380",
&["该委托不属于这个账户", "mismatch:gateway"],
),
(
"terminal:status 订单 ctp.CTP_0_1 已是 FINITED",
&["终态", "terminal:status"],
),
("limit:cancel-guard", &["上限", "limit:cancel-guard"]),
];
for (msg, needles) in cases {
let note = UiShared::cancel_reject_note(msg);
for n in needles {
assert!(note.contains(n), "「{msg}」译文中缺「{n}」:{note}");
}
}
let raw = UiShared::cancel_reject_note("weird:code 某新拒因");
assert!(
raw.contains("weird:code"),
"未认出的机器码必须原样显形:{raw}"
);
assert!(!raw.contains("(机器码:"), "未译出的不加译文壳:{raw}");
}
#[test]
fn c15_name_column_falls_back_to_the_code_not_a_dash() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/render.rs"))
.expect("读 src/render.rs");
let mut offenders: Vec<String> = Vec::new();
let mut fallbacks = 0usize;
for (idx, _) in src.match_indices("name.is_empty()") {
let window: String = src[idx..].chars().take(200).collect();
let dash = window.find("\"-\"");
let sym = window.find("symbol.clone()");
let namev = window.find("name.clone()");
let first_value = [sym, namev].into_iter().flatten().min();
if let Some(d) = dash
&& first_value.is_none_or(|v| d < v)
{
offenders.push(format!(
"第 {} 行附近:名称兜底是「-」:{}",
src[..idx].matches('\n').count() + 1,
window.lines().next().unwrap_or("").trim()
));
}
if window.contains("symbol.clone()") {
fallbacks += 1;
}
}
assert!(
offenders.is_empty(),
"C15:名称列缺名时必须显示代码,不得显示「-」,实测 {offenders:?}"
);
assert!(
fallbacks >= 4,
"C15:兜底点必须覆盖四张表(合约勾选前的 tick 也算一张),实测 {fallbacks} 处"
);
}
#[test]
fn c15b_names_backfill_when_contracts_arrive_after_the_rows() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); let shared = Arc::new(UiShared::new());
let mut o = ek_data::order::OrderData {
gateway_name: "ctp".to_string(),
symbol: "ag2611".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
orderid: "8_5_1".to_string(),
status: ek_data::constant::Status::SUBMITTING,
..ek_data::order::OrderData::default()
};
o.name = String::new();
shared.note_order(&o);
let mut t = ek_data::trade::TradeData {
gateway_name: "ctp".to_string(),
symbol: "ag2611".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
orderid: "8_5_1".to_string(),
tradeid: "T1".to_string(),
..ek_data::trade::TradeData::default()
};
t.name = String::new();
shared.note_trade(&t);
let snap0 = shared.trade_snap();
assert!(
snap0.orders.iter().all(|r| r.name.is_empty()),
"夹具前提:合约未到货时名字就是空"
);
drop(snap0);
shared.update_contract(&ek_data::contract::ContractData {
gateway_name: "ctp".to_string(),
symbol: "ag2611".to_string(),
exchange: ek_data::constant::Exchange::SHFE,
name: "沪银2611".to_string(),
..ek_data::contract::ContractData::default()
});
let snap = shared.trade_snap();
assert!(
snap.orders.iter().all(|r| r.name == "沪银2611"),
"C15b:委托行要按代码回补名称,实测 {:?}",
snap.orders.iter().map(|r| &r.name).collect::<Vec<_>>()
);
assert!(
snap.trades.iter().all(|r| r.name == "沪银2611"),
"C15b:成交行同样回补"
);
let mut o2 = o.clone();
o2.orderid = "8_5_2".to_string();
o2.name = "自带的名字".to_string();
shared.note_order(&o2);
shared.update_contract(&ek_data::contract::ContractData {
symbol: "ag2611".to_string(),
name: "沪银2611改口".to_string(),
..ek_data::contract::ContractData::default()
});
let snap2 = shared.trade_snap();
let keep = snap2
.orders
.iter()
.find(|r| r.orderid == "8_5_2")
.expect("行要在");
assert_eq!(keep.name, "自带的名字", "C15b:已有名字不得被覆盖");
}
fn lum(c: egui::Color32) -> f64 {
let ch = [c.r(), c.g(), c.b()].map(|v| {
let v = f64::from(v) / 255.0;
if v <= 0.03928 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
});
0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2]
}
fn contrast(a: egui::Color32, b: egui::Color32) -> f64 {
let (la, lb) = (lum(a), lum(b));
let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
(hi + 0.05) / (lo + 0.05)
}
fn hue(c: egui::Color32) -> f64 {
let (r, g, b) = (
f64::from(c.r()) / 255.0,
f64::from(c.g()) / 255.0,
f64::from(c.b()) / 255.0,
);
let (mx, mn) = (r.max(g).max(b), r.min(g).min(b));
let d = mx - mn;
if d == 0.0 {
return 0.0;
}
let h = if mx == r {
((g - b) / d).rem_euclid(6.0)
} else if mx == g {
(b - r) / d + 2.0
} else {
(r - g) / d + 4.0
};
h * 60.0
}
fn hue_delta(a: f64, b: f64) -> f64 {
let d = (a - b).abs() % 360.0;
if d > 180.0 { 360.0 - d } else { d }
}
#[test]
fn u9_dual_palette_ladder_holds_in_both_themes() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); use ek_ui::tokens::{PALETTE_DARK, PALETTE_LIGHT};
let rgb = |r: u8, g: u8, b: u8| egui::Color32::from_rgb(r, g, b);
assert_eq!(PALETTE_LIGHT.bg1, rgb(0xF9, 0xFA, 0xFB), "--st-bg");
assert_eq!(PALETTE_LIGHT.bg2, rgb(0xFF, 0xFF, 0xFF), "--st-surface");
assert_eq!(
PALETTE_LIGHT.bg4,
rgb(0xE9, 0xED, 0xF2),
"--st-surface-2 = 控件底 / 表头带底"
);
assert_eq!(PALETTE_LIGHT.bg5, rgb(0xDD, 0xE4, 0xEC), "hover 底");
assert_eq!(PALETTE_LIGHT.bg6, rgb(0xD3, 0xDB, 0xE6), "pressed 底");
assert_eq!(
PALETTE_LIGHT.accent2,
rgb(0xE8, 0xED, 0xF4),
"选中底(参考站 --st-accent)"
);
assert_eq!(
PALETTE_LIGHT.accent4,
rgb(0x40, 0x5D, 0xE6),
"--st-focus(用户裁定 E)"
);
assert_eq!(
PALETTE_LIGHT.border,
rgb(0xD5, 0xDD, 0xE8),
"--st-line(实色,不 alpha)"
);
assert_eq!(PALETTE_LIGHT.text1, rgb(0x22, 0x1E, 0x1F), "--st-ink");
assert_eq!(
PALETTE_LIGHT.text2,
rgb(0x30, 0x30, 0x30),
"--st-ink-soft(裁定 C 引入)"
);
assert_eq!(
PALETTE_LIGHT.text3,
rgb(0x5B, 0x61, 0x69),
"次级色(带底上 5.32,自审 R4)"
);
assert_eq!(PALETTE_DARK.bg1, rgb(0x0E, 0x11, 0x13), "--st-bg");
assert_eq!(PALETTE_DARK.bg2, rgb(0x16, 0x1A, 0x1D), "--st-surface");
assert_eq!(PALETTE_DARK.bg4, rgb(0x1D, 0x22, 0x26), "--st-surface-2");
assert_eq!(
PALETTE_DARK.bg5,
rgb(0x2A, 0x30, 0x36),
"hover 底(与描边同值,参考站同形)"
);
assert_eq!(PALETTE_DARK.bg6, rgb(0x34, 0x3B, 0x42), "pressed 底");
assert_eq!(
PALETTE_DARK.accent2,
rgb(0x34, 0x3B, 0x42),
"选中底 = pressed 同档(自审 R11)"
);
assert_eq!(
PALETTE_DARK.accent4,
rgb(0xE5, 0xE5, 0xE5),
"primary(裁定 E 深档不动)"
);
assert_eq!(
PALETTE_DARK.border,
rgb(0x2A, 0x30, 0x36),
"--st-line 实色(旧 alpha 口径退役)"
);
assert_eq!(
PALETTE_DARK.text1,
rgb(0xEE, 0xF1, 0xF3),
"--st-ink(旧 #FAFAFA 刺眼的那支退役)"
);
assert_eq!(PALETTE_DARK.text2, rgb(0xC7, 0xCD, 0xD3), "--st-ink-soft");
assert_eq!(PALETTE_DARK.text3, rgb(0x8E, 0x97, 0x9F), "--st-ink-muted");
let dark = PALETTE_DARK;
assert!(
lum(dark.bg1) < lum(dark.bg4)
&& lum(dark.bg4) < lum(dark.bg5)
&& lum(dark.bg5) < lum(dark.accent2),
"深档底色阶梯必须单调:页面 < 控件 < hover < 选中"
);
let light = PALETTE_LIGHT;
assert!(
lum(light.bg1) > lum(light.accent2) && lum(light.accent2) > lum(light.bg5),
"浅档必须反向:页面最白 > 选中 > hover(浅档的「更亮」= 更白)"
);
assert!(
lum(PALETTE_DARK.text1) > lum(PALETTE_DARK.text2)
&& lum(PALETTE_DARK.text2) > lum(PALETTE_DARK.text3),
"深档的正文 / 次正文 / 次级必须逐级变暗(亮 = 突出)"
);
let lp = PALETTE_LIGHT;
assert!(
lum(lp.text1) < lum(lp.text2) && lum(lp.text2) < lum(lp.text3),
"浅档的正文 / 次正文 / 次级必须逐级变淡(黑 = 突出)"
);
assert_ne!(lp.text1, lp.text2, "浅档正文与次正文同色 = 层级塌一档");
assert_ne!(lp.text2, lp.text3, "浅档次正文与次级同色 = 层级塌一档");
assert_ne!(
PALETTE_LIGHT.bg1, PALETTE_DARK.bg1,
"浅档页面底与深档同值 = 双档是假的"
);
assert!(
lum(PALETTE_LIGHT.bg1) > 0.8 && lum(PALETTE_DARK.bg1) < 0.1,
"浅档页面底必须是「白」,深档必须是「近黑」"
);
}
#[test]
fn u10_contrast_tiers_hold_in_both_themes() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); use ek_ui::tokens::{Theme, alert_of_theme, semantic_of_theme};
let mut low: Vec<String> = Vec::new();
for theme in [Theme::Dark, Theme::Light] {
let p = palette_of(theme);
let s = semantic_of_theme(theme);
let a = alert_of_theme(theme);
let mut check =
|tier: &str, need: f64, name: String, fg: egui::Color32, bg: egui::Color32| {
let c = contrast(fg, bg);
if c < need {
low.push(format!("{theme:?} {tier} {name}: {c:.2} < {need}"));
}
};
check("T1", 4.5, "正文/页面底".into(), p.text1, p.bg1);
check("T4", 4.5, "表头文字/带底".into(), p.text3, p.bg4);
check("T5", 4.5, "次正文/页面底".into(), p.text2, p.bg1);
check("T5", 4.5, "次正文/hover底".into(), p.text2, p.bg5);
check("T3", 4.0, "涨/选中行".into(), s.rise, p.accent2);
check("T3", 4.0, "跌/选中行".into(), s.fall, p.accent2);
check("T3", 4.0, "警/选中行".into(), s.warn, p.accent2);
check("T3", 4.0, "成/选中行".into(), s.ok, p.accent2);
check("T3", 4.0, "信息/选中行".into(), s.info, p.accent2);
check("T3", 3.0, "错/选中行".into(), s.error, p.accent2);
check("T3", 3.0, "弱化/选中行".into(), s.muted, p.accent2);
for (name, c) in [
("涨/页面底", s.rise),
("跌/页面底", s.fall),
("警/页面底", s.warn),
("错/页面底", s.error),
("成/页面底", s.ok),
("信息/页面底", s.info),
("弱化/页面底", s.muted),
] {
check("T2", 4.5, name.into(), c, p.bg1);
}
check("T6", 4.5, "告警·信息/卡片底".into(), a.info, p.bg2);
check("T6", 4.5, "告警·警告/卡片底".into(), a.warning, p.bg2);
check("T6", 4.5, "告警·错误/卡片底".into(), a.error, p.bg2);
}
assert!(low.is_empty(), "对比度不达标的项:{low:?}");
}
fn palette_of(theme: ek_ui::tokens::Theme) -> ek_ui::tokens::Palette {
theme.palette()
}
#[test]
fn u11_semantic_hues_are_constant_across_themes() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); use ek_ui::tokens::{Theme, semantic_of_theme};
let dark = semantic_of_theme(Theme::Dark);
let light = semantic_of_theme(Theme::Light);
for (name, d, l, want) in [
("RISE", dark.rise, light.rise, 'r'),
("FALL", dark.fall, light.fall, 'g'),
("WARN", dark.warn, light.warn, 'o'),
("ERROR", dark.error, light.error, 'r'),
("OK", dark.ok, light.ok, 'g'),
("INFO", dark.info, light.info, 'b'),
] {
let (hd, hl) = (hue(d), hue(l));
assert!(
hue_delta(hd, hl) <= 20.0,
"{name} 跨档色相漂移 {hd:.0}° → {hl:.0}°,容差 20°(语义不玩配色)"
);
match want {
'r' => assert!(d.r() > d.g() && l.r() > l.g(), "{name} 两档都必须是红"),
'g' => assert!(d.g() > d.r() && l.g() > l.r(), "{name} 两档都必须是绿"),
'o' => assert!(
d.r() > d.g() && d.g() > d.b() && l.r() > l.g() && l.g() > l.b(),
"{name} 两档都必须是橙(红>绿>蓝)"
),
_ => assert!(d.b() > d.r() && l.b() > l.r(), "{name} 两档都必须偏蓝"),
}
}
for (name, m) in [("深档", dark.muted), ("浅档", light.muted)] {
let mx = m.r().max(m.g()).max(m.b()) as i16;
let mn = m.r().min(m.g()).min(m.b()) as i16;
assert!(
mx - mn <= 24,
"{name} MUTED 必须近中性,实测分量差 {}",
mx - mn
);
}
for theme in [Theme::Dark, Theme::Light] {
let s = semantic_of_theme(theme);
assert_ne!(s.rise, s.error, "{theme:?} 涨与错同色");
assert_ne!(s.fall, s.ok, "{theme:?} 跌与成同色");
assert_ne!(s.rise, s.fall, "{theme:?} 涨与跌同色");
}
}
#[test]
fn u12_theme_encoding_roundtrips_and_default_is_dark() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); use ek_ui::tokens::Theme;
assert_eq!(
Theme::default(),
Theme::Dark,
"缺省必须是深色(裁定 B:没点过钮 = 现行为)"
);
for t in [Theme::Dark, Theme::Light] {
assert_eq!(
Theme::from_u8(t.as_u8()),
t,
"u8 往返必须稳定(状态位是 AtomicU8)"
);
assert_eq!(Theme::from_str(t.as_str()), t, "字面串往返必须稳定(存储值)");
assert_eq!(t.flipped().flipped(), t, "flipped 必须是对合(点两下回原档)");
}
assert_eq!(
Theme::Dark.as_u8(),
0,
"深档编码必须是 0(缺省 = AtomicU8 的初值)"
);
assert_eq!(Theme::Light.as_u8(), 1, "浅档编码必须是 1");
assert_eq!(Theme::Dark.as_str(), "dark", "存储字面串逐字钉死");
assert_eq!(Theme::Light.as_str(), "light", "存储字面串逐字钉死");
for (alias, want) in [
("浅色", Theme::Light),
(" LIGHT ", Theme::Light),
("深色", Theme::Dark),
] {
assert_eq!(
Theme::from_str(alias),
want,
"别名 {alias:?} 的认法逐字钉死"
);
}
for bad in ["", "DAY", "浅", "2", "255"] {
assert_eq!(
Theme::from_str(bad),
Theme::Dark,
"脏值 {bad:?} 必须落回缺省深色"
);
}
for bad in [2u8, 3, 200] {
assert_eq!(
Theme::from_u8(bad),
Theme::Dark,
"脏编码 {bad} 必须落回缺省深色"
);
}
}
#[test]
fn u13_theme_state_marks_persistence_only_on_user_toggle() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); let shared = ek_ui::state::UiShared::new();
assert_eq!(
shared.theme(),
ek_ui::tokens::Theme::Dark,
"初值必须缺省深色"
);
assert!(
!shared.take_theme_persist_due(),
"初值不得带落盘标记(没点过钮 = 不落盘)"
);
let gen0 = shared.rev();
shared.set_theme(ek_ui::tokens::Theme::Light);
assert_eq!(shared.theme(), ek_ui::tokens::Theme::Light);
assert!(
!shared.take_theme_persist_due(),
"读盘装档不得立落盘标记(否则每次启动都写文件)"
);
let next = shared.toggle_theme();
assert_eq!(next, ek_ui::tokens::Theme::Dark);
assert!(shared.take_theme_persist_due(), "手点必须立落盘标记");
assert!(
!shared.take_theme_persist_due(),
"标记是一次性的(取走即清,不许每帧写文件)"
);
assert!(
shared.rev() > gen0,
"手点必须 bump revision(渲染下一帧生效)"
);
}
#[test]
fn u14_text_tier_wiring_is_in_the_source() {
ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
ek_cfg::secret::force_fake_for_tests(); let read = |f: &str| {
std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("src")
.join(f),
)
.expect("读 src 文件")
};
let tokens = read("tokens.rs");
let render = read("render.rs");
let ui_ext = read("ui_ext.rs");
let ni = tokens
.split("widgets.noninteractive = egui::style::WidgetVisuals")
.nth(1)
.expect("tokens.rs 必须有 widgets.noninteractive 的显式装配");
let ni_head: String = ni.chars().take(400).collect();
assert!(
ni_head.contains("fg_stroke: stroke_body"),
"普通 label 的文字源必须是表体那支(stroke_body = p.text2);实测:{ni_head}"
);
assert!(
tokens.contains("let stroke_body = egui::Stroke::new(STROKE_WIDTH, p.text2);"),
"stroke_body 必须 = p.text2(参考站 td = ink-soft)"
);
assert!(
render.contains("theme_of(ui.ctx()).palette().text3"),
"表头文字必须在画字处吃当前档的 text3(参考站 th = ink-muted)"
);
assert!(
!ui_ext.contains("widgets.noninteractive.fg_stroke.color"),
"区块标题(标题层 = TEXT1)不能再从 noninteractive.fg_stroke 取色 —— 那一支自 0.8.2 起是表体色"
);
assert!(
ui_ext.contains("strong_text_color()"),
"标题层走 strong_text_color()(= widgets.active.fg_stroke = text1)"
);
assert!(
render.contains("ch.line_soft"),
"行线必须吃 Chrome.line_soft(= 当前档 palette.border_soft)"
);
assert!(
tokens.contains("border_soft: shadcn::BORDER_SOFT")
&& tokens.contains("border_soft: Color32::from_rgb(0xEE, 0xEE, 0xEE)"),
"行线描边必须是色板里明码的两支(深 #22272c / 浅 #eeeeee,参考站 --st-line-soft)"
);
}