From 1c5283058b0b6d1ca92b56ce91f994ff8d90383f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 00:21:14 +0800 Subject: [PATCH] =?UTF-8?q?refactor(m109):=20rebase=20=E5=88=B0=205adac23?= =?UTF-8?q?=20=E5=90=8E=E6=8C=89=E5=8A=9F=E8=83=BD=E6=8B=86=E4=B8=A4?= =?UTF-8?q?=E6=9D=A1=E8=B6=85=20400=20=E8=A1=8C=E7=9A=84=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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。 --- crates/app-iced/src/app/state.rs | 35 +---- crates/app-iced/src/app/state/boot_probe.rs | 40 ++++++ crates/app-iced/src/app/view.rs | 151 +------------------- crates/app-iced/src/app/view/tests.rs | 149 +++++++++++++++++++ crates/presenter/src/state.rs | 43 +----- crates/presenter/src/state/content.rs | 45 ++++++ 6 files changed, 246 insertions(+), 217 deletions(-) create mode 100644 crates/app-iced/src/app/state/boot_probe.rs create mode 100644 crates/app-iced/src/app/view/tests.rs create mode 100644 crates/presenter/src/state/content.rs diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index a4397bb..5d83db7 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -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 = 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 ` 不会往那个目录写东西。 - 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 - ); - } -} diff --git a/crates/app-iced/src/app/state/boot_probe.rs b/crates/app-iced/src/app/state/boot_probe.rs new file mode 100644 index 0000000..a5852ed --- /dev/null +++ b/crates/app-iced/src/app/state/boot_probe.rs @@ -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 = 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 ` 不会往那个目录写东西。 + 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 + ); + } +} diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index 003395a..ecb1f99 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -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) -> 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; diff --git a/crates/app-iced/src/app/view/tests.rs b/crates/app-iced/src/app/view/tests.rs new file mode 100644 index 0000000..5ff06d7 --- /dev/null +++ b/crates/app-iced/src/app/view/tests.rs @@ -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) -> 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 页不高亮任何导航项"); +} diff --git a/crates/presenter/src/state.rs b/crates/presenter/src/state.rs index f53ffea..7b33188 100644 --- a/crates/presenter/src/state.rs +++ b/crates/presenter/src/state.rs @@ -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; diff --git a/crates/presenter/src/state/content.rs b/crates/presenter/src/state/content.rs new file mode 100644 index 0000000..d4ca6c7 --- /dev/null +++ b/crates/presenter/src/state/content.rs @@ -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; + } +}