From 476bf92f97b02e64b58d51efc6b606d35d48b661 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 18:48:49 +0800 Subject: [PATCH] =?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); +}