//! `app` 的单测:主题切换、窗口尺寸、截图闸门、gallery 能构图,以及**presenter 接线的闭环**。 //! //! 闭环测试用的是 `MockBackend`(presenter 自带的假后端):它走的是与真 host 完全相同的 //! `dispatch → drain_events → AppState::apply → 视图模型` 通道,所以「点 Play → 状态真的变」 //! 这件事在这里就能判死,不必等图形环境。 use unlucky_design::color::SchemeFlavor; use unlucky_presenter::mock::MockBackend; use unlucky_presenter::router::Route; use unlucky_presenter::settings_bridge::SettingsBridge; use super::message::Message; use super::state::App; use super::{APP_TITLE, WINDOW_SIZE}; use crate::cli::{Options, Screen}; use crate::pages; use crate::theme::Families; const FAMILIES: Families = Families { text: "Segoe UI", display: "Segoe UI", mono: "Consolas", }; /// 不碰磁盘的 App(mock 后端 + 内存设置桥)。 fn headless(options: Options, flavor: SchemeFlavor) -> App { App::with_presenter( options, FAMILIES, flavor, Box::new(MockBackend::new()), SettingsBridge::memory_only(), ) } fn screenshot_options() -> Options { Options { screenshot: Some(std::path::PathBuf::from("x.png")), ..Options::default() } } #[test] fn switching_flavor_rebuilds_styles_but_keeps_ui_state() { let mut app = headless(Options::default(), SchemeFlavor::Light); app.state.navigate(Route::Logs); app.refresh_view(); assert_eq!(app.ui().name(), "ull-m3-light"); app.set_flavor(SchemeFlavor::Dark); assert_eq!(app.ui().name(), "ull-m3-dark", "切主题要换主题名"); assert_eq!(app.state.route(), &Route::Logs, "切主题不许重置 UI 状态"); assert_eq!(app.view.screen, pages::Screen::Logs, "视图模型跟着路由"); assert!(app.options.screenshot.is_none(), "切主题不许改启动参数"); } #[test] fn window_size_is_the_acceptance_size() { assert_eq!((WINDOW_SIZE.width, WINDOW_SIZE.height), (1180.0, 760.0)); assert_eq!(APP_TITLE, "Unlucky Launcher"); } #[test] fn screenshot_mode_never_waits_for_the_system_theme() { // 门禁依赖:CI 的 runtime-smoke 不带主题,xvfb 里也没有主题会话。 let options = screenshot_options(); assert!(!options.wants_system_theme(), "截图模式不许等系统主题"); assert_eq!(options.capture_flavor(), Some(SchemeFlavor::Light)); let app = headless(options, SchemeFlavor::Light); assert!(app.has_screenshot() && app.ui().name() == "ull-m3-light"); } #[test] fn gallery_screen_builds_a_tree() { assert_eq!(Options::default().screen, Screen::Shell, "默认仍是产品骨架"); let options = Options { screen: Screen::Gallery, ..Options::default() }; let app = headless(options, SchemeFlavor::Light); // 真实像素由 `--screen=gallery --screenshot` 出图 + `gallery::probe` 量 let _ = app.view(); } #[test] fn frames_are_only_requested_in_screenshot_mode() { let mut app = headless(Options::default(), SchemeFlavor::Light); assert!(!app.wants_frames(), "普通运行不订阅帧,避免每帧重绘"); app.options.screenshot = Some(std::path::PathBuf::from("x.png")); assert!( app.wants_frames(), "截图模式必须订阅帧事件才能在第 3 帧出图" ); } // --------------------------------------------------------------- presenter 闭环 /// **首屏快照在 Boot 就到位**(截图在第 3 帧落盘,等 30 fps 的 tick 会拍进空页面)。 #[test] fn boot_drains_the_first_snapshot_before_the_first_frame() { let mut app = headless(screenshot_options(), SchemeFlavor::Light); assert!(app.view.instances.rows.is_empty(), "还没 Boot"); let _ = app.update(Message::Boot); assert_eq!( app.view.instances.rows.len(), 4, "mock 的四个实例已进视图模型" ); assert_eq!(app.view.screen, pages::Screen::Instances); } /// 首屏自动选中第一行(右栏不该开局空白),且**只做一次**:返回列表后再刷新不弹回详情。 #[test] fn the_first_snapshot_selects_the_first_instance_once() { let mut app = headless(screenshot_options(), SchemeFlavor::Light); let _ = app.update(Message::Boot); assert_eq!( app.state.route(), &Route::Detail("survival".to_string()), "首屏快照到位后自动选中第一行" ); let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances))); assert_eq!(app.state.route(), &Route::Instances); // 再推进一拍(会 drain_events + auto_select):不该把他弹回详情页 app.advance(super::state::TICK); assert_eq!(app.state.route(), &Route::Instances, "自动选中只做一次"); } /// **点 Play → 命令真的派发出去 → 事件回流 → 页面真的变**(不做乐观回显)。 #[test] fn launching_goes_through_the_backend_and_comes_back_as_events() { let mut app = headless(screenshot_options(), SchemeFlavor::Light); let _ = app.update(Message::Boot); let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail( "survival".to_string(), )))); let detail = app.view.instances.detail.as_ref().expect("有选中项"); assert!( detail.can_launch && !detail.can_stop, "空闲:可启动不可停止" ); let _ = app.update(Message::Page(pages::Message::Launch( "survival".to_string(), ))); let detail = app.view.instances.detail.as_ref().expect("有选中项"); assert!( !detail.can_launch && detail.can_stop, "派发 LaunchInstance 后后端发回了 Busy 事件:按钮组合必须已经翻转" ); assert_eq!( detail.status.as_deref(), Some("Downloading · 0%"), "状态行来自事件里的进度,不是本地假造" ); } /// 时间推进真的把状态往前推(后端的脚本按 tick 的累计时长走)。 #[test] fn ticks_advance_the_backend_script() { let mut app = headless(screenshot_options(), SchemeFlavor::Light); let _ = app.update(Message::Boot); let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail( "survival".to_string(), )))); let _ = app.update(Message::Page(pages::Message::Launch( "survival".to_string(), ))); // 33 ms × 50 ≈ 1.65 s:mock 的脚本在 1.5 s 从 Download 切到 Verify for _ in 0..50 { app.advance(super::state::TICK); } let detail = app.view.instances.detail.as_ref().expect("有选中项"); let status = detail.status.as_deref().unwrap_or_default(); assert!( status.starts_with("Verifying") || status.starts_with("Starting"), "50 拍之后应已过下载阶段,实际 {status:?}" ); } /// 停止也是真命令:`StopInstance` 让 mock 直接回一条 Idle 事件。 #[test] fn stopping_returns_to_idle_through_an_event() { let mut app = headless(screenshot_options(), SchemeFlavor::Light); let _ = app.update(Message::Boot); let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail( "survival".to_string(), )))); let _ = app.update(Message::Page(pages::Message::Launch( "survival".to_string(), ))); let _ = app.update(Message::Page(pages::Message::Stop("survival".to_string()))); let detail = app.view.instances.detail.as_ref().expect("有选中项"); assert!(detail.can_launch && !detail.can_stop, "停下来回到空闲"); assert!(detail.status.is_none(), "空闲没有状态行"); } /// 导航:一级页替换、详情压栈、返回出栈——页面换的是 `Route`,不是页面自己的选中项。 #[test] fn navigation_follows_the_presenter_router() { let mut app = headless(screenshot_options(), SchemeFlavor::Light); let _ = app.update(Message::Boot); let _ = app.update(Message::Page(pages::Message::Navigate(Route::Detail( "survival".to_string(), )))); assert_eq!(app.view.screen, pages::Screen::Instances, "详情仍是实例页"); assert!(app.state.router.can_go_back()); let _ = app.update(Message::Page(pages::Message::Navigate(Route::Settings))); assert_eq!(app.view.screen, pages::Screen::Settings); assert_eq!( app.view.active, Some(pages::Screen::Settings.nav()), "导航轨高亮跟着路由走" ); assert!( !app.state.router.can_go_back(), "一级页互相替换,不进栈(presenter::router 的语义)" ); let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances))); assert_eq!(app.view.screen, pages::Screen::Instances); } /// 视图模型跟着窗口宽度走(交互模式下 600 / 840 两条断点靠 resize 事件生效)。 #[test] fn resizing_the_window_rebuilds_the_view_model() { let mut app = headless(Options::default(), SchemeFlavor::Light); let _ = app.update(Message::Boot); let _ = app.update(Message::Window(iced::window::Event::Resized( iced::Size::new(1180.0, 760.0), ))); assert!((app.view.window_width - 1180.0).abs() < f32::EPSILON); let _ = app.update(Message::Window(iced::window::Event::Resized( iced::Size::new(700.0, 600.0), ))); assert!((app.view.window_width - 700.0).abs() < f32::EPSILON); // 页面照旧能构图(窄窗退化到单列,不 panic) let _ = app.view(); } /// 截图模式不读设置文件里的主题(D30 可复现:两台机器出同一张图)。 #[test] fn screenshot_mode_ignores_the_settings_file_theme() { let mut app = headless(screenshot_options(), SchemeFlavor::Light); app.apply_config_theme(); assert_eq!(app.ui().name(), "ull-m3-light", "截图模式的主题只认命令行"); }