拆分(行为不变,截图四件套逐字节相同):
- 400 行的 app.rs 拆成 src/app/{mod,state,message,update,subscription,view,boot,tests}.rs,
每个文件 ≤ 300 行;components 的 #[path] 跟着搬到 src/app/mod.rs(逻辑路径仍是 crate::app::components),
可见性放到 pub(crate) 让页面层能用真组件。
- 拆分前后各出 shell/gallery 浅深四张图,cmp 全部 IDENTICAL。
presenter 接线(crate 文档 §4):
- App 持有 Box<dyn Backend> + SettingsBridge + AppState;启动 build_backend + SettingsBridge::open + AppState::new。
- 截图通道强制 mock(D30 可复现);交互模式走真 host,--data-dir 从 M90 起真的被消费。
- Boot 抽干首屏快照(第 3 帧落盘,等节拍会把空页面拍进图);节拍 = 帧事件 + 33ms 时间闸门
(iced::time::every 要 tokio/smol,见 finding),tick → drain_events → AppState::apply。
- 用户动作不乐观回显:Launch/Stop 走 Backend::dispatch,状态等事件回来才变。
- 路由:pages::Message::Navigate(Route) 直接进 AppState::navigate,选中项 = 路由栈顶(不再有本地副本);
首屏自动选中第一行(list-detail 桌面惯例),只做一次。
实例页打通(其余三页保留占位视觉 + Message/update + 路由):
- 列表行换成 components::navigation::ListItem(真五态/选中),页头与详情动作换成 components::buttons
(Play 是唯一的实心 accent),分隔线换成 components::divider。
- 数据全部来自 presenter 快照:VERSION/LOADER/ISOLATION/VERSION ID/FILES/LAST PLAYED + 运行状态行。
- 导航轨换成真 NavigationRail(88),5 项各自压/替换路由。
版面约束未破(实测):列表列 336、正文列 424(元信息卡 206+12+206,右缘 896)、rail 88(药丸 16..71)、
断点 600/840 走 frame_metrics;新增 pages/tests.rs 的 9 条数值断言钉住它们。
264 lines
9.6 KiB
Rust
264 lines
9.6 KiB
Rust
//! `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", "截图模式的主题只认命令行");
|
||
}
|