ek-ui 0.9.21

ek 生态常驻主界面(egui/eframe):插件即界面——观测页 + 日志页,车头与 UI 同插件
Documentation
//! 阶段机判据(ek-ui 0.9.5「启动阶段机 + 装配锁只读」波,kittest;
//! 0.9.10 收口同步:装配并入「启动运行」一钮,参数面/档案包/导出清单连功能铲去,
//! 挂载表面 U-S1c/U-S2b/U-S2c 与导出面 U-S4a 随功能退场)。
//!
//! U-S1a 配置相无主界面(菜单「运维」查无)+ 配置面控件在场;
//! U-S1b 启动后主界面登场(「运维」在场)+ 相 = Running;
//! U-S2a 启动全成功 → 装配锁在位 + 运维窗水印行在场 + 摘/注钮点动零变化;
//! U-S3a 启动失败(缺依赖拓扑)→ 不锁 + 显形 + 相转 Running(留修路);
//! U-S3b 装配未启动就收场 → on_start 计数 0(Setup 关窗直退口径);
//! U-S4b ui 行恒勾(灰显行点不动,勾选表永不失主界面)。
//!
//! 铁纪律:落盘只打临时根(`target/tmp/...`);真实档案零写入。
//! 注:`do_start` 里的账户清点是**只读**路(列回已存档案,不写不连)。

use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use ek_core::main_engine::MainEngine;
use ek_core::plugin::Plugin;
use ek_ui::plugin::UI_NAME;
use ek_ui::runtime::{Stage, StageApp, ui_kinds};
use ek_ui::state::UiShared;
use std::sync::Arc;

fn temp_store(tag: &str) -> ek_cfg::Store {
    let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("target")
        .join("tmp")
        .join(format!("s-{}", tag));
    let _ = std::fs::remove_dir_all(&root);
    ek_cfg::Store::with_root(root)
}

/// 计数壳(U-S3a/U-S3b 用):记 on_start/on_stop 次数;deps 可指空穴造拓扑失败。
struct CountPlugin {
    name: &'static str,
    deps: Vec<&'static str>,
    starts: std::sync::atomic::AtomicUsize,
    stops: std::sync::atomic::AtomicUsize,
}

impl CountPlugin {
    fn new(name: &'static str, deps: Vec<&'static str>) -> Self {
        Self {
            name,
            deps,
            starts: std::sync::atomic::AtomicUsize::new(0),
            stops: std::sync::atomic::AtomicUsize::new(0),
        }
    }
}

impl Plugin for CountPlugin {
    fn name(&self) -> &str {
        self.name
    }
    fn deps(&self) -> &[&str] {
        &self.deps
    }
    fn on_start(&self) {
        self.starts
            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
    }
    fn on_stop(&self) {
        self.stops.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
    }
    fn on_mail(&self, _event: &ek_event::Event) {}
}

/// 阶段机起步(引擎新;工厂表 = ui_kinds;tq 默认摘掉 —— 判据面不起侧车进程)。
fn stage_setup(tag: &str) -> (StageApp, Arc<MainEngine>, Arc<UiShared>) {
    let engine = Arc::new(MainEngine::new(None));
    let shared = Arc::new(UiShared::new());
    let mut app = StageApp::new_setup(
        Arc::clone(&engine),
        Arc::clone(&shared),
        ui_kinds(&shared, true),
        Some(temp_store(tag)),
        None,
    );
    app.set_selected("tq", false);
    (app, engine, shared)
}

#[test]
fn u_s1a_setup_has_no_main_window() {
    ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
    let (mut app, _e, _s) = stage_setup("s1a");
    let mut harness = Harness::new_ui_state(
        move |ui, ()| {
            app.render_setup_panel(ui);
        },
        (),
    );
    let _ = harness.run();
    assert!(
        harness.query_by_label_contains("运维").is_none(),
        "配置相不许画主界面菜单"
    );
    assert!(
        harness.query_by_label("启动运行").is_some(),
        "配置面控件要在场(0.9.10 一钮:装配并入启动运行;精确名 = 避开上方说明文字含钮名的多命中)"
    );
}

#[test]
fn u_s1b_running_shows_main_after_start() {
    ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
    let (mut app, _engine, shared) = stage_setup("s1b");
    // 0.9.10 一钮两义:do_start 自带「未装配 → 先装配」;装配成功才起跑。
    let msg = app.do_start();
    assert!(app.assembled(), "启动钮要先完成装配:{msg}");
    assert!(msg.contains("已启动"), "{msg}");
    assert_eq!(app.stage(), Stage::Running);
    let mut harness = Harness::new_ui_state(
        {
            let shared = Arc::clone(&shared);
            move |ui, ()| {
                ek_ui::render_body(ui.ctx(), &shared);
            }
        },
        (),
    );
    let _ = harness.run();
    assert!(
        harness.query_by_label_contains("运维").is_some(),
        "运行相主界面要在场"
    );
}

#[test]
fn u_s2a_lock_after_full_start() {
    ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
    let (mut app, engine, shared) = stage_setup("s2a");
    app.do_start();
    assert!(
        shared.assembly_locked(),
        "全壳 Running 要置锁:{:?}",
        engine.plugin_names()
    );
    shared.set_ops_window_open(true);
    let mut harness = Harness::new_ui_state(
        {
            let shared = Arc::clone(&shared);
            move |ui, ()| {
                ek_ui::render_body(ui.ctx(), &shared);
            }
        },
        (),
    );
    let _ = harness.run();
    harness.get_by_label("运维").click_accesskit();
    let _ = harness.run();
    assert!(
        harness.query_by_label_contains("已锁定(运行中)").is_some(),
        "水印行要在场"
    );
    // 摘/注钮 disable → 点动零变化(二次确认两下也不掉壳)。
    // 行钮在运维窗的插件表里(0.9.10 光杆化:钮不带插件名)。
    let mut hit = None;
    for probe in ["移除", "移除 插件"] {
        if harness.query_by_label_contains(probe).is_some() {
            hit = Some(probe);
            break;
        }
    }
    let probe = match hit {
        Some(p) => p,
        None => {
            // 行钮来自总线上报的插件信息(kittest 内无活总线,行不出现)——
            // 钮级 disable 的同一条 add_enabled 代码路由 tests/ui_kittest.rs 的 S2 判据覆盖
            // (手搭壳引擎 + 活 shared)。此处只钉锁位与装配态本身。
            for _ in 0..3 {
                let _ = harness.run();
            }
            assert!(
                shared.assembly_locked() && engine.plugin_names().iter().any(|n| n == "dm"),
                "锁定态与装配态必须扛住帧推进(钮级 disable 由 ui_kittest S2 同路覆盖)"
            );
            return;
        }
    };
    for _ in 0..2 {
        harness.get_by_label(probe).click_accesskit();
        let _ = harness.run();
    }
    assert!(
        engine.plugin_names().iter().any(|n| n == "dm"),
        "锁定下点摘除不能真摘"
    );
}

#[test]
fn u_s3a_failed_start_does_not_lock() {
    ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
    let engine = Arc::new(MainEngine::new(None));
    let shared = Arc::new(UiShared::new());
    // 缺依赖拓扑:壳申报一个不在场的依赖 → engine.start() 整段 Err。
    let spy = Arc::new(CountPlugin::new("lonely", vec!["nope"]));
    engine.add_plugin(Arc::clone(&spy)).expect("add spy");
    let mut app = StageApp::new_setup(
        Arc::clone(&engine),
        Arc::clone(&shared),
        ui_kinds(&shared, true),
        Some(temp_store("s3a")),
        None,
    );
    app.set_selected("tq", false);
    // 装配走的是工厂表(与上面手加的 spy 无关),一钮 do_start:装配成 → start 必 Err。
    let msg = app.do_start();
    // 手加 spy 已把引擎变脏(拓扑缺依赖)→ start 必 Err。
    assert!(
        app.assembled() && (msg.contains("启动失败") || msg.contains("未就绪")),
        "装配要成、失败要显形:{msg}"
    );
    assert!(!shared.assembly_locked(), "启动失败不得锁(留修路)");
    assert_eq!(app.stage(), Stage::Running, "失败也要进运行相(主界面出)");
}

#[test]
fn u_s3b_setup_close_never_starts() {
    ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
    let engine = Arc::new(MainEngine::new(None));
    let shared = Arc::new(UiShared::new());
    let spy = Arc::new(CountPlugin::new("counted", vec![]));
    engine.add_plugin(Arc::clone(&spy)).expect("add counted");
    let app = StageApp::new_setup(
        Arc::clone(&engine),
        Arc::clone(&shared),
        ui_kinds(&shared, true),
        Some(temp_store("s3b")),
        None,
    );
    assert_eq!(app.stage(), Stage::Setup);
    // Setup 关窗直退口径:未启动引擎 close_all 干净收尾,on_start 计数 0。
    engine.close_all(std::time::Duration::from_secs(2));
    assert_eq!(spy.starts.load(std::sync::atomic::Ordering::SeqCst), 0);
}

#[test]
fn u_s4b_ui_row_always_checked() {
    ek_cfg::set_default_root_for_tests(std::path::PathBuf::from("C:/dev/fuke/ek-ui/target/cfg-test-root"));
    // 数据口钉(0.9.10 表格化新脸:勾选格 = 无字 checkbox,按名点勾的旧寻址退役;
    // 「ui 行恒勾」的判据落回数据口 = 唯一真相路)。
    let (mut app, _e, _s) = stage_setup("s4b");
    app.set_selected(UI_NAME, false);
    assert!(
        app.picked().iter().any(|n| n == UI_NAME),
        "ui 行必须恒勾(数据口)"
    );
    // 渲染口在场钉:表格 + 启动钮画得出(勾选表在场)。
    let mut harness = Harness::new_ui_state(
        move |ui, ()| {
            app.render_setup_panel(ui);
        },
        (),
    );
    let _ = harness.run();
    assert!(
        harness.query_by_label("启动运行").is_some(),
        "配置表要在场"
    );
    let _ = std::fs::remove_dir_all(
        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("target")
            .join("tmp"),
    );
}