refactor(m109): rebase 到 5adac23 后按功能拆两条超 400 行的文件

rebase 到 M107/M108 之后的 main(5adac23),并集解冲突后 `app/state.rs`(401)与
`app/view.rs`(402)双双顶破 400 硬线。按功能拆,不压注释:

- `app/state/boot_probe.rs`:启动期取证两件——`app_options`(启动参数 → 后端选项)与
  `log_first_frame`(首帧耗时一行)+ 它们的两个全局量 `STARTED` / `FIRST_FRAME_LOGGED`;
  `state.rs` 用 `pub(crate) use` 再导出,`app/boot.rs`(`use super::state::STARTED`)与
  `app/view.rs`(`log_first_frame`)的路径一字未变。state.rs 401 → 376。
- `app/view/tests.rs`:`view.rs` 的守门测试独立成文件(`#[path] mod tests;`)。**内容取自
  当前 main 的测试体**(M106 的 `Lang` 形参 / `at()` 入口都在),不是旧分支那一份;view.rs 402 → 255。
- `presenter/src/state/content.rs` 与 `presenter/src/state/content_tests.rs`:上一轮已拆,本轮不变。

门禁(新 tip,`-j 2`,逐条前台跑并落盘退出码):fmt 0 / clippy -D warnings 0 /
`cargo test -p unlucky-app-iced --lib` 0(522 passed)/
`cargo test --workspace --exclude unlucky-app` 0。
This commit is contained in:
AskaEth committed 2026-11-05 03:39:50 +08:00
1 parent 42099d4336
commit 1c5283058b
6 files changed
+246 -217

No files matched your search

+5 -30
View File
@@ -13,14 +13,12 @@
//! 重建要把 N 个实例的名字/版本串克隆一遍,30 Hz 空转没有意义。缓存成字段是为了让
//! `view(&self)` 能只借 `&self`:iced 的 `view` 签名不允许返回一棵借用局部变量的树。
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::OnceLock;
use std::time::Instant;
use iced::window;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::backend::{build_backend, config_path, AppOptions};
use unlucky_presenter::backend::{build_backend, config_path};
use unlucky_presenter::dto::Backend;
use unlucky_presenter::prefs::ThemeMode;
use unlucky_presenter::settings_bridge::SettingsBridge;
@@ -59,10 +57,10 @@ pub(super) const IDLE_TICK: std::time::Duration = std::time::Duration::from_secs
/// 3 秒够真 host 把「命令已受理」的第一条事件交回来;之后由「有 Busy/Running 实例」这个条件续订。
pub(super) const WATCH_WINDOW: std::time::Duration = std::time::Duration::from_secs(3);
/// 进程内「run() 开始」的时刻(首帧耗时基线)。
pub(super) static STARTED: OnceLock<Instant> = OnceLock::new();
/// 首帧日志只打一次。
pub(super) static FIRST_FRAME_LOGGED: AtomicBool = AtomicBool::new(false);
mod boot_probe;
use boot_probe::app_options;
pub(crate) use boot_probe::{log_first_frame, STARTED};
/// 应用状态:UI 状态 + presenter 三件 + 展示转换缓存。
pub struct App {
@@ -376,26 +374,3 @@ impl App {
&self.ui
}
}
/// 启动参数 → presenter 的后端选项。
fn app_options(options: &Options) -> AppOptions {
AppOptions {
// `--data-dir`:M90 起**真的消费**它(此前是「接受并忽略」的翻牌兼容参数)。
// 截图通道强制 mock,所以 CI 冒烟那条 `--data-dir <tmp>` 不会往那个目录写东西。
data_root: options.data_dir.clone(),
mock: options.screenshot.is_some(),
}
}
/// 首帧耗时日志(只在第一次 `view()` 打一行)。
pub(super) fn log_first_frame() {
if FIRST_FRAME_LOGGED.swap(true, Ordering::Relaxed) {
return;
}
if let Some(started) = STARTED.get() {
eprintln!(
"[boot] first frame in {:.0} ms(run() 开始 → 首次 view,含后端探测与字体扫描)",
started.elapsed().as_secs_f64() * 1000.0
);
}
}
@@ -0,0 +1,40 @@
//! 启动期取证:后端选项的换算 + 首帧耗时那一行日志。
//!
//! 从 `app/state.rs` 拆出(rebase 到 5adac23 之后 `state.rs` 顶到 401 行)。这两件事都属于
//! 「进程启动那一刻记了什么」,与 `App` 的状态机无关;`state.rs` 只留 `pub(super) use` 再导出,
//! 于是 `app/boot.rs`(`use super::state::STARTED`)与 `app/view.rs`(`log_first_frame`)的既有路径一字未变。
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::OnceLock;
use std::time::Instant;
use crate::cli::Options;
use unlucky_presenter::backend::AppOptions;
/// 进程内「run() 开始」的时刻(首帧耗时基线)。
pub(crate) static STARTED: OnceLock<Instant> = OnceLock::new();
/// 首帧日志只打一次。
static FIRST_FRAME_LOGGED: AtomicBool = AtomicBool::new(false);
/// 启动参数 → presenter 的后端选项。
pub(super) fn app_options(options: &Options) -> AppOptions {
AppOptions {
// `--data-dir`:M90 起**真的消费**它(此前是「接受并忽略」的翻牌兼容参数)。
// 截图通道强制 mock,所以 CI 冒烟那条 `--data-dir <tmp>` 不会往那个目录写东西。
data_root: options.data_dir.clone(),
mock: options.screenshot.is_some(),
}
}
/// 首帧耗时日志(只在第一次 `view()` 打一行)。
pub(crate) fn log_first_frame() {
if FIRST_FRAME_LOGGED.swap(true, Ordering::Relaxed) {
return;
}
if let Some(started) = STARTED.get() {
eprintln!(
"[boot] first frame in {:.0} ms(run() 开始 → 首次 view,含后端探测与字体扫描)",
started.elapsed().as_secs_f64() * 1000.0
);
}
}
+2 -149
View File
@@ -251,152 +251,5 @@ pub(super) fn last_played(raw: Option<&str>) -> String {
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_presenter::dto::{InstanceSummary, LoaderInfo, LoaderKind, Progress};
use unlucky_presenter::mock_data::mock_instances;
fn instance(runtime: InstanceRuntime) -> InstanceSummary {
InstanceSummary {
id: "survival".to_string(),
name: "Survival".to_string(),
mc_version: "1.20.4".to_string(),
loader: LoaderInfo {
kind: LoaderKind::Fabric,
version: "0.16.5".to_string(),
},
last_played: Some("2026-10-29T19:22:00Z".to_string()),
runtime,
icon: None,
isolation: "isolated".to_string(),
version_id: "1.20.4".to_string(),
version_present: true,
}
}
/// **取证不变量(M100)**:`ForcedState` 的三档在产品路径上必须映射成「页面控件怎么画」。
///
/// 抓得住:① 谁把 `Rest` 映射成 Hovered/Pressed(那会让 `--screenshot` 的默认值反而画出状态层,
/// 与「取证密闭」正好相反,而且图看起来完全正常);② 加了变体却映射到别的态这种复制粘贴错。
/// 抓不住:`shell::overridden`(标题栏 / 导航轨按钮走的**另一处** match,断言在 `shell/tests.rs`),
/// 也抓不住不消费 `model.forced` 的裸 `iced::button`(设置页「外观」区就是)。
/// 测试用的薄包装:默认宽度 / 默认分类 / 空草稿。
fn at(state: &AppState, forced: Option<crate::cli::ForcedState>) -> pages::Model {
let (draft, lang) = (pages::InstallDraft::default(), Lang::En);
let page = pages::content::PageState::default();
model(
state,
1180.0,
forced,
pages::Category::All,
&draft,
lang,
&page,
)
}
#[test]
fn the_forced_state_maps_to_the_expected_interaction() {
let state = AppState::new();
let forced = |forced| at(&state, Some(forced)).forced;
assert_eq!(
forced(crate::cli::ForcedState::Rest),
Some(Interaction::Enabled),
"Rest = 静息态(压掉系统光标 hover)"
);
assert_eq!(
forced(crate::cli::ForcedState::Hover),
Some(Interaction::Hovered)
);
assert_eq!(
forced(crate::cli::ForcedState::Pressed),
Some(Interaction::Pressed)
);
assert_eq!(at(&state, None).forced, None);
}
#[test]
fn the_snapshot_becomes_the_page_model() {
let mut state = AppState::new();
state.apply(unlucky_presenter::dto::AppEvent::Core(
unlucky_presenter::dto::CoreEvent::InstancesChanged(mock_instances()),
));
state.navigate(Route::Detail("survival".to_string()));
let model = at(&state, None);
assert_eq!(model.screen, pages::Screen::Instances);
assert_eq!(model.active, Some(pages::Screen::Instances.nav()));
assert_eq!(model.window_width, 1180.0);
assert_eq!(model.instances.rows.len(), 4, "mock 的四个实例");
assert_eq!(model.instances.rows[0].id, "survival");
let detail = model.instances.detail.expect("路由是 Detail,要有详情");
assert_eq!(detail.id, "survival");
assert_eq!(detail.meta.len(), 6);
assert!(detail.can_launch, "空闲实例可启动");
assert!(!detail.can_stop);
assert!(detail.note.is_none(), "真快照没有正文段");
}
#[test]
fn a_busy_instance_offers_stop_instead_of_play() {
let mut state = AppState::new();
state.apply(unlucky_presenter::dto::AppEvent::Core(
unlucky_presenter::dto::CoreEvent::InstancesChanged(vec![instance(
InstanceRuntime::Busy {
stage: LaunchStage::Download,
progress: Progress {
done: 750,
total: 1_500,
},
},
)]),
));
state.navigate(Route::Detail("survival".to_string()));
let model = at(&state, None);
assert_eq!(
model.instances.rows[0].status.as_deref(),
Some("Downloading · 50%")
);
let detail = model.instances.detail.expect("有详情");
assert!(!detail.can_launch, "启动中不许再点 Play(不做乐观回显)");
assert!(detail.can_stop, "启动中要能 Stop");
}
#[test]
fn failed_instances_can_be_retried() {
let failed = instance(InstanceRuntime::Failed {
reason: "exit 1".to_string(),
});
assert_eq!(
runtime_status(&failed.runtime).as_deref(),
Some("Launch failed · exit 1")
);
assert!(detail_of(&failed).can_launch, "失败后要能重试");
}
#[test]
fn timestamps_are_sliced_not_guessed() {
assert_eq!(
last_played(Some("2026-10-29T19:22:00Z")),
"2026-10-29 19:22 UTC"
);
assert_eq!(last_played(None), "Never");
// 短串 / 非 ASCII 原样返回(不 panic)
assert_eq!(last_played(Some("2026-10")), "2026-10");
assert_eq!(last_played(Some("昨天")), "昨天");
}
#[test]
fn the_agent_route_has_no_page() {
let mut state = AppState::new();
state.navigate(Route::Agent);
let model = at(&state, None);
// Agent 没有页面:模型退回实例页的空壳,view 会走外壳回退(`None` 分支)
assert_eq!(pages::Screen::of(state.route()), None);
assert!(model.instances.detail.is_none());
assert_eq!(model.active, None, "AI 页不高亮任何导航项");
}
}
#[path = "view/tests.rs"]
mod tests;
+149
View File
@@ -0,0 +1,149 @@
//! `app/view.rs` 的守门测试(rebase 到 5adac23 之后 view.rs 贴 400 行上限,按「测试独立成文件」拆出)。
use super::*;
use unlucky_presenter::dto::{InstanceSummary, LoaderInfo, LoaderKind, Progress};
use unlucky_presenter::mock_data::mock_instances;
fn instance(runtime: InstanceRuntime) -> InstanceSummary {
InstanceSummary {
id: "survival".to_string(),
name: "Survival".to_string(),
mc_version: "1.20.4".to_string(),
loader: LoaderInfo {
kind: LoaderKind::Fabric,
version: "0.16.5".to_string(),
},
last_played: Some("2026-10-29T19:22:00Z".to_string()),
runtime,
icon: None,
isolation: "isolated".to_string(),
version_id: "1.20.4".to_string(),
version_present: true,
}
}
/// **取证不变量(M100)**:`ForcedState` 的三档在产品路径上必须映射成「页面控件怎么画」。
///
/// 抓得住:① 谁把 `Rest` 映射成 Hovered/Pressed(那会让 `--screenshot` 的默认值反而画出状态层,
/// 与「取证密闭」正好相反,而且图看起来完全正常);② 加了变体却映射到别的态这种复制粘贴错。
/// 抓不住:`shell::overridden`(标题栏 / 导航轨按钮走的**另一处** match,断言在 `shell/tests.rs`),
/// 也抓不住不消费 `model.forced` 的裸 `iced::button`(设置页「外观」区就是)。
/// 测试用的薄包装:默认宽度 / 默认分类 / 空草稿。
fn at(state: &AppState, forced: Option<crate::cli::ForcedState>) -> pages::Model {
let (draft, lang) = (pages::InstallDraft::default(), Lang::En);
let page = pages::content::PageState::default();
model(
state,
1180.0,
forced,
pages::Category::All,
&draft,
lang,
&page,
)
}
#[test]
fn the_forced_state_maps_to_the_expected_interaction() {
let state = AppState::new();
let forced = |forced| at(&state, Some(forced)).forced;
assert_eq!(
forced(crate::cli::ForcedState::Rest),
Some(Interaction::Enabled),
"Rest = 静息态(压掉系统光标 hover)"
);
assert_eq!(
forced(crate::cli::ForcedState::Hover),
Some(Interaction::Hovered)
);
assert_eq!(
forced(crate::cli::ForcedState::Pressed),
Some(Interaction::Pressed)
);
assert_eq!(at(&state, None).forced, None);
}
#[test]
fn the_snapshot_becomes_the_page_model() {
let mut state = AppState::new();
state.apply(unlucky_presenter::dto::AppEvent::Core(
unlucky_presenter::dto::CoreEvent::InstancesChanged(mock_instances()),
));
state.navigate(Route::Detail("survival".to_string()));
let model = at(&state, None);
assert_eq!(model.screen, pages::Screen::Instances);
assert_eq!(model.active, Some(pages::Screen::Instances.nav()));
assert_eq!(model.window_width, 1180.0);
assert_eq!(model.instances.rows.len(), 4, "mock 的四个实例");
assert_eq!(model.instances.rows[0].id, "survival");
let detail = model.instances.detail.expect("路由是 Detail,要有详情");
assert_eq!(detail.id, "survival");
assert_eq!(detail.meta.len(), 6);
assert!(detail.can_launch, "空闲实例可启动");
assert!(!detail.can_stop);
assert!(detail.note.is_none(), "真快照没有正文段");
}
#[test]
fn a_busy_instance_offers_stop_instead_of_play() {
let mut state = AppState::new();
state.apply(unlucky_presenter::dto::AppEvent::Core(
unlucky_presenter::dto::CoreEvent::InstancesChanged(vec![instance(
InstanceRuntime::Busy {
stage: LaunchStage::Download,
progress: Progress {
done: 750,
total: 1_500,
},
},
)]),
));
state.navigate(Route::Detail("survival".to_string()));
let model = at(&state, None);
assert_eq!(
model.instances.rows[0].status.as_deref(),
Some("Downloading · 50%")
);
let detail = model.instances.detail.expect("有详情");
assert!(!detail.can_launch, "启动中不许再点 Play(不做乐观回显)");
assert!(detail.can_stop, "启动中要能 Stop");
}
#[test]
fn failed_instances_can_be_retried() {
let failed = instance(InstanceRuntime::Failed {
reason: "exit 1".to_string(),
});
assert_eq!(
runtime_status(&failed.runtime).as_deref(),
Some("Launch failed · exit 1")
);
assert!(detail_of(&failed).can_launch, "失败后要能重试");
}
#[test]
fn timestamps_are_sliced_not_guessed() {
assert_eq!(
last_played(Some("2026-10-29T19:22:00Z")),
"2026-10-29 19:22 UTC"
);
assert_eq!(last_played(None), "Never");
// 短串 / 非 ASCII 原样返回(不 panic)
assert_eq!(last_played(Some("2026-10")), "2026-10");
assert_eq!(last_played(Some("昨天")), "昨天");
}
#[test]
fn the_agent_route_has_no_page() {
let mut state = AppState::new();
state.navigate(Route::Agent);
let model = at(&state, None);
// Agent 没有页面:模型退回实例页的空壳,view 会走外壳回退(`None` 分支)
assert_eq!(pages::Screen::of(state.route()), None);
assert!(model.instances.detail.is_none());
assert_eq!(model.active, None, "AI 页不高亮任何导航项");
}
+5 -38
View File
@@ -6,9 +6,9 @@
use crate::agent::AgentRail;
use crate::dialogs::DialogQueue;
use crate::dto::{
AccountView, AgentEvent, AppEvent, ContentQuery, ContentResults, CoreEvent, DeviceLoginView,
DiagnosisReport, InstallProgressView, InstanceRuntime, InstanceSummary, JavaPageView,
LoaderKind, LogLine, LogSession, NoticeView, Progress, SettingGroupView, VersionSummary,
AccountView, AgentEvent, AppEvent, CoreEvent, DeviceLoginView, DiagnosisReport,
InstallProgressView, InstanceRuntime, InstanceSummary, JavaPageView, LoaderKind, LogLine,
LogSession, NoticeView, Progress, SettingGroupView, VersionSummary,
};
use crate::prefs::UiPrefs;
// 内容库的视图态与其 DTO 放一起(`dto/content.rs`),路径 `state::ContentState` 不变。
@@ -279,41 +279,6 @@ impl AppState {
}
}
/// 内容库结果落地(M109):同条件换页 ⇒ **追加**;换条件 ⇒ **整批替换**;迟到 ⇒ 丢掉。
///
/// 这三条就是「列表画的永远是当前筛选条件」的全部保证——页面自己没有过滤逻辑,也没有第二份真相。
fn apply_content(&mut self, results: ContentResults) {
self.content.pending = false;
self.content.failed = None;
if !self.content.accepts(&results) {
// 迟到的一批:用户已经改了条件,覆盖上去就会出现「筛选条说 A、列表是 B」。
return;
}
// **先**按「这一页装没装满」记一位,再把它并进累计列表(复审 P1-2:判据不能读累计值)。
self.content.last_page_filled = results.has_more();
match &mut self.content.results {
Some(current) if results.query.offset > 0 => {
current.hits.extend(results.hits);
current.total = results.total;
current.curseforge = results.curseforge;
current.query = results.query;
}
_ => self.content.results = Some(results),
}
}
/// 记下新的检索条件并进入「请求在飞」态(指令由调用方随后 dispatch)。
pub fn set_content_query(&mut self, query: ContentQuery) {
self.content.pending = true;
self.content.failed = None;
if query.offset == 0 {
self.content.last_page_filled = false;
// 换条件(不是换页):先把旧结果清掉,否则加载态会画出上一批命中项。
self.content.results = None;
}
self.content.query = query;
}
/// agent 事件 → AI 栏视图模型(投影规则见 [`crate::agent::rail`])。
///
/// 未读规则:栏不可见时,助手侧新内容(助手/工具消息、错误、待确认请求)逐条累加 `unread`;
@@ -423,5 +388,7 @@ impl AppState {
#[path = "state_tests.rs"]
mod tests;
mod content;
#[cfg(test)]
mod content_tests;
+45
View File
@@ -0,0 +1,45 @@
//! 内容库的状态机(M109):结果落地与条件提交两件事。
//!
//! 从 `state.rs` 拆出(那个文件重新合流 M105 之后贴着 400 行上限)。放在 `impl AppState` 的
//! **另一个 impl 块**里,路径与可见性都不变:`apply_core` 直接调 `self.apply_content(..)`。
use crate::dto::{ContentQuery, ContentResults};
use super::AppState;
impl AppState {
/// 内容库结果落地(M109):同条件换页 ⇒ **追加**;换条件 ⇒ **整批替换**;迟到 ⇒ 丢掉。
///
/// 这三条就是「列表画的永远是当前筛选条件」的全部保证——页面自己没有过滤逻辑,也没有第二份真相。
pub(super) fn apply_content(&mut self, results: ContentResults) {
self.content.pending = false;
self.content.failed = None;
if !self.content.accepts(&results) {
// 迟到的一批:用户已经改了条件,覆盖上去就会出现「筛选条说 A、列表是 B」。
return;
}
// **先**按「这一页装没装满」记一位,再把它并进累计列表(复审 P1-2:判据不能读累计值)。
self.content.last_page_filled = results.has_more();
match &mut self.content.results {
Some(current) if results.query.offset > 0 => {
current.hits.extend(results.hits);
current.total = results.total;
current.curseforge = results.curseforge;
current.query = results.query;
}
_ => self.content.results = Some(results),
}
}
/// 记下新的检索条件并进入「请求在飞」态(指令由调用方随后 dispatch)。
pub fn set_content_query(&mut self, query: ContentQuery) {
self.content.pending = true;
self.content.failed = None;
if query.offset == 0 {
self.content.last_page_filled = false;
// 换条件(不是换页):先把旧结果清掉,否则加载态会画出上一批命中项。
self.content.results = None;
}
self.content.query = query;
}
}