From aa33210bb6a5da3ac6d45cad250949206b673b35 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 13:58:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(addon):=20=E5=A4=8D=E5=AE=A1=20r1=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E5=A4=B1=E8=B4=A5=E5=8E=BB=E9=87=8D?= =?UTF-8?q?=E3=80=81null=20=E8=BD=BD=E8=8D=B7=E3=80=81=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E6=80=81=E8=BF=9B=E5=BA=A6=E3=80=81=E9=A1=BA=E5=BA=8F=E5=AE=88?= =?UTF-8?q?=E9=97=A8=E3=80=81=E4=B8=8B=E8=BD=BD=E5=A4=B1=E8=B4=A5=E5=BD=92?= =?UTF-8?q?=E5=B9=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P2-1 失败分支不再「直接 continue」:`fail()` 记一条失败(`record_failure` 按 `requested_as` 去重)并把请求键写进 `alias`,后续同键走现成的 `fold_parents` 折父边 ⇒ 同一项目只取一次版本、失败只一条、重试名单不重复; - P2-2 `RawDependency.dependency_type` 改 `Option`:`#[serde(default)]` 兜不住 `null`,以前一份 `"dependency_type": null` 就让整份 `/version` 报 Malformed、整个项目装不上; 现在 null/未知一律 `Unknown`(不递归); - P2-3 完成态用 `finish()` 把 `files_done` 对齐 `files_total`(曾写死 `1`,装 3 个停在 1/3); 解析前 `files_total` 归 0(未知态),不再假装「已知 1 个文件」; - P2-4 新增 `tests/order.rs`:跨根的层内顺序 + 多根正序/逆序对照,删 `layer.sort_by` 会红; 另加 `required_project_ids` 自己的排序去重契约用例(`tests/regressions.rs`); - P2-5 下载阶段失败记进同一套两档结构(`Unreachable` + 叶子成因)并继续下一个文件: 已下好的不回滚、失败项进可重试名单、文案保留「已装入 N 个」的上下文;`retry_request` 自动覆盖它们; - nit① 在 `plan.rs` 写清「不兼容」为什么落在 DTO 的 `Unsupported`(不是笔误,是缺档); nit② 新增 `DependencyPlan::unresolved_required`(只有版本 id 的必备前置不再静默忽略); nit③ 补「同 mod 另一个版本在目录里仍要下载」的负向用例。 --- crates/core/src/addon/online/dependency.rs | 53 ++++- crates/core/src/addon/online/mod.rs | 2 +- crates/core/src/addon/online/model/parse.rs | 10 +- crates/core/src/addon/online/model/types.rs | 20 ++ crates/core/src/addon/online/resolve.rs | 96 ++++++--- crates/core/src/addon/online/tests/mod.rs | 2 + crates/core/src/addon/online/tests/order.rs | 137 +++++++++++++ .../src/addon/online/tests/regressions.rs | 189 ++++++++++++++++++ crates/core/src/addon/online/tests/support.rs | 20 ++ crates/presenter/src/host/addon/install.rs | 66 ++++-- crates/presenter/src/host/addon/plan.rs | 81 ++++++-- crates/presenter/src/host/addon/tests.rs | 87 +++++++- 12 files changed, 686 insertions(+), 77 deletions(-) create mode 100644 crates/core/src/addon/online/tests/order.rs create mode 100644 crates/core/src/addon/online/tests/regressions.rs diff --git a/crates/core/src/addon/online/dependency.rs b/crates/core/src/addon/online/dependency.rs index 12b2c5e..440742f 100644 --- a/crates/core/src/addon/online/dependency.rs +++ b/crates/core/src/addon/online/dependency.rs @@ -220,6 +220,20 @@ pub struct DependencyFailure { pub message: String, } +/// 声明了 `required`、但**只给了 `version_id`**(没有 `project_id`)的依赖。 +/// +/// BFS 需要项目 id 才能取版本(`/v2/project/{id}/version`),所以这条既装不了、也不该被 +/// 假装没看见:留在计划里,上层才能说「有 N 条前置没法解析」而不是默默少装一个。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnresolvedRequired { + /// 声明它的项目(规范 id) + pub parent: String, + /// 站点给的版本 id(`None` = 连版本 id 都没有,只有文件名) + pub version_id: Option, + /// 站点给的文件名 + pub file_name: Option, +} + /// 一次 BFS 的完整结果(成功、跳过、失败三份名单,都**不丢**)。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct DependencyPlan { @@ -233,6 +247,8 @@ pub struct DependencyPlan { pub failures: Vec, /// 撞到 `max_depth` 而没展开的项目键(排序去重) pub truncated: Vec, + /// 有 `required` 但没有项目 id、解析不了的前置(排序去重) + pub unresolved_required: Vec, } impl DependencyPlan { @@ -245,6 +261,7 @@ impl DependencyPlan { skipped: Vec::new(), failures: Vec::new(), truncated: Vec::new(), + unresolved_required: Vec::new(), } } @@ -259,19 +276,45 @@ impl DependencyPlan { self.failures.iter().filter(move |item| item.kind == kind) } + /// 记一条失败——**按 `requested_as` 去重**,父边合并。 + /// + /// 为什么要有这道去重:同一个项目可能被两条路径要求(A→GONE、B→C→GONE)。若不去重, + /// 「可只重试这 N 个」会写出 `GONE、GONE`,用户按名单重试等于发两次一样的请求。 + /// 解析阶段与下载阶段的失败都走这里,于是名单里一个项目只出现一次。 + pub fn record_failure(&mut self, failure: DependencyFailure) { + if let Some(existing) = self + .failures + .iter_mut() + .find(|item| item.requested_as == failure.requested_as) + { + existing.required_by.extend(failure.required_by); + existing.required_by.sort(); + existing.required_by.dedup(); + // 深度取更浅的那条:越靠近根越该先修 + existing.depth = existing.depth.min(failure.depth); + return; + } + self.failures.push(failure); + } + /// 「可重试名单」:失败项原样重启一次解析的请求。 /// /// 只含失败项 ⇒ 用户只重试这一部分,**不重下**已经装好的文件(`present` 沿用)。 /// 两档都在名单里,但每条自带 [`FailureKind`]:不兼容那一档重试前得换版本/加载器, /// 换完再调它才有意义。**这不是「后台自动重试」**——本模块只给请求,什么时候发由上层决定。 + /// + /// 调用方如果刚跑完一轮下载,重试前应**重新扫一遍目录**再填 `present`:这一轮新落盘的 + /// 文件不在旧的 `present` 里,不重扫就会把它们当「还没有」再下一份。 #[must_use] pub fn retry_request(&self) -> ResolveRequest { + let mut roots: Vec = Vec::new(); + for failure in &self.failures { + if !roots.contains(&failure.requested_as) { + roots.push(failure.requested_as.clone()); + } + } ResolveRequest { - roots: self - .failures - .iter() - .map(|failure| failure.requested_as.clone()) - .collect(), + roots, ..self.request.clone() } } diff --git a/crates/core/src/addon/online/mod.rs b/crates/core/src/addon/online/mod.rs index 0c05038..2fa4203 100644 --- a/crates/core/src/addon/online/mod.rs +++ b/crates/core/src/addon/online/mod.rs @@ -71,7 +71,7 @@ pub mod transport; pub use complete::identity_of; pub use dependency::{ present_key, DependencyFailure, DependencyPlan, FailureKind, PlannedNode, PresentFile, - ResolveRequest, SkipReason, SkippedNode, DEFAULT_MAX_DEPTH, + ResolveRequest, SkipReason, SkippedNode, UnresolvedRequired, DEFAULT_MAX_DEPTH, }; pub use download::{ digest_file, download_verified, ArtifactFetcher, Digest, Downloaded, EngineArtifactFetcher, diff --git a/crates/core/src/addon/online/model/parse.rs b/crates/core/src/addon/online/model/parse.rs index 175810e..0e4722e 100644 --- a/crates/core/src/addon/online/model/parse.rs +++ b/crates/core/src/addon/online/model/parse.rs @@ -127,9 +127,11 @@ struct RawDependency { version_id: Option, #[serde(default)] file_name: Option, - // 站点给的是 `required` / `optional` / `incompatible` / `embedded`;认不出 ⇒ `Unknown` + // 站点给的是 `required` / `optional` / `incompatible` / `embedded`。 + // 必须是 `Option`:`#[serde(default)]` 只兜「字段缺失」,兜不住 `null` + // (`"dependency_type": null` 用 `String` 会整份响应报 Malformed,于是整个项目都取不到版本)。 #[serde(default)] - dependency_type: String, + dependency_type: Option, } #[derive(Debug, Default, Deserialize)] @@ -263,7 +265,9 @@ fn version_of(raw: RawVersion) -> Version { project_id: non_empty(dependency.project_id), version_id: non_empty(dependency.version_id), file_name: non_empty(dependency.file_name), - dependency_type: DependencyType::parse(&dependency.dependency_type), + dependency_type: DependencyType::parse( + dependency.dependency_type.as_deref().unwrap_or_default(), + ), }) .collect(), } diff --git a/crates/core/src/addon/online/model/types.rs b/crates/core/src/addon/online/model/types.rs index 8bb0af9..3c2b57a 100644 --- a/crates/core/src/addon/online/model/types.rs +++ b/crates/core/src/addon/online/model/types.rs @@ -258,6 +258,26 @@ impl Version { ids.dedup(); ids } + + /// 声明了 `required`、却**没有项目 id** 的前置(站点只给了版本 id / 文件名)。 + /// + /// 这些条目 BFS 跟不下去(取版本要项目 id),但也不能当成「没有这条依赖」—— + /// 上层据此说「有 N 条前置没法解析」,见 `DependencyPlan::unresolved_required`。 + #[must_use] + pub fn unresolved_required(&self) -> Vec<&Dependency> { + self.dependencies + .iter() + .filter(|dependency| dependency.dependency_type == DependencyType::Required) + .filter(|dependency| { + dependency + .project_id + .as_deref() + .map(str::trim) + .map(str::is_empty) + .unwrap_or(true) + }) + .collect() + } } /// 版本里的一个文件。 diff --git a/crates/core/src/addon/online/resolve.rs b/crates/core/src/addon/online/resolve.rs index 977bda1..30a0e9d 100644 --- a/crates/core/src/addon/online/resolve.rs +++ b/crates/core/src/addon/online/resolve.rs @@ -31,11 +31,11 @@ use std::collections::{BTreeMap, BTreeSet}; use super::dependency::{ present_key, DependencyFailure, DependencyPlan, FailureKind, PlannedNode, PresentFile, - ResolveRequest, SkipReason, SkippedNode, + ResolveRequest, SkipReason, SkippedNode, UnresolvedRequired, }; use super::model::Version; use super::modrinth::Modrinth; -use super::transport::Transport; +use super::transport::{OnlineErrorKind, Transport}; /// 一层里还没处理的一项。 struct Wave { @@ -78,30 +78,32 @@ pub async fn resolve( Ok(versions) => match versions.into_iter().next() { Some(version) => version, None => { - plan.failures.push(DependencyFailure { - requested_as: item.key, - required_by: item.parents, - depth: item.depth, - kind: FailureKind::Incompatible, - cause: None, - message: filter_message(request), - }); + let message = filter_message(request); + fail( + &mut plan, + &mut alias, + item, + FailureKind::Incompatible, + None, + message, + ); continue; } }, Err(error) => { - plan.failures.push(DependencyFailure { - requested_as: item.key, - required_by: item.parents, - depth: item.depth, - kind: FailureKind::Unreachable, - cause: Some(error.kind()), - message: format!( - "取版本失败({}):{}", - error.kind().as_str(), - error.message() - ), - }); + let message = format!( + "取版本失败({}):{}", + error.kind().as_str(), + error.message() + ); + fail( + &mut plan, + &mut alias, + item, + FailureKind::Unreachable, + Some(error.kind()), + message, + ); continue; } }; @@ -115,14 +117,14 @@ pub async fn resolve( item.key, version_label(&version) ); - plan.failures.push(DependencyFailure { - requested_as: item.key, - required_by: item.parents, - depth: item.depth, - kind: FailureKind::Incompatible, - cause: None, + fail( + &mut plan, + &mut alias, + item, + FailureKind::Incompatible, + None, message, - }); + ); continue; }; @@ -168,15 +170,51 @@ pub async fn resolve( wave_in(&mut next, dependency, item.depth + 1, Some(&canonical)); } } + // 只有版本 id、没有项目 id 的 required:没有地址可取,记下来不假装没看见 + for dependency in version.unresolved_required() { + plan.unresolved_required.push(UnresolvedRequired { + parent: canonical.clone(), + version_id: dependency.version_id.clone(), + file_name: dependency.file_name.clone(), + }); + } } plan.truncated.sort(); plan.truncated.dedup(); layer = next; } + plan.unresolved_required.sort_by(|left, right| { + (&left.parent, &left.version_id).cmp(&(&right.parent, &right.version_id)) + }); + plan.unresolved_required.dedup(); plan } +/// 记一条失败,并把这个请求键标成「已处理」。 +/// +/// 标键这一步是**失败侧的去重网**:成功路径靠 `alias` 短路,失败路径以前直接 `continue`, +/// 于是同一个项目被两条路径要求时会各取一次版本、各记一条失败,重试名单里出现两次。 +/// 标记之后,后续同键请求在①处短路,走现成的 [`fold_parents`] 把父边折进那条失败。 +fn fail( + plan: &mut DependencyPlan, + alias: &mut BTreeMap, + item: Wave, + kind: FailureKind, + cause: Option, + message: String, +) { + plan.record_failure(DependencyFailure { + requested_as: item.key.clone(), + required_by: item.parents, + depth: item.depth, + kind, + cause, + message, + }); + alias.insert(item.key.clone(), item.key); +} + /// 把一项放进这一层的待处理表(同一个键已经排队 ⇒ 只追加父项目)。 fn wave_in(layer: &mut Vec, key: &str, depth: u8, parent: Option<&str>) { if let Some(existing) = layer.iter_mut().find(|item| item.key == key) { diff --git a/crates/core/src/addon/online/tests/mod.rs b/crates/core/src/addon/online/tests/mod.rs index 24c2c8d..31f60da 100644 --- a/crates/core/src/addon/online/tests/mod.rs +++ b/crates/core/src/addon/online/tests/mod.rs @@ -12,4 +12,6 @@ mod dependency; mod download; mod fixtures; mod fixtures_deps; +mod order; +mod regressions; mod support; diff --git a/crates/core/src/addon/online/tests/order.rs b/crates/core/src/addon/online/tests/order.rs new file mode 100644 index 0000000..35917a8 --- /dev/null +++ b/crates/core/src/addon/online/tests/order.rs @@ -0,0 +1,137 @@ +//! 顺序确定性(r1 P2-4):同层 ≥2 个前置、夹具数组故意逆序、多根乱序,结果必须一样。 +//! +//! # 这条用例守的是什么 +//! +//! `resolve` 在每层处理前把队列按**项目键字典序**排一遍。把那一行删掉,原来那 11 条用例 +//! (以及 core 的全部用例)**仍然全绿**——因为三跳链每层只有一个前置、扇入用例里两个同层 +//! 节点的先后恰好等于夹具数组顺序。也就是说「顺序由数据决定」这条性质当时没有守门。 +//! +//! # 归因(实测过,别写成「都靠这两条守门」) +//! +//! 删掉 `resolve` 里那行 `layer.sort_by` ⇒ +//! [`prerequisites_from_two_roots_are_ordered_by_project_key`] 与 +//! [`multiple_roots_are_processed_in_key_order_whatever_the_argument_order`] 红, +//! [`same_root_prerequisites_follow_key_order_not_payload_order`] 仍绿——因为那两个前置 +//! 是**同一个根**入队的,顺序已经被 `Version::required_project_ids()` 内部的排序排过了。 +//! 反过来,只删 `required_project_ids()` 的排序时三条都绿:`layer.sort_by` 会把结果重排一遍。 +//! +//! 也就是说:跨层顺序由 `layer.sort_by` 兜底(这两条用例钉它),同一个根内的顺序**两层保护** +//! 冗余存在;`required_project_ids()` 自己的「排序去重」契约由 `tests/regressions.rs` 的 +//! `required_project_ids_are_sorted_and_deduplicated_on_their_own` 单独钉(那条只删排序就会红)。 + +use crate::addon::online::dependency::{DependencyPlan, ResolveRequest}; +use crate::addon::online::{resolve, Modrinth}; +use crate::download::blocking::block_on; + +use super::support::{version_json, FixtureTransport}; + +const API: &str = "https://api.modrinth.com/v2/project"; + +fn request(roots: &[&str]) -> ResolveRequest { + ResolveRequest { + roots: roots.iter().map(|root| (*root).to_string()).collect(), + game_version: Some("1.20.1".to_string()), + loader: Some("fabric".to_string()), + ..ResolveRequest::default() + } +} + +fn plan_of(transport: &FixtureTransport, roots: &[&str]) -> DependencyPlan { + let client = Modrinth::new(transport); + block_on(resolve(&client, &request(roots))) +} + +fn projects(plan: &DependencyPlan) -> Vec<&str> { + plan.nodes + .iter() + .map(|node| node.project.as_str()) + .collect() +} + +#[test] +fn same_root_prerequisites_follow_key_order_not_payload_order() { + // 夹具里 ZEBRA01 在前、ALPHA01 在后(站点原文顺序反过来是常态)。 + // 这一条守的是 `Version::required_project_ids()` 里的排序:靠「入队顺序」展开的话, + // 站点换个数组顺序,安装顺序就跟着变。 + let transport = FixtureTransport::new() + .with( + &format!("{API}/order-root/version"), + &version_json( + "order-root", + "order-root.jar", + &[("ZEBRA01", "required"), ("ALPHA01", "required")], + ), + ) + .with( + &format!("{API}/ALPHA01/version"), + &version_json("ALPHA01", "alpha.jar", &[]), + ) + .with( + &format!("{API}/ZEBRA01/version"), + &version_json("ZEBRA01", "zebra.jar", &[]), + ); + + let plan = plan_of(&transport, &["order-root"]); + assert!(plan.is_clean(), "{:?}", plan.failures); + assert_eq!( + projects(&plan), + ["order-root", "ALPHA01", "ZEBRA01"], + "同层按项目键字典序,与 `dependencies` 数组顺序无关" + ); +} + +/// 两个根各带一个前置 ⇒ 它们在**同一层**相遇,且项目键的顺序与根的先后相反。 +/// +/// 这一条守的是 `resolve` 里每层的 `sort_by`:不排的话,层内顺序就是「谁先被入队」—— +/// 也就是参数顺序与站点响应顺序的混合,删掉那行 `sort_by` 这条会红 +/// (实测:`left: ["zebra-branch","alpha-branch"]` 之类的参数序)。 +#[test] +fn prerequisites_from_two_roots_are_ordered_by_project_key() { + let transport = FixtureTransport::new() + .with( + &format!("{API}/branch-a/version"), + &version_json("branch-a", "branch-a.jar", &[("ZEBRA01", "required")]), + ) + .with( + &format!("{API}/branch-b/version"), + &version_json("branch-b", "branch-b.jar", &[("ALPHA01", "required")]), + ) + .with( + &format!("{API}/ALPHA01/version"), + &version_json("ALPHA01", "alpha.jar", &[]), + ) + .with( + &format!("{API}/ZEBRA01/version"), + &version_json("ZEBRA01", "zebra.jar", &[]), + ); + + let plan = plan_of(&transport, &["branch-a", "branch-b"]); + assert!(plan.is_clean(), "{:?}", plan.failures); + assert_eq!( + projects(&plan), + ["branch-a", "branch-b", "ALPHA01", "ZEBRA01"], + "第二层两个前置按项目键排,不按「谁先入队」" + ); +} + +#[test] +fn multiple_roots_are_processed_in_key_order_whatever_the_argument_order() { + let transport = FixtureTransport::new() + .with( + &format!("{API}/zebra-root/version"), + &version_json("zebra-root", "zebra-root.jar", &[]), + ) + .with( + &format!("{API}/alpha-root/version"), + &version_json("alpha-root", "alpha-root.jar", &[]), + ); + + let forward = plan_of(&transport, &["zebra-root", "alpha-root"]); + let backward = plan_of(&transport, &["alpha-root", "zebra-root"]); + assert_eq!( + projects(&forward), + ["alpha-root", "zebra-root"], + "参数逆序也要按项目键处理" + ); + assert_eq!(projects(&backward), projects(&forward), "两次结果必须一样"); +} diff --git a/crates/core/src/addon/online/tests/regressions.rs b/crates/core/src/addon/online/tests/regressions.rs new file mode 100644 index 0000000..00b5419 --- /dev/null +++ b/crates/core/src/addon/online/tests/regressions.rs @@ -0,0 +1,189 @@ +//! M186 复审(r1)补的用例:失败去重、null 载荷、版本级去重。 +//! +//! 三条都对应一轮真实缺陷或真实缺口,而不是「补覆盖率」: +//! 1. [`the_same_failing_project_is_recorded_once_however_many_paths_ask_for_it`] — P2-1: +//! 失败分支以前直接 `continue`,同一项目被两条路径要求时取两次版本、记两条失败、 +//! 重试名单里写两遍同一个名字; +//! 2. [`a_null_dependency_type_does_not_poison_the_whole_version_list`] — P2-2: +//! `"dependency_type": null` 曾让整份 `/version` 报 Malformed,于是整个项目都装不上; +//! 3. [`another_version_of_the_same_mod_in_the_directory_still_downloads`] — nit③: +//! 归一化一旦改松,同 mod 的另一个版本会被误判成「已装」而跳过。 + +use crate::addon::online::dependency::{present_key, DependencyPlan, ResolveRequest}; +use crate::addon::online::model::parse_versions; +use crate::addon::online::{resolve, DependencyType, Modrinth, PresentFile}; +use crate::download::blocking::block_on; + +use super::fixtures_deps as deps; +use super::support::{version_json, FixtureTransport}; + +const API: &str = "https://api.modrinth.com/v2/project"; +const SODIUM_VERSIONS: &str = "https://api.modrinth.com/v2/project/sodium/version"; +const FABRIC_API_VERSIONS: &str = "https://api.modrinth.com/v2/project/P7dR8mSH/version"; +const YACL_VERSIONS: &str = "https://api.modrinth.com/v2/project/1eAoo2KR/version"; + +fn request(root: &str) -> ResolveRequest { + ResolveRequest { + roots: vec![root.to_string()], + game_version: Some("1.20.1".to_string()), + loader: Some("fabric".to_string()), + ..ResolveRequest::default() + } +} + +fn resolve_with(transport: &FixtureTransport, request: &ResolveRequest) -> DependencyPlan { + let client = Modrinth::new(transport); + block_on(resolve(&client, request)) +} + +fn chain_transport() -> FixtureTransport { + FixtureTransport::new() + .with(SODIUM_VERSIONS, deps::SODIUM_WITH_DEPENDENCIES) + .with(FABRIC_API_VERSIONS, deps::FABRIC_API_WITH_DEPENDENCIES) + .with(YACL_VERSIONS, deps::YACL) +} + +/// 同一个项目被两条路径要求、且它 404:**只请求一次、只记一条失败**,父边合起来。 +/// +/// r1 P2-1 的探针:`ROOT0001 → {BRANCH01, MIDDLE01}`、`BRANCH01 → GONE0001`(404)、 +/// `MIDDLE01 → LATE0001 → GONE0001`。修之前 GONE0001 被请求 2 次、失败 2 条、 +/// 重试名单是 `["GONE0001","GONE0001"]`(用户会看到「可只重试这 2 个:GONE0001、GONE0001」)。 +#[test] +fn the_same_failing_project_is_recorded_once_however_many_paths_ask_for_it() { + let transport = FixtureTransport::new() + .with( + &format!("{API}/root/version"), + &version_json( + "ROOT0001", + "root.jar", + &[("BRANCH01", "required"), ("MIDDLE01", "required")], + ), + ) + .with( + &format!("{API}/BRANCH01/version"), + &version_json("BRANCH01", "branch.jar", &[("GONE0001", "required")]), + ) + .with( + &format!("{API}/MIDDLE01/version"), + &version_json("MIDDLE01", "middle.jar", &[("LATE0001", "required")]), + ) + .with( + &format!("{API}/LATE0001/version"), + &version_json("LATE0001", "late.jar", &[("GONE0001", "required")]), + ) + .missing(&format!("{API}/GONE0001/version")); + + let plan = resolve_with(&transport, &request("root")); + + let gone_requests = transport + .seen() + .iter() + .filter(|url| url.contains("GONE0001")) + .count(); + assert_eq!( + gone_requests, + 1, + "同一个失败项目不许取两次:{:?}", + transport.seen() + ); + assert_eq!(plan.failures.len(), 1, "{:?}", plan.failures); + assert_eq!(plan.failures[0].requested_as, "GONE0001"); + assert_eq!( + plan.failures[0].required_by, + ["BRANCH01".to_string(), "LATE0001".to_string()], + "两条路径的父边都要留下" + ); + assert_eq!(plan.retry_request().roots, ["GONE0001"], "名单不许重复"); +} + +/// `"dependency_type": null` 不许把整份版本响应打成 Malformed(r1 P2-2 的回归)。 +/// +/// 以前 `RawDependency.dependency_type` 是 `String`:`#[serde(default)]` 只兜「字段缺失」, +/// 兜不住 null ⇒ 整份 `/version` 解析失败 ⇒ 根项目记 `Malformed`、`nodes` 为空 ⇒ 一个都装不上。 +#[test] +fn a_null_dependency_type_does_not_poison_the_whole_version_list() { + let body = r#"[{"id": "ver-null", "project_id": "NULLTYPE", "version_number": "1.0", + "files": [{"url": "https://cdn.modrinth.com/data/NULLTYPE/1.0/null-type.jar", + "filename": "null-type.jar", "size": 16, "primary": true}], + "dependencies": [{"project_id": "X1", "dependency_type": null}]}]"#; + + // 解析层:null 与缺失一样落 `Unknown` + let versions = parse_versions(body).expect("null 不该让整份响应失败"); + assert_eq!( + versions[0].dependencies[0].dependency_type, + DependencyType::Unknown + ); + assert_eq!(versions[0].required_project_ids(), Vec::<&str>::new()); + + // 解析流程:根照样装得上,X1 一次请求都不发 + let transport = FixtureTransport::new().with(&format!("{API}/null-type/version"), body); + let plan = resolve_with(&transport, &request("null-type")); + assert!(plan.is_clean(), "{:?}", plan.failures); + assert_eq!(plan.nodes.len(), 1, "根项目要还在计划里"); + assert_eq!(transport.seen().len(), 1, "未知类型不许递归"); +} + +/// 目录里是**同 mod 的另一个版本** ⇒ 文件名不同,仍然要下载(r1 nit③ 的负向用例)。 +/// +/// 归一化如果改松(例如「砍到第一个 `-` 前 + 砍扩展名」),`sodium-fabric-0.5.11.jar` 与 +/// `sodium-fabric-0.6.0.jar` 会被判成同一个文件,该装的被跳过。 +#[test] +fn another_version_of_the_same_mod_in_the_directory_still_downloads() { + let mut request = request("sodium"); + request.present = vec![PresentFile::new("sodium-fabric-0.5.11.jar")]; + let transport = chain_transport(); + let plan = resolve_with(&transport, &request); + + assert!(plan.skipped.is_empty(), "{:?}", plan.skipped); + assert_eq!(plan.nodes[0].file.filename, "sodium-fabric-0.6.0.jar"); + assert_ne!( + present_key("sodium-fabric-0.5.11.jar"), + present_key("sodium-fabric-0.6.0.jar"), + "不同版本的文件名归一化后不许相等" + ); +} + +/// 未知/大小写不同的取值一律 `Unknown`,**绝不**猜成 required(这条设计不许被 r1 的修法改坏)。 +#[test] +fn unknown_dependency_types_never_trigger_recursion() { + for raw in [ + "required_by_default", + "Required ", + "Recommended", + "", + "REQUIRED", + "req", + ] { + assert_eq!( + DependencyType::parse(raw), + DependencyType::Unknown, + "{raw:?} 不该被当成 required" + ); + } + assert_eq!(DependencyType::parse("required"), DependencyType::Required); +} + +/// `required_project_ids()` 的「排序 + 去重」契约单独钉住。 +/// +/// 为什么要单独钉:resolver 里 `layer.sort_by` 会把它重排一遍、`wave_in` 也会按同键合并, +/// 于是**这条契约在端到端用例里被兜住了**(删掉这里的 `sort_unstable` 三条顺序用例仍绿)。 +/// 但 `dedup()` 只去相邻重复,不排序就留下 `["ZEBRA01","ALPHA01","ZEBRA01"]`—— +/// 公开 API 说好了「排序去重」,就得有自己的一条。 +#[test] +fn required_project_ids_are_sorted_and_deduplicated_on_their_own() { + let body = version_json( + "dup-root", + "dup-root.jar", + &[ + ("ZEBRA01", "required"), + ("ALPHA01", "required"), + ("ZEBRA01", "required"), + ], + ); + let versions = parse_versions(&body).expect("夹具该解析成功"); + assert_eq!( + versions[0].required_project_ids(), + ["ALPHA01", "ZEBRA01"], + "既要去重(非相邻)也要排序" + ); +} diff --git a/crates/core/src/addon/online/tests/support.rs b/crates/core/src/addon/online/tests/support.rs index 6b0a9dd..f3a70fc 100644 --- a/crates/core/src/addon/online/tests/support.rs +++ b/crates/core/src/addon/online/tests/support.rs @@ -167,3 +167,23 @@ pub(super) fn hashes_of(bytes: &[u8]) -> (String, String) { crate::addon::online::download::to_hex(&sha512), ) } + +/// 拼一份 `/v2/project/{id}/version` 响应:一个版本、一个主文件、按给定顺序声明依赖。 +/// +/// `deps` 的每一项是 `(project_id, dependency_type)`,**顺序照抄站点原文**——顺序无关性由 +/// `resolve` 保证(`tests/order.rs` 用它做「夹具逆序也得到同一顺序」的守门)。 +pub(super) fn version_json(project: &str, file: &str, deps: &[(&str, &str)]) -> String { + let dependencies: Vec = deps + .iter() + .map(|(id, kind)| format!(r#"{{"project_id":"{id}","dependency_type":"{kind}"}}"#)) + .collect(); + format!( + r#"[{{"id":"ver-{project}","project_id":"{project}","name":"{project}", + "version_number":"1.0","version_type":"release","loaders":["fabric"], + "game_versions":["1.20.1"], + "files":[{{"hashes":{{}},"url":"https://cdn.modrinth.com/data/{project}/1.0/{file}", + "filename":"{file}","size":16,"primary":true}}], + "dependencies":[{}]}}]"#, + dependencies.join(",") + ) +} diff --git a/crates/presenter/src/host/addon/install.rs b/crates/presenter/src/host/addon/install.rs index 6f0a4e9..d549611 100644 --- a/crates/presenter/src/host/addon/install.rs +++ b/crates/presenter/src/host/addon/install.rs @@ -21,6 +21,10 @@ //! 列成「可只重试这 N 个」。**这不是自动重试**:重试要用户(或将来 UI 的按钮)再发一次, //! `DependencyPlan::retry_request()` 已经把请求备好了。 //! +//! 下载阶段的失败**也进同一套名单**(M186 r1):以前 `download_verified` 一失败就整单中断, +//! 用户既看不到「主文件已装、缺 fabric-api」,也拿不到可重试的项目。现在逐个文件下、失败记进 +//! `DependencyPlan::record_failure`(`Unreachable` + 叶子成因),已下好的不回滚,最后一起收口。 +//! //! ## 光影与数据包:支持到哪一步(M186 核对过,别写成「都支持」) //! //! | 内容 | 检索 | 装入 | 差在哪 | @@ -40,7 +44,8 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use unlucky_core::addon::online::{ - download_verified, resolve, EngineArtifactFetcher, EngineTransport, Modrinth, PlannedNode, + download_verified, resolve, DependencyFailure, EngineArtifactFetcher, EngineTransport, + FailureKind, Modrinth, PlannedNode, }; use unlucky_core::download::blocking::block_on; use unlucky_core::game::install::instance::read_instance_config; @@ -53,7 +58,7 @@ use crate::dto::{ use super::super::env::HostEnv; use super::super::job::{HostTask, JobSender}; -use super::{plan, sources::failure_of}; +use super::plan; /// 跑一次装入(同步;由 [`super::spawn_install`] 放进后台线程)。 pub(super) fn install( @@ -67,7 +72,8 @@ pub(super) fn install( version: request.title.clone(), instance: request.instance.clone(), loader: request.loader.label().to_string(), - files_total: 1, + // 文件数要等解析完才知道(含前置),这里留 0 = 未知(面板画不确定态)。 + files_total: 0, ..InstallProgressView::new() }; @@ -89,8 +95,7 @@ pub(super) fn install( }); match run(env, request, cancel, &mut progress, tx, epoch) { Ok(done) => { - progress.stage = InstallStage::Done; - progress.files_done = 1; + finish(&mut progress); progress.message = done; let _ = tx.send(HostTask::InstallDone { progress: Box::new(progress), @@ -161,7 +166,7 @@ fn run( plan::present_files(&target_dir), ); let core_engine = super::super::engine::engine(env); - let dependency_plan = { + let mut dependency_plan = { let transport = EngineTransport::new(&core_engine); let client = Modrinth::new(&transport); block_on(resolve(&client, &resolve_request)) @@ -189,11 +194,13 @@ fn run( }); let fetcher = EngineArtifactFetcher::new(&core_engine); - for node in &dependency_plan.nodes { + let mut installed: Vec = Vec::new(); + for index in 0..dependency_plan.nodes.len() { if cancel.load(Ordering::SeqCst) { return Err(local("已取消(下载中途)")); } - progress.message = node_label(node); + let node = dependency_plan.nodes[index].clone(); + progress.message = node_label(&node); let _ = tx.send(HostTask::Install { progress: Box::new(progress.clone()), epoch, @@ -201,15 +208,36 @@ fn run( // M162:走 `online::download_verified`——sha1 交给引擎流式校验、sha512 落盘后复核, // 失败重试且不留半截文件(内容库安装与整合包导入用**同一段**校验,不各写一份)。 let dest = target_dir.join(safe_file_name(&node.file.filename)); - block_on(download_verified( + let outcome = block_on(download_verified( &fetcher, &node.file.url, node.file.sha1(), node.file.sha512(), (node.file.size > 0).then_some(node.file.size), &dest, - )) - .map_err(failure_of)?; + )); + match outcome { + Ok(_) => installed.push(node), + Err(error) => { + // 下载阶段的失败**也记进同一套两档结构**(Unreachable + 叶子成因): + // 已下好的不回滚、失败项进「可只重试」,整份计划的上下文(缺的是谁的前置)不丢。 + // 以前这里 `.map_err(...)?` 直接中断,用户看不到「主文件已装、缺 fabric-api」。 + dependency_plan.record_failure(DependencyFailure { + requested_as: node.requested_as.clone(), + required_by: node.required_by.clone(), + depth: node.depth, + kind: FailureKind::Unreachable, + cause: Some(error.kind()), + message: format!( + "下载 {} 失败({}):{}", + node.file.filename, + error.kind().as_str(), + error.message() + ), + }); + continue; + } + } progress.files_done += 1; let _ = tx.send(HostTask::Install { progress: Box::new(progress.clone()), @@ -217,11 +245,21 @@ fn run( }); } - // 解析阶段的失败在这里收口:两档分成两句话(不合并成「下载失败」),并列出可只重试的项目。 - if let Some(failure) = plan::failure_of_plan(&dependency_plan) { + // 解析与下载两段的失败在这里一起收口:两档分成两句话(不合并成「下载失败」), + // 并列出「可只重试这 N 个」。 + if let Some(failure) = plan::failure_of_plan(&dependency_plan, installed.len()) { return Err(failure); } - Ok(plan::summary(&dependency_plan)) + Ok(plan::summary(&dependency_plan, &installed)) +} + +/// 完成态:`files_done` 对齐 `files_total`。 +/// +/// 别在这里另写数字:以前写死 `1`,装 3 个文件后终态是 `1/3`,下载页只要「已知」就画确定态 +/// 进度条 ⇒ 装完了条还停在 1/3(全部跳过时更糟,`1/0`)。 +pub(super) fn finish(progress: &mut InstallProgressView) { + progress.stage = InstallStage::Done; + progress.files_done = progress.files_total; } /// 进度面板上的那一行文件名(前置标出来,用户才知道多出来的文件是什么)。 diff --git a/crates/presenter/src/host/addon/plan.rs b/crates/presenter/src/host/addon/plan.rs index 296e7fc..f09986c 100644 --- a/crates/presenter/src/host/addon/plan.rs +++ b/crates/presenter/src/host/addon/plan.rs @@ -16,11 +16,20 @@ //! 「不兼容所选版本/加载器」与「取不到(网络 / 项目不存在)」是两句话、两种用户动作, //! 所以这里既不合并计数也不合并文案;DTO 大类按「用户接下来该做什么」选:离线优先说 //! 检查网络,其次查无此项,只有**全是**不兼容时才说「这条路走不通」(换版本/加载器才有救)。 +//! +//! # DTO 没有「不兼容」这一档,所以它落在 `Unsupported`(说清楚,别当它是笔误) +//! +//! `ContentFailureKind` 的五档里没有「站点有这个项目、但所选版本/加载器下没有条目」这一档 +//! (M186 已提 finding)。它**不是** `NotFound`(那是「站点说没这个项目」)也**不是** `Offline`, +//! 最贴的只有 `Unsupported`(「这条路走不通」)——所以根项目「没有匹配版本」的 DTO 大类由 +//! M162 的 `NotFound` 改成了 `Unsupported`,语义矫正而非回归;文案里的 +//! 「不兼容所选版本/加载器」才是给用户看的那句话。UI 当前只按 `is_offline()` 分流,无可见差异。 use std::path::Path; use unlucky_core::addon::online::{ - DependencyFailure, DependencyPlan, FailureKind, OnlineError, PresentFile, ResolveRequest, + DependencyFailure, DependencyPlan, FailureKind, OnlineError, PlannedNode, PresentFile, + ResolveRequest, }; use crate::dto::ContentFailureKind; @@ -59,13 +68,19 @@ pub(super) fn request( } /// 计划里有失败吗 ⇒ 一条带大类的失败(`None` = 干净)。 -pub(super) fn failure_of_plan(plan: &DependencyPlan) -> Option { +/// +/// `installed` = **真落盘的**文件数(下载失败的不算):失败文案里要能说清「主文件已装、 +/// 缺的是谁的前置」,所以不能拿 `plan.nodes.len()` 冒充。 +pub(super) fn failure_of_plan( + plan: &DependencyPlan, + installed: usize, +) -> Option { if plan.is_clean() { return None; } Some(crate::dto::ContentFailure::new( overall_kind(plan), - report(plan), + report(plan, installed), )) } @@ -96,7 +111,7 @@ fn kind_of(failure: &DependencyFailure) -> ContentFailureKind { } /// 失败清单(两档各起一段,各自的条数分开数)。 -fn report(plan: &DependencyPlan) -> String { +fn report(plan: &DependencyPlan, installed: usize) -> String { let mut lines: Vec = Vec::new(); let incompatible: Vec = plan .failures_of(FailureKind::Incompatible) @@ -120,12 +135,15 @@ fn report(plan: &DependencyPlan) -> String { unreachable.join(";") )); } - if !plan.nodes.is_empty() { - lines.push(format!("已装入 {} 个文件", plan.nodes.len())); + if installed > 0 { + lines.push(format!("已装入 {installed} 个文件(已下好的不回滚)")); } if !plan.skipped.is_empty() { lines.push(format!("跳过 {} 个已存在的文件", plan.skipped.len())); } + if let Some(note) = unresolved_note(plan) { + lines.push(note); + } if !plan.truncated.is_empty() { lines.push(format!( "依赖层数超过上限,未展开:{}", @@ -133,11 +151,7 @@ fn report(plan: &DependencyPlan) -> String { )); } // 可重试名单:只列失败项(重试时也只重解这些,不重下已装好的) - let retry: Vec<&str> = plan - .failures - .iter() - .map(|failure| failure.requested_as.as_str()) - .collect(); + let retry = plan.retry_request().roots; if !retry.is_empty() { lines.push(format!( "可只重试这 {} 个:{}", @@ -161,17 +175,43 @@ fn describe(failure: &DependencyFailure) -> String { } } +/// 「有 required 但没有项目 id」那一档的提示(解析不了,但也不是没事发生)。 +fn unresolved_note(plan: &DependencyPlan) -> Option { + if plan.unresolved_required.is_empty() { + return None; + } + let items: Vec = plan + .unresolved_required + .iter() + .map(|item| { + let what = item + .version_id + .clone() + .or_else(|| item.file_name.clone()) + .unwrap_or_else(|| "未给版本".to_string()); + format!("{}(被 {} 要求)", what, item.parent) + }) + .collect(); + Some(format!( + "有 {} 条必备前置只给了版本/文件名、没有项目 id,无法自动解析:{}", + items.len(), + items.join(";") + )) +} + /// 全部装成之后的落盘描述(含跳过与前置的条数、「为什么装」的父项目)。 -pub(super) fn summary(plan: &DependencyPlan) -> String { - if plan.nodes.is_empty() && plan.skipped.is_empty() { +/// +/// `installed` 是**真落盘**的那些(下载失败的不在里面),所以条数与文件名都从它来, +/// 不从 `plan.nodes` 来——否则「装成了 N 个」会把下载失败的也数进去。 +pub(super) fn summary(plan: &DependencyPlan, installed: &[PlannedNode]) -> String { + if installed.is_empty() && plan.skipped.is_empty() && plan.unresolved_required.is_empty() { return "没有要装的文件".to_string(); } let mut parts: Vec = Vec::new(); - if !plan.nodes.is_empty() { - let dependencies = plan.nodes.iter().filter(|node| !node.is_root()).count(); + if !installed.is_empty() { + let dependencies = installed.iter().filter(|node| !node.is_root()).count(); // 「为什么装」:前置标出要求它的项目(根是用户自己点的,不标) - let names: Vec = plan - .nodes + let names: Vec = installed .iter() .map(|node| { if node.is_root() { @@ -186,11 +226,11 @@ pub(super) fn summary(plan: &DependencyPlan) -> String { }) .collect(); let head = if dependencies == 0 { - format!("装了 {} 个文件", plan.nodes.len()) + format!("装了 {} 个文件", installed.len()) } else { format!( "装了 {} 个文件(含 {} 个前置)", - plan.nodes.len(), + installed.len(), dependencies ) }; @@ -208,6 +248,9 @@ pub(super) fn summary(plan: &DependencyPlan) -> String { names.join("、") )); } + if let Some(note) = unresolved_note(plan) { + parts.push(note); + } if !plan.truncated.is_empty() { parts.push(format!( "依赖层数超过上限,未展开:{}", diff --git a/crates/presenter/src/host/addon/tests.rs b/crates/presenter/src/host/addon/tests.rs index 1fe9bb9..89e6436 100644 --- a/crates/presenter/src/host/addon/tests.rs +++ b/crates/presenter/src/host/addon/tests.rs @@ -118,10 +118,12 @@ fn core_errors_map_to_dto_failure_kinds() { // ------------------------------------------------ M186:前置解析的两档失败与可重试名单 +use super::install::finish; use super::plan::{failure_of_plan, summary}; +use crate::dto::{InstallProgressView, InstallStage}; use unlucky_core::addon::online::{ DependencyFailure, DependencyPlan, FailureKind, PlannedNode, ResolveRequest, SkipReason, - SkippedNode, VersionFile, + SkippedNode, UnresolvedRequired, VersionFile, }; fn node(project: &str, file: &str, depth: u8, required_by: &[&str]) -> PlannedNode { @@ -172,6 +174,7 @@ fn plan( skipped, failures, truncated: Vec::new(), + unresolved_required: Vec::new(), } } @@ -188,7 +191,7 @@ fn an_incompatible_only_plan_is_not_reported_as_a_download_failure() { vec![node("AANobbMI", "sodium.jar", 0, &[])], Vec::new(), ); - let failure = failure_of_plan(&plan).expect("有失败就该给一条"); + let failure = failure_of_plan(&plan, 0).expect("有失败就该给一条"); assert_eq!(failure.kind, ContentFailureKind::Unsupported); assert!(failure.detail.contains("不兼容所选版本/加载器 1 个")); assert!(failure.detail.contains("可只重试这 1 个:P7dR8mSH")); @@ -221,7 +224,7 @@ fn a_mixed_plan_keeps_both_buckets_and_picks_the_actionable_kind() { Vec::new(), Vec::new(), ); - let failure = failure_of_plan(&plan).expect("有失败就该给一条"); + let failure = failure_of_plan(&plan, 0).expect("有失败就该给一条"); assert_eq!(failure.kind, ContentFailureKind::NotFound); assert!(failure.detail.contains("不兼容所选版本/加载器 1 个")); assert!(failure.detail.contains("取不到(网络 / 项目不存在)1 个")); @@ -249,7 +252,7 @@ fn an_offline_jump_wins_over_everything_else() { Vec::new(), Vec::new(), ); - let failure = failure_of_plan(&plan).expect("有失败就该给一条"); + let failure = failure_of_plan(&plan, 0).expect("有失败就该给一条"); assert_eq!(failure.kind, ContentFailureKind::Offline); assert!(failure.is_offline()); } @@ -272,8 +275,9 @@ fn the_success_message_counts_dependencies_and_skipped_files() { reason: SkipReason::AlreadyPresent, }], ); - assert!(failure_of_plan(&plan).is_none()); - let message = summary(&plan); + assert!(failure_of_plan(&plan, 2).is_none()); + let installed = plan.nodes.clone(); + let message = summary(&plan, &installed); assert!( message.contains("装了 2 个文件(含 1 个前置)"), "{message}" @@ -302,3 +306,74 @@ fn present_files_lists_only_files_in_the_target_directory() { assert_eq!(names, ["sodium.jar", "yacl.jar.disabled"]); let _ = std::fs::remove_dir_all(&dir); } + +// ------------------------------------------------ M186 r1:完成态进度、下载失败上下文、无地址前置 + +/// 完成态不许把进度倒回 `1/N`(r1 P2-3:这里曾写死 `files_done = 1`)。 +#[test] +fn finishing_aligns_the_terminal_progress_with_the_total() { + let mut progress = InstallProgressView { + files_total: 3, + files_done: 3, + stage: InstallStage::Downloading, + ..InstallProgressView::new() + }; + finish(&mut progress); + assert_eq!(progress.stage, InstallStage::Done); + assert_eq!(progress.files_done, progress.files_total); + assert_eq!(progress.ratio(), 1.0, "装完了条必须满"); + + // 全部跳过(total = 0)时也不许变成 1/0 + let mut skipped_all = InstallProgressView { + files_total: 0, + ..InstallProgressView::new() + }; + finish(&mut skipped_all); + assert_eq!(skipped_all.files_done, 0); + assert_eq!(skipped_all.stage, InstallStage::Done); +} + +/// 下载阶段的失败也要带上下文:已装几个 + 缺的是谁的前置 + 可只重试谁。 +#[test] +fn a_download_failure_keeps_the_already_installed_context() { + let plan = plan( + vec![failure( + "P7dR8mSH", + FailureKind::Unreachable, + Some(OnlineErrorKind::NotConnected), + "下载 fabric-api.jar 失败(online_not_connected):连不上 cdn.modrinth.com", + )], + vec![node("AANobbMI", "sodium.jar", 0, &[])], + Vec::new(), + ); + let failure = failure_of_plan(&plan, 1).expect("有失败就该给一条"); + assert_eq!(failure.kind, ContentFailureKind::Offline); + assert!( + failure.detail.contains("已装入 1 个文件(已下好的不回滚)"), + "{}", + failure.detail + ); + assert!(failure.detail.contains("取不到(网络 / 项目不存在)1 个")); + assert!(failure.detail.contains("可只重试这 1 个:P7dR8mSH")); +} + +/// 只有版本 id、没有项目 id 的必备前置:既装不了,也不许静默吞掉。 +#[test] +fn a_required_dependency_without_a_project_id_is_reported() { + let mut plan = plan( + Vec::new(), + vec![node("AANobbMI", "sodium.jar", 0, &[])], + Vec::new(), + ); + plan.unresolved_required = vec![UnresolvedRequired { + parent: "AANobbMI".to_string(), + version_id: Some("ver-embedded".to_string()), + file_name: Some("embedded-lib.jar".to_string()), + }]; + let installed = plan.nodes.clone(); + let message = summary(&plan, &installed); + assert!( + message.contains("有 1 条必备前置只给了版本/文件名、没有项目 id,无法自动解析:ver-embedded(被 AANobbMI 要求)"), + "{message}" + ); +}