From 5a52f38f35a241edd319de2be0ca768be675116b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 16:21:19 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(content):=20=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E7=9A=84=E4=B8=A4=E6=A1=A3=E5=88=86=E7=BB=84?= =?UTF-8?q?=E4=B8=8E=E3=80=8C=E5=8F=AA=E9=87=8D=E8=AF=95=E8=BF=99=E4=BA=9B?= =?UTF-8?q?=E3=80=8D(M191)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M186 把「可只重试失败项」算到了 core/presenter(DependencyPlan::retry_request()), 但 DTO 没字段、界面没按钮,用户拿不到。这一轮补最后一公里: - DTO:ContentFailureKind 加 Incompatible 一档(不再借 Unsupported,与「缺密钥」分开); InstallProgressView 加 retry: Vec(project + 档位 + core 原话), 档位是专门的 InstallFailureKind(Incompatible / Unreachable,穷尽映射 core 的 FailureKind); ContentInstallRequest 加 retry: bool(重试路径唯一读的字段是 instance)。 - presenter:plan.rs 把上一轮失败的计划按 host+实例记住(与 addon/pack.rs 同一手法, host/mod.rs 不在 scope);retry_request()/retry_plan() 就是「只重试这些」—— 调 core 的 retry_request()、只请求失败的那几个项目,present 重新扫目录。 install.rs 的重试入口没有记录时回一条失败,不退化成整单重装;成功则 forget + 清名单。 - 界面:内容库页插入按档分组的失败面板(两段文案、各自条目)+「只重试这 N 个」实心按钮; 判据只有一条且在模型里(stage==Failed && 名单非空),全成功时连同按钮一起不渲染。 - i18n:新增 6 条中英文案(不兼容 / 取不到 / 只重试这 N 个 / 标题 / 提示 / 关闭),金表同步。 守门(每条都做过变异验证,见报告): ① 把 retry_request() 换成根项目请求 ⇒ retrying_asks_for_the_failed_projects_only 红(roots 变 ["sodium"]); ② 两档合并 ⇒ the_retry_list_keeps_the_two_tiers_apart / the_two_tiers_are_two_groups_with_two_labels 红; ③ 恒渲染 ⇒ a_fully_successful_install_has_no_retry_surface_at_all 红。 数据包(datapack)本轮仍未做:ContentKind 没有这一项,界面上也没有半成品入口。 --- crates/app-iced/src/app/update/content.rs | 11 ++ crates/app-iced/src/pages/content.rs | 15 +- crates/app-iced/src/pages/content/demo.rs | 64 +++++++- crates/app-iced/src/pages/content/message.rs | 5 + crates/app-iced/src/pages/content/model.rs | 12 ++ .../app-iced/src/pages/content/model/retry.rs | 149 +++++++++++++++++ .../app-iced/src/pages/content/model/tests.rs | 7 + .../src/pages/content/model/tests/retry.rs | 129 +++++++++++++++ .../pages/content/packui/model/progress.rs | 2 + crates/app-iced/src/pages/content/parts.rs | 2 + .../app-iced/src/pages/content/parts/retry.rs | 139 ++++++++++++++++ crates/app-iced/src/pages/content/tests.rs | 13 ++ crates/app-iced/src/pages/content/view.rs | 7 +- crates/app-iced/src/pages/download/demo.rs | 2 + crates/i18n/src/catalog/content.rs | 24 +++ crates/i18n/src/catalog/golden/tail.rs | 6 + crates/presenter/src/dto/content.rs | 10 ++ crates/presenter/src/dto/content/detail.rs | 13 ++ crates/presenter/src/dto/install.rs | 83 ++++++++++ crates/presenter/src/dto/mod.rs | 5 +- crates/presenter/src/host/addon/install.rs | 100 +++++++++++- crates/presenter/src/host/addon/plan.rs | 140 ++++++++++++++-- crates/presenter/src/host/addon/tests.rs | 8 +- .../presenter/src/host/addon/tests/retry.rs | 150 ++++++++++++++++++ 24 files changed, 1072 insertions(+), 24 deletions(-) create mode 100644 crates/app-iced/src/pages/content/model/retry.rs create mode 100644 crates/app-iced/src/pages/content/model/tests/retry.rs create mode 100644 crates/app-iced/src/pages/content/parts/retry.rs create mode 100644 crates/presenter/src/host/addon/tests/retry.rs diff --git a/crates/app-iced/src/app/update/content.rs b/crates/app-iced/src/app/update/content.rs index 4d30049..34e2350 100644 --- a/crates/app-iced/src/app/update/content.rs +++ b/crates/app-iced/src/app/update/content.rs @@ -104,6 +104,15 @@ impl App { self.dispatch(AppCommand::InstallContent(Box::new(request))); } } + // M191:「只重试上次失败的那几项」。请求构造在 `pages/content/model.rs` + // (`retry_install_request`:载荷带 `retry: true`),这里只做转发。 + pages::content::Message::RetryFailed => { + let catalog = unlucky_i18n::Lang::En.catalog(); + let model = Content::from_snapshot(&self.state, &self.content, catalog); + if let Some(request) = model.retry_install_request() { + self.dispatch(AppCommand::InstallContent(Box::new(request))); + } + } // M113: import/export —— 导入/导出区(细节在 `on_pack`) pages::content::Message::Pack(message) => self.on_pack(message), } @@ -222,6 +231,8 @@ impl App { instance: target.id.clone(), game_version: target.mc_version.clone(), loader: target.loader, + // 普通装入:从根项目解起(「只重试这些」是另一条消息,`retry: true`) + retry: false, }) } } diff --git a/crates/app-iced/src/pages/content.rs b/crates/app-iced/src/pages/content.rs index a9a9d32..6d58ce8 100644 --- a/crates/app-iced/src/pages/content.rs +++ b/crates/app-iced/src/pages/content.rs @@ -47,6 +47,16 @@ //! | 3 | 整合包的「装入」是禁用态 | core 没有远程整合包的安装管线 | 导入管线落地后接同一入口 | //! | 5 | CurseForge 的「装入」同样是禁用态(`Blocker::SourceNotWired`) | 检索通了,**文件端点**还没接(CF 的文件列表要带 key) | 接上 CF 文件端点后删掉这条原因 | //! | 4 | 安装进度只有阶段级(无字节条) | presenter 依赖表里没有 tokio/futures,拿不到 `ProgressEmitter` 的接收端 | presenter 补依赖后换成字节级 | +//! +//! ## 安装失败之后:两档分组 + 「只重试这些」(M191) +//! +//! 装入失败且**有可重试项**时,页底那块失败面板换成 [`parts::retry_panel`]:按 +//! `InstallFailureKind` 分两段(不兼容 / 取不到,各有自己的词表文案),并给一个「只重试这 N 个」 +//! 的实心按钮。按钮发 [`Message::RetryFailed`],app 层翻成 `AppCommand::InstallContent{retry: true}`, +//! host 拿上一轮的计划调 `DependencyPlan::retry_request()` ⇒ **只请求失败的那几个项目**。 +//! +//! 判据只有一条且在模型里(`RetryPanel::of`):`stage == Failed && !retry.is_empty()`。 +//! 全成功、没有失败项、还在跑,三种情况都得到 `None`——**不渲染**,不是画一个灰按钮。 mod demo; mod layout; @@ -61,7 +71,10 @@ mod view; mod tests; pub use message::Message; -pub use model::{loader_name, Blocker, Content, ContentQueryView, Hit, PageState, Status, Target}; +pub use model::{ + loader_name, Blocker, Content, ContentQueryView, Hit, PageState, RetryGroup, RetryPanel, + Status, Target, +}; // M113: import/export pub use packui::{request_export, Pack, PackMessage, PackPageState}; diff --git a/crates/app-iced/src/pages/content/demo.rs b/crates/app-iced/src/pages/content/demo.rs index ca01959..e668369 100644 --- a/crates/app-iced/src/pages/content/demo.rs +++ b/crates/app-iced/src/pages/content/demo.rs @@ -11,7 +11,7 @@ //! | 变量 | 取值 | 作用 | //! |---|---|---| //! | `ULL_CONTENT_KIND` | `modpack` / `mod` / `resourcepack` / `shader` | 选内容类型(默认 `mod`) | -//! | `ULL_CONTENT_STATE` | `ok` / `empty` / `incompatible` / `install` / `cf` / `offline` | 选状态(默认 `ok`;`offline` = M162 的未联网态) | +//! | `ULL_CONTENT_STATE` | `ok` / `empty` / `incompatible` / `install` / `cf` / `offline` / `retryfail` / `retryok` | 选状态(默认 `ok`;`offline` = M162 的未联网态;`retryfail` = M191 的「有失败项」,`retryok` = M191 的「全部成功」) | //! //! 只影响本来就用演示数据的 harness 路径;产品二进制里 `demo()` 根本不会被调用。 @@ -19,10 +19,47 @@ use unlucky_i18n::{Catalog, Key, Lang}; use unlucky_presenter::dto::{ContentHit, ContentKind, ContentSort, ContentSource, LoaderKind}; use super::model::{ - blockers_of, Content, ContentQueryView, Hit, HitFacts, Status, Target, VersionRow, + blockers_of, Content, ContentQueryView, Hit, HitFacts, RetryPanel, Status, Target, VersionRow, }; use crate::pages::download::Download; +/// M191「有失败项」那一屏:两档各几个项目,面板按档分组。 +/// +/// **走真判据**([`RetryPanel::of`]),不手搓面板字段:出图与产品路径读同一个函数, +/// 判据一改这条演示就跟着变(否则演示图能一直画着一个已经不存在的面板)。 +fn retry_failure(instance: &str, catalog: Catalog) -> Option { + use unlucky_presenter::dto::{ + InstallFailureKind, InstallFailureView, InstallProgressView, InstallStage, + }; + let mut progress = InstallProgressView { + version: "Sodium".to_string(), + instance: instance.to_string(), + loader: "Fabric".to_string(), + stage: InstallStage::Failed, + files_done: 1, + files_total: 4, + ..InstallProgressView::new() + }; + progress.retry = vec![ + InstallFailureView::new( + "P7dR8mSH", + InstallFailureKind::Incompatible, + "没有匹配所选游戏版本/加载器(1.20.1 / fabric)的版本", + ), + InstallFailureView::new( + "1eAoo2KR", + InstallFailureKind::Unreachable, + "取版本失败(online_not_found):HTTP 404", + ), + InstallFailureView::new( + "AANobbMI", + InstallFailureKind::Unreachable, + "下载 fabric-api.jar 失败(online_not_connected):连不上 cdn.modrinth.com", + ), + ]; + RetryPanel::of(&progress, catalog) +} + /// 一个固定的演示环境:两个实例(一个 Fabric / 一个 Forge)。 fn targets() -> Vec { vec![ @@ -254,6 +291,12 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content { } else { None }, + // M191:`retryfail` 画「有失败项」,`retryok` 画「全部成功」——后者走同一条判据, + // 得到 `None`(连按钮都不渲染)。 + retry: match state.as_str() { + "retryfail" => retry_failure("survival", catalog), + _ => None, + }, versions: vec!["1.20.1".to_string(), "1.21.1".to_string()], available_versions: demo_versions, versions_note: None, @@ -265,3 +308,20 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content { pack, } } + +#[cfg(test)] +mod tests { + use super::*; + + /// 「有失败项」那一屏的两档走**真判据**(`RetryPanel::of`),不是手搓的面板字段。 + #[test] + fn the_failure_screen_shows_both_tiers() { + let panel = retry_failure("survival", Lang::En.catalog()).expect("演示必须有面板"); + assert_eq!(panel.total, 3); + assert!(panel.has_both_tiers(), "{:?}", panel.groups); + assert_eq!( + panel.items_of(unlucky_presenter::dto::InstallFailureKind::Unreachable), + Some(["1eAoo2KR".to_string(), "AANobbMI".to_string()].as_slice()) + ); + } +} diff --git a/crates/app-iced/src/pages/content/message.rs b/crates/app-iced/src/pages/content/message.rs index a5e9286..e8cad7d 100644 --- a/crates/app-iced/src/pages/content/message.rs +++ b/crates/app-iced/src/pages/content/message.rs @@ -37,6 +37,11 @@ pub enum Message { Retry, /// 装进目标实例 Install, + /// **只重试上次失败的那几项**(M191;不是「整单重装」) + /// + /// app 层的落点:`AppCommand::InstallContent` 带 `retry: true`——host 拿上一轮的依赖计划 + /// 调 `DependencyPlan::retry_request()`,只重解失败项。 + RetryFailed, /// 导入/导出区(M113:整合包导入与导出;一切细节在 `packui::Message`) Pack(super::packui::PackMessage), } diff --git a/crates/app-iced/src/pages/content/model.rs b/crates/app-iced/src/pages/content/model.rs index 51c5d84..8192881 100644 --- a/crates/app-iced/src/pages/content/model.rs +++ b/crates/app-iced/src/pages/content/model.rs @@ -32,10 +32,12 @@ use unlucky_i18n::{Catalog, Key}; use crate::pages::download::Download; mod items; +mod retry; pub(crate) use items::HitFacts; pub(crate) use items::{capitalize, thousands}; pub use items::{loader_name, Blocker, Hit, PageState, Status, Target, VersionRow}; +pub use retry::{RetryGroup, RetryPanel}; /// 内容库页要画的东西。 #[derive(Debug, Clone, PartialEq)] @@ -69,6 +71,11 @@ pub struct Content { /// 存整个 `Download` 而不是只存一帧视图:面板的入参就是 `&Download`,而 iced 的控件树 /// 借用它 ⇒ 被借的东西必须活得比这一帧长(局部临时值不行)。 pub panel: Option, + /// 「只重试这些」面板(M191):`None` = 不渲染(全成功 / 没有失败项 / 还在跑)。 + /// + /// 与 `panel` 互斥地画:失败且有可重试项时,这块**顶替**下载页那块通用失败面板, + /// 一屏只有一个失败块(两个会说同一件事,还各自给一个「关闭」)。 + pub retry: Option, /// 游戏版本筛选的候选(来自实例;去重、保持实例顺序) pub versions: Vec, /// 关键词草稿 @@ -187,6 +194,11 @@ impl Content { target_label, blockers, panel: content_install(state, catalog), + // M191:失败且有可重试项时这块才存在(全成功 ⇒ `None` ⇒ 视图里连按钮都没有) + retry: state + .install + .as_ref() + .and_then(|progress| RetryPanel::of(progress, catalog)), versions, draft: page.draft.clone(), pack, diff --git a/crates/app-iced/src/pages/content/model/retry.rs b/crates/app-iced/src/pages/content/model/retry.rs new file mode 100644 index 0000000..a6d8f17 --- /dev/null +++ b/crates/app-iced/src/pages/content/model/retry.rs @@ -0,0 +1,149 @@ +//! 「只重试这些」面板的视图模型(M191)。 +//! +//! ## 它画的是什么 +//! +//! 安装失败之后,界面要说清「哪几项没装上、分别是哪种没装上」: +//! +//! ```text +//! 安装未完成 —— 3 项没装上 +//! 不兼容所选版本/加载器 +//! P7dR8mSH +//! 取不到(网络 / 项目不存在) +//! 1eAoo2KR、AANobbMI +//! [只重试这 3 个] [关闭] +//! ``` +//! +//! ## 三条判据(都落在 [`RetryPanel::of`],纯函数,用例直接钉) +//! +//! 1. **全成功不渲染**:`retry` 名单空 ⇒ `None` ⇒ 视图里连按钮都不存在(不是置灰); +//! 2. **两档不混**:分组按 [`InstallFailureKind`] 分开,两档各有自己的标题 key; +//! 3. **只列名单里的**:条目原样来自 DTO(`presenter` 从 `DependencyPlan::retry_request()` +//! 的同一份失败名单产出),页面不自己挑、也不补「顺便重装根」。 +//! +//! ## 为什么这里不拼文案 +//! +//! 分组标题是 [`Key`](i18n),条目是项目键(站点数据,不是文案)。拼一句带量的中文句子 +//! 会把语序写死——英文与中文的量词位置不同(`Retry just these 3` / `只重试这 3 个`)。 + +use unlucky_i18n::{Catalog, Key}; +use unlucky_presenter::dto::{ + ContentInstallRequest, ContentSource, InstallFailureKind, InstallProgressView, InstallStage, + LoaderKind, +}; + +/// 面板上的一个档位分组。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RetryGroup { + /// 哪一档(顺序固定,见 [`InstallFailureKind::ALL`]) + pub kind: InstallFailureKind, + /// 这一档的标题 key(不硬编码中文) + pub title: Key, + /// 这一档的项目键(顺序 = 计划里的顺序) + pub items: Vec, +} + +/// 「只重试这些」面板(`None` = 不渲染:没有失败项、或已经全成功)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RetryPanel { + /// 失败的那次装入的目标实例(重试只对这个实例生效) + pub instance: String, + /// 标题(已按词表渲染:带条数) + pub title: String, + /// 主按钮文字(已按词表渲染:`只重试这 N 个`) + /// + /// 存在这里而不是画的时候现拼:`Button::new` 要的是 `&'a str`,而词表渲染出来的是 + /// `String`——存进模型(与 `Hit` / `VersionRow.line` 同一手法)就不必为了一个标签 + /// 去改组件库的入参类型。 + pub action: String, + /// 按档分组(空组不出现) + pub groups: Vec, + /// 失败项总数(标题与按钮都用这个数) + pub total: usize, +} + +impl RetryPanel { + /// 进度快照 → 面板(**唯一**判据;`None` = 视图里不该出现这块)。 + /// + /// 只认 `Failed` 这一态:`Done` 没有可重试的东西,`Cancelled` 是用户自己停的 + /// (那条路上根本没有失败名单),`Planning`/`Downloading` 还在跑。 + #[must_use] + pub fn of(progress: &InstallProgressView, catalog: Catalog) -> Option { + if progress.stage != InstallStage::Failed || !progress.has_retry() { + return None; + } + let total = progress.retry.len(); + let count = total.to_string(); + let groups = InstallFailureKind::ALL + .into_iter() + .map(|kind| RetryGroup { + kind, + title: title_of(kind), + items: progress + .retry_of(kind) + .map(|item| item.project.clone()) + .collect(), + }) + .filter(|group| !group.items.is_empty()) + .collect(); + Some(Self { + instance: progress.instance.clone(), + title: catalog.render(Key::ContentInstallFailedTitle, &[("count", count.as_str())]), + action: catalog.render(Key::ContentInstallRetryOnly, &[("count", count.as_str())]), + groups, + total, + }) + } + + /// 两档都在吗(「分组」这件事本身的判据:都出现时界面上是两段,不是一段)。 + #[must_use] + pub fn has_both_tiers(&self) -> bool { + self.groups.len() == 2 + } + + /// 某一档的条目(用例断言用)。 + #[must_use] + pub fn items_of(&self, kind: InstallFailureKind) -> Option<&[String]> { + self.groups + .iter() + .find(|group| group.kind == kind) + .map(|group| group.items.as_slice()) + } +} + +/// 档位 → 标题 key(**穷尽 match**:合并两档会少一条文案,用例会红)。 +#[must_use] +pub const fn title_of(kind: InstallFailureKind) -> Key { + match kind { + InstallFailureKind::Incompatible => Key::ContentInstallRetryIncompatible, + InstallFailureKind::Unreachable => Key::ContentInstallRetryUnreachable, + } +} + +impl super::Content { + /// 「只重试这些」那一次请求(M191;`None` = 没有可重试的面板 ⇒ 这个动作不存在)。 + /// + /// 载荷里 `retry: true` 是**唯一**的语义开关:host 收到它就不解根项目,而是拿上一轮的计划 + /// 调 `DependencyPlan::retry_request()`(只请求失败的那几个项目)。其余字段照旧填, + /// 是因为 `ContentInstallRequest` 的判据仍要能读(`project` / `title` 也用于日志); + /// 这些字段在重试路径上**不被 host 读取**——过滤条件与落盘目录都来自那份计划。 + /// + /// 实例取**失败那次的**实例,不是页面上此刻选中的那个:用户可能已经换了目标实例。 + pub fn retry_install_request(&self) -> Option { + let retry = self.retry.as_ref()?; + let hit = self.selected_hit(); + let target = self + .targets + .iter() + .find(|target| target.id == retry.instance); + Some(ContentInstallRequest { + project: hit.map(|hit| hit.id.clone()).unwrap_or_default(), + title: hit.map(|hit| hit.title.clone()).unwrap_or_default(), + kind: self.kind, + source: hit.map_or(ContentSource::Modrinth, |hit| hit.source), + instance: retry.instance.clone(), + game_version: target.map_or_else(String::new, |target| target.mc_version.clone()), + loader: target.map_or(LoaderKind::Vanilla, |target| target.loader), + retry: true, + }) + } +} diff --git a/crates/app-iced/src/pages/content/model/tests.rs b/crates/app-iced/src/pages/content/model/tests.rs index 2b60022..f929fbf 100644 --- a/crates/app-iced/src/pages/content/model/tests.rs +++ b/crates/app-iced/src/pages/content/model/tests.rs @@ -355,3 +355,10 @@ fn loader_names_are_ascii_so_the_english_baseline_stays_english() { "不能画「原版」" ); } + +// M191:安装失败的两档分组与「只重试这些」(按主题分卷,父文件已贴 400 行硬线) +// +// `#[path]` 显式给出:本文件自己是 `#[path = "model/tests.rs"]` 挂上去的,子模块默认按 +// 「父模块所在目录」找,会指到 `model/retry.rs`(视图模型本体)而不是这里的 `tests/retry.rs`。 +#[path = "tests/retry.rs"] +mod retry; diff --git a/crates/app-iced/src/pages/content/model/tests/retry.rs b/crates/app-iced/src/pages/content/model/tests/retry.rs new file mode 100644 index 0000000..d64a3f8 --- /dev/null +++ b/crates/app-iced/src/pages/content/model/tests/retry.rs @@ -0,0 +1,129 @@ +//! M191:安装失败的两档分组 + 「只重试这些」(`super` 按主题分卷,父文件已贴 400 行硬线)。 +//! +//! 三条变异守门:两档不许合并 / 全成功不许渲染 / 重试只针对失败那次的实例。 + +use super::super::{Content, PageState, RetryPanel}; +use super::{hit, results, state_with}; + +use unlucky_i18n::{Key, Lang}; +use unlucky_presenter::dto::{ + InstallFailureKind, InstallFailureView, InstallProgressView, InstallStage, +}; + +/// 一份「两档都有」的失败进度快照。 +fn failed_progress() -> unlucky_presenter::dto::InstallProgressView { + let mut progress = InstallProgressView { + version: "Sodium".to_string(), + instance: "survival".to_string(), + stage: InstallStage::Failed, + files_done: 1, + files_total: 4, + ..InstallProgressView::new() + }; + progress.retry = vec![ + InstallFailureView::new( + "P7dR8mSH", + InstallFailureKind::Incompatible, + "没有匹配的版本", + ), + InstallFailureView::new("1eAoo2KR", InstallFailureKind::Unreachable, "HTTP 404"), + InstallFailureView::new("AANobbMI", InstallFailureKind::Unreachable, "连不上 CDN"), + ]; + progress +} + +/// 两档**分组**画,各有自己的标题;条目按档分开,不混成一段。 +/// +/// 变异守门②:把两档合并(`kind_of` 都映成 `Unreachable`,或 `title_of` 都返回同一个 key) +/// ⇒ `groups.len()` 变 1 / 两个 title 相同 ⇒ 这条红。 +#[test] +fn the_two_tiers_are_two_groups_with_two_labels() { + let panel = RetryPanel::of(&failed_progress(), Lang::En.catalog()).expect("有失败项就有面板"); + assert_eq!(panel.total, 3); + assert!(panel.has_both_tiers(), "{:?}", panel.groups); + assert_eq!( + panel.items_of(InstallFailureKind::Incompatible), + Some(["P7dR8mSH".to_string()].as_slice()) + ); + assert_eq!( + panel.items_of(InstallFailureKind::Unreachable), + Some(["1eAoo2KR".to_string(), "AANobbMI".to_string()].as_slice()) + ); + let titles: Vec = panel.groups.iter().map(|group| group.title).collect(); + assert_ne!(titles[0], titles[1], "两档的标题 key 不许是同一个"); + // 文案来自词表(不是硬编码中文):英文目录下这两句必须不同且不含中文 + let incompatible = Lang::En.catalog().text(titles[0]); + let unreachable = Lang::En.catalog().text(titles[1]); + assert_ne!(incompatible, unreachable); + assert!(incompatible.is_ascii(), "{incompatible}"); + assert!(unreachable.is_ascii(), "{unreachable}"); + // 按钮文字带条数,也来自词表 + assert!(panel.action.contains('3'), "{}", panel.action); + assert!(panel.action.is_ascii(), "{}", panel.action); +} + +/// 只有一档失败时只画一段(空组不出现——空的「取不到」标题会让用户以为那里也坏了)。 +#[test] +fn an_empty_tier_does_not_get_a_group() { + let mut progress = InstallProgressView { + stage: InstallStage::Failed, + ..InstallProgressView::new() + }; + progress.retry = vec![InstallFailureView::new( + "P7dR8mSH", + InstallFailureKind::Incompatible, + "没有匹配的版本", + )]; + let panel = RetryPanel::of(&progress, Lang::En.catalog()).expect("有失败项就有面板"); + assert!(!panel.has_both_tiers()); + assert!(panel.items_of(InstallFailureKind::Unreachable).is_none()); +} + +/// **全成功时不渲染**:`Done` 且没有失败项 ⇒ 面板不存在(不是置灰,是连构造都没有)。 +/// +/// 变异守门③:把 `RetryPanel::of` 的判据去掉(恒返回 `Some`)⇒ 这条红。 +#[test] +fn a_fully_successful_install_has_no_retry_surface_at_all() { + let done = InstallProgressView { + version: "Sodium".to_string(), + instance: "survival".to_string(), + stage: InstallStage::Done, + files_done: 4, + files_total: 4, + ..InstallProgressView::new() + }; + assert!(RetryPanel::of(&done, Lang::En.catalog()).is_none()); + // 页面上也一样:状态是 Done ⇒ 页底那块既没有重试面板,也没有失败面板 + let mut state = state_with(Some(results(vec![hit(&["1.20.1"], &["fabric"])], 0)), false); + state.install = Some(done); + let model = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog()); + assert!(model.retry.is_none(), "全成功还在页面上画重试面板"); + assert!(model.panel.is_none()); + assert!(model.retry_install_request().is_none()); + + // 失败但没有名单(例如整合包那条本地失败)同样不画:没有可重试的东西 + let mut failed = InstallProgressView { + stage: InstallStage::Failed, + ..InstallProgressView::new() + }; + failed.retry.clear(); + assert!(RetryPanel::of(&failed, Lang::En.catalog()).is_none()); +} + +/// 「只重试这些」发出去的是**失败那次的实例** + `retry: true`(不是页面此刻选中的实例, +/// 也不是一次新的根项目安装)。 +#[test] +fn the_retry_action_targets_the_failed_attempt() { + let mut state = state_with(Some(results(vec![hit(&["1.20.1"], &["fabric"])], 0)), false); + // 名单要有,否则这块入口根本不存在 + state.install = Some(failed_progress()); + + let model = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog()); + assert!(model.retry.is_some()); + let request = model.retry_install_request().expect("有面板就该有请求"); + assert!( + request.retry, + "重试必须是 retry: true(否则 host 会从根解起)" + ); + assert_eq!(request.instance, "survival"); +} diff --git a/crates/app-iced/src/pages/content/packui/model/progress.rs b/crates/app-iced/src/pages/content/packui/model/progress.rs index f55d29c..35a7e94 100644 --- a/crates/app-iced/src/pages/content/packui/model/progress.rs +++ b/crates/app-iced/src/pages/content/packui/model/progress.rs @@ -100,6 +100,8 @@ pub fn panel_progress(progress: &ImportProgressView) -> InstallProgressView { eta_secs: 0, // 整合包导入的失败不产生内容库分类(它有自己的报告视图)。 failure_kind: None, + // 导入没有「只重试这些」这条路径(重试整个导入由导入区自己管)。 + retry: Vec::new(), message: progress.current.clone().unwrap_or_default(), } } diff --git a/crates/app-iced/src/pages/content/parts.rs b/crates/app-iced/src/pages/content/parts.rs index 806492a..0d846ce 100644 --- a/crates/app-iced/src/pages/content/parts.rs +++ b/crates/app-iced/src/pages/content/parts.rs @@ -30,9 +30,11 @@ use super::model::{Content, Hit}; mod detail; mod filters; +mod retry; pub(crate) use detail::detail; pub(crate) use filters::{filters, labelled}; +pub(crate) use retry::maybe as retry_panel; use super::Message as ContentMessage; use crate::pages::frame; diff --git a/crates/app-iced/src/pages/content/parts/retry.rs b/crates/app-iced/src/pages/content/parts/retry.rs new file mode 100644 index 0000000..bbe81ed --- /dev/null +++ b/crates/app-iced/src/pages/content/parts/retry.rs @@ -0,0 +1,139 @@ +//! 安装失败后的「只重试这些」面板(M191)。 +//! +//! ## 为什么这块画在内容库,而不是复用下载页那块失败面板 +//! +//! 下载页的失败面板讲的是「停在哪一步 + 打开日志」,而这里要讲的是**哪几项没装上、属于哪种 +//! 没装上、以及能不能只重试它们**。两件事混在一个块里会得到「失败 + 一堆条目 + 两个关闭」。 +//! 所以:有可重试名单时([`RetryPanel::of`] 给了 `Some`)这块**顶替**通用失败面板, +//! 一屏只有一个失败块。 +//! +//! ## 三个控件行为 +//! +//! | 控件 | 消息 | 落点 | +//! |---|---|---| +//! | 只重试这 N 个 | [`ContentMessage::RetryFailed`] | `AppCommand::InstallContent{retry: true}`(host 只重解失败项) | +//! | 关闭 | [`PageMessage::CancelInstall`] | 清 install 快照(对没在跑的安装是安全 no-op,与下载页同一个出口) | +//! +//! 两个按钮都**按不动就不画**:没有名单时整块不存在(不是画一个灰按钮)。 + +use iced::widget::{column, container, row}; +use iced::{Alignment, Background, Border, Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::icons::IconKind; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use unlucky_i18n::{Catalog, Key}; + +use crate::app::components::buttons::{self, Button}; +use crate::theme::Ui; + +use super::super::model::{Content, RetryPanel}; +use super::intent; +use crate::pages::content::Message as ContentMessage; +use crate::pages::frame; +use crate::pages::Message as PageMessage; + +/// 整块面板(`model.retry` 为 `None` 时调用方不该调它——判据在模型里,不在这里)。 +pub(crate) fn retry_panel<'a>( + ui: &'a Ui, + catalog: Catalog, + panel: &'a RetryPanel, +) -> Element<'a, PageMessage> { + let head = row![ + frame::glyph(IconKind::Error, ui.color(Role::Error), frame::ICON_PX), + frame::text_role( + ui, + TypeRole::TitleSmall, + true, + panel.title.as_str(), + ui.color(Role::Error), + ), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center); + + let mut body = column![head] + .spacing(ui.space(Space::X2)) + .width(Length::Fill); + + // 两档分成两段:**各自的标题 + 各自的条目**(合并成一段就说不出「换版本还是检查网络」)。 + // 条目之间用 ASCII 逗号:项目键是站点数据,句读不是文案,不该在这里挑中文的「、」。 + for group in &panel.groups { + let items = group.items.join(", "); + body = body.push( + column![ + frame::text_role( + ui, + TypeRole::LabelMedium, + true, + catalog.text(group.title), + ui.color(Role::OnErrorContainer), + ), + frame::text_role_owned( + ui, + TypeRole::BodySmall, + false, + items, + ui.color(Role::OnErrorContainer), + ), + ] + .spacing(ui.space(Space::X1)), + ); + } + + body = body.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + catalog.text(Key::ContentInstallRetryHint), + ui.color(Role::OnErrorContainer), + )); + + let actions = row![ + // 主按钮:只重试这些(这个块存在的**唯一**理由) + buttons::filled( + ui, + Button::new(panel.action.as_str(), intent(ContentMessage::RetryFailed)) + .leading(IconKind::Refresh), + ), + buttons::text( + ui, + Button::new( + catalog.text(Key::ContentInstallDismiss), + PageMessage::CancelInstall + ), + ), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center); + + body = body.push(actions); + + container(body) + .width(Length::Fill) + .padding(ui.space(Space::X4)) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.color(Role::ErrorContainer))), + border: Border { + radius: ui.radius_dp(ShapeScale::Large), + ..Border::default() + }, + ..container::Style::default() + }) + .into() +} + +/// 整页底部那块(判据在 [`Content::retry`];`None` ⇒ 一帧都不画)。 +pub(crate) fn maybe<'a>( + ui: &'a Ui, + catalog: Catalog, + model: &'a Content, +) -> Option> { + model + .retry + .as_ref() + .map(|panel| retry_panel(ui, catalog, panel)) +} diff --git a/crates/app-iced/src/pages/content/tests.rs b/crates/app-iced/src/pages/content/tests.rs index e4dcb08..5b1453f 100644 --- a/crates/app-iced/src/pages/content/tests.rs +++ b/crates/app-iced/src/pages/content/tests.rs @@ -85,3 +85,16 @@ fn the_demo_selection_is_always_one_of_the_hits() { ); } } + +/// M191:「全部成功」那一屏(默认态)**没有**重试面板——出图与判据同源,不是手搓的。 +#[test] +fn the_default_demo_screen_has_no_retry_surface() { + for kind in ContentKind::ALL { + let model = demo(kind, Lang::En); + assert!( + model.retry.is_none(), + "{} 默认态不该有重试面板", + kind.slug() + ); + } +} diff --git a/crates/app-iced/src/pages/content/view.rs b/crates/app-iced/src/pages/content/view.rs index 008bd5f..133a1e7 100644 --- a/crates/app-iced/src/pages/content/view.rs +++ b/crates/app-iced/src/pages/content/view.rs @@ -98,7 +98,12 @@ fn body<'a>( .spacing(ui.space(Space::X3)) .width(Length::Fill) .height(Length::Fill); - if let Some(panel) = &model.panel { + // M191:有可重试的失败项时,这块**顶替**下载页那块通用失败面板(一屏只有一个失败块); + // 没有名单时它连构造都不存在(`None`),不是画一个灰按钮。 + let retry = parts::retry_panel(ui, catalog, model); + if let Some(panel) = retry { + stack = stack.push(divider::full(ui)).push(panel); + } else if let Some(panel) = &model.panel { // **同一块安装面板**(下载页那个函数):内容库不另画一套进度/失败/取消。 stack = stack .push(divider::full(ui)) diff --git a/crates/app-iced/src/pages/download/demo.rs b/crates/app-iced/src/pages/download/demo.rs index 4658ce1..110eb95 100644 --- a/crates/app-iced/src/pages/download/demo.rs +++ b/crates/app-iced/src/pages/download/demo.rs @@ -297,6 +297,8 @@ fn install(stage: InstallStage, ratio: f32, catalog: Catalog) -> InstallView { message: String::new(), // 演示里这次失败不是内容库那条路来的,不给分类(面板照旧画「失败 + 文案」)。 failure_kind: None, + // 同理:没有失败名单 ⇒ 内容库那块「只重试这些」不出现(下载页也不画它)。 + retry: Vec::new(), }, catalog, ) diff --git a/crates/i18n/src/catalog/content.rs b/crates/i18n/src/catalog/content.rs index 196e20a..e26bd9b 100644 --- a/crates/i18n/src/catalog/content.rs +++ b/crates/i18n/src/catalog/content.rs @@ -95,6 +95,30 @@ macro_rules! content_entries { "Installing from CurseForge is not available yet", "CurseForge 来源的安装尚未开放" ), + // --- M191:安装失败的两档分组与「只重试这些」--- + // 两档**各一条**:不兼容要换版本/加载器,取不到要检查网络,合并成一句会让用户 + // 对着一条重试必然失败的项反复点重试。 + ContentInstallFailedTitle => ( + "Install did not finish — {count} items failed", + "安装未完成 —— {count} 项没装上" + ), + ContentInstallRetryIncompatible => ( + "Not available for this version/loader", + "不兼容所选版本/加载器" + ), + ContentInstallRetryUnreachable => ( + "Could not fetch (network or missing project)", + "取不到(网络 / 项目不存在)" + ), + ContentInstallRetryOnly => ( + "Retry just these {count}", + "只重试这 {count} 个" + ), + ContentInstallRetryHint => ( + "Retry asks again only for the items above; files that already landed stay put.", + "重试只重新解析上面这几项,已落盘的文件不动。" + ), + ContentInstallDismiss => ("Dismiss", "关闭"), // --- M113: pack import / export(PACKUI)--- PackTitle => ("Modpack import & export", "整合包导入 / 导出"), PackImportTitle => ("Import a modpack", "导入整合包"), diff --git a/crates/i18n/src/catalog/golden/tail.rs b/crates/i18n/src/catalog/golden/tail.rs index 6560f69..f9e23d9 100644 --- a/crates/i18n/src/catalog/golden/tail.rs +++ b/crates/i18n/src/catalog/golden/tail.rs @@ -386,6 +386,12 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("ContentModpackNotReady", "Installing modpacks from the library into an instance is not available yet", "从内容库一键安装整合包到实例还没做(本地的包可以直接导入)"), ("ContentSourceUnavailable", "CurseForge needs an API key: add one in Settings", "CurseForge 需要在设置里配置密钥才能搜索"), ("ContentSourceNotWired", "Installing from CurseForge is not available yet", "CurseForge 来源的安装尚未开放"), + ("ContentInstallFailedTitle", "Install did not finish — {count} items failed", "安装未完成 —— {count} 项没装上"), + ("ContentInstallRetryIncompatible", "Not available for this version/loader", "不兼容所选版本/加载器"), + ("ContentInstallRetryUnreachable", "Could not fetch (network or missing project)", "取不到(网络 / 项目不存在)"), + ("ContentInstallRetryOnly", "Retry just these {count}", "只重试这 {count} 个"), + ("ContentInstallRetryHint", "Retry asks again only for the items above; files that already landed stay put.", "重试只重新解析上面这几项,已落盘的文件不动。"), + ("ContentInstallDismiss", "Dismiss", "关闭"), ("PackTitle", "Modpack import & export", "整合包导入 / 导出"), ("PackImportTitle", "Import a modpack", "导入整合包"), ("PackDropHint", "Drop a .mrpack, .zip or a MultiMC/Prism instance folder here", "把 .mrpack / .zip 或 MultiMC·Prism 实例目录拖到这里"), diff --git a/crates/presenter/src/dto/content.rs b/crates/presenter/src/dto/content.rs index 3ef817c..43d3d61 100644 --- a/crates/presenter/src/dto/content.rs +++ b/crates/presenter/src/dto/content.rs @@ -284,6 +284,16 @@ pub struct ContentInstallRequest { pub game_version: String, /// 目标实例的加载器(选文件用) pub loader: LoaderKind, + /// **只重试上次失败的那些项目**(M191)。 + /// + /// `true` 时 host 不再解根项目:它拿上一次装入留下的依赖计划调 + /// `DependencyPlan::retry_request()`,只重解失败项(`present` 重新扫一遍目录),于是 + /// 「已装好的不重下、没失败的不重发」。这种模式下本结构里**只有 `instance` 会被读**—— + /// 过滤条件与落盘目录都来自那份计划。 + /// + /// 没有可重试的计划时(换进程 / 那一次是别的实例)一律回一条失败,**不**退化成「整单重装」: + /// 那正是本字段要避免的事。 + pub retry: bool, } /// 内容库页的状态(M109):**检索条件 + 最近一批结果 + 请求态**。 diff --git a/crates/presenter/src/dto/content/detail.rs b/crates/presenter/src/dto/content/detail.rs index de3e842..08a4d39 100644 --- a/crates/presenter/src/dto/content/detail.rs +++ b/crates/presenter/src/dto/content/detail.rs @@ -34,6 +34,12 @@ pub enum ContentFailureKind { Server, /// 这条路走不通(缺密钥 / 没有可用下载地址 / 摘要格式不对) Unsupported, + /// **站点有这个项目,但目标版本/加载器下没有条目**(M191;core 的 `FailureKind::Incompatible`) + /// + /// 与 [`Self::NotFound`] / [`Self::Offline`] 是**两种用户动作**:这里换版本或换加载器才有救, + /// 重试同样的条件只会得到同一条结论。以前它落在 `Unsupported`(与「缺密钥」共用一档), + /// 于是界面没法单独说「换个版本」——M191 把这一档单独拎出来。 + Incompatible, /// 其它(解析失败、本地 IO……) Other, } @@ -47,10 +53,17 @@ impl ContentFailureKind { Self::NotFound => "not_found", Self::Server => "server", Self::Unsupported => "unsupported", + Self::Incompatible => "incompatible", Self::Other => "other", } } + /// 是不是「所选版本/加载器下没有条目」(换版本/加载器才有救,重试没用)。 + #[must_use] + pub const fn is_incompatible(self) -> bool { + matches!(self, Self::Incompatible) + } + /// 是不是「没联网」(页面据此画独立的离线态,而不是通用失败态)。 #[must_use] pub const fn is_offline(self) -> bool { diff --git a/crates/presenter/src/dto/install.rs b/crates/presenter/src/dto/install.rs index 527536a..9b72d31 100644 --- a/crates/presenter/src/dto/install.rs +++ b/crates/presenter/src/dto/install.rs @@ -47,6 +47,65 @@ impl InstallStage { } } +/// 一条可只重试的失败的**档位**(M191;与 core 的 `FailureKind` 一一对应)。 +/// +/// ## 为什么不是一个 `bool` / 一句话 +/// +/// 「不兼容所选版本/加载器」与「取不到」要用户做的事不同:前者换版本或换加载器,后者检查网络 +/// 再试。**两档在类型上分开**,界面才能分组画两段、给两条不同的话;合并成一档之后 +/// 「重试」按钮会把一条重试必然失败的项也算进去(M191 的变异守门就钉这一条)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum InstallFailureKind { + /// 站点有这个项目,但目标版本/加载器下没有条目(换版本/加载器才有救) + Incompatible, + /// 取不到(没联网 / 项目不存在 / 站点出错 / 下载失败) + Unreachable, +} + +impl InstallFailureKind { + /// 稳定字符串 id(日志 / 用例断言用,**不要改名**)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Incompatible => "incompatible", + Self::Unreachable => "unreachable", + } + } + + /// 两档的固定顺序(界面分组按它排:先「换版本能救」,再「检查网络」)。 + pub const ALL: [Self; 2] = [Self::Incompatible, Self::Unreachable]; +} + +/// 一条「可只重试」的失败项(M191):项目键 + 档位 + core 给的原话。 +/// +/// 这是界面画「可只重试这 N 个」和分组所需的**全部**信息:项目键就是重试时发出去的那个键 +/// (`DependencyPlan::retry_request()` 用的同一份)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InstallFailureView { + /// 请求它用的项目键(slug 或 id;重试也用它) + pub project: String, + /// 哪一档 + pub kind: InstallFailureKind, + /// core 给的人读说明(引擎原话 + 上下文,**不是**界面文案) + pub detail: String, +} + +impl InstallFailureView { + /// 构造。 + #[must_use] + pub fn new( + project: impl Into, + kind: InstallFailureKind, + detail: impl Into, + ) -> Self { + Self { + project: project.into(), + kind, + detail: detail.into(), + } + } +} + /// 一次安装的进度快照。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct InstallProgressView { @@ -77,6 +136,12 @@ pub struct InstallProgressView { /// 放在进度快照里而不是只塞进文案:`message` 是给用户看的一句话,"没联网" 与 /// "这个项目不存在" 该画两种提示,判据不能靠那句话里有没有"网络"两个字。 pub failure_kind: Option, + /// 可只重试的失败项(M191):空 = 没有失败项 ⇒ 界面**不渲染**那条入口。 + /// + /// 每条自带档位([`InstallFailureKind`]),界面按档分组;`project` 就是 + /// `DependencyPlan::retry_request()` 会发出去的那几个项目键——名单由 plan 产出, + /// 界面不自己拼(拼一份就会与重试实际请求的那几个不一致)。 + pub retry: Vec, } impl Default for InstallProgressView { @@ -101,6 +166,7 @@ impl InstallProgressView { eta_secs: 0, message: String::new(), failure_kind: None, + retry: Vec::new(), } } @@ -159,6 +225,23 @@ impl InstallProgressView { } format!("{}/{} 文件", self.files_done, self.files_total) } + + /// 有可只重试的失败项吗(`false` ⇒ 界面**不渲染**那条入口,不是置灰)。 + #[must_use] + pub fn has_retry(&self) -> bool { + !self.retry.is_empty() + } + + /// 某一档的可重试项(两档分开画时用;顺序沿用计划里的顺序)。 + pub fn retry_of(&self, kind: InstallFailureKind) -> impl Iterator { + self.retry.iter().filter(move |item| item.kind == kind) + } + + /// 某一档有几项。 + #[must_use] + pub fn retry_count(&self, kind: InstallFailureKind) -> usize { + self.retry_of(kind).count() + } } /// 字节数 → 人读(`1.2 MB`)。 diff --git a/crates/presenter/src/dto/mod.rs b/crates/presenter/src/dto/mod.rs index a5d6ea6..16f5ec6 100644 --- a/crates/presenter/src/dto/mod.rs +++ b/crates/presenter/src/dto/mod.rs @@ -45,7 +45,10 @@ pub use content::{ ContentSource, ContentState, ContentVersionView, }; pub use events::{AppCommand, AppEvent, Backend, CoreEvent, LaunchFinished, LiveLogBatch}; -pub use install::{derive_speed, human_bytes, InstallProgressView, InstallStage}; +pub use install::{ + derive_speed, human_bytes, InstallFailureKind, InstallFailureView, InstallProgressView, + InstallStage, +}; pub use instances::{ CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, LaunchFailureCause, LaunchFailureStage, LaunchFailureView, LaunchStage, LoaderInfo, LoaderKind, Progress, diff --git a/crates/presenter/src/host/addon/install.rs b/crates/presenter/src/host/addon/install.rs index b3d2a4b..aa1d1c5 100644 --- a/crates/presenter/src/host/addon/install.rs +++ b/crates/presenter/src/host/addon/install.rs @@ -14,12 +14,18 @@ //! | 整合包 | 一律 `Failed` + 一句原因 | **未做**:core 没有「远程整合包装进实例」的管线(M111) | //! | CurseForge 文件解析 | 一律 `Failed` + 一句原因 | **未做**:需要带 key 的文件端点,本轮只做了 CF 的**检索** | //! -//! ## 前置的两档失败与重试名单(M186) +//! ## 前置的两档失败与重试名单(M186 / M191) //! //! 解析在 [`super::plan`] + `core::addon::online::resolve`:某个前置「不兼容所选版本/加载器」 //! 与「取不到(网络 / 项目不存在)」是**两条**记录,本文件把两档分开写进失败文案,并把失败项 -//! 列成「可只重试这 N 个」。**这不是自动重试**:重试要用户(或将来 UI 的按钮)再发一次, -//! `DependencyPlan::retry_request()` 已经把请求备好了。 +//! 列成「可只重试这 N 个」。**这不是自动重试**:重试要用户点(M191 的界面入口)。 +//! +//! ## 「只重试这些」真的只重试这些(M191) +//! +//! 上一轮失败时,那份 `DependencyPlan` 被 [`plan::remember`] 记下来;`ContentInstallRequest.retry` +//! 为真的第二次请求走 [`run`] 的重试入口——它调 core 的 `DependencyPlan::retry_request()`, +//! 于是重解**只请求失败的那几个项目**(`present` 重新扫一遍目录,已装好的不会被重下)。 +//! 没有记录时回一条失败,**不**退化成「整单重装」:那会把已装好的再下一遍、请求数放大。 //! //! 下载阶段的失败**也进同一套名单**(M186 r1):以前 `download_verified` 一失败就整单中断, //! 用户既看不到「主文件已装、缺 fabric-api」,也拿不到可重试的项目。现在逐个文件下、失败记进 @@ -40,11 +46,11 @@ //! 面板在「进度未知」时本来就只画一行说明、不画条(`download/install.rs` 的既定口径)。 //! 已登记为后续项:presenter 补 `futures-util` 之后换成字节级。 -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use unlucky_core::addon::online::{ - resolve, EngineArtifactFetcher, EngineTransport, Modrinth, PlannedNode, + resolve, DependencyPlan, EngineArtifactFetcher, EngineTransport, Modrinth, PlannedNode, }; use unlucky_core::download::blocking::block_on; use unlucky_core::game::install::instance::read_instance_config; @@ -78,7 +84,8 @@ pub(super) fn install( // 整合包:core 没有这条管线(M111),**不**假装能装。UI 侧按钮本来就是禁用的, // 这里再挡一次是为了「绕过 UI 直接发指令」也不会静默做半件事。 - if request.kind.dir().is_none() { + // 重试模式不看 `kind`:那种模式下落盘目录来自上一次的计划(见 `run`)。 + if !request.retry && request.kind.dir().is_none() { fail( tx, progress, @@ -134,6 +141,13 @@ fn fail(tx: &JobSender, mut progress: InstallProgressView, failure: ContentFailu /// /// 返回「装了哪些 / 跳过了哪些」的人读描述;解析阶段的两档失败由 /// [`plan::failure_of_plan`] 变成一条带大类的失败(**前置缺了不算装成**)。 +/// +/// 两条解析入口,别混: +/// +/// | 入口 | 请求从哪来 | 发几次 | +/// |---|---|---| +/// | 首次装入 | 根项目 + 过滤条件([`plan::request`]) | 一次,从根开始 BFS | +/// | **只重试失败项** | 上一轮计划的 `retry_request()`([`plan::retry_plan`]) | 一次,**只含失败的那几个项目** | fn run( env: &HostEnv, request: &ContentInstallRequest, @@ -143,6 +157,38 @@ fn run( epoch: u64, ) -> Result { let instance = find_instance(env, &request.instance)?; + + // 「只重试这些」:输入是上一轮那份计划,**不**重新发起根项目安装。 + // 没有记录时(换了实例 / 进程重启过)回一条失败,而不是退化成「整单重装」。 + if request.retry { + let Some((target_dir, previous)) = plan::remembered(env, &request.instance) else { + return Err(local(RETRY_LOST)); + }; + progress.version = previous + .request + .roots + .first() + .cloned() + .unwrap_or_else(|| request.instance.clone()); + let present = plan::present_files(&target_dir); + let core_engine = super::super::engine::engine(env); + let dependency_plan = plan::retry_plan(&previous, present, |retry| { + let transport = EngineTransport::new(&core_engine); + let client = Modrinth::new(&transport); + block_on(resolve(&client, retry)) + }); + return collect( + env, + request, + dependency_plan, + &target_dir, + cancel, + progress, + tx, + epoch, + ); + } + let game_dir = game_dir_of(env, &instance)?; let dir = request .kind @@ -165,12 +211,41 @@ fn run( plan::present_files(&target_dir), ); let core_engine = super::super::engine::engine(env); - let mut dependency_plan = { + let dependency_plan = { let transport = EngineTransport::new(&core_engine); let client = Modrinth::new(&transport); block_on(resolve(&client, &resolve_request)) }; + collect( + env, + request, + dependency_plan, + &target_dir, + cancel, + progress, + tx, + epoch, + ) +} +/// 解析之后**两条入口共用**的尾巴:进度 → 逐个下载 → 收口。 +/// +/// 收口的两件事在同一个地方做,是因为它们必须同时发生: +/// +/// | 结果 | 计划 | 可重试名单 | +/// |---|---|---| +/// | 有失败 | 记下(`plan::remember`,重试的输入) | 填(界面据此画「只重试这 N 个」) | +/// | 全成功 | 忘掉(`plan::forget`) | 清空(界面**不渲染**那条入口) | +fn collect( + env: &HostEnv, + request: &ContentInstallRequest, + mut dependency_plan: DependencyPlan, + target_dir: &Path, + cancel: &AtomicBool, + progress: &mut InstallProgressView, + tx: &JobSender, + epoch: u64, +) -> Result { progress.stage = InstallStage::Downloading; progress.files_total = dependency_plan.nodes.len() as u64; progress.files_done = 0; @@ -192,11 +267,12 @@ fn run( epoch, }); + let core_engine = super::super::engine::engine(env); let fetcher = EngineArtifactFetcher::new(&core_engine); // 下载阶段在 `downloads.rs`:可测(假下载器能驱动),失败进名单而不中断。 let installed = downloads::download_stage( &mut dependency_plan, - &target_dir, + target_dir, &fetcher, cancel, progress, @@ -207,11 +283,19 @@ fn run( // 解析与下载两段的失败在这里一起收口:两档分成两句话(不合并成「下载失败」), // 并列出「可只重试这 N 个」。 if let Some(failure) = plan::failure_of_plan(&dependency_plan, installed.len()) { + progress.retry = plan::retry_items(&dependency_plan); + plan::remember(env, &request.instance, target_dir, &dependency_plan); return Err(failure); } + progress.retry.clear(); + plan::forget(env, &request.instance); Ok(plan::summary(&dependency_plan, &installed)) } +/// 没有可重试的记录时那句话(**不**退化成「整单重装」)。 +const RETRY_LOST: &str = + "没有可重试的记录:这次失败发生在别的实例上、或进程重启过。请重新装入那一次。"; + /// 完成态:`files_done` 对齐 `files_total`。 /// /// 别在这里另写数字:以前写死 `1`,装 3 个文件后终态是 `1/3`,下载页只要「已知」就画确定态 diff --git a/crates/presenter/src/host/addon/plan.rs b/crates/presenter/src/host/addon/plan.rs index f09986c..50686df 100644 --- a/crates/presenter/src/host/addon/plan.rs +++ b/crates/presenter/src/host/addon/plan.rs @@ -17,23 +17,29 @@ //! 所以这里既不合并计数也不合并文案;DTO 大类按「用户接下来该做什么」选:离线优先说 //! 检查网络,其次查无此项,只有**全是**不兼容时才说「这条路走不通」(换版本/加载器才有救)。 //! -//! # DTO 没有「不兼容」这一档,所以它落在 `Unsupported`(说清楚,别当它是笔误) +//! # 「可只重试这些」的名单也在这里(M191) //! -//! `ContentFailureKind` 的五档里没有「站点有这个项目、但所选版本/加载器下没有条目」这一档 -//! (M186 已提 finding)。它**不是** `NotFound`(那是「站点说没这个项目」)也**不是** `Offline`, -//! 最贴的只有 `Unsupported`(「这条路走不通」)——所以根项目「没有匹配版本」的 DTO 大类由 -//! M162 的 `NotFound` 改成了 `Unsupported`,语义矫正而非回归;文案里的 -//! 「不兼容所选版本/加载器」才是给用户看的那句话。UI 当前只按 `is_offline()` 分流,无可见差异。 +//! [`retry_items`] 把计划里的失败项摊成 DTO 的 [`InstallFailureView`](项目键 + 档位 + 原话), +//! 界面据此分组画两段;[`retry_plan`] 是**真正重试**那一步——它调 core 的 +//! `DependencyPlan::retry_request()`,于是重试只请求失败的那几个项目,**不是**重新发起根项目安装。 +//! +//! # 不兼容有自己的一档(M191 起) +//! +//! M186 把它映成 `Unsupported`(当时 DTO 没有这一档,与「缺密钥」共用),于是界面没法单独说 +//! 「换个版本/加载器」。M191 给 `ContentFailureKind` 加了 `Incompatible`,[`kind_of`] 直接映射, +//! **不再借 `Unsupported`**。 -use std::path::Path; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; use unlucky_core::addon::online::{ DependencyFailure, DependencyPlan, FailureKind, OnlineError, PlannedNode, PresentFile, ResolveRequest, }; -use crate::dto::ContentFailureKind; +use crate::dto::{ContentFailureKind, InstallFailureKind, InstallFailureView}; +use super::super::env::HostEnv; use super::sources::failure_of; /// 扫目标目录(不递归)里已存在的文件名。 @@ -98,7 +104,8 @@ fn overall_kind(plan: &DependencyPlan) -> ContentFailureKind { return wanted; } } - ContentFailureKind::Unsupported + // 只剩「不兼容」这一档:换版本/加载器才有救(M191 起它有自己的大类,不再借 `Unsupported`) + ContentFailureKind::Incompatible } /// 单条失败的 DTO 大类(**复用** `sources::failure_of` 的映射表,不另写一份判据)。 @@ -106,10 +113,66 @@ fn kind_of(failure: &DependencyFailure) -> ContentFailureKind { match failure.cause { Some(cause) => failure_of(OnlineError::new(cause, failure.message.clone())).kind, // 不兼容那一档没有网络成因:它要用户换版本/加载器,不是「再试一次」 - None => ContentFailureKind::Unsupported, + None => ContentFailureKind::Incompatible, } } +/// 可只重试的失败项 → DTO 名单(M191;界面按档分组画的那几条)。 +/// +/// 档位**一一映射** core 的 [`FailureKind`]:两档在 DTO 上分开,界面才有两段文案。 +/// 顺序沿用计划里的顺序(`record_failure` 已按项目键去重,同一项目只出现一次)。 +#[must_use] +pub(super) fn retry_items(plan: &DependencyPlan) -> Vec { + plan.failures + .iter() + .map(|failure| { + InstallFailureView::new( + failure.requested_as.clone(), + failure_kind_of(failure.kind), + failure.message.clone(), + ) + }) + .collect() +} + +/// core 的档位 → DTO 的档位(**穷尽 match**:谁把两档合并,这条会报错或让用例红)。 +const fn failure_kind_of(kind: FailureKind) -> InstallFailureKind { + match kind { + FailureKind::Incompatible => InstallFailureKind::Incompatible, + FailureKind::Unreachable => InstallFailureKind::Unreachable, + } +} + +/// 下一次解析的请求:**只含失败项**(M191)。 +/// +/// 这就是「只重试这些」的全部实现——请求来自 core 的 +/// `DependencyPlan::retry_request()`(它只把失败项列进 `roots`),过滤条件沿用上次那份。 +/// `present` 必须**重新扫**:这一轮新落盘的文件不在旧名单里,不重扫会被当成「还没有」再下一份 +/// (core 的 `retry_request` 文档明确要求)。 +/// +/// 拿不到计划时(换进程 / 那次是别的实例)调用方**不许**退回「整单重装」:那会把已经装好的 +/// 文件再下一遍,且请求数放大——正是本函数要避免的行为。 +#[must_use] +pub(super) fn retry_request(plan: &DependencyPlan, present: Vec) -> ResolveRequest { + ResolveRequest { + present, + ..plan.retry_request() + } +} + +/// 用「只含失败项」的请求重新解析(**生产**用它;用例注入假解析器断言只请求了哪几个项目)。 +/// +/// 解析器用闭包注入而不是直接调 core:这样「重试请求里到底有哪几个项目、发了几次」在没有网络 +/// 的用例里也能钉住(M191 的变异守门:把 `retry_request()` 换成根项目请求,用例必须红)。 +pub(super) fn retry_plan( + plan: &DependencyPlan, + present: Vec, + resolve: impl FnOnce(&ResolveRequest) -> DependencyPlan, +) -> DependencyPlan { + let request = retry_request(plan, present); + resolve(&request) +} + /// 失败清单(两档各起一段,各自的条数分开数)。 fn report(plan: &DependencyPlan, installed: usize) -> String { let mut lines: Vec = Vec::new(); @@ -199,6 +262,63 @@ fn unresolved_note(plan: &DependencyPlan) -> Option { )) } +/// 「上一次装入留下的计划」表(M191):`只重试这些` 的输入。 +/// +/// ## 为什么要一张静态表(说清楚这笔债) +/// +/// 重试要的输入是**上一轮那份 `DependencyPlan`**(`retry_request()` 是它的方法)。它现在活在 +/// `CoreHost`(`host/dispatch.rs`)之外,因为那个文件不在本 mission 的 scope;与 +/// `addon/pack.rs` 的 `PACK_STATE` 同一手法:每 host 一格(键 = 数据根),于是测试里两个 host +/// 互不干扰。`host/mod.rs` 一旦进 scope,把这张表搬成一个字段即可(这表就是那个字段)。 +/// +/// 键里带 `instance`:同一台机器上往两个实例装过东西,重试只该重试这个实例那一次的计划。 +/// 成功之后由 [`forget`] 清掉——「全成功」时不该留下任何可重试的东西。 +struct Remembered { + host: PathBuf, + instance: String, + /// 落盘目录(重试要按它重新扫 `present`,且**不许**换目录) + dir: PathBuf, + plan: DependencyPlan, +} + +static REMEMBERED: Mutex> = Mutex::new(Vec::new()); + +/// 记下这一轮的计划(**覆盖**同一个 host + 实例的上一份)。 +pub(super) fn remember(env: &HostEnv, instance: &str, dir: &Path, plan: &DependencyPlan) { + let host = env.layout.home().to_path_buf(); + let mut table = REMEMBERED + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + table.retain(|item| !(item.host == host && item.instance == instance)); + table.push(Remembered { + host, + instance: instance.to_string(), + dir: dir.to_path_buf(), + plan: plan.clone(), + }); +} + +/// 忘掉这个实例的计划(装成功 / 没有失败项时调:没有可重试的东西了)。 +pub(super) fn forget(env: &HostEnv, instance: &str) { + let host = env.layout.home().to_path_buf(); + let mut table = REMEMBERED + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + table.retain(|item| !(item.host == host && item.instance == instance)); +} + +/// 这个实例上一次失败的计划(+ 当时的落盘目录);`None` = 没有可重试的记录。 +pub(super) fn remembered(env: &HostEnv, instance: &str) -> Option<(PathBuf, DependencyPlan)> { + let host = env.layout.home().to_path_buf(); + let table = REMEMBERED + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + table + .iter() + .find(|item| item.host == host && item.instance == instance) + .map(|item| (item.dir.clone(), item.plan.clone())) +} + /// 全部装成之后的落盘描述(含跳过与前置的条数、「为什么装」的父项目)。 /// /// `installed` 是**真落盘**的那些(下载失败的不在里面),所以条数与文件名都从它来, diff --git a/crates/presenter/src/host/addon/tests.rs b/crates/presenter/src/host/addon/tests.rs index 89e6436..9959ab7 100644 --- a/crates/presenter/src/host/addon/tests.rs +++ b/crates/presenter/src/host/addon/tests.rs @@ -192,7 +192,10 @@ fn an_incompatible_only_plan_is_not_reported_as_a_download_failure() { Vec::new(), ); let failure = failure_of_plan(&plan, 0).expect("有失败就该给一条"); - assert_eq!(failure.kind, ContentFailureKind::Unsupported); + // M191:「不兼容」有了自己的大类(以前借 `Unsupported`,与「缺密钥」共用一档) + assert_eq!(failure.kind, ContentFailureKind::Incompatible); + assert!(failure.kind.is_incompatible()); + assert!(!failure.is_offline()); assert!(failure.detail.contains("不兼容所选版本/加载器 1 个")); assert!(failure.detail.contains("可只重试这 1 个:P7dR8mSH")); assert!(!failure.detail.contains("下载失败"), "{}", failure.detail); @@ -377,3 +380,6 @@ fn a_required_dependency_without_a_project_id_is_reported() { "{message}" ); } + +// M191:「只重试这些」的名单与重试路径(按主题分卷,父文件已贴 400 行硬线) +mod retry; diff --git a/crates/presenter/src/host/addon/tests/retry.rs b/crates/presenter/src/host/addon/tests/retry.rs new file mode 100644 index 0000000..fb45742 --- /dev/null +++ b/crates/presenter/src/host/addon/tests/retry.rs @@ -0,0 +1,150 @@ +//! M191:「只重试这些」的两档名单 + 真重试路径(`super` 的用例按主题分卷,这一卷专管重试)。 +//! +//! 三条判据对应的变异守门在各自的文档里写清了「改了什么这条会红」。 + +use super::{failure, node, plan}; +use crate::dto::{InstallFailureKind, InstallFailureView}; +use crate::host::addon::plan::{ + failure_of_plan, forget, remember, remembered, retry_items, retry_plan, retry_request, +}; +use unlucky_core::addon::online::{DependencyPlan, FailureKind, OnlineErrorKind, PresentFile}; + +/// 一份「根装好了、两个前置失败」的计划(重试路径的典型输入)。 +fn plan_with_two_tier_failures() -> DependencyPlan { + plan( + vec![ + failure( + "P7dR8mSH", + FailureKind::Incompatible, + None, + "没有匹配的版本", + ), + failure( + "1eAoo2KR", + FailureKind::Unreachable, + Some(OnlineErrorKind::NotFound), + "取版本失败(online_not_found):HTTP 404", + ), + ], + vec![node("AANobbMI", "sodium.jar", 0, &[])], + Vec::new(), + ) +} + +/// 可重试名单:**两档分在类型上**,每条的 project 就是重试会发出去的那个键。 +/// +/// 抓得住:把 `Incompatible` 并进 `Unreachable`(两档合成一档)⇒ 这条立刻红。 +#[test] +fn the_retry_list_keeps_the_two_tiers_apart() { + let items = retry_items(&plan_with_two_tier_failures()); + assert_eq!(items.len(), 2); + assert_eq!( + items + .iter() + .map(|item| (item.project.as_str(), item.kind)) + .collect::>(), + [ + ("P7dR8mSH", InstallFailureKind::Incompatible), + ("1eAoo2KR", InstallFailureKind::Unreachable), + ] + ); + // 两档的名字**必须不同**(界面拿它分组、也是日志/用例里的稳定 id) + assert_ne!( + InstallFailureKind::Incompatible.as_str(), + InstallFailureKind::Unreachable.as_str() + ); +} + +/// 全成功 ⇒ 名单是空的(界面据此**不渲染**那条入口,而不是画一个灰按钮)。 +#[test] +fn a_clean_plan_offers_nothing_to_retry() { + let clean = plan( + Vec::new(), + vec![node("AANobbMI", "sodium.jar", 0, &[])], + Vec::new(), + ); + assert!(retry_items(&clean).is_empty()); + assert!(failure_of_plan(&clean, 1).is_none()); +} + +/// 「只重试这些」**只请求失败的那几个项目**:根项目不再发一次,解析只跑一次。 +/// +/// 这条是 M191 的变异守门①:把 `retry_request()` 换成「重新装根」(`ResolveRequest::single(root)` +/// 或 `plan.request.clone()`)⇒ `roots` 里会出现 `AANobbMI`,这条红。 +#[test] +fn retrying_asks_for_the_failed_projects_only() { + let plan = plan_with_two_tier_failures(); + let present = vec![PresentFile::new("sodium.jar")]; + let mut seen: Vec = Vec::new(); + let _ = retry_plan(&plan, present, |request| { + seen.push(request.clone()); + // 假解析器:把请求的根原样回成节点(用例只关心「请求了谁、请求了几次」) + DependencyPlan { + request: request.clone(), + nodes: request + .roots + .iter() + .map(|root| node(root, "x.jar", 0, &[])) + .collect(), + skipped: Vec::new(), + failures: Vec::new(), + truncated: Vec::new(), + unresolved_required: Vec::new(), + } + }); + + assert_eq!(seen.len(), 1, "重试只发一次解析,请求数不许放大"); + let roots = &seen[0].roots; + assert_eq!(roots, &["P7dR8mSH".to_string(), "1eAoo2KR".to_string()]); + assert!( + !roots.contains(&"AANobbMI".to_string()), + "根项目已装好,重试不许再解它:{roots:?}" + ); + // `present` 重新扫过(这一轮新落盘的文件要在里面,否则会被当成「还没有」再下一份) + assert_eq!(seen[0].present, vec![PresentFile::new("sodium.jar")]); + // 过滤条件沿用上次那份 + assert_eq!(seen[0].game_version, plan.request.game_version); +} + +/// 计划被记住 / 忘掉:键是「host + 实例」,成功之后不该留可重试的记录。 +#[test] +fn the_last_failed_plan_is_remembered_per_instance() { + let root = std::env::temp_dir().join(format!("ull-m191-retry-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let env = crate::host::HostEnv::at_root(&root); + assert!(remembered(&env, "survival").is_none()); + + let plan = plan_with_two_tier_failures(); + let dir = root.join(".minecraft/mods"); + remember(&env, "survival", &dir, &plan); + let (saved_dir, saved) = remembered(&env, "survival").expect("记住了这一次"); + assert_eq!(saved_dir, dir); + assert_eq!(saved.failures.len(), 2); + // 别的实例不受影响(同一台机器上装过两个实例) + assert!(remembered(&env, "modded").is_none()); + + // 重试请求从「记住的计划」里取:仍然只含失败项 + let request = retry_request(&saved, Vec::new()); + assert_eq!(request.roots, vec!["P7dR8mSH", "1eAoo2KR"]); + + forget(&env, "survival"); + assert!(remembered(&env, "survival").is_none()); + let _ = std::fs::remove_dir_all(&root); +} + +/// DTO 名单与「真重试请求」是**同一份**:界面画的 N 个 = 重试会请求的 N 个。 +/// +/// 抓得住:界面名单自己另拼一份(多列/少列一个项目)⇒ 这条红。 +#[test] +fn the_dto_list_is_exactly_what_the_retry_request_asks_for() { + let plan = plan_with_two_tier_failures(); + let items: Vec = retry_items(&plan); + let request = retry_request(&plan, Vec::new()); + assert_eq!( + items + .iter() + .map(|item| item.project.clone()) + .collect::>(), + request.roots + ); +} From 961ff453a04651f897a463046890ef451e1bdf93 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 17:20:58 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(content):=20=E9=87=8D=E8=AF=95=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E7=94=BB=E5=8F=AF=E8=AF=BB=E5=90=8D=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=8F=AA=E6=9C=89=E7=AB=99=E7=82=B9=20id=20(M191=20r1?= =?UTF-8?q?=20P2-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 P2-1:面板原来只画 `P7dR8mSH` 这种项目键,用户拿到的是一串哈希。 可读名其实都在手边,只是没进 DTO——现在补上,按复审给的**最小修法**做,没扩散: - `InstallFailureView` 加 `label: String`(**语言中立**:站点标题或文件名), 配 `line()` 统一决定「名字 (id)」还是「只有 id」(名字为空时不编造)。 `detail`(core 中文散文)**依旧不进界面**——那会踩 i18n R2。 - `plan::label_of` 三个来源,零额外请求: 根项目 → 请求里的 `title`;计划里有节点(下载失败)→ `node.file.filename`; 都没有(解析 404 / 不兼容的前置)→ 空,界面退回只画 id(复审认这条兜底)。 - 计划被记住时连显示名一起记(`plan::remember` 多一个 `title`),于是重试那一轮 面板头与失败名单沿用的是同一个名字(顺手收掉复审 N5 的「重试时面板头显示 id」)。 - N2:删掉新加的 `ContentInstallDismiss`,复用既有的 `DownloadDismiss`(同一句话不立两条 key)。 - 行数/clippy:`collect` 的参数捆成 `Placement{dir,title}`(9 个参数超限); `plan.rs` 加完 `label_of` 到 429 行,把「记住的计划」表纯搬移到 `plan/remembered.rs`(363 + 82)。 守门(真跑红再还原): - 把 `label_of` 置空 ⇒ `the_retry_list_shows_a_readable_name_not_just_the_project_id` 红 (三行全变成 `["AANobbMI","P7dR8mSH","1eAoo2KR"]`); - 审阅指出的另一半判据也补上了:`Done` + 残留名单 ⇒ 必须不画;现在**只去掉** `stage != Failed` 那一半就红(之前只去掉它仍是全绿,报告的表述不准,已改)。 --- crates/app-iced/src/pages/content/demo.rs | 32 ++++-- .../app-iced/src/pages/content/model/retry.rs | 16 ++- .../src/pages/content/model/tests/retry.rs | 39 ++++++- .../app-iced/src/pages/content/parts/retry.rs | 3 +- crates/i18n/src/catalog/content.rs | 1 - crates/i18n/src/catalog/golden/tail.rs | 1 - crates/presenter/src/dto/install.rs | 32 +++++- crates/presenter/src/host/addon/install.rs | 42 ++++--- crates/presenter/src/host/addon/plan.rs | 107 +++++++----------- .../src/host/addon/plan/remembered.rs | 82 ++++++++++++++ .../presenter/src/host/addon/tests/retry.rs | 75 +++++++++++- 11 files changed, 326 insertions(+), 104 deletions(-) create mode 100644 crates/presenter/src/host/addon/plan/remembered.rs diff --git a/crates/app-iced/src/pages/content/demo.rs b/crates/app-iced/src/pages/content/demo.rs index e668369..cc16373 100644 --- a/crates/app-iced/src/pages/content/demo.rs +++ b/crates/app-iced/src/pages/content/demo.rs @@ -40,21 +40,26 @@ fn retry_failure(instance: &str, catalog: Catalog) -> Option { files_total: 4, ..InstallProgressView::new() }; + // 三种可显示性各一条,与 host 侧 `plan::label_of` 的三种情况同形: + // 根项目 = 请求里的标题、下载失败 = 文件名、解析 404 = 没有名字(只画 id)。 progress.retry = vec![ + InstallFailureView::new( + "AANobbMI", + "Sodium", + InstallFailureKind::Unreachable, + "下载 sodium-fabric-0.6.0.jar 失败(online_not_connected):连不上 cdn.modrinth.com", + ), InstallFailureView::new( "P7dR8mSH", + "", InstallFailureKind::Incompatible, "没有匹配所选游戏版本/加载器(1.20.1 / fabric)的版本", ), InstallFailureView::new( "1eAoo2KR", + "fabric-api-0.92.2+1.20.1.jar", InstallFailureKind::Unreachable, - "取版本失败(online_not_found):HTTP 404", - ), - InstallFailureView::new( - "AANobbMI", - InstallFailureKind::Unreachable, - "下载 fabric-api.jar 失败(online_not_connected):连不上 cdn.modrinth.com", + "下载 fabric-api-0.92.2+1.20.1.jar 失败(online_not_connected):连不上 cdn.modrinth.com", ), ]; RetryPanel::of(&progress, catalog) @@ -321,7 +326,20 @@ mod tests { assert!(panel.has_both_tiers(), "{:?}", panel.groups); assert_eq!( panel.items_of(unlucky_presenter::dto::InstallFailureKind::Unreachable), - Some(["1eAoo2KR".to_string(), "AANobbMI".to_string()].as_slice()) + Some( + [ + // 根项目:名字来自请求标题 + "Sodium (AANobbMI)".to_string(), + // 下载失败:名字来自文件名 + "fabric-api-0.92.2+1.20.1.jar (1eAoo2KR)".to_string(), + ] + .as_slice() + ) + ); + // 拿不到名的那一档(解析 404 / 不兼容):只画 id,不编名字 + assert_eq!( + panel.items_of(unlucky_presenter::dto::InstallFailureKind::Incompatible), + Some(["P7dR8mSH".to_string()].as_slice()) ); } } diff --git a/crates/app-iced/src/pages/content/model/retry.rs b/crates/app-iced/src/pages/content/model/retry.rs index a6d8f17..9606944 100644 --- a/crates/app-iced/src/pages/content/model/retry.rs +++ b/crates/app-iced/src/pages/content/model/retry.rs @@ -7,12 +7,16 @@ //! ```text //! 安装未完成 —— 3 项没装上 //! 不兼容所选版本/加载器 -//! P7dR8mSH +//! P7dR8mSH ← 拿不到名字时只画 id(合理兜底) //! 取不到(网络 / 项目不存在) -//! 1eAoo2KR、AANobbMI +//! Sodium (AANobbMI) ← 根项目:名字来自请求标题 +//! fabric-api-0.92.2.jar (1eAoo2KR) ← 下载失败:名字来自文件名 //! [只重试这 3 个] [关闭] //! ``` //! +//! 每行是 `InstallFailureView::line()` 的结果(名字优先、id 次要)。名字是**语言中立**的站点 +//! 标题或文件名,不是 core 的中文散文——那条线(i18n R2)踩了就回不去。 +//! //! ## 三条判据(都落在 [`RetryPanel::of`],纯函数,用例直接钉) //! //! 1. **全成功不渲染**:`retry` 名单空 ⇒ `None` ⇒ 视图里连按钮都不存在(不是置灰); @@ -27,8 +31,8 @@ use unlucky_i18n::{Catalog, Key}; use unlucky_presenter::dto::{ - ContentInstallRequest, ContentSource, InstallFailureKind, InstallProgressView, InstallStage, - LoaderKind, + ContentInstallRequest, ContentSource, InstallFailureKind, InstallFailureView, + InstallProgressView, InstallStage, LoaderKind, }; /// 面板上的一个档位分组。 @@ -38,7 +42,7 @@ pub struct RetryGroup { pub kind: InstallFailureKind, /// 这一档的标题 key(不硬编码中文) pub title: Key, - /// 这一档的项目键(顺序 = 计划里的顺序) + /// 这一档要画的每一行(`名字 (id)`;拿不到名字时就只有 id,顺序 = 计划里的顺序) pub items: Vec, } @@ -80,7 +84,7 @@ impl RetryPanel { title: title_of(kind), items: progress .retry_of(kind) - .map(|item| item.project.clone()) + .map(InstallFailureView::line) .collect(), }) .filter(|group| !group.items.is_empty()) diff --git a/crates/app-iced/src/pages/content/model/tests/retry.rs b/crates/app-iced/src/pages/content/model/tests/retry.rs index d64a3f8..28e2fc5 100644 --- a/crates/app-iced/src/pages/content/model/tests/retry.rs +++ b/crates/app-iced/src/pages/content/model/tests/retry.rs @@ -20,14 +20,22 @@ fn failed_progress() -> unlucky_presenter::dto::InstallProgressView { files_total: 4, ..InstallProgressView::new() }; + // 三种可显示性各一条(与 `plan::label_of` 的三种情况同形): + // 根项目用请求里的标题、下载失败用文件名、拿不到名的只画 id。 progress.retry = vec![ + InstallFailureView::new( + "AANobbMI", + "Sodium", + InstallFailureKind::Unreachable, + "连不上 CDN", + ), InstallFailureView::new( "P7dR8mSH", + "fabric-api.jar", InstallFailureKind::Incompatible, "没有匹配的版本", ), - InstallFailureView::new("1eAoo2KR", InstallFailureKind::Unreachable, "HTTP 404"), - InstallFailureView::new("AANobbMI", InstallFailureKind::Unreachable, "连不上 CDN"), + InstallFailureView::new("1eAoo2KR", "", InstallFailureKind::Unreachable, "HTTP 404"), ]; progress } @@ -41,13 +49,21 @@ fn the_two_tiers_are_two_groups_with_two_labels() { let panel = RetryPanel::of(&failed_progress(), Lang::En.catalog()).expect("有失败项就有面板"); assert_eq!(panel.total, 3); assert!(panel.has_both_tiers(), "{:?}", panel.groups); + // **P2-1 的守门**:有名字的画「名字 (id)」,没有名字的才只画 id assert_eq!( panel.items_of(InstallFailureKind::Incompatible), - Some(["P7dR8mSH".to_string()].as_slice()) + Some(["fabric-api.jar (P7dR8mSH)".to_string()].as_slice()) ); assert_eq!( panel.items_of(InstallFailureKind::Unreachable), - Some(["1eAoo2KR".to_string(), "AANobbMI".to_string()].as_slice()) + Some(["Sodium (AANobbMI)".to_string(), "1eAoo2KR".to_string()].as_slice()) + ); + // 变异守门:把标签置空 ⇒ 这一行退回裸 id,上面两条立刻红 + assert!( + panel + .items_of(InstallFailureKind::Unreachable) + .is_some_and(|items| items[0].starts_with("Sodium")), + "根项目必须显示请求里的标题,不是 id" ); let titles: Vec = panel.groups.iter().map(|group| group.title).collect(); assert_ne!(titles[0], titles[1], "两档的标题 key 不许是同一个"); @@ -71,6 +87,7 @@ fn an_empty_tier_does_not_get_a_group() { }; progress.retry = vec![InstallFailureView::new( "P7dR8mSH", + "fabric-api.jar", InstallFailureKind::Incompatible, "没有匹配的版本", )]; @@ -101,7 +118,19 @@ fn a_fully_successful_install_has_no_retry_surface_at_all() { assert!(model.panel.is_none()); assert!(model.retry_install_request().is_none()); - // 失败但没有名单(例如整合包那条本地失败)同样不画:没有可重试的东西 + // 判据是**两半**:`Failed` **且** 名单非空。第一半单独钉住——就算快照里残留了名单 + // (不该发生,host 成功时会清),`Done` 也不画(r1 复审指出:只去掉这一半曾全绿)。 + let mut stale = InstallProgressView { + stage: InstallStage::Done, + ..InstallProgressView::new() + }; + stale.retry = failed_progress().retry; + assert!( + RetryPanel::of(&stale, Lang::En.catalog()).is_none(), + "Done 态不许画重试面板(判据是「失败且有名单」,不是「有名单」)" + ); + + // 第二半:失败但没有名单(例如整合包那条本地失败)同样不画:没有可重试的东西 let mut failed = InstallProgressView { stage: InstallStage::Failed, ..InstallProgressView::new() diff --git a/crates/app-iced/src/pages/content/parts/retry.rs b/crates/app-iced/src/pages/content/parts/retry.rs index bbe81ed..7b7c750 100644 --- a/crates/app-iced/src/pages/content/parts/retry.rs +++ b/crates/app-iced/src/pages/content/parts/retry.rs @@ -101,8 +101,9 @@ pub(crate) fn retry_panel<'a>( ), buttons::text( ui, + // 复用下载页那条现有的「关闭」:同一句话不另立一条要同步的 key(r1 复审 N2) Button::new( - catalog.text(Key::ContentInstallDismiss), + catalog.text(Key::DownloadDismiss), PageMessage::CancelInstall ), ), diff --git a/crates/i18n/src/catalog/content.rs b/crates/i18n/src/catalog/content.rs index e26bd9b..a7829e3 100644 --- a/crates/i18n/src/catalog/content.rs +++ b/crates/i18n/src/catalog/content.rs @@ -118,7 +118,6 @@ macro_rules! content_entries { "Retry asks again only for the items above; files that already landed stay put.", "重试只重新解析上面这几项,已落盘的文件不动。" ), - ContentInstallDismiss => ("Dismiss", "关闭"), // --- M113: pack import / export(PACKUI)--- PackTitle => ("Modpack import & export", "整合包导入 / 导出"), PackImportTitle => ("Import a modpack", "导入整合包"), diff --git a/crates/i18n/src/catalog/golden/tail.rs b/crates/i18n/src/catalog/golden/tail.rs index f9e23d9..b81c3f3 100644 --- a/crates/i18n/src/catalog/golden/tail.rs +++ b/crates/i18n/src/catalog/golden/tail.rs @@ -391,7 +391,6 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("ContentInstallRetryUnreachable", "Could not fetch (network or missing project)", "取不到(网络 / 项目不存在)"), ("ContentInstallRetryOnly", "Retry just these {count}", "只重试这 {count} 个"), ("ContentInstallRetryHint", "Retry asks again only for the items above; files that already landed stay put.", "重试只重新解析上面这几项,已落盘的文件不动。"), - ("ContentInstallDismiss", "Dismiss", "关闭"), ("PackTitle", "Modpack import & export", "整合包导入 / 导出"), ("PackImportTitle", "Import a modpack", "导入整合包"), ("PackDropHint", "Drop a .mrpack, .zip or a MultiMC/Prism instance folder here", "把 .mrpack / .zip 或 MultiMC·Prism 实例目录拖到这里"), diff --git a/crates/presenter/src/dto/install.rs b/crates/presenter/src/dto/install.rs index 9b72d31..fd7b8dd 100644 --- a/crates/presenter/src/dto/install.rs +++ b/crates/presenter/src/dto/install.rs @@ -76,17 +76,29 @@ impl InstallFailureKind { pub const ALL: [Self; 2] = [Self::Incompatible, Self::Unreachable]; } -/// 一条「可只重试」的失败项(M191):项目键 + 档位 + core 给的原话。 +/// 一条「可只重试」的失败项(M191):项目键 + 可读标签 + 档位 + core 给的原话。 /// /// 这是界面画「可只重试这 N 个」和分组所需的**全部**信息:项目键就是重试时发出去的那个键 /// (`DependencyPlan::retry_request()` 用的同一份)。 +/// +/// ## 为什么还要 `label`(M191 r1 复审 P2-1) +/// +/// `project` 是站点 id(`P7dR8mSH`),直接画在界面上等于给用户一串哈希。可读名其实都在手边, +/// 只是没进 DTO:根项目的名字就在请求的 `title` 里;失败文件的名字在 `PlannedNode.file.filename` +/// 里(下载失败那种)。`label` 收的正是这两个**语言中立**的字符串(站点标题 / 文件名), +/// **不是** `detail` 那种 core 中文散文——那会踩 i18n 的 R2(core 中文不进界面)。 +/// +/// `label` 为空是合法且**必须**保留的一档:项目不存在(404)时确实没有名字可显示, +/// 那时界面只画 id(评审认这条兜底是合理的)。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct InstallFailureView { /// 请求它用的项目键(slug 或 id;重试也用它) pub project: String, + /// 语言中立的可读标签(站点给的项目标题 / 文件名;空 = 拿不到名字) + pub label: String, /// 哪一档 pub kind: InstallFailureKind, - /// core 给的人读说明(引擎原话 + 上下文,**不是**界面文案) + /// core 给的人读说明(引擎原话 + 上下文,**不是**界面文案;**不要**画到界面上) pub detail: String, } @@ -95,15 +107,31 @@ impl InstallFailureView { #[must_use] pub fn new( project: impl Into, + label: impl Into, kind: InstallFailureKind, detail: impl Into, ) -> Self { Self { project: project.into(), + label: label.into(), kind, detail: detail.into(), } } + + /// 界面该画的那一行:有名字就「名字 (id)」,没有就只画 id。 + /// + /// 界面上**名字优先、id 次要**:名字让人认出是哪个项目,括号里的 id 是给「名字对不上时」 + /// 或排查用的。定在这个方法里而不是视图里,是因为「有名字用名字」这条要有用例钉住。 + #[must_use] + pub fn line(&self) -> String { + let label = self.label.trim(); + if label.is_empty() || label == self.project { + self.project.clone() + } else { + format!("{label} ({})", self.project) + } + } } /// 一次安装的进度快照。 diff --git a/crates/presenter/src/host/addon/install.rs b/crates/presenter/src/host/addon/install.rs index aa1d1c5..3009502 100644 --- a/crates/presenter/src/host/addon/install.rs +++ b/crates/presenter/src/host/addon/install.rs @@ -161,15 +161,11 @@ fn run( // 「只重试这些」:输入是上一轮那份计划,**不**重新发起根项目安装。 // 没有记录时(换了实例 / 进程重启过)回一条失败,而不是退化成「整单重装」。 if request.retry { - let Some((target_dir, previous)) = plan::remembered(env, &request.instance) else { + let Some((target_dir, title, previous)) = plan::remembered(env, &request.instance) else { return Err(local(RETRY_LOST)); }; - progress.version = previous - .request - .roots - .first() - .cloned() - .unwrap_or_else(|| request.instance.clone()); + // 面板头继续显示那次的显示名(不是项目 id:r1 复审 N5) + progress.version = title.clone(); let present = plan::present_files(&target_dir); let core_engine = super::super::engine::engine(env); let dependency_plan = plan::retry_plan(&previous, present, |retry| { @@ -181,7 +177,10 @@ fn run( env, request, dependency_plan, - &target_dir, + Placement { + dir: &target_dir, + title: &title, + }, cancel, progress, tx, @@ -220,7 +219,10 @@ fn run( env, request, dependency_plan, - &target_dir, + Placement { + dir: &target_dir, + title: &request.title, + }, cancel, progress, tx, @@ -228,6 +230,14 @@ fn run( ) } +/// 「装到哪、那次叫什么」——这两样总是一起出现,捆成一个参数(否则 `collect` 顶破 8 个)。 +struct Placement<'a> { + /// 落盘目录(`mods/` / `resourcepacks/` / `shaderpacks/`) + dir: &'a Path, + /// 那次装入的显示名(失败名单里根项目的可读名就是它) + title: &'a str, +} + /// 解析之后**两条入口共用**的尾巴:进度 → 逐个下载 → 收口。 /// /// 收口的两件事在同一个地方做,是因为它们必须同时发生: @@ -240,7 +250,7 @@ fn collect( env: &HostEnv, request: &ContentInstallRequest, mut dependency_plan: DependencyPlan, - target_dir: &Path, + placement: Placement<'_>, cancel: &AtomicBool, progress: &mut InstallProgressView, tx: &JobSender, @@ -272,7 +282,7 @@ fn collect( // 下载阶段在 `downloads.rs`:可测(假下载器能驱动),失败进名单而不中断。 let installed = downloads::download_stage( &mut dependency_plan, - target_dir, + placement.dir, &fetcher, cancel, progress, @@ -283,8 +293,14 @@ fn collect( // 解析与下载两段的失败在这里一起收口:两档分成两句话(不合并成「下载失败」), // 并列出「可只重试这 N 个」。 if let Some(failure) = plan::failure_of_plan(&dependency_plan, installed.len()) { - progress.retry = plan::retry_items(&dependency_plan); - plan::remember(env, &request.instance, target_dir, &dependency_plan); + progress.retry = plan::retry_items(&dependency_plan, placement.title); + plan::remember( + env, + &request.instance, + placement.dir, + placement.title, + &dependency_plan, + ); return Err(failure); } progress.retry.clear(); diff --git a/crates/presenter/src/host/addon/plan.rs b/crates/presenter/src/host/addon/plan.rs index 50686df..dd3654e 100644 --- a/crates/presenter/src/host/addon/plan.rs +++ b/crates/presenter/src/host/addon/plan.rs @@ -19,18 +19,20 @@ //! //! # 「可只重试这些」的名单也在这里(M191) //! -//! [`retry_items`] 把计划里的失败项摊成 DTO 的 [`InstallFailureView`](项目键 + 档位 + 原话), -//! 界面据此分组画两段;[`retry_plan`] 是**真正重试**那一步——它调 core 的 +//! [`retry_items`] 把计划里的失败项摊成 DTO 的 [`InstallFailureView`](项目键 + 可读标签 + +//! 档位 + 原话),界面据此分组画两段;[`retry_plan`] 是**真正重试**那一步——它调 core 的 //! `DependencyPlan::retry_request()`,于是重试只请求失败的那几个项目,**不是**重新发起根项目安装。 //! +//! 可读标签由 [`label_of`] 定(根项目用请求标题、下载失败用文件名、解析失败留空), +//! **不许**拿 `failure.message` 顶上——那是 core 的中文散文,界面照画会踩 i18n R2。 +//! //! # 不兼容有自己的一档(M191 起) //! //! M186 把它映成 `Unsupported`(当时 DTO 没有这一档,与「缺密钥」共用),于是界面没法单独说 //! 「换个版本/加载器」。M191 给 `ContentFailureKind` 加了 `Incompatible`,[`kind_of`] 直接映射, //! **不再借 `Unsupported`**。 -use std::path::{Path, PathBuf}; -use std::sync::Mutex; +use std::path::Path; use unlucky_core::addon::online::{ DependencyFailure, DependencyPlan, FailureKind, OnlineError, PlannedNode, PresentFile, @@ -39,9 +41,12 @@ use unlucky_core::addon::online::{ use crate::dto::{ContentFailureKind, InstallFailureKind, InstallFailureView}; -use super::super::env::HostEnv; use super::sources::failure_of; +mod remembered; + +pub(super) use remembered::{forget, remember, remembered}; + /// 扫目标目录(不递归)里已存在的文件名。 /// /// 只给名字,不算摘要:为了「跳过」把整个 `mods/` 哈希一遍不划算,同名就是同一份 jar 的 @@ -121,13 +126,18 @@ fn kind_of(failure: &DependencyFailure) -> ContentFailureKind { /// /// 档位**一一映射** core 的 [`FailureKind`]:两档在 DTO 上分开,界面才有两段文案。 /// 顺序沿用计划里的顺序(`record_failure` 已按项目键去重,同一项目只出现一次)。 +/// +/// `root_title` 是**用户点的那次装入的显示名**(`ContentInstallRequest.title`,来自检索命中的 +/// 项目标题)。它只用来给「根项目自己失败」那条垫一个可读名(M191 r1 P2-1:面板原来只画 +/// `P7dR8mSH` 这种站点 id,用户拿到的是一串哈希)。 #[must_use] -pub(super) fn retry_items(plan: &DependencyPlan) -> Vec { +pub(super) fn retry_items(plan: &DependencyPlan, root_title: &str) -> Vec { plan.failures .iter() .map(|failure| { InstallFailureView::new( failure.requested_as.clone(), + label_of(plan, failure, root_title), failure_kind_of(failure.kind), failure.message.clone(), ) @@ -135,6 +145,34 @@ pub(super) fn retry_items(plan: &DependencyPlan) -> Vec { .collect() } +/// 一条失败的可读标签(**语言中立**:站点标题或文件名;拿不到就是空串)。 +/// +/// 两个来源,都在手边、零额外请求: +/// +/// | 情况 | 标签 | 为什么 | +/// |---|---|---| +/// | 根项目自己失败 | 请求里的 `title` | 用户刚才点的就是它,界面其它地方用的也是这个名字 | +/// | 计划里有这个节点(下载失败) | `node.file.filename` | 「哪个文件没下来」比项目名更具体 | +/// | 都没有(解析阶段 404 / 不兼容) | 空 | 站点没给过名字;界面退回只画 id(合理兜底) | +/// +/// **不许**拿 `failure.message` 顶:那是 core 的中文散文,画到界面上就是 i18n R2 的违规。 +#[must_use] +fn label_of(plan: &DependencyPlan, failure: &DependencyFailure, root_title: &str) -> String { + if plan + .request + .roots + .first() + .is_some_and(|root| root == &failure.requested_as) + { + return root_title.to_string(); + } + plan.nodes + .iter() + .find(|node| node.requested_as == failure.requested_as) + .map(|node| node.file.filename.clone()) + .unwrap_or_default() +} + /// core 的档位 → DTO 的档位(**穷尽 match**:谁把两档合并,这条会报错或让用例红)。 const fn failure_kind_of(kind: FailureKind) -> InstallFailureKind { match kind { @@ -262,63 +300,6 @@ fn unresolved_note(plan: &DependencyPlan) -> Option { )) } -/// 「上一次装入留下的计划」表(M191):`只重试这些` 的输入。 -/// -/// ## 为什么要一张静态表(说清楚这笔债) -/// -/// 重试要的输入是**上一轮那份 `DependencyPlan`**(`retry_request()` 是它的方法)。它现在活在 -/// `CoreHost`(`host/dispatch.rs`)之外,因为那个文件不在本 mission 的 scope;与 -/// `addon/pack.rs` 的 `PACK_STATE` 同一手法:每 host 一格(键 = 数据根),于是测试里两个 host -/// 互不干扰。`host/mod.rs` 一旦进 scope,把这张表搬成一个字段即可(这表就是那个字段)。 -/// -/// 键里带 `instance`:同一台机器上往两个实例装过东西,重试只该重试这个实例那一次的计划。 -/// 成功之后由 [`forget`] 清掉——「全成功」时不该留下任何可重试的东西。 -struct Remembered { - host: PathBuf, - instance: String, - /// 落盘目录(重试要按它重新扫 `present`,且**不许**换目录) - dir: PathBuf, - plan: DependencyPlan, -} - -static REMEMBERED: Mutex> = Mutex::new(Vec::new()); - -/// 记下这一轮的计划(**覆盖**同一个 host + 实例的上一份)。 -pub(super) fn remember(env: &HostEnv, instance: &str, dir: &Path, plan: &DependencyPlan) { - let host = env.layout.home().to_path_buf(); - let mut table = REMEMBERED - .lock() - .unwrap_or_else(|poison| poison.into_inner()); - table.retain(|item| !(item.host == host && item.instance == instance)); - table.push(Remembered { - host, - instance: instance.to_string(), - dir: dir.to_path_buf(), - plan: plan.clone(), - }); -} - -/// 忘掉这个实例的计划(装成功 / 没有失败项时调:没有可重试的东西了)。 -pub(super) fn forget(env: &HostEnv, instance: &str) { - let host = env.layout.home().to_path_buf(); - let mut table = REMEMBERED - .lock() - .unwrap_or_else(|poison| poison.into_inner()); - table.retain(|item| !(item.host == host && item.instance == instance)); -} - -/// 这个实例上一次失败的计划(+ 当时的落盘目录);`None` = 没有可重试的记录。 -pub(super) fn remembered(env: &HostEnv, instance: &str) -> Option<(PathBuf, DependencyPlan)> { - let host = env.layout.home().to_path_buf(); - let table = REMEMBERED - .lock() - .unwrap_or_else(|poison| poison.into_inner()); - table - .iter() - .find(|item| item.host == host && item.instance == instance) - .map(|item| (item.dir.clone(), item.plan.clone())) -} - /// 全部装成之后的落盘描述(含跳过与前置的条数、「为什么装」的父项目)。 /// /// `installed` 是**真落盘**的那些(下载失败的不在里面),所以条数与文件名都从它来, diff --git a/crates/presenter/src/host/addon/plan/remembered.rs b/crates/presenter/src/host/addon/plan/remembered.rs new file mode 100644 index 0000000..b2f139d --- /dev/null +++ b/crates/presenter/src/host/addon/plan/remembered.rs @@ -0,0 +1,82 @@ +//! 「上一次装入留下的计划」表(M191):`retry_plan()` 的输入。 +//! +//! 从 `plan.rs` 拆出(父文件加了 `label_of` 之后顶到 429 行,越过 400 行硬线)。 +//! 拆法与本仓既有 `-p1/-p2` 一致:**纯搬移**,一行不改语义;调用方路径不变 +//! (`plan.rs` 仍然 `pub(super) use` 这三个函数)。 +//! + +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use unlucky_core::addon::online::DependencyPlan; + +use super::super::super::env::HostEnv; + +/// 「上一次装入留下的计划」表(M191):`只重试这些` 的输入。 +/// +/// ## 为什么要一张静态表(说清楚这笔债) +/// +/// 重试要的输入是**上一轮那份 `DependencyPlan`**(`retry_request()` 是它的方法)。它现在活在 +/// `CoreHost`(`host/dispatch.rs`)之外,因为那个文件不在本 mission 的 scope;与 +/// `addon/pack.rs` 的 `PACK_STATE` 同一手法:每 host 一格(键 = 数据根),于是测试里两个 host +/// 互不干扰。`host/mod.rs` 一旦进 scope,把这张表搬成一个字段即可(这表就是那个字段)。 +/// +/// 键里带 `instance`:同一台机器上往两个实例装过东西,重试只该重试这个实例那一次的计划。 +/// 成功之后由 [`forget`] 清掉——「全成功」时不该留下任何可重试的东西。 +struct Remembered { + host: PathBuf, + instance: String, + /// 落盘目录(重试要按它重新扫 `present`,且**不许**换目录) + dir: PathBuf, + /// 那次装入的显示名(重试时面板头与失败名单的可读名都沿用它,不再退回项目 id) + title: String, + plan: DependencyPlan, +} + +static REMEMBERED: Mutex> = Mutex::new(Vec::new()); + +/// 记下这一轮的计划(**覆盖**同一个 host + 实例的上一份)。 +pub(in crate::host::addon) fn remember( + env: &HostEnv, + instance: &str, + dir: &Path, + title: &str, + plan: &DependencyPlan, +) { + let host = env.layout.home().to_path_buf(); + let mut table = REMEMBERED + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + table.retain(|item| !(item.host == host && item.instance == instance)); + table.push(Remembered { + host, + instance: instance.to_string(), + dir: dir.to_path_buf(), + title: title.to_string(), + plan: plan.clone(), + }); +} + +/// 忘掉这个实例的计划(装成功 / 没有失败项时调:没有可重试的东西了)。 +pub(in crate::host::addon) fn forget(env: &HostEnv, instance: &str) { + let host = env.layout.home().to_path_buf(); + let mut table = REMEMBERED + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + table.retain(|item| !(item.host == host && item.instance == instance)); +} + +/// 这个实例上一次失败的计划(+ 当时的落盘目录 + 显示名);`None` = 没有可重试的记录。 +pub(in crate::host::addon) fn remembered( + env: &HostEnv, + instance: &str, +) -> Option<(PathBuf, String, DependencyPlan)> { + let host = env.layout.home().to_path_buf(); + let table = REMEMBERED + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + table + .iter() + .find(|item| item.host == host && item.instance == instance) + .map(|item| (item.dir.clone(), item.title.clone(), item.plan.clone())) +} diff --git a/crates/presenter/src/host/addon/tests/retry.rs b/crates/presenter/src/host/addon/tests/retry.rs index fb45742..dea9b01 100644 --- a/crates/presenter/src/host/addon/tests/retry.rs +++ b/crates/presenter/src/host/addon/tests/retry.rs @@ -36,7 +36,7 @@ fn plan_with_two_tier_failures() -> DependencyPlan { /// 抓得住:把 `Incompatible` 并进 `Unreachable`(两档合成一档)⇒ 这条立刻红。 #[test] fn the_retry_list_keeps_the_two_tiers_apart() { - let items = retry_items(&plan_with_two_tier_failures()); + let items = retry_items(&plan_with_two_tier_failures(), "Sodium"); assert_eq!(items.len(), 2); assert_eq!( items @@ -63,7 +63,7 @@ fn a_clean_plan_offers_nothing_to_retry() { vec![node("AANobbMI", "sodium.jar", 0, &[])], Vec::new(), ); - assert!(retry_items(&clean).is_empty()); + assert!(retry_items(&clean, "Sodium").is_empty()); assert!(failure_of_plan(&clean, 1).is_none()); } @@ -116,9 +116,11 @@ fn the_last_failed_plan_is_remembered_per_instance() { let plan = plan_with_two_tier_failures(); let dir = root.join(".minecraft/mods"); - remember(&env, "survival", &dir, &plan); - let (saved_dir, saved) = remembered(&env, "survival").expect("记住了这一次"); + remember(&env, "survival", &dir, "Sodium", &plan); + let (saved_dir, saved_title, saved) = remembered(&env, "survival").expect("记住了这一次"); assert_eq!(saved_dir, dir); + // 显示名跟着计划一起记住:重试时面板头与失败名单的可读名都沿用它(不再退回项目 id) + assert_eq!(saved_title, "Sodium"); assert_eq!(saved.failures.len(), 2); // 别的实例不受影响(同一台机器上装过两个实例) assert!(remembered(&env, "modded").is_none()); @@ -138,7 +140,7 @@ fn the_last_failed_plan_is_remembered_per_instance() { #[test] fn the_dto_list_is_exactly_what_the_retry_request_asks_for() { let plan = plan_with_two_tier_failures(); - let items: Vec = retry_items(&plan); + let items: Vec = retry_items(&plan, "Sodium"); let request = retry_request(&plan, Vec::new()); assert_eq!( items @@ -148,3 +150,66 @@ fn the_dto_list_is_exactly_what_the_retry_request_asks_for() { request.roots ); } + +/// **P2-1 的守门**:面板上写的是可读名,不是裸项目 id。 +/// +/// 三个来源各一条(`plan::label_of` 的三种情况),而且都必须**语言中立**(站点标题 / 文件名, +/// 不是 core 的中文散文): +/// +/// | 项 | 情况 | 期望那一行 | +/// |---|---|---| +/// | 根项目 | 它自己失败 | `Sodium (AANobbMI)` —— 名字来自请求的 `title` | +/// | 前置 | 下载失败(计划里有节点) | `fabric-api.jar (P7dR8mSH)` —— 名字来自文件名 | +/// | 前置 | 解析阶段 404(没有节点) | `1eAoo2KR` —— 拿不到名,只画 id(合理兜底) | +/// +/// 变异守门:把 `label_of` 的标签置空(或 `retry_items` 传空串)⇒ 这条红。 +#[test] +fn the_retry_list_shows_a_readable_name_not_just_the_project_id() { + let mut plan = plan( + vec![ + // 根项目自己失败(下载失败):requested_as 与 roots[0] 相同 + failure( + "AANobbMI", + FailureKind::Unreachable, + Some(OnlineErrorKind::NotConnected), + "下载 sodium.jar 失败(online_not_connected):连不上 cdn.modrinth.com", + ), + // 前置下载失败:计划里有它的节点 ⇒ 用文件名 + failure( + "P7dR8mSH", + FailureKind::Unreachable, + Some(OnlineErrorKind::NotConnected), + "下载 fabric-api.jar 失败(online_not_connected):连不上 cdn.modrinth.com", + ), + // 前置解析 404:没有节点、也不是根 ⇒ 没有名字可显示 + failure( + "1eAoo2KR", + FailureKind::Unreachable, + Some(OnlineErrorKind::NotFound), + "取版本失败(online_not_found):HTTP 404", + ), + ], + vec![ + node("AANobbMI", "sodium-fabric-0.6.0.jar", 0, &[]), + node("P7dR8mSH", "fabric-api.jar", 1, &["AANobbMI"]), + ], + Vec::new(), + ); + // 根必须是请求里的那个键(`plan::request` 只放一个根) + plan.request = unlucky_core::addon::online::ResolveRequest::single("AANobbMI"); + + let items = retry_items(&plan, "Sodium"); + let lines: Vec = items.iter().map(InstallFailureView::line).collect(); + assert_eq!( + lines, + vec![ + "Sodium (AANobbMI)".to_string(), + "fabric-api.jar (P7dR8mSH)".to_string(), + // 拿不到名的那一档:只画 id,不编造、也不拿 core 的中文 message 顶上 + "1eAoo2KR".to_string(), + ] + ); + // 标签是语言中立的:站点标题/文件名,不是 core 的中文散文 + assert!(!items[0].label.contains("下载"), "{}", items[0].label); + assert!(!items[1].label.contains("失败"), "{}", items[1].label); +} From 26e7b7333ea90231b038a5433e41f0181842e09f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 18:09:11 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(content):=20=E4=B8=8D=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E6=A1=A3=E5=9C=A8=E5=A4=B1=E8=B4=A5=E8=B7=AF=E5=BE=84=E4=B8=8A?= =?UTF-8?q?=E5=8F=96=E9=A1=B9=E7=9B=AE=E5=90=8D=20+=20=E9=87=8D=E8=AF=95?= =?UTF-8?q?=E8=BD=AE=E4=B8=8D=E5=86=8D=E8=B4=B4=E9=94=99=E6=A0=B9=E7=9A=84?= =?UTF-8?q?=E6=A0=87=E9=A2=98=20(M191=20r2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 r2 两条 p2,都修了,并按复审给的口径验过红。 **P2-1(续):不兼容档不再只有裸 id** `FailureKind::Incompatible` 的项目**确实存在**(站点只是没有匹配目标版本/加载器的条目), 所以失败路径上值得现取一次名字——`plan::names_for_incompatible` 按失败项去重、一项一次 GET (`Modrinth::project`),`fetch_title` 把任何失败都折成 `None`: **拿名字失败不许影响安装结果**,取不到 / 拿到空标题就退回 id。只有这一档发请求; 「取不到」档 404 那部分项目根本不存在,再发一次只会再吃 404。 **P2-2(新):重试轮把根的标题贴到了前置头上** 根因是判「这条是不是根项目自己失败」用了 `plan.request.roots.first()`,而重试那一轮的 plan 来自 `retry_request()`,它的 `roots` 就是上一轮的失败名单——第一个是前置。 现在判据改成 **id 比对**(`Root{id,title}`,id 由 `remember` 一起记下来): `label_of` 只在这条失败的 id 真的等于原始根 id 时才用根的标题。复审给的那个形状 (roots=["P7dR8mSH","1eAoo2KR"] + root_title="Sodium")现在画 `P7dR8mSH`,不再是 `Sodium (P7dR8mSH)`。 **守门(每条都真跑红过,且在拆完模块的最终代码上复跑)** - 去掉不兼容档的名字来源 ⇒ `an_incompatible_item_shows_the_project_name_once_it_is_known` 红 (`left: "P7dR8mSH"` / `right: "Entity Culling (P7dR8mSH)"`)。 - `label_of` 换回 `roots.first()` ⇒ `the_retry_round_does_not_paste_the_root_title_onto_a_dependency` 红 (`left: ["Sodium (P7dR8mSH)","1eAoo2KR"]`,正是复审实测的那一行)。 - 取名字不降级(`expect`)⇒ `a_failed_name_lookup_falls_back_to_the_id_without_breaking_anything` 红。 **行数**:`plan.rs` 加完这套到 414 行、`install.rs` 到 403 行,都越过 400 硬线 ⇒ 把「名字从哪来」整块(`Root`/`Names`/`names_for_incompatible`/`label_of`/`fetch_title`) 纯搬移到 `plan/labels.rs`(111 行):`plan.rs` 336、`install.rs` 388。 --- crates/app-iced/src/pages/content/demo.rs | 8 +- .../src/pages/content/model/tests/retry.rs | 6 +- crates/presenter/src/host/addon/install.rs | 37 +++-- crates/presenter/src/host/addon/plan.rs | 42 +----- .../presenter/src/host/addon/plan/labels.rs | 111 ++++++++++++++ .../src/host/addon/plan/remembered.rs | 48 ++++-- .../presenter/src/host/addon/tests/retry.rs | 138 ++++++++++++++++-- 7 files changed, 313 insertions(+), 77 deletions(-) create mode 100644 crates/presenter/src/host/addon/plan/labels.rs diff --git a/crates/app-iced/src/pages/content/demo.rs b/crates/app-iced/src/pages/content/demo.rs index cc16373..60023f4 100644 --- a/crates/app-iced/src/pages/content/demo.rs +++ b/crates/app-iced/src/pages/content/demo.rs @@ -41,7 +41,7 @@ fn retry_failure(instance: &str, catalog: Catalog) -> Option { ..InstallProgressView::new() }; // 三种可显示性各一条,与 host 侧 `plan::label_of` 的三种情况同形: - // 根项目 = 请求里的标题、下载失败 = 文件名、解析 404 = 没有名字(只画 id)。 + // 根项目 = 请求里的标题、下载失败 = 文件名、不兼容档 = 失败路径上现取的项目名。 progress.retry = vec![ InstallFailureView::new( "AANobbMI", @@ -51,7 +51,7 @@ fn retry_failure(instance: &str, catalog: Catalog) -> Option { ), InstallFailureView::new( "P7dR8mSH", - "", + "Entity Culling", InstallFailureKind::Incompatible, "没有匹配所选游戏版本/加载器(1.20.1 / fabric)的版本", ), @@ -336,10 +336,10 @@ mod tests { .as_slice() ) ); - // 拿不到名的那一档(解析 404 / 不兼容):只画 id,不编名字 + // 不兼容档:失败路径上现取的项目名(不是 id) assert_eq!( panel.items_of(unlucky_presenter::dto::InstallFailureKind::Incompatible), - Some(["P7dR8mSH".to_string()].as_slice()) + Some(["Entity Culling (P7dR8mSH)".to_string()].as_slice()) ); } } diff --git a/crates/app-iced/src/pages/content/model/tests/retry.rs b/crates/app-iced/src/pages/content/model/tests/retry.rs index 28e2fc5..f5ba6d0 100644 --- a/crates/app-iced/src/pages/content/model/tests/retry.rs +++ b/crates/app-iced/src/pages/content/model/tests/retry.rs @@ -31,7 +31,7 @@ fn failed_progress() -> unlucky_presenter::dto::InstallProgressView { ), InstallFailureView::new( "P7dR8mSH", - "fabric-api.jar", + "Entity Culling", InstallFailureKind::Incompatible, "没有匹配的版本", ), @@ -52,7 +52,7 @@ fn the_two_tiers_are_two_groups_with_two_labels() { // **P2-1 的守门**:有名字的画「名字 (id)」,没有名字的才只画 id assert_eq!( panel.items_of(InstallFailureKind::Incompatible), - Some(["fabric-api.jar (P7dR8mSH)".to_string()].as_slice()) + Some(["Entity Culling (P7dR8mSH)".to_string()].as_slice()) ); assert_eq!( panel.items_of(InstallFailureKind::Unreachable), @@ -87,7 +87,7 @@ fn an_empty_tier_does_not_get_a_group() { }; progress.retry = vec![InstallFailureView::new( "P7dR8mSH", - "fabric-api.jar", + "Entity Culling", InstallFailureKind::Incompatible, "没有匹配的版本", )]; diff --git a/crates/presenter/src/host/addon/install.rs b/crates/presenter/src/host/addon/install.rs index 3009502..92bbaee 100644 --- a/crates/presenter/src/host/addon/install.rs +++ b/crates/presenter/src/host/addon/install.rs @@ -161,14 +161,14 @@ fn run( // 「只重试这些」:输入是上一轮那份计划,**不**重新发起根项目安装。 // 没有记录时(换了实例 / 进程重启过)回一条失败,而不是退化成「整单重装」。 if request.retry { - let Some((target_dir, title, previous)) = plan::remembered(env, &request.instance) else { + let Some(saved) = plan::remembered(env, &request.instance) else { return Err(local(RETRY_LOST)); }; // 面板头继续显示那次的显示名(不是项目 id:r1 复审 N5) - progress.version = title.clone(); - let present = plan::present_files(&target_dir); + progress.version = saved.title.clone(); + let present = plan::present_files(&saved.dir); let core_engine = super::super::engine::engine(env); - let dependency_plan = plan::retry_plan(&previous, present, |retry| { + let dependency_plan = plan::retry_plan(&saved.plan, present, |retry| { let transport = EngineTransport::new(&core_engine); let client = Modrinth::new(&transport); block_on(resolve(&client, retry)) @@ -178,8 +178,11 @@ fn run( request, dependency_plan, Placement { - dir: &target_dir, - title: &title, + dir: &saved.dir, + root: plan::Root { + id: &saved.root, + title: &saved.title, + }, }, cancel, progress, @@ -221,7 +224,10 @@ fn run( dependency_plan, Placement { dir: &target_dir, - title: &request.title, + root: plan::Root { + id: &request.project, + title: &request.title, + }, }, cancel, progress, @@ -230,12 +236,15 @@ fn run( ) } -/// 「装到哪、那次叫什么」——这两样总是一起出现,捆成一个参数(否则 `collect` 顶破 8 个)。 +/// 「装到哪 + 那次装入的根是谁」——总是一起出现,捆成一个参数(否则 `collect` 顶破 8 个)。 struct Placement<'a> { /// 落盘目录(`mods/` / `resourcepacks/` / `shaderpacks/`) dir: &'a Path, - /// 那次装入的显示名(失败名单里根项目的可读名就是它) - title: &'a str, + /// 当初那次装入的根项目(id + 显示名);重试轮里由 `plan::remembered` 提供 + /// + /// **必须**带 id:重试那一轮 `plan.request.roots` 是上一轮的失败名单,不能拿它判「是不是根」 + /// (r2 复审 P2-2 就是这条:第一前置被贴上了根的标题)。 + root: plan::Root<'a>, } /// 解析之后**两条入口共用**的尾巴:进度 → 逐个下载 → 收口。 @@ -293,12 +302,16 @@ fn collect( // 解析与下载两段的失败在这里一起收口:两档分成两句话(不合并成「下载失败」), // 并列出「可只重试这 N 个」。 if let Some(failure) = plan::failure_of_plan(&dependency_plan, installed.len()) { - progress.retry = plan::retry_items(&dependency_plan, placement.title); + // 「不兼容」那一档项目**存在**(站点只是没有匹配目标版本/加载器的条目),所以在这里 + // 现取一次名字——只在失败路径上发、一个项目一次 GET,取不到就退回 id(见 `names_for_incompatible`)。 + let names = plan::names_for_incompatible(&dependency_plan, |id| plan::fetch_title(env, id)); + progress.retry = plan::retry_items(&dependency_plan, placement.root, &names); plan::remember( env, &request.instance, placement.dir, - placement.title, + placement.root.id, + placement.root.title, &dependency_plan, ); return Err(failure); diff --git a/crates/presenter/src/host/addon/plan.rs b/crates/presenter/src/host/addon/plan.rs index dd3654e..2e7b8d8 100644 --- a/crates/presenter/src/host/addon/plan.rs +++ b/crates/presenter/src/host/addon/plan.rs @@ -43,8 +43,10 @@ use crate::dto::{ContentFailureKind, InstallFailureKind, InstallFailureView}; use super::sources::failure_of; +mod labels; mod remembered; +pub(super) use labels::{fetch_title, names_for_incompatible, Root}; pub(super) use remembered::{forget, remember, remembered}; /// 扫目标目录(不递归)里已存在的文件名。 @@ -126,18 +128,18 @@ fn kind_of(failure: &DependencyFailure) -> ContentFailureKind { /// /// 档位**一一映射** core 的 [`FailureKind`]:两档在 DTO 上分开,界面才有两段文案。 /// 顺序沿用计划里的顺序(`record_failure` 已按项目键去重,同一项目只出现一次)。 -/// -/// `root_title` 是**用户点的那次装入的显示名**(`ContentInstallRequest.title`,来自检索命中的 -/// 项目标题)。它只用来给「根项目自己失败」那条垫一个可读名(M191 r1 P2-1:面板原来只画 -/// `P7dR8mSH` 这种站点 id,用户拿到的是一串哈希)。 #[must_use] -pub(super) fn retry_items(plan: &DependencyPlan, root_title: &str) -> Vec { +pub(super) fn retry_items( + plan: &DependencyPlan, + root: labels::Root<'_>, + names: &labels::Names, +) -> Vec { plan.failures .iter() .map(|failure| { InstallFailureView::new( failure.requested_as.clone(), - label_of(plan, failure, root_title), + labels::label_of(plan, failure, root, names), failure_kind_of(failure.kind), failure.message.clone(), ) @@ -145,34 +147,6 @@ pub(super) fn retry_items(plan: &DependencyPlan, root_title: &str) -> Vec String { - if plan - .request - .roots - .first() - .is_some_and(|root| root == &failure.requested_as) - { - return root_title.to_string(); - } - plan.nodes - .iter() - .find(|node| node.requested_as == failure.requested_as) - .map(|node| node.file.filename.clone()) - .unwrap_or_default() -} - /// core 的档位 → DTO 的档位(**穷尽 match**:谁把两档合并,这条会报错或让用例红)。 const fn failure_kind_of(kind: FailureKind) -> InstallFailureKind { match kind { diff --git a/crates/presenter/src/host/addon/plan/labels.rs b/crates/presenter/src/host/addon/plan/labels.rs new file mode 100644 index 0000000..03b2312 --- /dev/null +++ b/crates/presenter/src/host/addon/plan/labels.rs @@ -0,0 +1,111 @@ +//! 「这条失败该显示什么名字」——**语言中立**的标签来源(M191 r1 P2-1 / r2 P2-1 续)。 +//! +//! 从 `plan.rs` 拆出(加完失败路径上取名字那次 GET 之后父文件到 414 行,越过 400 行硬线)。 +//! 拆的口径是「一件事一个文件」:这里只管**名字从哪来**,判定与说话仍在 `plan.rs`。 +//! +//! 三个来源,按可信度排(细节在 [`label_of`]):原始根项目的标题、失败文件的文件名、 +//! 失败路径上现取的项目名;都没有就留空,界面只画项目 id。 +//! +//! **不许**拿 `DependencyFailure::message` 顶:那是 core 的中文散文,画到界面上就是 i18n R2 的违规。 + +use unlucky_core::addon::online::{ + DependencyFailure, DependencyPlan, EngineTransport, FailureKind, Modrinth, +}; +use unlucky_core::download::blocking::block_on; + +use super::super::super::env::HostEnv; + +/// 这一次装入的**原始根项目**(id + 显示名)。 +/// +/// 「是不是根」只能按 `id` 比(r2 复审 P2-2):重试那一轮的 plan 来自 +/// `DependencyPlan::retry_request()`,它的 `request.roots` **就是上一轮的失败名单**—— +/// 第一个是前置,拿它当「根」会把根的标题贴到前置头上。所以判据是「这条失败的 id 是不是 +/// 当初那次装入的根 id」,两个值都由 [`remembered`] / `ContentInstallRequest` 提供。 +#[derive(Clone, Copy)] +pub(in crate::host::addon) struct Root<'a> { + /// 当初那次装入的根项目 id(`ContentInstallRequest.project`) + pub id: &'a str, + /// 它的显示名(`ContentInstallRequest.title`;只配给 `id` 用) + pub title: &'a str, +} + +/// 失败路径上现取的项目名(id → title)。取不到的项目不在表里。 +pub(in crate::host::addon) type Names = Vec<(String, String)>; + +/// 为「不兼容」档现取项目名(**只在失败路径上**,一个项目一次 GET)。 +/// +/// 为什么只给这一档:`FailureKind::Incompatible` 是「站点**有这个项目**,但目标版本/加载器下 +/// 没有条目」——项目确实存在,所以这次 GET 有东西可拿。「取不到」档里 404 的那部分项目根本 +/// 不存在,给它发请求只会再吃一个 404,于是那一档仍显示 id(合理兜底)。 +/// +/// `lookup` 由调用方注入(生产是 `Modrinth::project`,用例是闭包):于是「取名字失败也要能降级」 +/// 这件事在没有网络的用例里也能钉住。取不到 / 拿到空串的项一律**不进**名单。 +pub(in crate::host::addon) fn names_for_incompatible( + plan: &DependencyPlan, + lookup: impl Fn(&str) -> Option, +) -> Names { + plan.failures_of(FailureKind::Incompatible) + .filter_map(|failure| { + let title = lookup(&failure.requested_as)?; + let title = title.trim(); + (!title.is_empty()).then(|| (failure.requested_as.clone(), title.to_string())) + }) + .collect() +} + +/// 一条失败的可读标签(**语言中立**:站点标题、文件名或现取的项目名;拿不到就是空串)。 +/// +/// 三个来源,**按可信度排**: +/// +/// | 情况 | 标签 | 为什么 | +/// |---|---|---| +/// | 这条失败的 id == 原始根 id | 请求里的 `title` | 用户刚才点的就是它 | +/// | 计划里有这个节点(下载失败) | `node.file.filename` | 「哪个文件没下来」比项目名更具体 | +/// | 不兼容档 | [`names_for_incompatible`] 现取的名字 | 项目存在,一次 GET 拿得到 | +/// | 其余(解析 404 等) | 空 | 项目不存在,显示 id 是合理兜底 | +/// +/// **不许**拿 `failure.message` 顶:那是 core 的中文散文,画到界面上就是 i18n R2 的违规。 +#[must_use] +pub(super) fn label_of( + plan: &DependencyPlan, + failure: &DependencyFailure, + root: Root<'_>, + names: &Names, +) -> String { + // ① 根项目自己失败:**按 id 比**,不看 `plan.request.roots`(重试轮里那是失败名单) + if failure.requested_as == root.id { + return root.title.to_string(); + } + // ② 计划里有这个节点(下载失败):文件名最具体 + if let Some(node) = plan + .nodes + .iter() + .find(|node| node.requested_as == failure.requested_as) + { + return node.file.filename.clone(); + } + // ③ 不兼容档:失败路径上现取的名字;取不到就空 ⇒ 界面只画 id + if failure.kind == FailureKind::Incompatible { + return names + .iter() + .find(|(id, _)| id == &failure.requested_as) + .map(|(_, title)| title.clone()) + .unwrap_or_default(); + } + String::new() +} + +/// 取一个项目的显示名(**只在失败路径上**发)。 +/// +/// 失败一律回 `None`:**拿名字失败不许影响安装结果**——这条是「只重试这些」的补充信息, +/// 坏一次网络不该把「装了什么、缺了什么」这整件事变成一条错误。 +pub(in crate::host::addon) fn fetch_title(env: &HostEnv, id: &str) -> Option { + let core_engine = crate::host::engine::engine(env); + let transport = EngineTransport::new(&core_engine); + let client = Modrinth::new(&transport); + let id = id.to_string(); + block_on(async move { client.project(&id).await }) + .ok() + .map(|project| project.title) + .filter(|title| !title.trim().is_empty()) +} diff --git a/crates/presenter/src/host/addon/plan/remembered.rs b/crates/presenter/src/host/addon/plan/remembered.rs index b2f139d..e8bd735 100644 --- a/crates/presenter/src/host/addon/plan/remembered.rs +++ b/crates/presenter/src/host/addon/plan/remembered.rs @@ -3,7 +3,6 @@ //! 从 `plan.rs` 拆出(父文件加了 `label_of` 之后顶到 429 行,越过 400 行硬线)。 //! 拆法与本仓既有 `-p1/-p2` 一致:**纯搬移**,一行不改语义;调用方路径不变 //! (`plan.rs` 仍然 `pub(super) use` 这三个函数)。 -//! use std::path::{Path, PathBuf}; use std::sync::Mutex; @@ -12,7 +11,7 @@ use unlucky_core::addon::online::DependencyPlan; use super::super::super::env::HostEnv; -/// 「上一次装入留下的计划」表(M191):`只重试这些` 的输入。 +/// 「上一次装入留下的计划」表的一行(M191):`只重试这些` 的输入。 /// /// ## 为什么要一张静态表(说清楚这笔债) /// @@ -23,23 +22,45 @@ use super::super::super::env::HostEnv; /// /// 键里带 `instance`:同一台机器上往两个实例装过东西,重试只该重试这个实例那一次的计划。 /// 成功之后由 [`forget`] 清掉——「全成功」时不该留下任何可重试的东西。 -struct Remembered { +/// +/// ## 为什么连**原始根项目 id** 一起记(r2 复审 P2-2) +/// +/// 「这一条是不是根项目自己失败」不能靠 `plan.request.roots.first()` 判:重试那一轮的 plan 来自 +/// `DependencyPlan::retry_request()`,它的 `roots` 就是**上一轮的失败名单**——第一个是前置, +/// 拿它跟根的显示名一比,就会把 `Sodium` 贴到一个前置头上。所以判据只能是「这个 id 是不是 +/// 当初那次装入的根 id」,那个 id 得跟计划一起记下来。 +struct Entry { host: PathBuf, instance: String, /// 落盘目录(重试要按它重新扫 `present`,且**不许**换目录) dir: PathBuf, - /// 那次装入的显示名(重试时面板头与失败名单的可读名都沿用它,不再退回项目 id) + /// 当初那次装入的根项目 id(`ContentInstallRequest.project`,判「是不是根」的唯一依据) + root: String, + /// 当初那次装入的显示名(只配给 `root` 用) title: String, plan: DependencyPlan, } -static REMEMBERED: Mutex> = Mutex::new(Vec::new()); +static REMEMBERED: Mutex> = Mutex::new(Vec::new()); + +/// 取回的那一份记录(拆成结构体而不是四元组:调用点读得懂哪一个是哪个)。 +pub(in crate::host::addon) struct Remembered { + /// 落盘目录 + pub dir: PathBuf, + /// 当初那次装入的根项目 id + pub root: String, + /// 当初那次装入的显示名 + pub title: String, + /// 上一轮的依赖计划(`retry_request()` 的来源) + pub plan: DependencyPlan, +} /// 记下这一轮的计划(**覆盖**同一个 host + 实例的上一份)。 pub(in crate::host::addon) fn remember( env: &HostEnv, instance: &str, dir: &Path, + root: &str, title: &str, plan: &DependencyPlan, ) { @@ -48,10 +69,11 @@ pub(in crate::host::addon) fn remember( .lock() .unwrap_or_else(|poison| poison.into_inner()); table.retain(|item| !(item.host == host && item.instance == instance)); - table.push(Remembered { + table.push(Entry { host, instance: instance.to_string(), dir: dir.to_path_buf(), + root: root.to_string(), title: title.to_string(), plan: plan.clone(), }); @@ -66,11 +88,8 @@ pub(in crate::host::addon) fn forget(env: &HostEnv, instance: &str) { table.retain(|item| !(item.host == host && item.instance == instance)); } -/// 这个实例上一次失败的计划(+ 当时的落盘目录 + 显示名);`None` = 没有可重试的记录。 -pub(in crate::host::addon) fn remembered( - env: &HostEnv, - instance: &str, -) -> Option<(PathBuf, String, DependencyPlan)> { +/// 这个实例上一次失败的计划(+ 落盘目录 + 原始根 id 与显示名);`None` = 没有可重试的记录。 +pub(in crate::host::addon) fn remembered(env: &HostEnv, instance: &str) -> Option { let host = env.layout.home().to_path_buf(); let table = REMEMBERED .lock() @@ -78,5 +97,10 @@ pub(in crate::host::addon) fn remembered( table .iter() .find(|item| item.host == host && item.instance == instance) - .map(|item| (item.dir.clone(), item.title.clone(), item.plan.clone())) + .map(|item| Remembered { + dir: item.dir.clone(), + root: item.root.clone(), + title: item.title.clone(), + plan: item.plan.clone(), + }) } diff --git a/crates/presenter/src/host/addon/tests/retry.rs b/crates/presenter/src/host/addon/tests/retry.rs index dea9b01..026300d 100644 --- a/crates/presenter/src/host/addon/tests/retry.rs +++ b/crates/presenter/src/host/addon/tests/retry.rs @@ -5,7 +5,8 @@ use super::{failure, node, plan}; use crate::dto::{InstallFailureKind, InstallFailureView}; use crate::host::addon::plan::{ - failure_of_plan, forget, remember, remembered, retry_items, retry_plan, retry_request, + failure_of_plan, forget, names_for_incompatible, remember, remembered, retry_items, retry_plan, + retry_request, Root, }; use unlucky_core::addon::online::{DependencyPlan, FailureKind, OnlineErrorKind, PresentFile}; @@ -36,7 +37,14 @@ fn plan_with_two_tier_failures() -> DependencyPlan { /// 抓得住:把 `Incompatible` 并进 `Unreachable`(两档合成一档)⇒ 这条立刻红。 #[test] fn the_retry_list_keeps_the_two_tiers_apart() { - let items = retry_items(&plan_with_two_tier_failures(), "Sodium"); + let items = retry_items( + &plan_with_two_tier_failures(), + Root { + id: "AANobbMI", + title: "Sodium", + }, + &Vec::new(), + ); assert_eq!(items.len(), 2); assert_eq!( items @@ -63,7 +71,15 @@ fn a_clean_plan_offers_nothing_to_retry() { vec![node("AANobbMI", "sodium.jar", 0, &[])], Vec::new(), ); - assert!(retry_items(&clean, "Sodium").is_empty()); + assert!(retry_items( + &clean, + Root { + id: "AANobbMI", + title: "Sodium", + }, + &Vec::new(), + ) + .is_empty()); assert!(failure_of_plan(&clean, 1).is_none()); } @@ -116,17 +132,18 @@ fn the_last_failed_plan_is_remembered_per_instance() { let plan = plan_with_two_tier_failures(); let dir = root.join(".minecraft/mods"); - remember(&env, "survival", &dir, "Sodium", &plan); - let (saved_dir, saved_title, saved) = remembered(&env, "survival").expect("记住了这一次"); - assert_eq!(saved_dir, dir); - // 显示名跟着计划一起记住:重试时面板头与失败名单的可读名都沿用它(不再退回项目 id) - assert_eq!(saved_title, "Sodium"); - assert_eq!(saved.failures.len(), 2); + remember(&env, "survival", &dir, "AANobbMI", "Sodium", &plan); + let saved = remembered(&env, "survival").expect("记住了这一次"); + assert_eq!(saved.dir, dir); + // 原始根 id + 显示名跟着计划一起记住:重试时面板头用它,判「是不是根」也用它(r2 P2-2) + assert_eq!(saved.root, "AANobbMI"); + assert_eq!(saved.title, "Sodium"); + assert_eq!(saved.plan.failures.len(), 2); // 别的实例不受影响(同一台机器上装过两个实例) assert!(remembered(&env, "modded").is_none()); // 重试请求从「记住的计划」里取:仍然只含失败项 - let request = retry_request(&saved, Vec::new()); + let request = retry_request(&saved.plan, Vec::new()); assert_eq!(request.roots, vec!["P7dR8mSH", "1eAoo2KR"]); forget(&env, "survival"); @@ -140,7 +157,14 @@ fn the_last_failed_plan_is_remembered_per_instance() { #[test] fn the_dto_list_is_exactly_what_the_retry_request_asks_for() { let plan = plan_with_two_tier_failures(); - let items: Vec = retry_items(&plan, "Sodium"); + let items: Vec = retry_items( + &plan, + Root { + id: "AANobbMI", + title: "Sodium", + }, + &Vec::new(), + ); let request = retry_request(&plan, Vec::new()); assert_eq!( items @@ -198,7 +222,14 @@ fn the_retry_list_shows_a_readable_name_not_just_the_project_id() { // 根必须是请求里的那个键(`plan::request` 只放一个根) plan.request = unlucky_core::addon::online::ResolveRequest::single("AANobbMI"); - let items = retry_items(&plan, "Sodium"); + let items = retry_items( + &plan, + Root { + id: "AANobbMI", + title: "Sodium", + }, + &Vec::new(), + ); let lines: Vec = items.iter().map(InstallFailureView::line).collect(); assert_eq!( lines, @@ -213,3 +244,86 @@ fn the_retry_list_shows_a_readable_name_not_just_the_project_id() { assert!(!items[0].label.contains("下载"), "{}", items[0].label); assert!(!items[1].label.contains("失败"), "{}", items[1].label); } + +/// **r2 P2-2 的守门**:重试那一轮,第一条失败项**不许**被贴成原根项目的名字。 +/// +/// 形状就是复审给的那个:重试轮的 plan 来自 `retry_request()`,它的 `roots` 是上一轮的失败名单 +/// (第一个是**前置**),而 `root` 记的是原始根 `AANobbMI / Sodium`。老实现拿 `roots.first()` +/// 判「是不是根」⇒ 画出 `Sodium (P7dR8mSH)`;现在按 id 比 ⇒ 第一条是它自己的名字(或 id)。 +/// +/// 变异守门:把 `label_of` 的判据换回 `plan.request.roots.first()` ⇒ 这条红。 +#[test] +fn the_retry_round_does_not_paste_the_root_title_onto_a_dependency() { + let mut plan = plan_with_two_tier_failures(); + // 重试请求:roots = 上一轮的失败名单 + plan.request = unlucky_core::addon::online::ResolveRequest { + roots: vec!["P7dR8mSH".to_string(), "1eAoo2KR".to_string()], + ..plan.request.clone() + }; + // 重试轮里没有被安装的节点(上一轮装好的不会再进计划),于是标签只能来自 id + let items = retry_items( + &plan, + Root { + id: "AANobbMI", + title: "Sodium", + }, + &Vec::new(), + ); + let lines: Vec = items.iter().map(InstallFailureView::line).collect(); + assert_eq!(lines, vec!["P7dR8mSH".to_string(), "1eAoo2KR".to_string()]); + assert!( + !lines.iter().any(|line| line.contains("Sodium")), + "重试轮把根的标题贴到前置上了:{lines:?}" + ); +} + +/// **r2 P2-1 续的守门**:不兼容档要显示项目名,不是 id。 +/// +/// 那一档的项目**确实存在**(站点只是没有匹配目标版本/加载器的条目),所以失败路径上值得 +/// 现取一次名字。变异守门:把这次获取去掉(`names` 传空)⇒ 第一条退回 `P7dR8mSH`,这条红。 +#[test] +fn an_incompatible_item_shows_the_project_name_once_it_is_known() { + let plan = plan_with_two_tier_failures(); + let root = Root { + id: "AANobbMI", + title: "Sodium", + }; + // 生产路径:`names_for_incompatible` 用注入的查询取名字(这里模拟"站点有回应") + let names = names_for_incompatible(&plan, |id| { + (id == "P7dR8mSH").then(|| "Entity Culling".to_string()) + }); + assert_eq!( + names, + vec![("P7dR8mSH".to_string(), "Entity Culling".to_string())], + "只给不兼容档取名字(404 那条不发请求)" + ); + + let items = retry_items(&plan, root, &names); + assert_eq!(items[0].line(), "Entity Culling (P7dR8mSH)"); + // 「取不到」那档不受影响:没有节点、没有名字 ⇒ 只画 id + assert_eq!(items[1].line(), "1eAoo2KR"); +} + +/// **降级**:取名字本身失败,不许把安装结果搞坏,也不许编名字——照旧画 id。 +/// +/// 变异守门:让 `project_title` 的错误往外冒(`?` / `expect`)⇒ 这条连编译都过不了, +/// 或者(退回成「拿不到就 panic」)在这里红。 +#[test] +fn a_failed_name_lookup_falls_back_to_the_id_without_breaking_anything() { + let plan = plan_with_two_tier_failures(); + // 站点 404 / 没网 / 坏响应:`lookup` 全是 `None` + let names = names_for_incompatible(&plan, |_| None); + assert!(names.is_empty(), "{names:?}"); + let items = retry_items( + &plan, + Root { + id: "AANobbMI", + title: "Sodium", + }, + &names, + ); + assert_eq!(items[0].line(), "P7dR8mSH", "取不到名字就画 id,不编造"); + // 拿到空串(站点给了个空标题)同样不算名字 + let blank = names_for_incompatible(&plan, |_| Some(" ".to_string())); + assert!(blank.is_empty(), "{blank:?}"); +} From 476bf92f97b02e64b58d51efc6b606d35d48b661 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 18:48:49 +0800 Subject: [PATCH 4/4] =?UTF-8?q?test(addon):=20=E7=BB=99=E3=80=8C=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E8=B7=AF=E5=BE=84=E5=8F=96=E5=90=8D=E5=AD=97=E3=80=8D?= =?UTF-8?q?=E7=9A=84=E7=94=9F=E4=BA=A7=E6=8E=A5=E7=BA=BF=E4=B8=8E=E9=99=8D?= =?UTF-8?q?=E7=BA=A7=E8=A1=A5=E5=AE=88=E5=8D=AB=20(M191=20r3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 r3 p2:那两处都只靠消费层用例撑着,生产接线上删掉/改坏也全绿。补两条能真红的守卫。 **接线层**:`install.rs` 里那次 `names_for_incompatible(..)` 原来不可驱动。现在下载器与 取名字器从 `Ports` 参数进来(生产 = 引擎 + `plan::fetch_title`,用例 = 假实现), 于是用例能驱动**生产那个 `collect`**:一份「一个文件下成功 + 一个不兼容前置 + 一条 404」的 计划跑完,断言不兼容那条拿到了名字、且**只**给不兼容档问过一次。 变异(把那一行短路成空名单)⇒ `the_failed_install_path_really_fetches_names_for_the_incompatible_items` 红:`left: "P7dR8mSH"` / `right: "Entity Culling (P7dR8mSH)"`。 **降级层**:把「一次查询 → 有名字/没名字」的折叠抽成 `title_from(query, id)` (`fetch_title` 只剩「建客户端 + 调它」)。参数化之后三种「没名字」都能离线钉住: 查询失败 / 空标题 / 全空白 ⇒ `None`,不 panic。 变异 ①`ok` 换 `expect` ⇒ `a_failed_or_blank_lookup_yields_no_name` 红 (panic:`带断网`);②去掉空串过滤 ⇒ 同一条红(空白档)。 **顺手**:`fetch_title` 真发一次 HTTP 的那一跳没进用例(本轮实测它确实取到了真数据 `P7dR8mSH → "Fabric API"`,但把网络调用放进 `cargo test` 会变脆,所以只把可坏的那一步——折叠—— 参数化测掉,报告里如实写「HTTP 这一跳仍无用例」)。 **行数**:`install.rs` 加到 415、`tests/retry.rs` 到 436,都越过 400 硬线 ⇒ 两步纯搬移: `collect` + `Ports` + `Placement` → `install/collect.rs`(117 行,`install.rs` 321); r3 用例 → `tests/retry/wire.rs`(115 行,父文件 332)。 --- crates/presenter/src/host/addon/install.rs | 111 ++++------------- .../src/host/addon/install/collect.rs | 117 ++++++++++++++++++ .../presenter/src/host/addon/plan/labels.rs | 72 ++++++++++- .../presenter/src/host/addon/tests/retry.rs | 3 + .../src/host/addon/tests/retry/wire.rs | 115 +++++++++++++++++ 5 files changed, 324 insertions(+), 94 deletions(-) create mode 100644 crates/presenter/src/host/addon/install/collect.rs create mode 100644 crates/presenter/src/host/addon/tests/retry/wire.rs diff --git a/crates/presenter/src/host/addon/install.rs b/crates/presenter/src/host/addon/install.rs index 92bbaee..991aadc 100644 --- a/crates/presenter/src/host/addon/install.rs +++ b/crates/presenter/src/host/addon/install.rs @@ -46,11 +46,11 @@ //! 面板在「进度未知」时本来就只画一行说明、不画条(`download/install.rs` 的既定口径)。 //! 已登记为后续项:presenter 补 `futures-util` 之后换成字节级。 -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use unlucky_core::addon::online::{ - resolve, DependencyPlan, EngineArtifactFetcher, EngineTransport, Modrinth, PlannedNode, + resolve, EngineArtifactFetcher, EngineTransport, Modrinth, PlannedNode, }; use unlucky_core::download::blocking::block_on; use unlucky_core::game::install::instance::read_instance_config; @@ -63,6 +63,10 @@ use crate::dto::{ use super::super::env::HostEnv; use super::super::job::{HostTask, JobSender}; +mod collect; + +pub(super) use collect::{collect, Placement, Ports}; + use super::{downloads, plan}; /// 跑一次装入(同步;由 [`super::spawn_install`] 放进后台线程)。 @@ -173,8 +177,15 @@ fn run( let client = Modrinth::new(&transport); block_on(resolve(&client, retry)) }); - return collect( + let fetcher = EngineArtifactFetcher::new(&core_engine); + let title = |id: &str| plan::fetch_title(env, id); + let ports = Ports { env, + fetcher: &fetcher, + title: &title, + }; + return collect( + &ports, request, dependency_plan, Placement { @@ -218,8 +229,15 @@ fn run( let client = Modrinth::new(&transport); block_on(resolve(&client, &resolve_request)) }; - collect( + let fetcher = EngineArtifactFetcher::new(&core_engine); + let title = |id: &str| plan::fetch_title(env, id); + let ports = Ports { env, + fetcher: &fetcher, + title: &title, + }; + collect( + &ports, request, dependency_plan, Placement { @@ -236,91 +254,6 @@ fn run( ) } -/// 「装到哪 + 那次装入的根是谁」——总是一起出现,捆成一个参数(否则 `collect` 顶破 8 个)。 -struct Placement<'a> { - /// 落盘目录(`mods/` / `resourcepacks/` / `shaderpacks/`) - dir: &'a Path, - /// 当初那次装入的根项目(id + 显示名);重试轮里由 `plan::remembered` 提供 - /// - /// **必须**带 id:重试那一轮 `plan.request.roots` 是上一轮的失败名单,不能拿它判「是不是根」 - /// (r2 复审 P2-2 就是这条:第一前置被贴上了根的标题)。 - root: plan::Root<'a>, -} - -/// 解析之后**两条入口共用**的尾巴:进度 → 逐个下载 → 收口。 -/// -/// 收口的两件事在同一个地方做,是因为它们必须同时发生: -/// -/// | 结果 | 计划 | 可重试名单 | -/// |---|---|---| -/// | 有失败 | 记下(`plan::remember`,重试的输入) | 填(界面据此画「只重试这 N 个」) | -/// | 全成功 | 忘掉(`plan::forget`) | 清空(界面**不渲染**那条入口) | -fn collect( - env: &HostEnv, - request: &ContentInstallRequest, - mut dependency_plan: DependencyPlan, - placement: Placement<'_>, - cancel: &AtomicBool, - progress: &mut InstallProgressView, - tx: &JobSender, - epoch: u64, -) -> Result { - progress.stage = InstallStage::Downloading; - progress.files_total = dependency_plan.nodes.len() as u64; - progress.files_done = 0; - progress.message = if dependency_plan.nodes.is_empty() { - "没有要下载的文件".to_string() - } else { - format!( - "{} 个文件(含 {} 个前置)", - dependency_plan.nodes.len(), - dependency_plan - .nodes - .iter() - .filter(|node| !node.is_root()) - .count() - ) - }; - let _ = tx.send(HostTask::Install { - progress: Box::new(progress.clone()), - epoch, - }); - - let core_engine = super::super::engine::engine(env); - let fetcher = EngineArtifactFetcher::new(&core_engine); - // 下载阶段在 `downloads.rs`:可测(假下载器能驱动),失败进名单而不中断。 - let installed = downloads::download_stage( - &mut dependency_plan, - placement.dir, - &fetcher, - cancel, - progress, - tx, - epoch, - )?; - - // 解析与下载两段的失败在这里一起收口:两档分成两句话(不合并成「下载失败」), - // 并列出「可只重试这 N 个」。 - if let Some(failure) = plan::failure_of_plan(&dependency_plan, installed.len()) { - // 「不兼容」那一档项目**存在**(站点只是没有匹配目标版本/加载器的条目),所以在这里 - // 现取一次名字——只在失败路径上发、一个项目一次 GET,取不到就退回 id(见 `names_for_incompatible`)。 - let names = plan::names_for_incompatible(&dependency_plan, |id| plan::fetch_title(env, id)); - progress.retry = plan::retry_items(&dependency_plan, placement.root, &names); - plan::remember( - env, - &request.instance, - placement.dir, - placement.root.id, - placement.root.title, - &dependency_plan, - ); - return Err(failure); - } - progress.retry.clear(); - plan::forget(env, &request.instance); - Ok(plan::summary(&dependency_plan, &installed)) -} - /// 没有可重试的记录时那句话(**不**退化成「整单重装」)。 const RETRY_LOST: &str = "没有可重试的记录:这次失败发生在别的实例上、或进程重启过。请重新装入那一次。"; diff --git a/crates/presenter/src/host/addon/install/collect.rs b/crates/presenter/src/host/addon/install/collect.rs new file mode 100644 index 0000000..199bfcb --- /dev/null +++ b/crates/presenter/src/host/addon/install/collect.rs @@ -0,0 +1,117 @@ +//! 解析之后的公共尾巴:进度 → 逐个下载 → 收口(失败名单 / 计划留存)。 +//! +//! 从 `install.rs` 拆出(r3 复审的接线守卫加完 `Ports` 之后父文件到 415 行,越过 400 行硬线)。 +//! 拆法同既有 `-p1/-p2`:**纯搬移**,调用方路径不变(`install.rs` 仍 `pub(super) use` 这三个名字)。 +//! +//! 为什么不把 `Ports` 再往里藏:`collect` 是**要能在用例里真跑**的那一段(r3 复审 p2: +//! 「失败路径真的去取了名字」原来零覆盖)。下载器与取名字器从参数进来,假实现就能驱动整条 +//! 收口路径,不碰网络。 + +use std::path::Path; +use std::sync::atomic::AtomicBool; + +use unlucky_core::addon::online::{ArtifactFetcher, DependencyPlan}; + +use crate::dto::{ContentFailure, ContentInstallRequest, InstallProgressView, InstallStage}; + +use crate::host::env::HostEnv; +use crate::host::job::{HostTask, JobSender}; + +use super::{downloads, plan}; + +/// `collect` 要用的「外部世界」:宿主环境 + 下载器 + 取名字器。 +/// +/// 三样都从这里进,是为了让**收口那段能在用例里真跑**:假下载器 + 假取名字器就能驱动 +/// 「下载 → 收口 → 失败名单」整条路,于是「失败路径真的去取了不兼容档的名字」这件事有用例 +/// 钉住(r3 复审 p2:那一行原来删掉也全绿)。 +pub(in crate::host::addon) struct Ports<'a> { + /// 宿主环境(记「上一轮的计划」要用它的数据根) + pub(in crate::host::addon) env: &'a HostEnv, + /// 下载器(生产 = 引擎;用例 = 假实现,不碰网络) + pub(in crate::host::addon) fetcher: &'a dyn ArtifactFetcher, + /// 取项目显示名(生产 = [`plan::fetch_title`];用例 = 假实现) + pub(in crate::host::addon) title: &'a dyn Fn(&str) -> Option, +} + +/// 「装到哪 + 那次装入的根是谁」——总是一起出现,捆成一个参数(否则 `collect` 顶破 8 个)。 +pub(in crate::host::addon) struct Placement<'a> { + /// 落盘目录(`mods/` / `resourcepacks/` / `shaderpacks/`) + pub(in crate::host::addon) dir: &'a Path, + /// 当初那次装入的根项目(id + 显示名);重试轮里由 `plan::remembered` 提供 + /// + /// **必须**带 id:重试那一轮 `plan.request.roots` 是上一轮的失败名单,不能拿它判「是不是根」 + /// (r2 复审 P2-2 就是这条:第一前置被贴上了根的标题)。 + pub(in crate::host::addon) root: plan::Root<'a>, +} + +/// 解析之后**两条入口共用**的尾巴:进度 → 逐个下载 → 收口。 +/// +/// 收口的两件事在同一个地方做,是因为它们必须同时发生: +/// +/// | 结果 | 计划 | 可重试名单 | +/// |---|---|---| +/// | 有失败 | 记下(`plan::remember`,重试的输入) | 填(界面据此画「只重试这 N 个」) | +/// | 全成功 | 忘掉(`plan::forget`) | 清空(界面**不渲染**那条入口) | +pub(in crate::host::addon) fn collect( + ports: &Ports<'_>, + request: &ContentInstallRequest, + mut dependency_plan: DependencyPlan, + placement: Placement<'_>, + cancel: &AtomicBool, + progress: &mut InstallProgressView, + tx: &JobSender, + epoch: u64, +) -> Result { + progress.stage = InstallStage::Downloading; + progress.files_total = dependency_plan.nodes.len() as u64; + progress.files_done = 0; + progress.message = if dependency_plan.nodes.is_empty() { + "没有要下载的文件".to_string() + } else { + format!( + "{} 个文件(含 {} 个前置)", + dependency_plan.nodes.len(), + dependency_plan + .nodes + .iter() + .filter(|node| !node.is_root()) + .count() + ) + }; + let _ = tx.send(HostTask::Install { + progress: Box::new(progress.clone()), + epoch, + }); + + // 下载阶段在 `downloads.rs`:可测(假下载器能驱动),失败进名单而不中断。 + let installed = downloads::download_stage( + &mut dependency_plan, + placement.dir, + ports.fetcher, + cancel, + progress, + tx, + epoch, + )?; + + // 解析与下载两段的失败在这里一起收口:两档分成两句话(不合并成「下载失败」), + // 并列出「可只重试这 N 个」。 + if let Some(failure) = plan::failure_of_plan(&dependency_plan, installed.len()) { + // 「不兼容」那一档项目**存在**(站点只是没有匹配目标版本/加载器的条目),所以在这里 + // 现取一次名字——只在失败路径上发、一个项目一次 GET,取不到就退回 id(见 `names_for_incompatible`)。 + let names = plan::names_for_incompatible(&dependency_plan, ports.title); + progress.retry = plan::retry_items(&dependency_plan, placement.root, &names); + plan::remember( + ports.env, + &request.instance, + placement.dir, + placement.root.id, + placement.root.title, + &dependency_plan, + ); + return Err(failure); + } + progress.retry.clear(); + plan::forget(ports.env, &request.instance); + Ok(plan::summary(&dependency_plan, &installed)) +} diff --git a/crates/presenter/src/host/addon/plan/labels.rs b/crates/presenter/src/host/addon/plan/labels.rs index 03b2312..458b2a8 100644 --- a/crates/presenter/src/host/addon/plan/labels.rs +++ b/crates/presenter/src/host/addon/plan/labels.rs @@ -9,7 +9,8 @@ //! **不许**拿 `DependencyFailure::message` 顶:那是 core 的中文散文,画到界面上就是 i18n R2 的违规。 use unlucky_core::addon::online::{ - DependencyFailure, DependencyPlan, EngineTransport, FailureKind, Modrinth, + DependencyFailure, DependencyPlan, EngineTransport, FailureKind, Modrinth, OnlineError, + ProjectDetail, }; use unlucky_core::download::blocking::block_on; @@ -97,15 +98,76 @@ pub(super) fn label_of( /// 取一个项目的显示名(**只在失败路径上**发)。 /// -/// 失败一律回 `None`:**拿名字失败不许影响安装结果**——这条是「只重试这些」的补充信息, -/// 坏一次网络不该把「装了什么、缺了什么」这整件事变成一条错误。 +/// 一次 GET,折成「有名字 / 没名字」的那一步在 [`title_from`](那里才是能坏的地方,因此那一步 +/// 单独可测;本函数只有「怎么发这一枪」)。 pub(in crate::host::addon) fn fetch_title(env: &HostEnv, id: &str) -> Option { let core_engine = crate::host::engine::engine(env); let transport = EngineTransport::new(&core_engine); let client = Modrinth::new(&transport); - let id = id.to_string(); - block_on(async move { client.project(&id).await }) + title_from(|id| block_on(async { client.project(id).await }), id) +} + +/// 把「一次项目查询」折成「有名字 / 没名字」——**降级就在这一层**(r3 复审 p2 要的守卫)。 +/// +/// 三种「没名字」都算 `None`,一个都不许往外冒: +/// +/// | 情况 | 结果 | +/// |---|---| +/// | 查询失败(没网 / 404 / 坏响应) | `None`(**不是 panic,也不是错误**) | +/// | 站点给了空标题 | `None` | +/// | 站点给了全空白标题 | `None` | +/// +/// 为什么单独抽出来:这条链路上唯一会「坏」的就是这一句折叠。抽成参数化的纯函数之后, +/// 「查询失败仍退回 id」与「空白不算名字」都能在没有网络的用例里钉住(变异:`ok` 换 `expect` +/// ⇒ 红;去掉空串过滤 ⇒ 红)。 +fn title_from( + query: impl FnOnce(&str) -> Result, + id: &str, +) -> Option { + query(id) .ok() .map(|project| project.title) .filter(|title| !title.trim().is_empty()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn detail(title: &str) -> ProjectDetail { + ProjectDetail { + id: "P7dR8mSH".to_string(), + title: title.to_string(), + ..ProjectDetail::default() + } + } + + /// **降级层**:查询失败 / 空标题 / 全空白 ⇒ 一律 `None`(界面退回只画 id),不 panic。 + /// + /// 抓得住:把 `.ok()` 换成 `expect`(查询失败即 panic)⇒ 第一条红; + /// 去掉 `.filter(..)`(空白也算名字)⇒ 后两条红。 + #[test] + fn a_failed_or_blank_lookup_yields_no_name() { + // ① 查询失败(没网 / 404 / 坏响应) + assert!(title_from(|_| Err(OnlineError::offline("断网")), "P7dR8mSH").is_none()); + // ② 站点给了空标题 + assert!(title_from(|_| Ok(detail("")), "P7dR8mSH").is_none()); + // ③ 站点给了全空白标题 + assert!(title_from(|_| Ok(detail(" ")), "P7dR8mSH").is_none()); + // ④ 正常情况:原样给出 + assert_eq!( + title_from(|_| Ok(detail("Entity Culling")), "P7dR8mSH"), + Some("Entity Culling".to_string()) + ); + // 传进去的 id 就是查询用的那个(不要拿别的键去问) + let mut asked = String::new(); + let _ = title_from( + |id| { + asked.push_str(id); + Ok(detail("X")) + }, + "P7dR8mSH", + ); + assert_eq!(asked, "P7dR8mSH"); + } +} diff --git a/crates/presenter/src/host/addon/tests/retry.rs b/crates/presenter/src/host/addon/tests/retry.rs index 026300d..d4429a5 100644 --- a/crates/presenter/src/host/addon/tests/retry.rs +++ b/crates/presenter/src/host/addon/tests/retry.rs @@ -327,3 +327,6 @@ fn a_failed_name_lookup_falls_back_to_the_id_without_breaking_anything() { let blank = names_for_incompatible(&plan, |_| Some(" ".to_string())); assert!(blank.is_empty(), "{blank:?}"); } + +// r3 复审 p2:生产接线与降级守卫(按主题分卷,父文件已贴 400 行硬线) +mod wire; diff --git a/crates/presenter/src/host/addon/tests/retry/wire.rs b/crates/presenter/src/host/addon/tests/retry/wire.rs new file mode 100644 index 0000000..6cfc285 --- /dev/null +++ b/crates/presenter/src/host/addon/tests/retry/wire.rs @@ -0,0 +1,115 @@ +//! r3 复审 p2:**生产接线**与**降级**的守卫(`super` 按主题分卷)。 +//! +//! 这一卷的用例驱动的是生产那个 `collect`(不是它的复制品):假下载器 + 假取名字器, +//! 于是「失败路径真的去取了不兼容档的名字」这件事在没有网络的用例里也能钉住。 + +use std::sync::atomic::AtomicBool; +use std::sync::Mutex; + +use unlucky_core::addon::online::{ + ArtifactFetcher, FetchArgs, OnlineError, OnlineErrorKind, OnlineFuture, +}; + +use super::plan_with_two_tier_failures; +use crate::dto::{ + ContentInstallRequest, ContentSource, InstallFailureKind, InstallProgressView, LoaderKind, +}; +use crate::host::addon::install::{collect, Placement, Ports}; +use crate::host::addon::plan::Root; + +/// 假下载器:一律「写一行字节成功」——本组用例只关心收口那一半(下载阶段另有自己的用例)。 +struct QuietFetcher; + +impl ArtifactFetcher for QuietFetcher { + fn fetch<'a>(&'a self, args: FetchArgs<'a>) -> OnlineFuture<'a, Result> { + Box::pin(async move { + std::fs::write(args.dest, b"jar").map_err(|error| { + OnlineError::new(OnlineErrorKind::Io, format!("写盘失败:{error}")) + })?; + Ok(4) + }) + } +} + +fn request_for(instance: &str) -> ContentInstallRequest { + ContentInstallRequest { + project: "AANobbMI".to_string(), + title: "Sodium".to_string(), + kind: crate::dto::ContentKind::Mod, + source: ContentSource::Modrinth, + instance: instance.to_string(), + game_version: "1.20.1".to_string(), + loader: LoaderKind::Fabric, + retry: false, + } +} + +/// **接线层守卫**(r3 复审 p2 第 1 条):安装失败路径**真的**去取了不兼容档的名字。 +/// +/// 这条驱动的是生产那个 `collect`(不是它的复制品):假下载器 + 假取名字器, +/// 于是一份「一个文件下成功 + 一个不兼容前置 + 一条 404」的计划跑完后: +/// +/// - 不兼容那条**拿到了名字**(`Entity Culling`); +/// - 取名字器只被**不兼容**那条问过一次(404 那条不发请求)。 +/// +/// 变异守门:把 `collect` 里那行 `names_for_incompatible(...)` 删掉/短路(传空名单)⇒ 红。 +#[test] +fn the_failed_install_path_really_fetches_names_for_the_incompatible_items() { + let root = std::env::temp_dir().join(format!("ull-m191-wire-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let dir = root.join(".minecraft/mods"); + std::fs::create_dir_all(&dir).expect("建目录"); + let env = crate::host::HostEnv::at_root(&root); + + let plan = plan_with_two_tier_failures(); + let asked: Mutex> = Mutex::new(Vec::new()); + let title = |id: &str| { + asked.lock().expect("锁没中毒").push(id.to_string()); + (id == "P7dR8mSH").then(|| "Entity Culling".to_string()) + }; + let fetcher = QuietFetcher; + let ports = Ports { + env: &env, + fetcher: &fetcher, + title: &title, + }; + let (tx, _rx) = crate::host::job::channel(); + let cancel = AtomicBool::new(false); + let mut progress = InstallProgressView::new(); + let request = request_for("survival"); + + let result = collect( + &ports, + &request, + plan, + Placement { + dir: &dir, + root: Root { + id: "AANobbMI", + title: "Sodium", + }, + }, + &cancel, + &mut progress, + &tx, + 1, + ); + + assert!(result.is_err(), "有失败项就该回一条失败"); + let incompatible = progress + .retry + .iter() + .find(|item| item.kind == InstallFailureKind::Incompatible) + .expect("不兼容档要在名单里"); + assert_eq!( + incompatible.line(), + "Entity Culling (P7dR8mSH)", + "失败路径没有把取到的名字贴上去" + ); + assert_eq!( + *asked.lock().expect("锁没中毒"), + vec!["P7dR8mSH".to_string()], + "只该给不兼容档取名字(404 那条不发请求)" + ); + let _ = std::fs::remove_dir_all(&root); +}