use std::sync::Arc;
use std::time::{Duration, Instant};
use ek_core::main_engine::MainEngine;
use ek_event::EVENT_TIMER;
use ek_event::Event;
use ek_station::{StationConfig, boot};
use ek_ui::state::{ConfirmStep, UiCmd, UiShared, is_quarantined};
fn wait_until(tag: &str, secs: u64, f: impl Fn() -> bool) {
let deadline = Instant::now() + Duration::from_secs(secs);
while Instant::now() < deadline {
if f() {
return;
}
std::thread::sleep(Duration::from_millis(20));
}
panic!("wait_until timeout: {tag}");
}
#[test]
fn u0_cmd_queue_roundtrip() {
let s = UiShared::new();
s.push_cmd(UiCmd::Start {
factory: "dm".to_string(),
name: String::new(),
});
s.push_cmd(UiCmd::Remove {
name: "dicy".to_string(),
});
assert_eq!(
s.drain_cmds(),
vec![
UiCmd::Start {
factory: "dm".to_string(),
name: String::new()
},
UiCmd::Remove {
name: "dicy".to_string()
},
]
);
assert!(s.drain_cmds().is_empty(), "drain 是消费性取走");
}
#[test]
fn u1_confirm_steps_and_cancel() {
let s = UiShared::new();
assert_eq!(s.confirm_step("a"), ConfirmStep::Armed);
assert_eq!(s.pending_confirm().as_deref(), Some("a"));
assert_eq!(s.confirm_step("a"), ConfirmStep::Fired);
assert_eq!(s.pending_confirm(), None);
assert_eq!(s.confirm_step("b"), ConfirmStep::Armed);
assert_eq!(s.confirm_step("c"), ConfirmStep::Switched);
assert_eq!(s.pending_confirm().as_deref(), Some("c"));
s.confirm_cancel();
assert_eq!(s.pending_confirm(), None);
assert_eq!(s.confirm_step("b"), ConfirmStep::Armed); }
#[test]
fn u2_quarantined_detection() {
assert!(is_quarantined("Quarantined"));
assert!(is_quarantined("State::Quarantined(panic x3)"));
assert!(!is_quarantined("Running"));
assert!(!is_quarantined("Ready"));
}
#[test]
fn u3_pipeline_poll_cadence_is_four_ticks() {
let s = UiShared::new();
let due: Vec<bool> = (0..9).map(|_| s.pipeline_poll_due()).collect();
assert_eq!(
due,
vec![false, false, false, true, false, false, false, true, false],
"节拍 = 每 4 拍一次(第 4、第 8 拍)"
);
let status: Vec<bool> = (0..9).map(|_| s.status_poll_due()).collect();
assert_eq!(
status.iter().filter(|b| **b).count(),
4,
"状态轮询仍是每 2 拍一次(第 2、4、6、8 拍)"
);
}
fn put_timer(engine: &MainEngine) {
engine.event_engine().put(Event::new(EVENT_TIMER)).unwrap();
}
#[test]
fn t0_remove_then_restart_loop() {
let engine = Arc::new(MainEngine::new(None));
let shared = Arc::new(UiShared::new());
boot(
&engine,
&ek_ui::ui_kinds(&shared, true),
&StationConfig {
plugins: vec!["dm".to_string(), "oms".to_string(), "ui".to_string()],
},
)
.unwrap();
engine.start().unwrap();
wait_until("rows fill", 4, || shared.snapshot().rows.len() >= 4);
assert!(shared.snapshot().rows.iter().any(|r| r.name == "dm"));
shared.push_cmd(UiCmd::Remove {
name: "dm".to_string(),
});
put_timer(&engine);
wait_until("dm removed", 4, || {
shared.snapshot().rows.iter().all(|r| r.name != "dm")
});
assert!(
shared
.logs_snapshot()
.0
.iter()
.any(|l| l.contains("[ops] svctl.remove dm 完成")),
"remove log missing"
);
shared.push_cmd(UiCmd::Start {
factory: "dm".to_string(),
name: String::new(),
});
put_timer(&engine);
wait_until("dm back", 4, || {
shared
.snapshot()
.rows
.iter()
.any(|r| r.name == "dm" && r.state == "Running")
});
assert!(
shared
.logs_snapshot()
.0
.iter()
.any(|l| l.contains("svctl.start dm → dm 上线")),
"start log missing"
);
engine.close_all(Duration::from_secs(2));
}
#[test]
fn t1_unknown_factory_rejected_logged() {
let engine = Arc::new(MainEngine::new(None));
let shared = Arc::new(UiShared::new());
boot(
&engine,
&ek_ui::ui_kinds(&shared, true),
&StationConfig {
plugins: vec!["dm".to_string(), "oms".to_string(), "ui".to_string()],
},
)
.unwrap();
engine.start().unwrap();
wait_until("rows fill", 4, || shared.snapshot().rows.len() >= 4);
shared.push_cmd(UiCmd::Start {
factory: "ghost".to_string(),
name: String::new(),
});
put_timer(&engine);
wait_until("rejected log", 4, || {
shared
.logs_snapshot()
.0
.iter()
.any(|l| l.contains("start ghost 被拒"))
});
engine.close_all(Duration::from_secs(2));
}
#[test]
fn t2_self_removal_refused_on_both_faces() {
let engine = Arc::new(MainEngine::new(None));
let shared = Arc::new(UiShared::new());
boot(
&engine,
&ek_ui::ui_kinds(&shared, true),
&StationConfig {
plugins: vec!["dm".to_string(), "oms".to_string(), "ui".to_string()],
},
)
.unwrap();
engine.start().unwrap();
wait_until("rows fill", 4, || shared.snapshot().rows.len() >= 4);
assert!(
shared.snapshot().rows.iter().any(|r| r.name == "svctl"),
"svctl 应在场(它是 svctl.* 的提供方)"
);
shared.push_cmd(UiCmd::Remove {
name: "ui".to_string(),
});
put_timer(&engine);
wait_until("ui refusal logged", 4, || {
shared
.logs_snapshot()
.0
.iter()
.any(|l| l.contains("拒绝移除 ui"))
});
assert!(
shared
.logs_snapshot()
.0
.iter()
.all(|l| !l.contains("svctl.remove ui 完成")),
"不得真摘 ui"
);
shared.push_cmd(UiCmd::Remove {
name: "svctl".to_string(),
});
put_timer(&engine);
wait_until("svctl refusal logged", 4, || {
shared
.logs_snapshot()
.0
.iter()
.any(|l| l.contains("remove svctl 被拒"))
});
put_timer(&engine);
wait_until("both faces still listed", 4, || {
let rows = &shared.snapshot().rows;
rows.iter().any(|r| r.name == "svctl") && rows.iter().any(|r| r.name == "ui")
});
assert!(
shared
.snapshot()
.rows
.iter()
.all(|r| r.name != "dm" || r.state == "Running"),
"dm 不该被顺手摘掉"
);
engine.close_all(Duration::from_secs(2));
}
#[test]
fn t3_pipeline_status_reaches_ui_state() {
let engine = Arc::new(MainEngine::new(None));
let shared = Arc::new(UiShared::new());
boot(
&engine,
&ek_ui::ui_kinds(&shared, true),
&StationConfig {
plugins: vec!["dm".to_string(), "oms".to_string(), "ui".to_string()],
},
)
.unwrap();
engine.start().unwrap();
wait_until("rows fill", 4, || shared.snapshot().rows.len() >= 4);
put_timer(&engine);
wait_until("pipeline lines", 6, || !shared.pipeline_lines().is_empty());
let lines = shared.pipeline_lines();
assert!(
lines
.iter()
.any(|l| l == "stage:validate|split|risk|send|book"),
"五段管线没显形: {lines:?}"
);
assert!(
lines.iter().any(|l| l.starts_with("floor:field|rate(")),
"限速/撤单守卫没显形: {lines:?}"
);
assert!(
lines.iter().any(|l| l == "splitter:ek-converter"),
"拆单器没显形: {lines:?}"
);
for gate in [
"gate:ek-risk:contract",
"gate:ek-risk:volume",
"gate:ek-risk:position-limit",
"gate:ek-risk:price-band",
] {
assert!(
lines.iter().any(|l| l == gate),
"工厂注入的风控闸 {gate} 没显形: {lines:?}"
);
}
engine.close_all(Duration::from_secs(2));
}
fn spill_row(name: &str, spilled: u64) -> ek_station::wire::PlugRow {
ek_station::wire::PlugRow {
name: name.to_string(),
version: String::new(),
state: "Running".to_string(),
deps: Vec::new(),
spilled,
panics: 0,
}
}
#[test]
fn u4_merge_frame_splits_timeout_from_target_gone() {
let prev = vec![1u32, 2];
assert_eq!(
ek_ui::plugin::merge_frame(Ok(vec![9u32]), prev.clone()),
vec![9]
);
assert_eq!(
ek_ui::plugin::merge_frame(
Err(ek_core::error::AskError::Timeout {
target: "oms".to_string(),
}),
prev.clone(),
),
prev,
"超时保旧帧:原先 unwrap_or_default 把四张表一起清空"
);
for gone in [
ek_core::error::AskError::TargetGone {
target: "oms".to_string(),
},
ek_core::error::AskError::UnknownService {
service: "oms.get_all_orders".to_string(),
},
ek_core::error::AskError::InvalidPayload {
target: "oms".to_string(),
},
ek_core::error::AskError::BusStopped,
] {
assert_eq!(
ek_ui::plugin::merge_frame::<u32>(Err(gone.clone()), prev.clone()),
Vec::<u32>::new(),
"目标不在场类错必须清空(留旧帧才是骗人): {gone}"
);
}
}
#[test]
fn u5_trade_frame_stale_is_a_transition_and_alerts_on_entry() {
let s = UiShared::new();
assert!(!s.trade_frame_stale());
assert!(s.note_trade_frame_stale(true), "进入 stale 是一次迁移");
assert_eq!(
s.pending_alert_count(),
1,
"旧帧必须被标明:进入 stale 灌一条告警"
);
assert!(
!s.note_trade_frame_stale(true),
"持续 stale 不是迁移(拥堵期不每拍刷)"
);
assert_eq!(s.pending_alert_count(), 1, "同一状态不重复灌");
assert!(s.note_trade_frame_stale(false), "回到当前帧也是一次迁移");
assert_eq!(s.pending_alert_count(), 1, "恢复不灌 toast(日志给)");
}
#[test]
fn u6_spill_alerts_once_per_plugin_then_again_at_mailbox_cap() {
let s = UiShared::new();
s.note_plugin_spill(&[spill_row("ui", 3), spill_row("dm", 0)]);
assert_eq!(s.pending_alert_count(), 1, "只有真溢出的那一行该响");
s.note_plugin_spill(&[spill_row("ui", 77)]);
assert_eq!(
s.pending_alert_count(),
1,
"持续溢出不刷屏(ek-core 侧每 128 条才打一条 stderr)"
);
s.note_plugin_spill(&[spill_row("ui", ek_core::plugin::MAILBOX_CAP as u64)]);
assert_eq!(
s.pending_alert_count(),
2,
"溢满一整轮邮箱是另一件事,必须再弹一次"
);
s.note_plugin_spill(&[spill_row("ui", ek_core::plugin::MAILBOX_CAP as u64 + 9)]);
assert_eq!(s.pending_alert_count(), 2, "容量档也只弹一次");
}
#[test]
fn u7_ops_window_stat_color_paints_the_worst_item() {
assert_eq!(
ek_ui::render::stat_color(0, 0),
ek_ui::tokens::semantic::MUTED,
"0/0 的意思是「没事」,不是数据"
);
assert_eq!(
ek_ui::render::stat_color(1, 0),
ek_ui::tokens::semantic::WARN
);
assert_eq!(
ek_ui::render::stat_color(ek_core::plugin::MAILBOX_CAP as u64, 0),
ek_ui::tokens::semantic::ERROR,
"溢满一整轮 = 丢的是一段时期的事件"
);
assert_eq!(
ek_ui::render::stat_color(0, 1),
ek_ui::tokens::semantic::ERROR
);
}
#[test]
fn t4_removed_oms_clears_the_frame_instead_of_marking_it_stale() {
let engine = Arc::new(MainEngine::new(None));
let shared = Arc::new(UiShared::new());
boot(
&engine,
&ek_ui::ui_kinds(&shared, true),
&StationConfig {
plugins: vec!["dm".to_string(), "oms".to_string(), "ui".to_string()],
},
)
.unwrap();
engine.start().unwrap();
wait_until("rows fill", 4, || shared.snapshot().rows.len() >= 4);
put_timer(&engine);
wait_until("frame asked", 4, || !shared.pipeline_lines().is_empty());
assert!(
!shared.trade_frame_stale(),
"oms 在场且空闲:四问不该进 stale"
);
shared.push_cmd(UiCmd::Remove {
name: "oms".to_string(),
});
put_timer(&engine);
wait_until("oms removed", 4, || {
shared.snapshot().rows.iter().all(|r| r.name != "oms")
});
put_timer(&engine);
wait_until("frame cleared", 4, || shared.pipeline_lines().is_empty());
let ts = shared.trade_snap();
assert!(
ts.orders.is_empty()
&& ts.trades.is_empty()
&& ts.positions.is_empty()
&& ts.accounts.is_empty(),
"oms 不在场 = 事实,四张表必须清空"
);
assert!(
!shared.trade_frame_stale(),
"目标不在场不是「问不到」,不许标 stale"
);
engine.close_all(Duration::from_secs(2));
}
#[test]
fn u8_oms_ask_tier_is_a_named_constant_and_the_slow_ports_stay_at_two_seconds() {
let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/plugin.rs"))
.expect("读 src/plugin.rs");
let window_after = |needle: &str, len: usize| -> String {
let idx = src
.find(needle)
.unwrap_or_else(|| panic!("plugin.rs 里找不到 {needle:?}"));
src[idx..(idx + len).min(src.len())].to_string()
};
assert!(
src.contains("pub const OMS_ASK_TIMEOUT: Duration = Duration::from_secs(1)"),
"P7b 的 1 秒必须是命名常量(带理由),不是散在四口里的字面量"
);
for svc in [
"\"oms.get_all_orders\"",
"\"oms.get_all_trades\"",
"\"oms.get_all_positions\"",
"\"oms.get_all_accounts\"",
] {
let w = window_after(svc, 160);
assert!(
w.contains("OMS_ASK_TIMEOUT"),
"{svc} 必须吃分级常量,实测窗口 {w:?}"
);
assert!(
!w.contains("Duration::from_secs(2)"),
"{svc} 不许留回 2 秒,实测窗口 {w:?}"
);
}
let w_list = window_after("ask_service::<ListReq, Vec<PlugRow>>", 120);
assert!(
w_list.contains("Duration::from_secs(2)"),
"svctl.list 留 2 秒(可能正撞 close_one 的摘除窗),实测窗口 {w_list:?}"
);
let w_status = window_after("ask_service::<StatusReq, GatewayStatus>", 200);
assert!(
w_status.contains("Duration::from_secs(2)"),
"{{gw}}.status 留 2 秒(可能正撞网关查询协调点的墙钟护栏),实测窗口 {w_status:?}"
);
}