ek-ui 0.2.2

ek 生态常驻主界面(egui/eframe):插件即界面——观测页 + 日志页,车头与 UI 同插件
Documentation
//! S2 验收:svctl 操作闭环真链(ui 窗口按钮 → UiCmd 队列 → eTimer 辞行 →
//! svctl ask → 日志/快照)+ 状态机单元(指令 / 二次确认 / 隔离判定)。

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}");
}

// ---- L1:状态机 ----

#[test]
fn u0_cmd_queue_roundtrip() {
    let s = UiShared::new();
    s.push_cmd(UiCmd::Start {
        factory: "dm".to_string(),
    });
    s.push_cmd(UiCmd::Remove {
        name: "dicy".to_string(),
    });
    assert_eq!(
        s.drain_cmds(),
        vec![
            UiCmd::Start {
                factory: "dm".to_string()
            },
            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"));
}

// ---- 集成:cmd → eTimer 辞行 → svctl 真链 ----

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();

    // 快照先落(等 eTimer 真实一拍)。
    wait_until("rows fill", 4, || shared.snapshot().rows.len() >= 4);
    assert!(shared.snapshot().rows.iter().any(|r| r.name == "dm"));

    // 摘除 dm:push → 人工拍 eTimer → 行消失 + 业务回执日志。
    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"
    );

    // 重注入 dm:单步直发 → 行回来。
    shared.push_cmd(UiCmd::Start {
        factory: "dm".to_string(),
    });
    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(),
    });
    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));
}