From 26e7b7333ea90231b038a5433e41f0181842e09f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 18:09:11 +0800 Subject: [PATCH] =?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:?}"); +}