Files
Unlucky-Launcher/crates/app-iced/src/app/tests.rs
T
AskaEth ee67965b9b fix(m157): r4 修复未收集的周快照用例 + 两处 clippy 红,并补整环回归(复审 p1-2)
P1-1 crates/core/src/launch/overrides/tests.rs:modded_weekly_snapshot_needs_the_root_version_for_the_gate 原先写在 backend_allowed_combines_capability_and_threshold 函数体内(unnameable_test_items,永不执行)。移到模块层;移动后首次真正执行暴露出用例自身的 EmptyJavaPath(AdapterOptions 默认 java 为空),补 JavaSelection 后绿。--list 现在 10 条 launch::overrides::tests::(含该名字)。

P1-2 clippy -D warnings 两处:cli/commands/instance_launch.rs needless_borrows_for_generic_args(去 &);presenter/mock_data.rs items_after_test_module(mock_launch 移到 mod tests 之前)。

P3-a 新增整环回归 app/tests/instances_save_loop.rs:真 Message → dispatch → 后端回错误 Toast → pump → refresh_view → sync_launch_form,断言 4g 草稿与脏标记都还在。
P3-b LaunchForm::matches 内存改按数值比(解析失败退文本),补 memory_is_compared_by_value_not_by_text(04096 / +4096 / 清空三档)。
P3-c 门槛文案不再在 catalog 硬编码 26.2:InstancesLaunchBackendGated 改用 {min} 占位符,页面从 LaunchFacts::min_version(← core GRAPHICS_BACKEND_MIN_VERSION)代入;金表同步;灰化态截图重拍。
2026-11-07 11:06:18 +08:00

385 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `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;
#[cfg(test)]
mod appearance;
#[cfg(test)]
mod instances_save_loop;
#[cfg(test)]
mod presenter;
use super::message::Message;
use super::state::App;
use super::{APP_TITLE, WINDOW_SIZE};
use crate::cli::{Options, Screen};
use crate::pages;
use crate::shell::agent::RailState;
use crate::shell::NavItem;
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 帧出图"
);
}
// --------------------------------------------------------------- P2-1 按需节拍
/// 空闲态**不要每帧节拍**(但要有 1 Hz 心跳)。
///
/// 意图与 r2 那条 `an_idle_app_asks_for_no_clock` 一致:空闲时不许按帧循环跑(那会以每秒几百帧
/// 的速度重绘整窗),改成「低频心跳」。断言拆成两半:
///
/// 1. `needs_frames()` 为假 ⇒ **不订阅** `window::frames()`;
/// 2. 空闲时钟的周期 = [`IDLE_TICK`] = 1 s,且心跳每拍推进的 tick 预算 = 30(= 1 s / 33 ms),
/// 这样 `theme::config_poll_due`(30 拍)仍是「约 1 秒比对一次 mtime」。
///
/// 抓得住:有人把「无条件订阅帧」改回来(第 1 半红);有人把心跳周期改快/改慢(第 2 半红)。
/// 抓不住:真实窗口的重绘次数(那要 `ULL_TICK_TRACE=1` 跑一次交互模式看日志,见报告)。
#[test]
fn an_idle_app_uses_a_slow_heartbeat_instead_of_frames() {
let app = headless(screenshot_options(), SchemeFlavor::Light);
assert!(app.needs_frames(), "截图通道永远要帧(第 3 帧落盘)");
let mut idle = headless(Options::default(), SchemeFlavor::Light);
let _ = idle.update(Message::Boot);
assert!(
idle.needs_frames(),
"Boot 刚派发过 RefreshAll:3 秒观察窗内要节拍(等真 host 的首批事件)"
);
// 观察窗过期、也没有忙碌实例 ⇒ 空闲态不订阅帧事件(改走 1 Hz 心跳)
idle.watch_until = None;
assert!(!idle.needs_frames(), "空闲交互态:不订阅帧事件(只有心跳)");
// 派发之后观察窗重新打开
let _ = idle.update(Message::Page(pages::Message::Navigate(Route::Detail(
"survival".to_string(),
))));
let _ = idle.update(Message::Page(pages::Message::Launch(
"survival".to_string(),
)));
assert!(idle.needs_frames(), "派发后观察窗内要有节拍");
// 进入 Busy 之后由「忙碌实例」这个条件自己续订(不靠观察窗)
idle.watch_until = None;
assert!(
idle.needs_frames(),
"有 Busy 实例时即便观察窗过期也要继续 tick"
);
}
/// 心跳的**节奏**:1 Hz,且每拍折算 30 个 33 ms 单位 ⇒ `config_poll_due` ≈ 每秒一次。
///
/// 抓得住:心跳周期常量、以及 `advance()` 里「按流逝时间折算 ticks」这条记账(改回
/// `ticks += 1` 会让空闲态的配置轮询变成 30 秒一次,这条就红)。
/// 抓不住:`Task::perform` 那条自续链真的每秒触发一次(那是运行期 trace 的事,见报告)。
#[test]
fn the_idle_heartbeat_budgets_one_config_poll_per_second() {
use unlucky_presenter::theme::config_poll_due;
assert_eq!(
super::state::IDLE_TICK,
std::time::Duration::from_secs(1),
"空闲心跳周期必须是 1 s"
);
let mut app = headless(Options::default(), SchemeFlavor::Light);
let _ = app.update(Message::Boot);
app.watch_until = None;
// 一次空闲心跳(1 s)之后:ticks 增加 30,且正好落在 config 轮询点上
let before = app.ticks;
app.advance(super::state::IDLE_TICK);
assert_eq!(app.ticks - before, 30, "1 s 应折算 30 个 33 ms 拍");
assert!(
config_poll_due(app.ticks),
"空闲心跳每拍都要落在 config 轮询点上(ticks={})",
app.ticks
);
}
/// 空闲态每拍**不再重建视图模型**(P2-1 的另一半:`refresh_view` 的 30 Hz × N 实例白克隆)。
///
/// 抓得住:把 `advance()` 里的 `if self.view_dirty` 去掉(改回无条件 `refresh_view()`)时,
/// 这条断言里的 `refreshes` 会从「不增长」变成「每拍 +1」。
/// 抓不住:「重建一次花了多少微秒」(没有基准测试;量级判断见报告)。
#[test]
fn idle_ticks_do_not_rebuild_the_view_model() {
let mut app = headless(Options::default(), SchemeFlavor::Light);
let _ = app.update(Message::Boot);
let before = app.refreshes;
for _ in 0..100 {
app.advance(super::state::TICK);
}
assert_eq!(
app.refreshes,
before,
"空闲 100 拍:视图模型一次都不该重建(实际 +{})",
app.refreshes - before
);
// 有事件进来时仍然要重建(否则页面不刷新)
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Logs)));
assert!(app.refreshes > before, "导航之后必须重建一次");
}
/// 视图模型跟着窗口宽度走(交互模式下 600 / 840 两条断点靠 resize 事件生效)。
///
/// M104 起 `view.window_width` 装的是**中栗可用宽度**(窗口 − 右栗 − 拖拽缝),所以这里按三档
/// 各钉一个**字面量**:48 的折叠条与 320 的面板各自的减法都得对。
/// (断言里不调 `agent.content_width(..)`——那是同义反复,实现错一处它照样绿。)
#[test]
fn resizing_the_window_rebuilds_the_view_model() {
let mut app = headless(Options::default(), SchemeFlavor::Light);
let _ = app.update(Message::Boot);
// 出厂是 Collapsed(48) 档:中栗 = 窗口 − 48
let _ = app.update(Message::Window(iced::window::Event::Resized(
iced::Size::new(1180.0, 760.0),
)));
assert!(
(app.view.window_width - 1132.0).abs() < f32::EPSILON,
"1180 − 48(折叠条)= 1132,实际 {}",
app.view.window_width
);
let _ = app.update(Message::Window(iced::window::Event::Resized(
iced::Size::new(700.0, 600.0),
)));
assert!(
(app.view.window_width - 652.0).abs() < f32::EPSILON,
"700 − 48(折叠条)= 652,实际 {}",
app.view.window_width
);
// Expanded(320) 档:中栗 = 窗口 − 320(面板)− 8(拖拽缝)= 852
app.agent.expand();
app.view_dirty = true;
app.refresh_view();
assert!(
(app.view.window_width - 652.0).abs() < f32::EPSILON,
"700 上展开档不成立(上限 700 − 88 − 472 − 8 = 132 < 280)⇒ 实际按折叠条 48 算: 700 − 48 = 652,实际 {}",
app.view.window_width
);
let _ = app.update(Message::Window(iced::window::Event::Resized(
iced::Size::new(1180.0, 760.0),
)));
assert!(
(app.view.window_width - 852.0).abs() < f32::EPSILON,
"1180 − 320 − 8 = 852,实际 {}",
app.view.window_width
);
// Detached 档:右栗不占宽,中栗 = 窗口 = 1180。
// **必须走路由**(`Select(Ai)`):r1 之后「档位 = Detached」与「路由 = Agent」是一条不变式,
// 直接 `agent.detach()` 再手工 refresh 会被 `sync_agent_route` 立刻收回右栗(那正是要的语义)。
let _ = app.update(Message::Select(NavItem::Ai));
app.view_dirty = true;
app.refresh_view();
assert!(
(app.view.window_width - 1180.0).abs() < f32::EPSILON,
"拆出后右栗不占宽:1180 − 0 = 1180,实际 {}",
app.view.window_width
);
// 页面照旧能构图(窄窗退化到单列,不 panic)
let _ = app.view();
}
/// **不变式(复审 r1 的 P2-1)**:`Route::Agent` ⟺ `RailState::Detached`。
///
/// 走的是**真实路由入口**(外壳导航第五项 `Assistant` → `Message::Select(NavItem::Ai)`),不是
/// 直接调 `agent.detach()`:r1 复现的 bug 正是「导航进页面时档位没变」,只测 `detach()` 抓不到它。
///
/// 抓得住:①进独立页后档位 = Detached(页头才会是 `Dock back` 而不是 `Open assistant`/`Pop out`);
/// ②未读在进页时清零;③`Esc` 在独立页真的离开(不是只把档位改成折叠);④从页上点别的导航,
/// 会话收回右栗(档位不再停在 Detached)。
/// 抓不住:按钮在像素上长什么样(那要出图,见 `13/14` 两张)。
#[test]
fn entering_the_agent_route_detaches_the_rail() {
let mut app = headless(Options::default(), SchemeFlavor::Light);
let _ = app.update(Message::Boot);
assert_eq!(app.agent.rail, RailState::Collapsed, "出厂是折叠档");
// M119:出厂没有样本会话,未读由 presenter 按真事件累加 ⇒ 出厂就是 0
assert_eq!(app.agent_host.rail().unread, 0, "出厂 0 条未读");
// ① 外壳导航第五项(真实入口)
let _ = app.update(Message::Select(NavItem::Ai));
assert_eq!(app.state.route(), &Route::Agent);
assert_eq!(
app.agent.rail,
RailState::Detached,
"进了独立页,档位必须是 Detached——否则页头会画右栗才有的动作"
);
assert_eq!(app.agent_host.rail().unread, 0, "进独立页即读");
// ③ Esc 真的离开这一页
let _ = app.update(Message::Agent(crate::shell::agent::Message::Escape));
assert_ne!(app.state.route(), &Route::Agent, "独立页上 Esc 必须离开");
assert_eq!(app.agent.rail, RailState::Expanded, "收回到右栗展开档");
// ④ 从独立页点别的导航:会话收回右栗(档位不能停在 Detached)
let _ = app.update(Message::Select(NavItem::Ai));
assert_eq!(app.agent.rail, RailState::Detached);
let _ = app.update(Message::Select(NavItem::Instances));
assert_eq!(app.state.route(), &Route::Instances);
assert_eq!(
app.agent.rail,
RailState::Expanded,
"离开独立页 ⇒ 会话收回右栗(否则页走了、栏还是 0 宽)"
);
}
/// 同一条不变式的另一个入口:页面层的 `Navigate(Route::Agent)`(右栗里的 `pages::Message` 路径)。
#[test]
fn the_page_navigation_entry_also_detaches() {
let mut app = headless(Options::default(), SchemeFlavor::Light);
let _ = app.update(Message::Boot);
// 走真消息(不是直接改字段):这样「展开即读」的 `agent_mark_read` 也会真的发生
let _ = app.update(Message::Agent(crate::shell::agent::Message::Expand));
assert_eq!(app.agent.rail, RailState::Expanded);
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Agent)));
assert_eq!(app.state.route(), &Route::Agent);
assert_eq!(app.agent.rail, RailState::Detached);
assert_eq!(app.agent_host.rail().unread, 0);
// 页面层回退(不是 Dock 消息)也要把档位收回来
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances)));
assert_eq!(app.agent.rail, RailState::Expanded);
}
/// 截图模式不读设置文件里的主题(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", "截图模式的主题只认命令行");
}
/// 主题优先级:`--theme` > 配置文件 `appearance.theme_mode` > 系统主题。
///
/// r2 之前 `Message::SystemTheme` 无条件 `set_flavor`,于是「配置文件写 dark、系统是浅色」会
/// 出现 `light -> dark -> light` 的一次抖动、配置永远不生效(r3 做 P2-2 的配置热切实测时暴露
/// 出来,一并修)。
///
/// 抓得住:系统主题不许盖掉显式配置/命令行给出的主题(三条分支各一条断言)。
/// 抓不住:真实桌面切换系统主题时的事件时序(只有消息级验证)。
#[test]
fn the_system_theme_never_overrides_an_explicit_one() {
use unlucky_presenter::prefs::ThemeMode;
// ① auto(内存桥默认):系统主题说了算
let mut auto = headless(Options::default(), SchemeFlavor::Light);
assert_eq!(auto.theme_mode, ThemeMode::Auto);
let _ = auto.update(Message::SystemTheme(iced::theme::Mode::Dark));
assert_eq!(auto.ui().name(), "ull-m3-dark", "auto 模式要跟随系统");
// ② 配置文件显式 dark:系统给浅色也不许改
let mut pinned = headless(Options::default(), SchemeFlavor::Dark);
pinned.theme_mode = ThemeMode::Dark;
let _ = pinned.update(Message::SystemTheme(iced::theme::Mode::Light));
assert_eq!(
pinned.ui().name(),
"ull-m3-dark",
"配置文件说 dark,系统说 light:不许改"
);
// ③ 命令行 `--theme` 优先级最高
let mut explicit = headless(
Options {
theme: Some(SchemeFlavor::Dark),
..Options::default()
},
SchemeFlavor::Dark,
);
let _ = explicit.update(Message::SystemTheme(iced::theme::Mode::Light));
assert_eq!(
explicit.ui().name(),
"ull-m3-dark",
"--theme 优先级最高,系统主题不许盖"
);
}