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); +}