From 011d525e8de07d4a12bbcea361c6d44851bc52ee Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 13:24:35 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(addon):=20Modrinth=20=E5=89=8D?= =?UTF-8?q?=E7=BD=AE=E4=BE=9D=E8=B5=96=20BFS=20=E8=A7=A3=E6=9E=90=E4=B8=8E?= =?UTF-8?q?=E4=B8=A4=E6=A1=A3=E5=A4=B1=E8=B4=A5=E5=90=8D=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - model 纳入 `dependencies`(project_id / version_id / file_name + required / optional / incompatible / embedded / unknown 五值),只跟 required 递归; - 新增 `online::dependency`(请求/计划/跳过的形状)与 `online::resolve` (BFS:规范项目 id 去重防环、每项目只解一次版本、同层按项目键字典序、 默认深度 6 且截断写进计划、父子关系进 `required_by`); - 失败从类型上分两档:`Incompatible`(所选版本/加载器没有匹配项)与 `Unreachable`(并保留叶子成因 NotFound/NotConnected/…),`retry_request()` 只含失败项,解析永不返回 Err(失败是数据,不连坐); - 盘上第二道去重:`present_key` 归一化文件名(小写、砍路径、去 `.disabled`), 命中即跳过下载,但依赖图仍然展开; - presenter 装入路径改成「解析计划 → 逐个下载」,文案把两档分开写并列出 「可只重试这 N 个」;`KNOWN_LOADERS` 补 iris/optifine(光影的加载器); - 离线夹具覆盖:嵌套两层、环、扇入、深度截断、两档失败、已存在跳过、光影加载器。 --- crates/core/src/addon/online/dependency.rs | 287 ++++++++++++++++ crates/core/src/addon/online/mod.rs | 24 +- crates/core/src/addon/online/model/mod.rs | 4 +- crates/core/src/addon/online/model/parse.rs | 30 +- crates/core/src/addon/online/model/query.rs | 15 +- crates/core/src/addon/online/model/types.rs | 82 +++++ crates/core/src/addon/online/resolve.rs | 257 +++++++++++++++ .../core/src/addon/online/tests/dependency.rs | 306 ++++++++++++++++++ .../src/addon/online/tests/fixtures_deps.rs | 277 ++++++++++++++++ crates/core/src/addon/online/tests/mod.rs | 2 + crates/presenter/src/host/addon.rs | 4 +- crates/presenter/src/host/addon/install.rs | 191 +++++------ crates/presenter/src/host/addon/plan.rs | 218 +++++++++++++ crates/presenter/src/host/addon/tests.rs | 187 +++++++++++ 14 files changed, 1787 insertions(+), 97 deletions(-) create mode 100644 crates/core/src/addon/online/dependency.rs create mode 100644 crates/core/src/addon/online/resolve.rs create mode 100644 crates/core/src/addon/online/tests/dependency.rs create mode 100644 crates/core/src/addon/online/tests/fixtures_deps.rs create mode 100644 crates/presenter/src/host/addon/plan.rs diff --git a/crates/core/src/addon/online/dependency.rs b/crates/core/src/addon/online/dependency.rs new file mode 100644 index 0000000..12b2c5e --- /dev/null +++ b/crates/core/src/addon/online/dependency.rs @@ -0,0 +1,287 @@ +//! 依赖图谱的**形状**:一次解析要什么([`ResolveRequest`])、出什么([`DependencyPlan`])。 +//! +//! 算法在 [`super::resolve`];本文件只放类型与不变量,于是「两档失败从类型上可分辨」这件事 +//! 不必读算法也能核对。 +//! +//! # 三份名单,一份都不许丢 +//! +//! | 名单 | 含义 | 上层该做什么 | +//! |------|------|--------------| +//! | [`DependencyPlan::nodes`] | 要下的(BFS 顺序、父子关系齐全) | 逐个下载 | +//! | [`DependencyPlan::skipped`] | 目标目录里已经有同名文件 | 提示「已存在,跳过」 | +//! | [`DependencyPlan::failures`] | 装不了的,**每条自带 [`FailureKind`]** | 两档分开说;进可重试名单 | +//! +//! `truncated` 是第四样东西:撞到 [`DEFAULT_MAX_DEPTH`] 而没展开的项目。它既不是成功也不是 +//! 失败(站点没拒绝,我们主动停),但**必须**露出来——静默截断会让用户看着「装好了」而缺前置。 +//! +//! # 两档为什么不能合成一句 +//! +//! 「所选版本/加载器没有匹配项」与「网络异常 / 项目不存在」要用户做的事完全不同: +//! 前者换版本或换加载器,后者检查网络再试。字符串错误只能靠 `contains("网络")` 猜, +//! 一改文案就失效;[`FailureKind`] 把判据放在类型上,叶子成因(404 / 连不上 / 5xx) +//! 另存在 [`DependencyFailure::cause`] 上,允许上层再细分而不用重新判。 + +use super::model::{VersionFile, VersionQuery}; + +/// 默认最大深度(根 = 0)。六层足够覆盖真实套娃(前置的前置的前置……), +/// 同时给环与异常链一个硬边界——撞上就记 [`DependencyPlan::truncated`],**不静默截断**。 +pub const DEFAULT_MAX_DEPTH: u8 = 6; + +/// 目标目录里已存在的文件(调用方扫描后给;core 不读盘)。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PresentFile { + /// 文件名(可以带路径,比对时会砍掉) + pub name: String, +} + +impl PresentFile { + /// 记一个已存在的文件名。 + #[must_use] + pub fn new(name: impl Into) -> Self { + Self { name: name.into() } + } + + /// 归一化后的比对键(见 [`present_key`])。 + #[must_use] + pub fn key(&self) -> String { + present_key(&self.name) + } +} + +/// 文件名 → 比对键:只留文件名、小写、去掉 `.disabled` 后缀。 +/// +/// 为什么带 `.disabled`:本仓用**路径后缀**表示禁用(`mods/x.jar.disabled`,见 +/// `core::addon::export`),禁用中的 jar 也是「装过」,再下一份会和它并存。 +#[must_use] +pub fn present_key(name: &str) -> String { + let base = name + .rsplit(['/', '\\']) + .next() + .unwrap_or(name) + .trim() + .to_ascii_lowercase(); + base.strip_suffix(".disabled").unwrap_or(&base).to_string() +} + +/// 一次解析请求。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolveRequest { + /// 起点(项目 id 或 slug);同层处理时按项目键字典序,与这里给的顺序无关 + pub roots: Vec, + /// 目标游戏版本(选版本用;`None` = 不限) + pub game_version: Option, + /// 目标加载器(选版本用;`None` = 不限) + pub loader: Option, + /// 最大深度(根 = 0,见 [`DEFAULT_MAX_DEPTH`]) + pub max_depth: u8, + /// 目标目录里已存在的文件(盘上去重的依据;空 = 目录是空的) + pub present: Vec, +} + +impl Default for ResolveRequest { + fn default() -> Self { + Self { + roots: Vec::new(), + game_version: None, + loader: None, + max_depth: DEFAULT_MAX_DEPTH, + present: Vec::new(), + } + } +} + +impl ResolveRequest { + /// 单根请求(内容库「装进实例」的入口形状)。 + #[must_use] + pub fn single(project: impl Into) -> Self { + Self { + roots: vec![project.into()], + ..Self::default() + } + } + + /// 这个项目的版本请求(过滤条件相同,只换项目)。 + #[must_use] + pub fn version_query(&self, project: &str) -> VersionQuery { + VersionQuery { + project: project.to_string(), + game_version: self.game_version.clone(), + loader: self.loader.clone(), + } + } +} + +/// 计划里的一条「要装什么」。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlannedNode { + /// 规范项目 id(站点回的 `project_id`;空则用请求时的键) + pub project: String, + /// 当初请求它用的键(slug 或 id)——重试要用同一个 + pub requested_as: String, + /// 版本 id + pub version_id: String, + /// 版本名 + pub version_name: String, + /// 版本号 + pub version_number: String, + /// 要下载的那个文件(`primary` 优先,见 `Version::primary_file`) + pub file: VersionFile, + /// 距根的层数(根 = 0) + pub depth: u8, + /// 谁要求装它(规范项目 id,排序去重;根为空 = 用户自己点的) + pub required_by: Vec, +} + +impl PlannedNode { + /// 是用户点的那个项目吗(根 = 层数 0)。 + /// + /// 判据是 `depth` 而不是 `required_by.is_empty()`:环里根也会被别人「要求」 + /// (A ⇄ B 时 A 的 `required_by` 里有 B),但它在计划里仍是起点。 + #[must_use] + pub fn is_root(&self) -> bool { + self.depth == 0 + } +} + +/// 为什么跳过。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SkipReason { + /// 目标目录里已经有同名文件(含 `.disabled`) + AlreadyPresent, +} + +impl SkipReason { + /// 稳定字符串 id(日志 / DTO 用,**不要改名**)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::AlreadyPresent => "already_present", + } + } +} + +/// 一条被跳过的(本来要下载的)项目。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkippedNode { + /// 规范项目 id + pub project: String, + /// 请求它用的键 + pub requested_as: String, + /// 计划里的文件名(在目录里找到了同名的那个) + pub file_name: String, + /// 距根的层数 + pub depth: u8, + /// 谁要求装它 + pub required_by: Vec, + /// 跳过原因 + pub reason: SkipReason, +} + +/// 失败的两档(**类型上就分开,不许拼成一句「下载失败」**)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FailureKind { + /// 站点有这个项目,但所选游戏版本 / 加载器下没有可装的条目 + /// (版本列表为空,或选中的版本没有可下载的主文件)。 + /// + /// 用户该做的动作是**换版本或换加载器**;原样重试得到的还是这一档。 + Incompatible, + /// 取不到:没联网 / 项目不存在 / 站点出错 / 响应坏。 + /// + /// 用户该做的动作是**检查网络后再试**;具体叶子成因保留在 + /// [`DependencyFailure::cause`] 上(`NotFound` 与 `NotConnected` 不能混为一谈)。 + Unreachable, +} + +impl FailureKind { + /// 稳定字符串 id(日志 / 命令行输出用,**不要改名**)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Incompatible => "incompatible", + Self::Unreachable => "unreachable", + } + } +} + +/// 一条解析失败(进「可重试名单」的就是它们)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DependencyFailure { + /// 请求它用的键(重试也用它) + pub requested_as: String, + /// 当初因为谁而被要求(规范项目 id;根为空) + pub required_by: Vec, + /// 距根的层数 + pub depth: u8, + /// 哪一档 + pub kind: FailureKind, + /// 叶子成因(只有 [`FailureKind::Unreachable`] 有) + pub cause: Option, + /// 人读说明(站点/引擎原话 + 上下文,不是界面文案) + pub message: String, +} + +/// 一次 BFS 的完整结果(成功、跳过、失败三份名单,都**不丢**)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DependencyPlan { + /// 这次用的请求(重试要沿用同样的过滤条件与同一份 `present`) + pub request: ResolveRequest, + /// 要装的(BFS 顺序:层号小的在前,同层按项目键字典序) + pub nodes: Vec, + /// 目录里已经有了、不用再下的 + pub skipped: Vec, + /// 装不了的(两档混在同一个列表里,但每条自带 [`FailureKind`]) + pub failures: Vec, + /// 撞到 `max_depth` 而没展开的项目键(排序去重) + pub truncated: Vec, +} + +impl DependencyPlan { + /// 空计划(`resolve` 从这里开始填)。 + #[must_use] + pub fn new(request: &ResolveRequest) -> Self { + Self { + request: request.clone(), + nodes: Vec::new(), + skipped: Vec::new(), + failures: Vec::new(), + truncated: Vec::new(), + } + } + + /// 一条失败都没有吗(空循环也算干净)。 + #[must_use] + pub fn is_clean(&self) -> bool { + self.failures.is_empty() + } + + /// 某一档的失败(两档分开看时用)。 + pub fn failures_of(&self, kind: FailureKind) -> impl Iterator { + self.failures.iter().filter(move |item| item.kind == kind) + } + + /// 「可重试名单」:失败项原样重启一次解析的请求。 + /// + /// 只含失败项 ⇒ 用户只重试这一部分,**不重下**已经装好的文件(`present` 沿用)。 + /// 两档都在名单里,但每条自带 [`FailureKind`]:不兼容那一档重试前得换版本/加载器, + /// 换完再调它才有意义。**这不是「后台自动重试」**——本模块只给请求,什么时候发由上层决定。 + #[must_use] + pub fn retry_request(&self) -> ResolveRequest { + ResolveRequest { + roots: self + .failures + .iter() + .map(|failure| failure.requested_as.clone()) + .collect(), + ..self.request.clone() + } + } + + /// 这个项目为什么在计划里(规范 id → 要求装它的项目)。`None` = 不在计划里。 + #[must_use] + pub fn required_by(&self, project: &str) -> Option<&[String]> { + self.nodes + .iter() + .find(|node| node.project == project) + .map(|node| node.required_by.as_slice()) + } +} diff --git a/crates/core/src/addon/online/mod.rs b/crates/core/src/addon/online/mod.rs index 7886548..0c05038 100644 --- a/crates/core/src/addon/online/mod.rs +++ b/crates/core/src/addon/online/mod.rs @@ -46,23 +46,43 @@ //! | [`modrinth`] | 端点拼装与响应解析(纯逻辑,测试直接喂录制 JSON) | //! | [`download`] | 取下载地址之后那一步:引擎下载 + **sha1/sha512 复核** + 重试 + 不留半截文件 | //! | [`complete`] | 按摘要认文件([`identity_of`]):站点返回的版本里哪条才是我们要的那个 | +//! | [`dependency`] | 依赖图谱的形状(请求 / 计划 / **两档失败**) | +//! | [`resolve`] | BFS 解析(只跟 `required`):去重、按项目缓存、深度上限、确定性顺序 | +//! +//! # 一次「装一个模组」到底发几个请求 +//! +//! M186 之前的实测口径(也是现在仍然成立的基座):`GET /v2/project/{id}/version` 一次 +//! (带 `game_versions` / `loaders` 两个数组参数)+ 文件下载一次 = **2 个请求**, +//! 其中**没有任何**依赖解析——`dependencies` 字段根本没进模型,所以「装了模组但进游戏崩」 +//! 这条路只能靠用户自己发现缺前置。 +//! +//! M186 之后:根项目同样是 2 个请求(版本 + 下载),每个 `required` 前置再各加 1 次版本 +//! 请求 + 1 次下载。与参考实现相比省掉了「每个前置再查一次 `/v2/project/{id}` 换 slug」, +//! 因为依赖里给的就是项目 id,而 `/project/{id}/version` 认 id。 pub mod complete; +pub mod dependency; pub mod download; pub mod model; pub mod modrinth; +pub mod resolve; pub mod transport; pub use complete::identity_of; +pub use dependency::{ + present_key, DependencyFailure, DependencyPlan, FailureKind, PlannedNode, PresentFile, + ResolveRequest, SkipReason, SkippedNode, DEFAULT_MAX_DEPTH, +}; pub use download::{ digest_file, download_verified, ArtifactFetcher, Digest, Downloaded, EngineArtifactFetcher, FetchArgs, RETRY_ATTEMPTS, }; pub use model::{ - ContentType, FileHashes, ProjectDetail, ProjectHit, SearchPage, SearchQuery, SortIndex, - Version, VersionFile, VersionQuery, VersionType, + ContentType, Dependency, DependencyType, FileHashes, ProjectDetail, ProjectHit, SearchPage, + SearchQuery, SortIndex, Version, VersionFile, VersionQuery, VersionType, }; pub use modrinth::{HashAlgorithm, Modrinth, MODRINTH_API}; +pub use resolve::resolve; pub use transport::{EngineTransport, OnlineError, OnlineErrorKind, OnlineFuture, Transport}; #[cfg(test)] diff --git a/crates/core/src/addon/online/model/mod.rs b/crates/core/src/addon/online/model/mod.rs index 5faf941..67af82b 100644 --- a/crates/core/src/addon/online/model/mod.rs +++ b/crates/core/src/addon/online/model/mod.rs @@ -32,6 +32,6 @@ mod types; pub use parse::{known_loaders, parse_project, parse_search, parse_version, parse_versions}; pub use query::{ContentType, SearchQuery, SortIndex, KNOWN_LOADERS}; pub use types::{ - FileHashes, ProjectDetail, ProjectHit, SearchPage, Version, VersionFile, VersionQuery, - VersionType, + Dependency, DependencyType, FileHashes, ProjectDetail, ProjectHit, SearchPage, Version, + VersionFile, VersionQuery, VersionType, }; diff --git a/crates/core/src/addon/online/model/parse.rs b/crates/core/src/addon/online/model/parse.rs index e7d1ceb..175810e 100644 --- a/crates/core/src/addon/online/model/parse.rs +++ b/crates/core/src/addon/online/model/parse.rs @@ -8,7 +8,10 @@ use std::collections::BTreeMap; use serde::Deserialize; use super::query::KNOWN_LOADERS; -use super::types::{ProjectDetail, ProjectHit, SearchPage, Version, VersionFile, VersionType}; +use super::types::{ + Dependency, DependencyType, ProjectDetail, ProjectHit, SearchPage, Version, VersionFile, + VersionType, +}; use crate::addon::online::transport::{OnlineError, OnlineErrorKind}; // --------------------------------------------------------------- 解析 @@ -112,6 +115,21 @@ struct RawVersion { downloads: u64, #[serde(default)] files: Vec, + #[serde(default)] + dependencies: Vec, +} + +#[derive(Debug, Default, Deserialize)] +struct RawDependency { + #[serde(default)] + project_id: Option, + #[serde(default)] + version_id: Option, + #[serde(default)] + file_name: Option, + // 站点给的是 `required` / `optional` / `incompatible` / `embedded`;认不出 ⇒ `Unknown` + #[serde(default)] + dependency_type: String, } #[derive(Debug, Default, Deserialize)] @@ -238,6 +256,16 @@ fn version_of(raw: RawVersion) -> Version { primary: file.primary, }) .collect(), + dependencies: raw + .dependencies + .into_iter() + .map(|dependency| Dependency { + 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), + }) + .collect(), } } diff --git a/crates/core/src/addon/online/model/query.rs b/crates/core/src/addon/online/model/query.rs index 6c660e5..1e2b57f 100644 --- a/crates/core/src/addon/online/model/query.rs +++ b/crates/core/src/addon/online/model/query.rs @@ -83,7 +83,20 @@ impl SortIndex { /// 站点认的加载器 facet 值(`categories` 里混着 `optimization` 这种**分类标签**, /// 直接整份当加载器会画出假加载器)。 -pub const KNOWN_LOADERS: [&str; 6] = ["fabric", "forge", "neoforge", "quilt", "liteloader", "rift"]; +/// +/// 后两项是**光影**的加载器(Modrinth 把 shader 的 `iris` / `optifine` 也放在 `categories` +/// 里):不登记它们,光影检索结果的 `loaders` 就会恒为空,页面既画不出「支持 Iris」, +/// 也用不上「只看 Iris」这种判断。 +pub const KNOWN_LOADERS: [&str; 8] = [ + "fabric", + "forge", + "neoforge", + "quilt", + "liteloader", + "rift", + "iris", + "optifine", +]; /// 一次检索请求(分页游标也在里面:换页就是换一份 query)。 #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/core/src/addon/online/model/types.rs b/crates/core/src/addon/online/model/types.rs index 532fb64..8bb0af9 100644 --- a/crates/core/src/addon/online/model/types.rs +++ b/crates/core/src/addon/online/model/types.rs @@ -140,6 +140,67 @@ impl VersionType { } } +/// 前置依赖的类型(`/version` 的 `dependencies[].dependency_type`)。 +/// +/// # 为什么要留 `Unknown` 而不是「认不出就当可选」 +/// +/// 站点以后加新取值时,**只有 `Required` 才该触发递归**。认不出的值落进 `Unknown`, +/// 于是「站点改名」的下场是「少装一个前置」(可观察、可补),而不是「把所有建议项都当必备项 +/// 递归拉一遍」(一次装模组变成几十个请求,还装了一堆不兼容的东西)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum DependencyType { + /// 必须装(BFS **只跟这一档**) + Required, + /// 可选(装不装都能跑) + #[default] + Optional, + /// 明确不兼容(**不要**和它一起装) + Incompatible, + /// 已内嵌在被依赖的文件里(不用另外装) + Embedded, + /// 站点给了个我们不认识的取值 + Unknown, +} + +impl DependencyType { + /// 站点原文。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Required => "required", + Self::Optional => "optional", + Self::Incompatible => "incompatible", + Self::Embedded => "embedded", + Self::Unknown => "unknown", + } + } + + /// 从站点原文解析;认不出(含空串)返回 [`Self::Unknown`]。 + #[must_use] + pub fn parse(raw: &str) -> Self { + match raw.trim() { + "required" => Self::Required, + "optional" => Self::Optional, + "incompatible" => Self::Incompatible, + "embedded" => Self::Embedded, + _ => Self::Unknown, + } + } +} + +/// 版本声明的一条前置(`dependencies[]`)。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Dependency { + /// 依赖的**项目** id(BFS 的地址;有了它就不用像参考实现那样再查一次 slug) + pub project_id: Option, + /// 依赖的版本 id(站点基本只对 `embedded` 给) + pub version_id: Option, + /// 站点给的文件名(只对 `embedded` / `incompatible` 有意义) + pub file_name: Option, + /// 依赖类型 + pub dependency_type: DependencyType, +} + /// 一个版本(`/v2/project/{id}/version[]` 与 `/v2/version_file/{hash}` 同一形状)。 #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct Version { @@ -163,6 +224,8 @@ pub struct Version { pub downloads: u64, /// 文件列表 pub files: Vec, + /// 前置声明(站点原文顺序;空 = 站点没说 / 没有) + pub dependencies: Vec, } impl Version { @@ -176,6 +239,25 @@ impl Version { .find(|file| file.primary) .or_else(|| self.files.first()) } + + /// 要跟下去的**前置项目 id**:只取 `required`,去掉空值,**排序去重**。 + /// + /// 排序在这里做(不是让调用方排):BFS 的展开顺序必须是数据本身决定的, + /// 否则站点把 `dependencies` 换个顺序、或者用 `HashMap` 收一遍,测试就会随机红。 + #[must_use] + pub fn required_project_ids(&self) -> Vec<&str> { + let mut ids: Vec<&str> = self + .dependencies + .iter() + .filter(|dependency| dependency.dependency_type == DependencyType::Required) + .filter_map(|dependency| dependency.project_id.as_deref()) + .map(str::trim) + .filter(|id| !id.is_empty()) + .collect(); + ids.sort_unstable(); + ids.dedup(); + ids + } } /// 版本里的一个文件。 diff --git a/crates/core/src/addon/online/resolve.rs b/crates/core/src/addon/online/resolve.rs new file mode 100644 index 0000000..977bda1 --- /dev/null +++ b/crates/core/src/addon/online/resolve.rs @@ -0,0 +1,257 @@ +//! BFS 依赖解析(**只跟 `required`**)。形状在 [`super::dependency`],这里只有算法。 +//! +//! # 与参考实现(Python 版 Modrinth 下载器)的四处差别 +//! +//! | 参考实现 | 这里 | 为什么 | +//! |---|---|---| +//! | 每个前置再 `GET /v2/project/{id}` 换 slug | **不查**,直接拿 `dependencies[].project_id` 当地址 | 依赖里给的就是项目 id,`/project/{id}/version` 认它;一次安装省掉每个前置一个请求 | +//! | 失败名单是字符串,靠括号里的中文区分「不兼容」与「网络异常」 | [`FailureKind`] 枚举 | 「哪一档」要能从类型上判,不能靠 `contains("网络")` | +//! | 一个 dict 兼做缓存与去重 | 键 = **规范项目 id**,别名单设一张表 | 同一项目可能一次以 slug、一次以 id 出现;只按请求串去重会把它装两遍 | +//! | 队列按 `dependencies` 原文顺序展开 | 每层按项目键**字典序**处理 | 顺序必须是数据决定的,否则测试靠 HashMap 运气 | +//! +//! # 失败是**数据**,不是 `Err` +//! +//! [`resolve`] 永远返回一个 [`DependencyPlan`]:某个前置取不到版本时,它进 `failures`, +//! 解析继续做别的分支。若中途 `return Err`,用户看到的是「装这个模组失败」,而真正该看到的 +//! 是「主文件装上了,缺 fabric-api(不兼容 1.20.1/fabric)」+一份可单独重试的名单。 +//! +//! # 两张去重网 +//! +//! 1. **图内**:同一项目只解一次版本(`nodes` 里每个项目最多一条),多条依赖路径折进 +//! [`PlannedNode::required_by`]; +//! 2. **盘上**:目标目录里已经有同名文件(`.disabled` 也算)就进 `skipped`、**不再下载**。 +//! 这是「已装过的同一项目跳过并提示」里我们真能判的那一半;另一半(同项目**另一个版本** +//! 已装)需要实例侧的已装清单,本模块判不出来(见报告)。 +//! +//! 盘上判定只比文件名(`present` 由调用方扫目录得到,core 不读盘):内容级去重需要先算一份 +//! sha1,为了「跳过」把整个 `mods/` 哈希一遍不划算;同名文件就是同一份 jar 的最常见形态 +//! (我们落盘用站点给的文件名,不重命名)。 + +use std::collections::{BTreeMap, BTreeSet}; + +use super::dependency::{ + present_key, DependencyFailure, DependencyPlan, FailureKind, PlannedNode, PresentFile, + ResolveRequest, SkipReason, SkippedNode, +}; +use super::model::Version; +use super::modrinth::Modrinth; +use super::transport::Transport; + +/// 一层里还没处理的一项。 +struct Wave { + key: String, + depth: u8, + parents: Vec, +} + +/// BFS 解析。**不返回 `Err`**:失败进 [`DependencyPlan::failures`](见模块文档)。 +pub async fn resolve( + client: &Modrinth<'_, T>, + request: &ResolveRequest, +) -> DependencyPlan { + let mut plan = DependencyPlan::new(request); + let present: BTreeSet = request.present.iter().map(PresentFile::key).collect(); + // 请求键 → 规范 id(同一项目可能一次给 slug、一次给 id) + let mut alias: BTreeMap = BTreeMap::new(); + let mut resolved: BTreeSet = BTreeSet::new(); + + let mut layer: Vec = Vec::new(); + for root in &request.roots { + let key = root.trim(); + if !key.is_empty() { + wave_in(&mut layer, key, 0, None); + } + } + + while !layer.is_empty() { + // 同层按项目键字典序:顺序由数据决定,不靠插入顺序或 HashMap 的运气。 + layer.sort_by(|left, right| left.key.cmp(&right.key)); + let mut next: Vec = Vec::new(); + for mut item in layer.drain(..) { + normalize(&mut item.parents); + // ① 已经解过(含「A 依赖 B、B 又依赖 A」的环)⇒ 只把父边折进已有节点 + if let Some(canonical) = alias.get(&item.key).cloned() { + fold_parents(&mut plan, &canonical, &item.parents); + continue; + } + let version = match client.versions(&request.version_query(&item.key)).await { + 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), + }); + 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() + ), + }); + continue; + } + }; + let Some(file) = version + .primary_file() + .filter(|file| !file.url.trim().is_empty()) + .cloned() + else { + let message = format!( + "{} 的版本 {} 里没有可下载的主文件", + 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, + message, + }); + continue; + }; + + let canonical = canonical_of(&item.key, &version); + alias.insert(item.key.clone(), canonical.clone()); + alias + .entry(canonical.clone()) + .or_insert_with(|| canonical.clone()); + // ② 同一项目走了两条路(slug 与 id 各一次)⇒ 折边,不重复装 + if !resolved.insert(canonical.clone()) { + fold_parents(&mut plan, &canonical, &item.parents); + continue; + } + + let file_name = file.filename.clone(); + if present.contains(&present_key(&file_name)) { + plan.skipped.push(SkippedNode { + project: canonical.clone(), + requested_as: item.key.clone(), + file_name, + depth: item.depth, + required_by: item.parents.clone(), + reason: SkipReason::AlreadyPresent, + }); + } else { + plan.nodes.push(PlannedNode { + project: canonical.clone(), + requested_as: item.key.clone(), + version_id: version.id.clone(), + version_name: version.name.clone(), + version_number: version.version_number.clone(), + file, + depth: item.depth, + required_by: item.parents.clone(), + }); + } + + // 子项:只跟 required(排序去重由 `required_project_ids` 保证) + for dependency in version.required_project_ids() { + if item.depth >= request.max_depth { + plan.truncated.push(dependency.to_string()); + } else { + wave_in(&mut next, dependency, item.depth + 1, Some(&canonical)); + } + } + } + plan.truncated.sort(); + plan.truncated.dedup(); + layer = next; + } + + plan +} + +/// 把一项放进这一层的待处理表(同一个键已经排队 ⇒ 只追加父项目)。 +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) { + if let Some(parent) = parent { + existing.parents.push(parent.to_string()); + } + return; + } + layer.push(Wave { + key: key.to_string(), + depth, + parents: parent + .map(|parent| vec![parent.to_string()]) + .unwrap_or_default(), + }); +} + +/// 往已经建好的节点上折一条父边(节点可能在被跳过的那一档,也可能只出现在失败名单里)。 +fn fold_parents(plan: &mut DependencyPlan, project: &str, parents: &[String]) { + if parents.is_empty() { + return; + } + let node = plan + .nodes + .iter_mut() + .find(|node| node.project == project) + .map(|node| &mut node.required_by); + let node = node.or_else(|| { + plan.skipped + .iter_mut() + .find(|node| node.project == project) + .map(|node| &mut node.required_by) + }); + if let Some(required_by) = node { + required_by.extend(parents.iter().cloned()); + normalize(required_by); + return; + } + if let Some(failure) = plan + .failures + .iter_mut() + .find(|failure| failure.requested_as == project) + { + failure.required_by.extend(parents.iter().cloned()); + normalize(&mut failure.required_by); + } +} + +fn normalize(list: &mut Vec) { + list.sort(); + list.dedup(); +} + +/// 规范身份:站点回的 `project_id` 才是身份;没给就退回请求键。 +fn canonical_of(key: &str, version: &Version) -> String { + let project_id = version.project_id.trim(); + if project_id.is_empty() { + key.to_string() + } else { + project_id.to_string() + } +} + +/// 「哪里没有匹配项」这句话(第一档的固定说法,带出用的是什么过滤条件)。 +fn filter_message(request: &ResolveRequest) -> String { + let game_version = request.game_version.as_deref().unwrap_or("不限"); + let loader = request.loader.as_deref().unwrap_or("不限"); + format!("没有匹配所选游戏版本/加载器({game_version} / {loader})的版本") +} + +fn version_label(version: &Version) -> String { + let number = version.version_number.trim(); + if number.is_empty() { + version.id.clone() + } else { + number.to_string() + } +} diff --git a/crates/core/src/addon/online/tests/dependency.rs b/crates/core/src/addon/online/tests/dependency.rs new file mode 100644 index 0000000..1a75948 --- /dev/null +++ b/crates/core/src/addon/online/tests/dependency.rs @@ -0,0 +1,306 @@ +//! 依赖解析的离线用例(录制 JSON 夹具,**不打真实网络**)。 +//! +//! 三条守门对应任务书的三条: +//! 1. [`two_nested_levels_are_pulled_in_a_predictable_order`] —— 嵌套两层能拉全,顺序确定; +//! 2. [`a_dependency_cycle_terminates_and_each_project_appears_once`] —— 环不无限递归; +//! 3. [`an_incompatible_dependency_lands_in_the_retry_list`] —— 不兼容进重试名单,不静默丢。 +//! +//! 每条都配过一次「把实现改坏就该红」的变异(过程写在 M186 报告里),所以断言不是装饰。 + +use crate::addon::online::dependency::{ + present_key, DependencyPlan, FailureKind, PresentFile, ResolveRequest, SkipReason, +}; +use crate::addon::online::model::parse_versions; +use crate::addon::online::{resolve, Modrinth, OnlineErrorKind, MODRINTH_API}; +use crate::download::blocking::block_on; + +use super::fixtures_deps as deps; +use super::support::FixtureTransport; + +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() + } +} + +/// 三层链的传输层(sodium → fabric-api → yacl)。 +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) +} + +fn resolve_with(transport: &FixtureTransport, request: &ResolveRequest) -> DependencyPlan { + let client = Modrinth::new(transport); + block_on(resolve(&client, request)) +} + +// ---------------------------------------------------------------- ① 嵌套两层 + +#[test] +fn two_nested_levels_are_pulled_in_a_predictable_order() { + let transport = chain_transport(); + let plan = resolve_with(&transport, &request("sodium")); + + assert!(plan.is_clean(), "不该有失败:{:?}", plan.failures); + // 顺序 = BFS 层序,同层按项目键字典序:根 → fabric-api → yacl + let order: Vec<(&str, u8)> = plan + .nodes + .iter() + .map(|node| (node.project.as_str(), node.depth)) + .collect(); + assert_eq!( + order, + vec![("AANobbMI", 0), ("P7dR8mSH", 1), ("1eAoo2KR", 2)] + ); + + // 「为什么装」:父子关系逐条对得上 + assert!(plan.required_by("AANobbMI").expect("根在计划里").is_empty()); + assert_eq!( + plan.required_by("P7dR8mSH").expect("fabric-api 在计划里"), + ["AANobbMI".to_string()].as_slice() + ); + assert_eq!( + plan.required_by("1eAoo2KR").expect("yacl 在计划里"), + ["P7dR8mSH".to_string()].as_slice() + ); + + // 只跟 required:optional / incompatible / embedded 三个 id 一次请求都没发 + let seen = transport.seen(); + assert_eq!(seen.len(), 3, "只该有 3 次版本请求:{seen:?}"); + for quiet in ["OCS3z7f6", "AAAABBBB", "embedded-lib"] { + assert!( + !seen.iter().any(|url| url.contains(quiet)), + "{quiet} 不该被跟:{seen:?}" + ); + } + + // 主文件取 primary(第一个是 sources.jar) + assert_eq!(plan.nodes[0].file.filename, "sodium-fabric-0.6.0.jar"); + assert_eq!(plan.nodes[1].file.filename, "fabric-api-0.92.2+1.20.1.jar"); +} + +#[test] +fn only_required_dependencies_are_followed() { + // 模型层:四值都解析得出来,`required_project_ids` 只吐 required 那一条。 + let versions = parse_versions(deps::SODIUM_WITH_DEPENDENCIES).expect("夹具该解析成功"); + let version = &versions[0]; + assert_eq!(version.dependencies.len(), 4); + let kinds: Vec<&str> = version + .dependencies + .iter() + .map(|dependency| dependency.dependency_type.as_str()) + .collect(); + assert_eq!(kinds, ["required", "optional", "incompatible", "embedded"]); + assert_eq!(version.required_project_ids(), ["P7dR8mSH"]); + // embedded 那条只有 file_name、没有 project_id,不该冒出一个空 id + assert!(version.dependencies[3].project_id.is_none()); +} + +#[test] +fn the_depth_limit_is_reported_instead_of_silently_cutting_the_chain() { + let transport = chain_transport(); + let mut request = request("sodium"); + request.max_depth = 1; + let plan = resolve_with(&transport, &request); + + assert_eq!(plan.nodes.len(), 2, "根 + 第一层"); + assert_eq!(plan.truncated, ["1eAoo2KR"], "截断必须写在计划里"); +} + +// ---------------------------------------------------------------- ② 环 + +#[test] +fn a_dependency_cycle_terminates_and_each_project_appears_once() { + let transport = FixtureTransport::new() + .with( + "https://api.modrinth.com/v2/project/cycle-a/version", + deps::CYCLE_A, + ) + .with( + "https://api.modrinth.com/v2/project/cycBid01/version", + deps::CYCLE_B, + ); + let plan = resolve_with(&transport, &request("cycle-a")); + + let projects: Vec<&str> = plan + .nodes + .iter() + .map(|node| node.project.as_str()) + .collect(); + assert_eq!(projects, ["cycAid01", "cycBid01"], "每个项目只出现一次"); + assert_eq!(transport.seen().len(), 2, "环不许把请求数放大"); + // B 是被 A 要来的;A 又被 B 要(环)——这条边折回已有节点,不新建节点 + assert_eq!( + plan.required_by("cycBid01").expect("B 在计划里"), + ["cycAid01".to_string()].as_slice() + ); + assert_eq!( + plan.required_by("cycAid01").expect("A 在计划里"), + ["cycBid01".to_string()].as_slice() + ); + assert!(plan.nodes[0].is_root(), "根判据是 depth,不是 required_by"); + assert!(!plan.nodes[1].is_root()); +} + +// ---------------------------------------------------------------- ③ 两档失败 + +#[test] +fn an_incompatible_dependency_lands_in_the_retry_list() { + // fabric-api 在 1.20.1/fabric 下没有条目(站点回空数组) + let transport = FixtureTransport::new() + .with(SODIUM_VERSIONS, deps::SODIUM_WITH_DEPENDENCIES) + .with(FABRIC_API_VERSIONS, "[]"); + let plan = resolve_with(&transport, &request("sodium")); + + // 根还在计划里(失败不连坐),缺的那条单独立案 + assert_eq!(plan.nodes.len(), 1); + assert_eq!(plan.failures.len(), 1, "不许静默丢:{:?}", plan.failures); + let failure = &plan.failures[0]; + assert_eq!(failure.kind, FailureKind::Incompatible); + assert_eq!(failure.requested_as, "P7dR8mSH"); + assert_eq!(failure.depth, 1); + assert_eq!(failure.required_by, ["AANobbMI".to_string()]); + assert!(failure.cause.is_none(), "这一档不是网络成因"); + assert!(failure.message.contains("1.20.1"), "文案要带过滤条件"); + assert!(plan.failures_of(FailureKind::Unreachable).next().is_none()); + + // 可重试名单只含失败项,过滤条件沿用 + let retry = plan.retry_request(); + assert_eq!(retry.roots, ["P7dR8mSH"]); + assert_eq!(retry.game_version.as_deref(), Some("1.20.1")); + assert_eq!(retry.loader.as_deref(), Some("fabric")); +} + +#[test] +fn a_missing_project_is_unreachable_and_keeps_its_leaf_cause() { + let transport = FixtureTransport::new() + .with(SODIUM_VERSIONS, deps::SODIUM_WITH_DEPENDENCIES) + .missing(FABRIC_API_VERSIONS); + let plan = resolve_with(&transport, &request("sodium")); + + let failure = &plan.failures[0]; + assert_eq!(failure.kind, FailureKind::Unreachable); + assert_eq!(failure.cause, Some(OnlineErrorKind::NotFound)); + // 两档从类型上就分得开:同一个计划里两种成因不会挤进一条 + assert_eq!(plan.failures_of(FailureKind::Incompatible).count(), 0); +} + +#[test] +fn a_version_without_a_usable_file_is_incompatible_not_a_network_error() { + let transport = FixtureTransport::new() + .with(SODIUM_VERSIONS, deps::SODIUM_WITH_DEPENDENCIES) + .with( + FABRIC_API_VERSIONS, + r#"[{"id": "ver-empty", "project_id": "P7dR8mSH", + "version_number": "0.0", "files": []}]"#, + ); + let plan = resolve_with(&transport, &request("sodium")); + assert_eq!(plan.failures[0].kind, FailureKind::Incompatible); + assert!(plan.failures[0].message.contains("没有可下载的主文件")); +} + +// ---------------------------------------------------------------- 两道去重 + +#[test] +fn one_project_required_by_two_parents_is_planned_once_with_both_parents() { + let transport = FixtureTransport::new() + .with( + "https://api.modrinth.com/v2/project/fanin-root/version", + deps::FANIN_ROOT, + ) + .with( + "https://api.modrinth.com/v2/project/BRANCHAA/version", + deps::FANIN_A, + ) + .with( + "https://api.modrinth.com/v2/project/BRANCHBB/version", + deps::FANIN_B, + ) + .with( + "https://api.modrinth.com/v2/project/SHARED01/version", + deps::SHARED_LIB, + ); + let plan = resolve_with(&transport, &request("fanin-root")); + + let projects: Vec<&str> = plan + .nodes + .iter() + .map(|node| node.project.as_str()) + .collect(); + assert_eq!( + projects, + ["ROOT0001", "BRANCHAA", "BRANCHBB", "SHARED01"], + "同层按项目键字典序,公共前置只出现一次" + ); + assert_eq!( + plan.required_by("SHARED01").expect("公共前置在计划里"), + ["BRANCHAA".to_string(), "BRANCHBB".to_string()].as_slice(), + "两条依赖路径都要留下" + ); + let shared_requests = transport + .seen() + .iter() + .filter(|url| url.contains("SHARED01")) + .count(); + assert_eq!(shared_requests, 1, "一个项目只解一次版本"); +} + +#[test] +fn a_file_that_already_exists_is_skipped_but_its_dependencies_are_still_walked() { + let mut request = request("sodium"); + // 禁用中的 jar 也算「装过」(本仓用 `.disabled` 后缀表示禁用) + request.present = vec![PresentFile::new("fabric-api-0.92.2+1.20.1.jar.disabled")]; + let transport = chain_transport(); + let plan = resolve_with(&transport, &request); + + assert_eq!(plan.skipped.len(), 1); + let skipped = &plan.skipped[0]; + assert_eq!(skipped.project, "P7dR8mSH"); + assert_eq!(skipped.reason, SkipReason::AlreadyPresent); + assert_eq!(skipped.file_name, "fabric-api-0.92.2+1.20.1.jar"); + // 跳过的是**下载**,不是图:fabric-api 的前置照样解析出来 + let projects: Vec<&str> = plan + .nodes + .iter() + .map(|node| node.project.as_str()) + .collect(); + assert_eq!(projects, ["AANobbMI", "1eAoo2KR"]); +} + +#[test] +fn present_keys_normalize_paths_case_and_the_disabled_suffix() { + assert_eq!(present_key("mods/Sodium-0.6.0.jar"), "sodium-0.6.0.jar"); + assert_eq!(present_key("a\\b\\x.jar.disabled"), "x.jar"); + assert_eq!(present_key(" y.jar "), "y.jar"); + assert_eq!(present_key(""), ""); +} + +// ---------------------------------------------------------------- 光影过滤器 + +#[test] +fn shader_loaders_are_recognised_in_search_hits() { + let transport = + FixtureTransport::new().with(&format!("{MODRINTH_API}/search"), deps::SEARCH_SHADER); + let client = Modrinth::new(&transport); + let page = block_on(client.search(&crate::addon::online::SearchQuery { + text: String::new(), + project_type: Some(crate::addon::online::ContentType::Shader), + limit: 1, + ..crate::addon::online::SearchQuery::default() + })) + .expect("解析应当成功"); + assert_eq!( + page.hits[0].loaders, + vec!["iris".to_string(), "optifine".to_string()], + "光影的加载器不该被当成分类标签丢掉" + ); +} diff --git a/crates/core/src/addon/online/tests/fixtures_deps.rs b/crates/core/src/addon/online/tests/fixtures_deps.rs new file mode 100644 index 0000000..4680eac --- /dev/null +++ b/crates/core/src/addon/online/tests/fixtures_deps.rs @@ -0,0 +1,277 @@ +//! 依赖解析用的录制响应(形状按站点原文;数值是示意,不是某天的真值)。 +//! +//! 从 `fixtures.rs` 分出来的原因很实在:那里已经在钉「字段名与 `primary`」这类解析细节, +//! 再往里塞四五个带 `dependencies` 的版本对象就会贴到 400 行硬线。 +//! +//! 三条刻意的布置: +//! - 根项目(`sodium`)的版本里**同时有四类依赖**:只有 `required` 该被跟; +//! - 每个版本都有 `primary: false` 的 `sources.jar`,取第一个就会装错; +//! - 环(`cycle-a` ⇄ `cycle-b`)与扇入(两个分支都要 `shared-lib`)各有一组独立夹具。 + +/// 根项目 `sodium` 的版本:一条 required + 一条 optional + 一条 incompatible + 一条 embedded。 +pub(super) const SODIUM_WITH_DEPENDENCIES: &str = r#"[ + { + "id": "ver-sodium", + "project_id": "AANobbMI", + "name": "Sodium 0.6.0 for 1.20.1", + "version_number": "mc1.20.1-0.6.0", + "version_type": "release", + "loaders": ["fabric"], + "game_versions": ["1.20.1"], + "date_published": "2026-02-01T12:00:00Z", + "downloads": 4321, + "files": [ + { + "hashes": {"sha1": "1111111111111111111111111111111111111111", "sha512": "aa"}, + "url": "https://cdn.modrinth.com/data/AANobbMI/versions/ver-sodium/sodium-sources.jar", + "filename": "sodium-sources.jar", + "size": 1000, + "primary": false + }, + { + "hashes": {"sha1": "2222222222222222222222222222222222222222", "sha512": "bb"}, + "url": "https://cdn.modrinth.com/data/AANobbMI/versions/ver-sodium/sodium-fabric-0.6.0.jar", + "filename": "sodium-fabric-0.6.0.jar", + "size": 2048, + "primary": true + } + ], + "dependencies": [ + {"version_id": null, "project_id": "P7dR8mSH", "file_name": null, "dependency_type": "required"}, + {"version_id": null, "project_id": "OCS3z7f6", "file_name": null, "dependency_type": "optional"}, + {"version_id": null, "project_id": "AAAABBBB", "file_name": null, "dependency_type": "incompatible"}, + {"version_id": "ver-embedded", "project_id": null, "file_name": "embedded-lib.jar", "dependency_type": "embedded"} + ] + } +]"#; + +/// `fabric-api`(`P7dR8mSH`):它自己又要求 `yacl`(`1eAoo2KR`)⇒ 第二层嵌套。 +pub(super) const FABRIC_API_WITH_DEPENDENCIES: &str = r#"[ + { + "id": "ver-fabric-api", + "project_id": "P7dR8mSH", + "name": "Fabric API 0.92.2 for 1.20.1", + "version_number": "0.92.2+1.20.1", + "version_type": "release", + "loaders": ["fabric"], + "game_versions": ["1.20.1"], + "date_published": "2025-12-01T00:00:00Z", + "downloads": 9999, + "files": [ + { + "hashes": {"sha1": "3333333333333333333333333333333333333333", "sha512": "cc"}, + "url": "https://cdn.modrinth.com/data/P7dR8mSH/versions/ver-fabric-api/fabric-api-0.92.2-sources.jar", + "filename": "fabric-api-0.92.2-sources.jar", + "size": 512, + "primary": false + }, + { + "hashes": {"sha1": "4444444444444444444444444444444444444444", "sha512": "dd"}, + "url": "https://cdn.modrinth.com/data/P7dR8mSH/versions/ver-fabric-api/fabric-api-0.92.2+1.20.1.jar", + "filename": "fabric-api-0.92.2+1.20.1.jar", + "size": 4096, + "primary": true + } + ], + "dependencies": [ + {"version_id": null, "project_id": "1eAoo2KR", "file_name": null, "dependency_type": "required"} + ] + } +]"#; + +/// `yacl`(`1eAoo2KR`):链的末端,没有依赖。 +pub(super) const YACL: &str = r#"[ + { + "id": "ver-yacl", + "project_id": "1eAoo2KR", + "name": "YetAnotherConfigLib 3.3.2", + "version_number": "3.3.2+1.20.1", + "version_type": "release", + "loaders": ["fabric"], + "game_versions": ["1.20.1"], + "date_published": "2025-11-01T00:00:00Z", + "downloads": 1234, + "files": [ + { + "hashes": {"sha1": "5555555555555555555555555555555555555555", "sha512": "ee"}, + "url": "https://cdn.modrinth.com/data/1eAoo2KR/versions/ver-yacl/yacl-3.3.2.jar", + "filename": "yacl-3.3.2+1.20.1.jar", + "size": 8192, + "primary": true + } + ], + "dependencies": [] + } +]"#; + +/// 扇入的根:两个 required 分支。 +pub(super) const FANIN_ROOT: &str = r#"[ + { + "id": "ver-root", + "project_id": "ROOT0001", + "name": "Fan-in Root 1.0", + "version_number": "1.0", + "version_type": "release", + "loaders": ["fabric"], + "game_versions": ["1.20.1"], + "files": [ + { + "hashes": {"sha1": "6666666666666666666666666666666666666666", "sha512": "ff"}, + "url": "https://cdn.modrinth.com/data/ROOT0001/versions/ver-root/fanin-root-1.0.jar", + "filename": "fanin-root-1.0.jar", + "size": 1024, + "primary": true + } + ], + "dependencies": [ + {"project_id": "BRANCHAA", "dependency_type": "required"}, + {"project_id": "BRANCHBB", "dependency_type": "required"} + ] + } +]"#; + +/// 分支 A:也要 `shared-lib`。 +pub(super) const FANIN_A: &str = r#"[ + { + "id": "ver-branch-a", + "project_id": "BRANCHAA", + "name": "Branch A 2.0", + "version_number": "2.0", + "version_type": "release", + "loaders": ["fabric"], + "game_versions": ["1.20.1"], + "files": [ + { + "hashes": {"sha1": "7777777777777777777777777777777777777777", "sha512": "ab"}, + "url": "https://cdn.modrinth.com/data/BRANCHAA/versions/ver-branch-a/branch-a-2.0.jar", + "filename": "branch-a-2.0.jar", + "size": 1024, + "primary": true + } + ], + "dependencies": [ + {"project_id": "SHARED01", "dependency_type": "required"} + ] + } +]"#; + +/// 分支 B:也要 `shared-lib`(同一项目两条路径 ⇒ 只装一次)。 +pub(super) const FANIN_B: &str = r#"[ + { + "id": "ver-branch-b", + "project_id": "BRANCHBB", + "name": "Branch B 3.0", + "version_number": "3.0", + "version_type": "release", + "loaders": ["fabric"], + "game_versions": ["1.20.1"], + "files": [ + { + "hashes": {"sha1": "8888888888888888888888888888888888888888", "sha512": "ac"}, + "url": "https://cdn.modrinth.com/data/BRANCHBB/versions/ver-branch-b/branch-b-3.0.jar", + "filename": "branch-b-3.0.jar", + "size": 1024, + "primary": true + } + ], + "dependencies": [ + {"project_id": "SHARED01", "dependency_type": "required"} + ] + } +]"#; + +/// 扇入的公共前置。 +pub(super) const SHARED_LIB: &str = r#"[ + { + "id": "ver-shared", + "project_id": "SHARED01", + "name": "Shared Lib 1.5", + "version_number": "1.5", + "version_type": "release", + "loaders": ["fabric"], + "game_versions": ["1.20.1"], + "files": [ + { + "hashes": {"sha1": "9999999999999999999999999999999999999999", "sha512": "ad"}, + "url": "https://cdn.modrinth.com/data/SHARED01/versions/ver-shared/shared-lib-1.5.jar", + "filename": "shared-lib-1.5.jar", + "size": 2048, + "primary": true + } + ], + "dependencies": [] + } +]"#; + +/// 环的一端:`cycle-a` 要求 `cycle-b`。 +pub(super) const CYCLE_A: &str = r#"[ + { + "id": "ver-cycle-a", + "project_id": "cycAid01", + "name": "Cycle A 1.0", + "version_number": "1.0", + "version_type": "release", + "loaders": ["fabric"], + "game_versions": ["1.20.1"], + "files": [ + { + "hashes": {"sha1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "sha512": "a1"}, + "url": "https://cdn.modrinth.com/data/cycAid01/versions/ver-cycle-a/cycle-a-1.0.jar", + "filename": "cycle-a-1.0.jar", + "size": 1024, + "primary": true + } + ], + "dependencies": [ + {"project_id": "cycBid01", "dependency_type": "required"} + ] + } +]"#; + +/// 环的另一端:`cycle-b` 又要求 `cycle-a`(不设防就是无限递归)。 +pub(super) const CYCLE_B: &str = r#"[ + { + "id": "ver-cycle-b", + "project_id": "cycBid01", + "name": "Cycle B 1.0", + "version_number": "1.0", + "version_type": "release", + "loaders": ["fabric"], + "game_versions": ["1.20.1"], + "files": [ + { + "hashes": {"sha1": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "sha512": "b1"}, + "url": "https://cdn.modrinth.com/data/cycBid01/versions/ver-cycle-b/cycle-b-1.0.jar", + "filename": "cycle-b-1.0.jar", + "size": 1024, + "primary": true + } + ], + "dependencies": [ + {"project_id": "cycAid01", "dependency_type": "required"} + ] + } +]"#; + +/// 光影检索的一页:`categories` 里是 `iris` / `optifine`(光影的加载器)。 +pub(super) const SEARCH_SHADER: &str = r#"{ + "hits": [ + { + "project_id": "shader01", + "project_type": "shader", + "slug": "complementary-reimagined", + "author": "EminGT", + "title": "Complementary Reimagined", + "description": "A shader pack", + "categories": ["iris", "optifine", "shaders"], + "versions": ["1.20.1"], + "downloads": 1234567, + "follows": 4242, + "icon_url": "", + "date_modified": "2026-01-02T03:04:05Z" + } + ], + "offset": 0, + "limit": 1, + "total_hits": 1 +}"#; diff --git a/crates/core/src/addon/online/tests/mod.rs b/crates/core/src/addon/online/tests/mod.rs index f2509a1..24c2c8d 100644 --- a/crates/core/src/addon/online/tests/mod.rs +++ b/crates/core/src/addon/online/tests/mod.rs @@ -8,6 +8,8 @@ mod api; mod classify; +mod dependency; mod download; mod fixtures; +mod fixtures_deps; mod support; diff --git a/crates/presenter/src/host/addon.rs b/crates/presenter/src/host/addon.rs index be2e7c4..24246ab 100644 --- a/crates/presenter/src/host/addon.rs +++ b/crates/presenter/src/host/addon.rs @@ -24,7 +24,8 @@ //! |---|---| //! | [`sources`] | 两个站点的 JSON 方言 → [`crate::dto::ContentHit`] | //! | `http` | 取文本:无 header 走引擎、带 header 走 client + 退避、百分号编码 | -//! | `install` | 一键装入:解析文件 → 下载到实例目录 → 进度回流 | +//! | [`install`] | 一键装入:解析文件(含 `required` 前置)→ 下载到实例目录 → 进度回流 | +//! | [`plan`] | 依赖计划的宿主侧:扫目录、两档失败 → DTO 大类、人读文案 | //! //! ## 整合包与 CF 的边界(都写进报告) //! @@ -34,6 +35,7 @@ mod http; mod install; +mod plan; // M113: import/export —— 整合包导入/导出(拆成子目录:父文件只留既有内容 + mod) mod pack; mod sources; diff --git a/crates/presenter/src/host/addon/install.rs b/crates/presenter/src/host/addon/install.rs index e71388d..6f0a4e9 100644 --- a/crates/presenter/src/host/addon/install.rs +++ b/crates/presenter/src/host/addon/install.rs @@ -1,19 +1,33 @@ -//! 「一键装入指定实例」(M109)。 +//! 「一键装入指定实例」(M109;M186 起把前置一起装上)。 //! //! ## 网络只走 code 的在线内容源(M162 r1) //! -//! 解析「哪个文件」用 `core::addon::online::Modrinth::versions`(同一个引擎传输层 ⇒ 信号量、 -//! 源健康度、退避都在引擎里),下载用 `online::download_verified`(sha1 + sha512 双检)。 -//! 本文件**不再**自己拼 `api.modrinth.com` 的 URL,也不再自己 `serde_json::from_str`。 +//! 解析「哪个文件」用 `core::addon::online`(同一个引擎传输层 ⇒ 信号量、源健康度、退避都在 +//! 引擎里),下载用 `online::download_verified`(sha1 + sha512 双检)。本文件**不再**自己拼 +//! `api.modrinth.com` 的 URL,也不再自己 `serde_json::from_str`。 //! //! ## 它到底做了什么(诚实版) //! //! | 内容类型 | 做什么 | 状态 | //! |---|---|---| -//! | Mod / 资源包 / 光影 | 解析「该项目下匹配目标实例(版本 + 加载器)的那个文件」→ core 下载引擎下载到实例运行目录的 `mods/` `resourcepacks/` `shaderpacks/` → 进度经 `InstallProgressView` 回流(**复用下载页那块面板**) | 已接 | +//! | Mod / 资源包 / 光影 | 解析「该项目 + 它的 `required` 前置」在目标实例(版本 + 加载器)下的文件 → core 下载引擎下到 `mods/` `resourcepacks/` `shaderpacks/` → 进度经 `InstallProgressView` 回流 | 已接(M186 起含前置) | //! | 整合包 | 一律 `Failed` + 一句原因 | **未做**:core 没有「远程整合包装进实例」的管线(M111) | //! | CurseForge 文件解析 | 一律 `Failed` + 一句原因 | **未做**:需要带 key 的文件端点,本轮只做了 CF 的**检索** | //! +//! ## 前置的两档失败与重试名单(M186) +//! +//! 解析在 [`super::plan`] + `core::addon::online::resolve`:某个前置「不兼容所选版本/加载器」 +//! 与「取不到(网络 / 项目不存在)」是**两条**记录,本文件把两档分开写进失败文案,并把失败项 +//! 列成「可只重试这 N 个」。**这不是自动重试**:重试要用户(或将来 UI 的按钮)再发一次, +//! `DependencyPlan::retry_request()` 已经把请求备好了。 +//! +//! ## 光影与数据包:支持到哪一步(M186 核对过,别写成「都支持」) +//! +//! | 内容 | 检索 | 装入 | 差在哪 | +//! |---|---|---|---| +//! | 光影 | 支持(`project_type:shader`);命中项的加载器标签现在认得 `iris` / `optifine`(`KNOWN_LOADERS`) | 支持(落 `shaderpacks/`) | 加载器**过滤**仍筛不了:`LoaderKind::OptiFine` 没有 facet(`loader_facet` 返回 `None`),所以「只看 Iris 光影」只能靠标签目测 | +//! | 数据包 | core 的 `ContentType::Datapack` 有 `datapack` facet | **不支持** | DTO 的 `ContentKind` 没有这一项;数据包还要落到「某个世界的 `datapacks/`」,现在没有选择世界这一步 | +//! //! ## 为什么进度只有阶段级,没有字节条 //! //! 字节级进度要走 `ProgressEmitter`(core 的 tokio 广播),而 presenter 的依赖表里没有 @@ -26,7 +40,7 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use unlucky_core::addon::online::{ - download_verified, EngineArtifactFetcher, EngineTransport, Modrinth, VersionQuery, + download_verified, resolve, EngineArtifactFetcher, EngineTransport, Modrinth, PlannedNode, }; use unlucky_core::download::blocking::block_on; use unlucky_core::game::install::instance::read_instance_config; @@ -39,7 +53,7 @@ use crate::dto::{ use super::super::env::HostEnv; use super::super::job::{HostTask, JobSender}; -use super::sources::failure_of; +use super::{plan, sources::failure_of}; /// 跑一次装入(同步;由 [`super::spawn_install`] 放进后台线程)。 pub(super) fn install( @@ -112,7 +126,10 @@ fn fail(tx: &JobSender, mut progress: InstallProgressView, failure: ContentFailu }); } -/// 真正干活的那一段,返回一句人读的落盘描述(失败带**大类**,离线不等同于「查无此项」)。 +/// 真正干活的那一段:解析(含前置)→ 逐个下载 → 汇总。 +/// +/// 返回「装了哪些 / 跳过了哪些」的人读描述;解析阶段的两档失败由 +/// [`plan::failure_of_plan`] 变成一条带大类的失败(**前置缺了不算装成**)。 fn run( env: &HostEnv, request: &ContentInstallRequest, @@ -130,33 +147,90 @@ fn run( let target_dir = game_dir.join(dir); std::fs::create_dir_all(&target_dir) .map_err(|error| local(format!("建目录 {} 失败:{error}", target_dir.display())))?; - - let file = resolve_file(env, request)?; - if cancel.load(Ordering::SeqCst) { - return Err(local("已取消(下载开始前)")); + if request.source != ContentSource::Modrinth { + return Err(ContentFailure::unsupported( + "CurseForge 的文件解析未接通(要带 key 的文件端点;见报告「没验的部分」)", + )); } + + // 解析:根项目 + 只跟 `required` 的前置(顺序与父子关系由 core 给,这里不重排)。 + let resolve_request = plan::request( + &request.project, + &request.game_version, + loader_facet(request.loader), + plan::present_files(&target_dir), + ); + let core_engine = super::super::engine::engine(env); + let dependency_plan = { + let transport = EngineTransport::new(&core_engine); + let client = Modrinth::new(&transport); + block_on(resolve(&client, &resolve_request)) + }; + progress.stage = InstallStage::Downloading; - progress.message = file.name.clone(); + 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 dest = target_dir.join(&file.name); - // M162:走 `online::download_verified`——sha1 交给引擎流式校验、sha512 落盘后复核, - // 失败重试且不留半截文件(内容库安装与整合包导入用**同一段**校验,不各写一份)。 - let core_engine = super::super::engine::engine(env); let fetcher = EngineArtifactFetcher::new(&core_engine); - block_on(download_verified( - &fetcher, - &file.url, - file.sha1.as_deref(), - file.sha512.as_deref(), - file.size, - &dest, - )) - .map_err(failure_of)?; - Ok(format!("{}({})", file.name, dest.display())) + for node in &dependency_plan.nodes { + if cancel.load(Ordering::SeqCst) { + return Err(local("已取消(下载中途)")); + } + progress.message = node_label(node); + let _ = tx.send(HostTask::Install { + progress: Box::new(progress.clone()), + epoch, + }); + // M162:走 `online::download_verified`——sha1 交给引擎流式校验、sha512 落盘后复核, + // 失败重试且不留半截文件(内容库安装与整合包导入用**同一段**校验,不各写一份)。 + let dest = target_dir.join(safe_file_name(&node.file.filename)); + 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)?; + progress.files_done += 1; + let _ = tx.send(HostTask::Install { + progress: Box::new(progress.clone()), + epoch, + }); + } + + // 解析阶段的失败在这里收口:两档分成两句话(不合并成「下载失败」),并列出可只重试的项目。 + if let Some(failure) = plan::failure_of_plan(&dependency_plan) { + return Err(failure); + } + Ok(plan::summary(&dependency_plan)) +} + +/// 进度面板上的那一行文件名(前置标出来,用户才知道多出来的文件是什么)。 +fn node_label(node: &PlannedNode) -> String { + if node.is_root() { + node.file.filename.clone() + } else { + format!("{}(前置)", node.file.filename) + } } /// 本地失败(找实例 / 建目录 / 取消)——不是网络问题,归到 `Other`。 @@ -187,69 +261,6 @@ fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result, - sha512: Option, - size: Option, -} - -/// 解析「这个项目下最适合目标实例的那个文件」。 -fn resolve_file( - env: &HostEnv, - request: &ContentInstallRequest, -) -> Result { - match request.source { - ContentSource::Modrinth => modrinth_file(env, request), - ContentSource::Curseforge => Err(ContentFailure::unsupported( - "CurseForge 的文件解析未接通(要带 key 的文件端点;见报告「没验的部分」)", - )), - } -} - -/// 版本列表 → 目标文件:走 core 的在线内容源(**本文件不再拼 URL、不再自己解析 JSON**)。 -/// -/// 选 `primary: true` 的那个(Modrinth 官方口径):直接取第一个会拿到 `sources.jar` 这类坑, -/// `Version::primary_file()` 就是为这件事存在的。 -fn modrinth_file( - env: &HostEnv, - request: &ContentInstallRequest, -) -> Result { - let core_engine = super::super::engine::engine(env); - let transport = EngineTransport::new(&core_engine); - let client = Modrinth::new(&transport); - let versions = block_on(client.versions(&VersionQuery { - project: request.project.clone(), - game_version: Some(request.game_version.clone()), - loader: loader_facet(request.loader).map(str::to_string), - })) - .map_err(failure_of)?; - let version = versions.first().ok_or_else(|| { - ContentFailure::new( - ContentFailureKind::NotFound, - "这个项目没有匹配目标实例(版本 / 加载器)的文件", - ) - })?; - let file = version - .primary_file() - .ok_or_else(|| ContentFailure::new(ContentFailureKind::NotFound, "版本里没有可用文件"))?; - if file.url.trim().is_empty() { - return Err(ContentFailure::new( - ContentFailureKind::Unsupported, - "版本里的主文件没有下载地址", - )); - } - Ok(RemoteFile { - url: file.url.clone(), - name: safe_file_name(&file.filename), - sha1: file.sha1().map(ToString::to_string), - sha512: file.sha512().map(ToString::to_string), - size: (file.size > 0).then_some(file.size), - }) -} - /// 只留文件名本身:站点给的名字可能带路径分隔符,落盘前必须砍掉 /// (与 `core::addon::pack::path` 的穿越双检同一条纪律)。 pub(super) fn safe_file_name(raw: &str) -> String { diff --git a/crates/presenter/src/host/addon/plan.rs b/crates/presenter/src/host/addon/plan.rs new file mode 100644 index 0000000..296e7fc --- /dev/null +++ b/crates/presenter/src/host/addon/plan.rs @@ -0,0 +1,218 @@ +//! 依赖计划的**宿主侧那一半**:扫目录、把两档失败映成 DTO 大类、拼给用户看的那句话。 +//! +//! # 为什么判定在 core、说话在 presenter +//! +//! 「哪个前置不兼容、哪个前置取不到」由 `unlucky_core::addon::online::resolve` 判定并可离线 +//! 测试(录制 JSON 夹具)。本文件只做三件宿主才知道的事: +//! +//! | 事 | 为什么只能在这里 | +//! |---|---| +//! | 目标目录里有什么 | core 不读盘(`PresentFile` 由调用方扫) | +//! | 失败大类怎么映 | DTO 在 presenter(`ContentFailureKind`) | +//! | 文案怎么说 | 界面文案不归 core | +//! +//! # 两档失败**分开说** +//! +//! 「不兼容所选版本/加载器」与「取不到(网络 / 项目不存在)」是两句话、两种用户动作, +//! 所以这里既不合并计数也不合并文案;DTO 大类按「用户接下来该做什么」选:离线优先说 +//! 检查网络,其次查无此项,只有**全是**不兼容时才说「这条路走不通」(换版本/加载器才有救)。 + +use std::path::Path; + +use unlucky_core::addon::online::{ + DependencyFailure, DependencyPlan, FailureKind, OnlineError, PresentFile, ResolveRequest, +}; + +use crate::dto::ContentFailureKind; + +use super::sources::failure_of; + +/// 扫目标目录(不递归)里已存在的文件名。 +/// +/// 只给名字,不算摘要:为了「跳过」把整个 `mods/` 哈希一遍不划算,同名就是同一份 jar 的 +/// 最常见形态(我们落盘用站点给的文件名,不重命名)。 +pub(super) fn present_files(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + entries + .flatten() + .filter(|entry| entry.path().is_file()) + .map(|entry| PresentFile::new(entry.file_name().to_string_lossy())) + .collect() +} + +/// 一次装入要用的解析请求(过滤条件与落盘目录一致)。 +pub(super) fn request( + project: &str, + game_version: &str, + loader: Option<&str>, + present: Vec, +) -> ResolveRequest { + ResolveRequest { + roots: vec![project.to_string()], + game_version: Some(game_version.to_string()), + loader: loader.map(str::to_string), + present, + ..ResolveRequest::default() + } +} + +/// 计划里有失败吗 ⇒ 一条带大类的失败(`None` = 干净)。 +pub(super) fn failure_of_plan(plan: &DependencyPlan) -> Option { + if plan.is_clean() { + return None; + } + Some(crate::dto::ContentFailure::new( + overall_kind(plan), + report(plan), + )) +} + +/// 整体大类:按「用户接下来该做什么」排序,离线 > 查无此项 > 站点错 > 其它 > 不兼容。 +fn overall_kind(plan: &DependencyPlan) -> ContentFailureKind { + let kinds: Vec = plan.failures.iter().map(kind_of).collect(); + for wanted in [ + ContentFailureKind::Offline, + ContentFailureKind::NotFound, + ContentFailureKind::Server, + ContentFailureKind::Other, + ContentFailureKind::Unsupported, + ] { + if kinds.contains(&wanted) { + return wanted; + } + } + ContentFailureKind::Unsupported +} + +/// 单条失败的 DTO 大类(**复用** `sources::failure_of` 的映射表,不另写一份判据)。 +fn kind_of(failure: &DependencyFailure) -> ContentFailureKind { + match failure.cause { + Some(cause) => failure_of(OnlineError::new(cause, failure.message.clone())).kind, + // 不兼容那一档没有网络成因:它要用户换版本/加载器,不是「再试一次」 + None => ContentFailureKind::Unsupported, + } +} + +/// 失败清单(两档各起一段,各自的条数分开数)。 +fn report(plan: &DependencyPlan) -> String { + let mut lines: Vec = Vec::new(); + let incompatible: Vec = plan + .failures_of(FailureKind::Incompatible) + .map(describe) + .collect(); + let unreachable: Vec = plan + .failures_of(FailureKind::Unreachable) + .map(describe) + .collect(); + if !incompatible.is_empty() { + lines.push(format!( + "不兼容所选版本/加载器 {} 个:{}", + incompatible.len(), + incompatible.join(";") + )); + } + if !unreachable.is_empty() { + lines.push(format!( + "取不到(网络 / 项目不存在){} 个:{}", + unreachable.len(), + unreachable.join(";") + )); + } + if !plan.nodes.is_empty() { + lines.push(format!("已装入 {} 个文件", plan.nodes.len())); + } + if !plan.skipped.is_empty() { + lines.push(format!("跳过 {} 个已存在的文件", plan.skipped.len())); + } + if !plan.truncated.is_empty() { + lines.push(format!( + "依赖层数超过上限,未展开:{}", + plan.truncated.join("、") + )); + } + // 可重试名单:只列失败项(重试时也只重解这些,不重下已装好的) + let retry: Vec<&str> = plan + .failures + .iter() + .map(|failure| failure.requested_as.as_str()) + .collect(); + if !retry.is_empty() { + lines.push(format!( + "可只重试这 {} 个:{}", + retry.len(), + retry.join("、") + )); + } + lines.join("\n") +} + +fn describe(failure: &DependencyFailure) -> String { + if failure.required_by.is_empty() { + format!("{}({})", failure.requested_as, failure.message) + } else { + format!( + "{}({};被 {} 要求)", + failure.requested_as, + failure.message, + failure.required_by.join("、") + ) + } +} + +/// 全部装成之后的落盘描述(含跳过与前置的条数、「为什么装」的父项目)。 +pub(super) fn summary(plan: &DependencyPlan) -> String { + if plan.nodes.is_empty() && plan.skipped.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(); + // 「为什么装」:前置标出要求它的项目(根是用户自己点的,不标) + let names: Vec = plan + .nodes + .iter() + .map(|node| { + if node.is_root() { + node.file.filename.clone() + } else { + format!( + "{}(前置,被 {} 要求)", + node.file.filename, + node.required_by.join("、") + ) + } + }) + .collect(); + let head = if dependencies == 0 { + format!("装了 {} 个文件", plan.nodes.len()) + } else { + format!( + "装了 {} 个文件(含 {} 个前置)", + plan.nodes.len(), + dependencies + ) + }; + parts.push(format!("{head}:{}", names.join("、"))); + } + if !plan.skipped.is_empty() { + let names: Vec<&str> = plan + .skipped + .iter() + .map(|node| node.file_name.as_str()) + .collect(); + parts.push(format!( + "跳过 {} 个(目录里已存在,没有重复下载):{}", + plan.skipped.len(), + names.join("、") + )); + } + if !plan.truncated.is_empty() { + parts.push(format!( + "依赖层数超过上限,未展开:{}", + plan.truncated.join("、") + )); + } + parts.join("\n") +} diff --git a/crates/presenter/src/host/addon/tests.rs b/crates/presenter/src/host/addon/tests.rs index 3c29d1e..1fe9bb9 100644 --- a/crates/presenter/src/host/addon/tests.rs +++ b/crates/presenter/src/host/addon/tests.rs @@ -115,3 +115,190 @@ fn core_errors_map_to_dto_failure_kinds() { "connect timed out" ); } + +// ------------------------------------------------ M186:前置解析的两档失败与可重试名单 + +use super::plan::{failure_of_plan, summary}; +use unlucky_core::addon::online::{ + DependencyFailure, DependencyPlan, FailureKind, PlannedNode, ResolveRequest, SkipReason, + SkippedNode, VersionFile, +}; + +fn node(project: &str, file: &str, depth: u8, required_by: &[&str]) -> PlannedNode { + PlannedNode { + project: project.to_string(), + requested_as: project.to_string(), + version_id: format!("ver-{project}"), + version_name: project.to_string(), + version_number: "1.0".to_string(), + file: VersionFile { + url: format!("https://cdn.modrinth.com/data/{project}/1.0/{file}"), + filename: file.to_string(), + size: 1024, + ..VersionFile::default() + }, + depth, + required_by: required_by + .iter() + .map(|parent| (*parent).to_string()) + .collect(), + } +} + +fn failure( + project: &str, + kind: FailureKind, + cause: Option, + message: &str, +) -> DependencyFailure { + DependencyFailure { + requested_as: project.to_string(), + required_by: vec!["AANobbMI".to_string()], + depth: 1, + kind, + cause, + message: message.to_string(), + } +} + +fn plan( + failures: Vec, + nodes: Vec, + skipped: Vec, +) -> DependencyPlan { + DependencyPlan { + request: ResolveRequest::single("sodium"), + nodes, + skipped, + failures, + truncated: Vec::new(), + } +} + +/// 全是「不兼容」时:说换版本/加载器,而不是「下载失败」,并且照样给重试名单。 +#[test] +fn an_incompatible_only_plan_is_not_reported_as_a_download_failure() { + let plan = plan( + vec![failure( + "P7dR8mSH", + FailureKind::Incompatible, + None, + "没有匹配所选游戏版本/加载器(1.20.1 / fabric)的版本", + )], + vec![node("AANobbMI", "sodium.jar", 0, &[])], + Vec::new(), + ); + let failure = failure_of_plan(&plan).expect("有失败就该给一条"); + assert_eq!(failure.kind, ContentFailureKind::Unsupported); + assert!(failure.detail.contains("不兼容所选版本/加载器 1 个")); + assert!(failure.detail.contains("可只重试这 1 个:P7dR8mSH")); + assert!(!failure.detail.contains("下载失败"), "{}", failure.detail); + assert!( + !failure.detail.contains("取不到"), + "两档不许混:{}", + failure.detail + ); +} + +/// 两档同时出现时:两段都写,大类按「用户接下来该做什么」选(查无此项 > 不兼容)。 +#[test] +fn a_mixed_plan_keeps_both_buckets_and_picks_the_actionable_kind() { + let plan = plan( + vec![ + failure( + "P7dR8mSH", + FailureKind::Incompatible, + None, + "没有匹配的版本", + ), + failure( + "1eAoo2KR", + FailureKind::Unreachable, + Some(OnlineErrorKind::NotFound), + "取版本失败(online_not_found):HTTP 404", + ), + ], + Vec::new(), + Vec::new(), + ); + let failure = failure_of_plan(&plan).expect("有失败就该给一条"); + assert_eq!(failure.kind, ContentFailureKind::NotFound); + assert!(failure.detail.contains("不兼容所选版本/加载器 1 个")); + assert!(failure.detail.contains("取不到(网络 / 项目不存在)1 个")); + assert!(failure.detail.contains("可只重试这 2 个")); +} + +/// 没网那一档:大类是离线(页面据此画「检查网络」而不是「换个关键词」)。 +#[test] +fn an_offline_jump_wins_over_everything_else() { + let plan = plan( + vec![ + failure( + "P7dR8mSH", + FailureKind::Incompatible, + None, + "没有匹配的版本", + ), + failure( + "1eAoo2KR", + FailureKind::Unreachable, + Some(OnlineErrorKind::NotConnected), + "连不上 api.modrinth.com", + ), + ], + Vec::new(), + Vec::new(), + ); + let failure = failure_of_plan(&plan).expect("有失败就该给一条"); + assert_eq!(failure.kind, ContentFailureKind::Offline); + assert!(failure.is_offline()); +} + +/// 全装成的那句话要点出「含 N 个前置」与「跳过 M 个已存在」。 +#[test] +fn the_success_message_counts_dependencies_and_skipped_files() { + let plan = plan( + Vec::new(), + vec![ + node("AANobbMI", "sodium.jar", 0, &[]), + node("P7dR8mSH", "fabric-api.jar", 1, &["AANobbMI"]), + ], + vec![SkippedNode { + project: "1eAoo2KR".to_string(), + requested_as: "1eAoo2KR".to_string(), + file_name: "yacl.jar".to_string(), + depth: 2, + required_by: vec!["P7dR8mSH".to_string()], + reason: SkipReason::AlreadyPresent, + }], + ); + assert!(failure_of_plan(&plan).is_none()); + let message = summary(&plan); + assert!( + message.contains("装了 2 个文件(含 1 个前置)"), + "{message}" + ); + // 「为什么装」:前置那句要带出要求它的项目 + assert!( + message.contains("fabric-api.jar(前置,被 AANobbMI 要求)"), + "{message}" + ); + assert!(message.contains("跳过 1 个(目录里已存在,没有重复下载):yacl.jar")); +} + +/// 扫目录只认文件、不认子目录(子目录不是 jar,混进来会让去重误判)。 +#[test] +fn present_files_lists_only_files_in_the_target_directory() { + let dir = std::env::temp_dir().join(format!("ull-m186-present-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("nested")).expect("建临时目录"); + std::fs::write(dir.join("sodium.jar"), b"x").expect("写文件"); + std::fs::write(dir.join("yacl.jar.disabled"), b"x").expect("写文件"); + let mut names: Vec = super::plan::present_files(&dir) + .into_iter() + .map(|file| file.name) + .collect(); + names.sort(); + assert_eq!(names, ["sodium.jar", "yacl.jar.disabled"]); + let _ = std::fs::remove_dir_all(&dir); +} From aa33210bb6a5da3ac6d45cad250949206b673b35 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 13:58:16 +0800 Subject: [PATCH 2/3] =?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}" + ); +} From ff2f229b802175b65d1bcc30a26608d95e70bdff Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 14:26:50 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(addon):=20=E5=A4=8D=E5=AE=A1=20r2=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E4=B8=8B=E8=BD=BD=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=E6=8A=BD=E5=87=BA=E5=8F=AF=E6=B5=8B=E8=BE=B9=E7=95=8C=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E5=AE=88=E9=97=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 阻塞项:下载循环从 `install::run` 抽到新文件 `addon/downloads.rs` (`download_failure()` + `download_stage()`),假下载器就能驱动它;三条用例分别钉 ①失败→Unreachable+叶子成因、②第 2 个失败不中断且已装不回滚、③解析完就取消一份都不下。 实测删掉 `record_failure` 那行会红(failures 0 vs 1),把 `cause` 丢掉也会红; - nit① 补 core 用例:夹具里 `{"version_id":"vid-only","dependency_type":"required"}` ⇒ `plan.unresolved_required == [UnresolvedRequired{parent:"MIXED001",…}]`,同版本的 `project_id` required 仍被跟;删掉收集那段即红(left: [] vs 一条); - nit② 取消检查加回解析之后(`download_stage` 入口),并用例钉「解析后取消 ⇒ 不下任何文件」; - nit③ `fail()` 文档写明去重按**请求键**而非规范 id 的边界(先 slug 后 project_id 仍会两条), 不消掉但不再让人误以为全覆盖; - `install.rs` 只留解析 + 收口(275 行),模块表登记 `downloads`。 --- crates/core/src/addon/online/resolve.rs | 8 + .../src/addon/online/tests/regressions.rs | 45 ++- crates/presenter/src/host/addon.rs | 2 + crates/presenter/src/host/addon/downloads.rs | 315 ++++++++++++++++++ crates/presenter/src/host/addon/install.rs | 69 +--- 5 files changed, 383 insertions(+), 56 deletions(-) create mode 100644 crates/presenter/src/host/addon/downloads.rs diff --git a/crates/core/src/addon/online/resolve.rs b/crates/core/src/addon/online/resolve.rs index 30a0e9d..16966c7 100644 --- a/crates/core/src/addon/online/resolve.rs +++ b/crates/core/src/addon/online/resolve.rs @@ -196,6 +196,14 @@ pub async fn resolve( /// 标键这一步是**失败侧的去重网**:成功路径靠 `alias` 短路,失败路径以前直接 `continue`, /// 于是同一个项目被两条路径要求时会各取一次版本、各记一条失败,重试名单里出现两次。 /// 标记之后,后续同键请求在①处短路,走现成的 [`fold_parents`] 把父边折进那条失败。 +/// +/// # 这道去重按**请求键**,不是规范 id(已知边界,别当成全覆盖) +/// +/// 规范 id(`project_id`)要拿到响应才知道;失败时没有响应,能用的只有请求键。所以同一个 +/// 失败项目如果**先以 slug、后以 project_id** 被两个不同的父项目要求,仍会被取两次、名单里 +/// 出现两个名字。真实载荷里 `dependencies[].project_id` 与用户点的 slug 混用的情况没有证据, +/// 且这一档本来就是「取不到」,重复一条的代价是名单多一行、不是多装一个东西——故本轮不消掉, +/// 只把边界写在这里(复审 r2 nit③)。 fn fail( plan: &mut DependencyPlan, alias: &mut BTreeMap, diff --git a/crates/core/src/addon/online/tests/regressions.rs b/crates/core/src/addon/online/tests/regressions.rs index 00b5419..605c2fc 100644 --- a/crates/core/src/addon/online/tests/regressions.rs +++ b/crates/core/src/addon/online/tests/regressions.rs @@ -11,7 +11,7 @@ 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::addon::online::{resolve, DependencyType, Modrinth, PresentFile, UnresolvedRequired}; use crate::download::blocking::block_on; use super::fixtures_deps as deps; @@ -187,3 +187,46 @@ fn required_project_ids_are_sorted_and_deduplicated_on_their_own() { "既要去重(非相邻)也要排序" ); } + +/// 只有 `version_id` 的必备前置:**core 自己**要把它们收进 `unresolved_required`(r2 nit①)。 +/// +/// presenter 那条用例是直接赋值 `plan.unresolved_required`,绕过了 core;这里从夹具走一遍解析, +/// 顺带钉住「别为了收集把正常的 required 也吞掉」——同一版本里带 `project_id` 的那条仍要被跟。 +#[test] +fn a_required_dependency_with_only_a_version_id_is_collected_by_the_resolver() { + let body = r#"[{"id": "ver-mixed", "project_id": "MIXED001", "version_number": "1.0", + "files": [{"url": "https://cdn.modrinth.com/data/MIXED001/1.0/mixed.jar", + "filename": "mixed.jar", "size": 16, "primary": true}], + "dependencies": [ + {"version_id": "vid-only", "dependency_type": "required"}, + {"project_id": "FOLLOW01", "dependency_type": "required"} + ]}]"#; + let transport = FixtureTransport::new() + .with(&format!("{API}/mixed/version"), body) + .with( + &format!("{API}/FOLLOW01/version"), + &version_json("FOLLOW01", "follow.jar", &[]), + ); + + let plan = resolve_with(&transport, &request("mixed")); + + assert_eq!( + plan.unresolved_required, + [UnresolvedRequired { + parent: "MIXED001".to_string(), + version_id: Some("vid-only".to_string()), + file_name: None, + }], + "只给版本 id 的 required 必须被记下来" + ); + let projects: Vec<&str> = plan + .nodes + .iter() + .map(|node| node.project.as_str()) + .collect(); + assert_eq!( + projects, + ["MIXED001", "FOLLOW01"], + "同一版本里带 project_id 的 required 仍要被跟" + ); +} diff --git a/crates/presenter/src/host/addon.rs b/crates/presenter/src/host/addon.rs index 24246ab..92e6073 100644 --- a/crates/presenter/src/host/addon.rs +++ b/crates/presenter/src/host/addon.rs @@ -25,6 +25,7 @@ //! | [`sources`] | 两个站点的 JSON 方言 → [`crate::dto::ContentHit`] | //! | `http` | 取文本:无 header 走引擎、带 header 走 client + 退避、百分号编码 | //! | [`install`] | 一键装入:解析文件(含 `required` 前置)→ 下载到实例目录 → 进度回流 | +//! | [`downloads`] | 下载阶段(可测):逐个下、失败进名单不中断、两处取消点 | //! | [`plan`] | 依赖计划的宿主侧:扫目录、两档失败 → DTO 大类、人读文案 | //! //! ## 整合包与 CF 的边界(都写进报告) @@ -33,6 +34,7 @@ //! host 侧再挡一次(就算绕过 UI 发指令也只回一条 `Failed`)。真流程是 M111 的导入管线。 //! - CurseForge 本轮只做**检索**(需要用户配 `ULL_CF_API_KEY`),文件解析未接通。 +mod downloads; mod http; mod install; mod plan; diff --git a/crates/presenter/src/host/addon/downloads.rs b/crates/presenter/src/host/addon/downloads.rs new file mode 100644 index 0000000..3d91061 --- /dev/null +++ b/crates/presenter/src/host/addon/downloads.rs @@ -0,0 +1,315 @@ +//! 下载阶段的**可测那一段**:把计划里的文件逐个下下来,失败进名单而不中断。 +//! +//! # 为什么单独一个文件 +//! +//! 这段逻辑以前整块躺在 `install::run` 里,而 `run` 需要实例目录 + 真引擎 ⇒ 没有用例能驱动它, +//! 于是「下载失败记进两档名单」这条改动**整段删掉也不会红一条**(M186 r2 复审实测: +//! 整段包进 `if false` 后 `cargo test -p unlucky-presenter --lib host::addon` 仍 36 passed)。 +//! 抽成只依赖 [`ArtifactFetcher`] 的函数之后,假下载器就能驱动它。 +//! +//! # 三条不变量(每条都有用例) +//! +//! 1. **失败不中断**:某个文件下载失败 ⇒ 记进 [`DependencyPlan::record_failure`],继续下一个; +//! 2. **已装的不回滚**:成功的文件留在盘上,并出现在返回的「已装」名单里; +//! 3. **两处取消点**:进入本阶段先查一次(`run` 解析完就调它 ⇒ 解析阶段的取消生效, +//! 全部文件都「已存在」而 `nodes` 为空时也不会漏掉取消),循环每次迭代再查一次。 + +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; + +use unlucky_core::addon::online::{ + download_verified, ArtifactFetcher, DependencyFailure, DependencyPlan, FailureKind, + OnlineError, PlannedNode, +}; +use unlucky_core::download::blocking::block_on; + +use crate::dto::{ContentFailure, InstallProgressView}; + +use super::super::job::{HostTask, JobSender}; +use super::install::{local, node_label, safe_file_name}; + +/// 下载失败 → 两档失败里的一条(`Unreachable` + 叶子成因 `cause`)。 +/// +/// 为什么归 `Unreachable`:`download_verified` 的失败就是「取不到这份字节」——没网 / 站点错 / +/// 内容与声明不符 / 本地 IO,叶子成因留在 `cause` 上,上层照 `sources::failure_of` 映射成 +/// DTO 大类;**不是** `Incompatible`(换版本/加载器也救不了一次断网)。 +#[must_use] +pub(super) fn download_failure(node: &PlannedNode, error: &OnlineError) -> DependencyFailure { + 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() + ), + } +} + +/// 下载阶段:先查一次取消,再逐个下(失败进名单、不中断)。 +/// +/// 返回**真落盘**的节点(失败的不在里面),调用方据此拼「装了 N 个」。 +/// +/// # Errors +/// 用户取消时返回一条 `ContentFailure`(detail 以「已取消」开头,`install()` 据此画 `Cancelled`)。 +pub(super) fn download_stage( + plan: &mut DependencyPlan, + target_dir: &Path, + fetcher: &dyn ArtifactFetcher, + cancel: &AtomicBool, + progress: &mut InstallProgressView, + tx: &JobSender, + epoch: u64, +) -> Result, ContentFailure> { + // 解析完立刻查一次:解析最多要跑 6 层版本请求,此时取消不该先下一份文件再说 + // (main 的 `resolve_file` 之后就有这条检查,r2 复审指出我们把它丢了)。 + if cancel.load(Ordering::SeqCst) { + return Err(local("已取消(下载开始前)")); + } + let mut installed: Vec = Vec::new(); + for index in 0..plan.nodes.len() { + if cancel.load(Ordering::SeqCst) { + return Err(local("已取消(下载中途)")); + } + let node = plan.nodes[index].clone(); + progress.message = node_label(&node); + let _ = tx.send(HostTask::Install { + progress: Box::new(progress.clone()), + epoch, + }); + // M162:走 `online::download_verified`——sha1 交给引擎流式校验、sha512 落盘后复核, + // 失败重试且不留半截文件(内容库安装与整合包导入用**同一段**校验,不各写一份)。 + let dest = target_dir.join(safe_file_name(&node.file.filename)); + 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, + )); + match outcome { + Ok(_) => installed.push(node), + Err(error) => { + // 下载阶段的失败**也记进同一套两档结构**(Unreachable + 叶子成因): + // 已下好的不回滚、失败项进「可只重试」,整份计划的上下文(缺的是谁的前置)不丢。 + plan.record_failure(download_failure(&node, &error)); + continue; + } + } + progress.files_done += 1; + let _ = tx.send(HostTask::Install { + progress: Box::new(progress.clone()), + epoch, + }); + } + Ok(installed) +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::AtomicBool; + use std::sync::Mutex; + + use unlucky_core::addon::online::{ + ArtifactFetcher, DependencyPlan, FailureKind, FetchArgs, OnlineError, OnlineErrorKind, + OnlineFuture, PlannedNode, ResolveRequest, VersionFile, + }; + + use crate::dto::InstallProgressView; + + use super::{download_failure, download_stage}; + + /// 假下载器:`fail_on` 里的文件名一律「没联网」,其余写一行字节(**不校验摘要**)。 + struct FakeFetcher { + fail_on: Vec<&'static str>, + attempts: Mutex>, + } + + impl FakeFetcher { + fn new(fail_on: &[&'static str]) -> Self { + Self { + fail_on: fail_on.to_vec(), + attempts: Mutex::new(Vec::new()), + } + } + + fn attempts(&self) -> Vec { + self.attempts.lock().expect("锁没中毒").clone() + } + } + + impl ArtifactFetcher for FakeFetcher { + fn fetch<'a>(&'a self, args: FetchArgs<'a>) -> OnlineFuture<'a, Result> { + Box::pin(async move { + let name = args + .dest + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default(); + self.attempts.lock().expect("锁没中毒").push(name.clone()); + if self.fail_on.contains(&name.as_str()) { + return Err(OnlineError::offline("假装的断网")); + } + std::fs::write(args.dest, b"jar").map_err(|error| { + OnlineError::new(OnlineErrorKind::Io, format!("写盘失败:{error}")) + })?; + Ok(4) + }) + } + } + + fn node(project: &str, file: &str, required_by: &[&str]) -> PlannedNode { + PlannedNode { + project: project.to_string(), + requested_as: project.to_string(), + version_id: format!("ver-{project}"), + version_name: project.to_string(), + version_number: "1.0".to_string(), + file: VersionFile { + url: format!("https://cdn.modrinth.com/data/{project}/1.0/{file}"), + filename: file.to_string(), + size: 0, + ..VersionFile::default() + }, + depth: u8::from(!required_by.is_empty()), + required_by: required_by + .iter() + .map(|parent| (*parent).to_string()) + .collect(), + } + } + + fn plan(nodes: Vec) -> DependencyPlan { + DependencyPlan { + request: ResolveRequest::single("AANobbMI"), + nodes, + skipped: Vec::new(), + failures: Vec::new(), + truncated: Vec::new(), + unresolved_required: Vec::new(), + } + } + + fn temp_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("ull-m186-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("建临时目录"); + dir + } + + /// 失败的那一条**进名单**、父边与叶子成因都带着(守的是 [`download_failure`] 的映射)。 + #[test] + fn a_download_error_becomes_an_unreachable_failure_with_its_leaf_cause() { + let node = node("P7dR8mSH", "fabric-api.jar", &["AANobbMI"]); + let failure = download_failure(&node, &OnlineError::offline("连不上 cdn.modrinth.com")); + + assert_eq!(failure.kind, FailureKind::Unreachable); + assert_eq!(failure.cause, Some(OnlineErrorKind::NotConnected)); + assert_eq!(failure.requested_as, "P7dR8mSH"); + assert_eq!(failure.required_by, ["AANobbMI".to_string()]); + assert_eq!(failure.depth, 1); + assert!( + failure.message.contains("fabric-api.jar"), + "{}", + failure.message + ); + assert!( + failure.message.contains("online_not_connected"), + "成因要带 code:{}", + failure.message + ); + } + + /// 第 2 个文件失败:**不中断**、已装的不回滚、失败进名单、第 3 个仍会被尝试。 + #[test] + fn a_failed_file_does_not_stop_the_rest_and_keeps_what_already_landed() { + let dir = temp_dir("download-partial"); + let mut plan = plan(vec![ + node("AANobbMI", "sodium.jar", &[]), + node("P7dR8mSH", "fabric-api.jar", &["AANobbMI"]), + node("1eAoo2KR", "yacl.jar", &["P7dR8mSH"]), + ]); + let fetcher = FakeFetcher::new(&["fabric-api.jar"]); + let (tx, _rx) = std::sync::mpsc::channel(); + let mut progress = InstallProgressView { + files_total: 3, + ..InstallProgressView::new() + }; + + let installed = download_stage( + &mut plan, + &dir, + &fetcher, + &AtomicBool::new(false), + &mut progress, + &tx, + 7, + ) + .expect("没取消就不该回 Err"); + + let names: Vec<&str> = installed + .iter() + .map(|node| node.file.filename.as_str()) + .collect(); + assert_eq!( + names, + ["sodium.jar", "yacl.jar"], + "成功的不回滚、后面的照下" + ); + // 失败的那个会被 `download_verified` 重试 `RETRY_ATTEMPTS` 次(这里 3 次), + // 关键是**它之后**还会去试第 3 个文件。 + let attempts = fetcher.attempts(); + assert_eq!(attempts.first().map(String::as_str), Some("sodium.jar")); + assert_eq!(attempts.last().map(String::as_str), Some("yacl.jar")); + assert_eq!( + attempts + .iter() + .filter(|name| *name == "fabric-api.jar") + .count(), + 3, + "失败的尝试 + 重试都发生在第 3 个文件之前:{attempts:?}" + ); + assert_eq!(plan.failures.len(), 1, "{:?}", plan.failures); + assert_eq!(plan.failures[0].requested_as, "P7dR8mSH"); + assert!(plan.failures[0].message.contains("fabric-api.jar")); + assert_eq!(progress.files_done, 2, "进度只数真落盘的"); + assert!(dir.join("sodium.jar").exists(), "已下好的文件不许删"); + assert!(!dir.join("fabric-api.jar").exists()); + assert!(dir.join("yacl.jar").exists()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// 解析完就取消 ⇒ 一条文件都不下(r2 nit②:main 有这条检查,我们曾把它丢了)。 + #[test] + fn cancelling_right_after_resolution_touches_nothing() { + let dir = temp_dir("download-cancelled"); + let mut plan = plan(vec![ + node("AANobbMI", "sodium.jar", &[]), + node("P7dR8mSH", "fabric-api.jar", &["AANobbMI"]), + ]); + let fetcher = FakeFetcher::new(&[]); + let (tx, _rx) = std::sync::mpsc::channel(); + let mut progress = InstallProgressView::new(); + + let error = download_stage( + &mut plan, + &dir, + &fetcher, + &AtomicBool::new(true), + &mut progress, + &tx, + 7, + ) + .expect_err("取消必须回 Err"); + + assert!(error.detail.contains("已取消"), "{}", error.detail); + assert!(fetcher.attempts().is_empty(), "一份都不许下"); + assert!(plan.failures.is_empty(), "取消不是失败,不进名单"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/presenter/src/host/addon/install.rs b/crates/presenter/src/host/addon/install.rs index d549611..b3d2a4b 100644 --- a/crates/presenter/src/host/addon/install.rs +++ b/crates/presenter/src/host/addon/install.rs @@ -44,8 +44,7 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use unlucky_core::addon::online::{ - download_verified, resolve, DependencyFailure, EngineArtifactFetcher, EngineTransport, - FailureKind, Modrinth, PlannedNode, + resolve, EngineArtifactFetcher, EngineTransport, Modrinth, PlannedNode, }; use unlucky_core::download::blocking::block_on; use unlucky_core::game::install::instance::read_instance_config; @@ -58,7 +57,7 @@ use crate::dto::{ use super::super::env::HostEnv; use super::super::job::{HostTask, JobSender}; -use super::plan; +use super::{downloads, plan}; /// 跑一次装入(同步;由 [`super::spawn_install`] 放进后台线程)。 pub(super) fn install( @@ -194,56 +193,16 @@ fn run( }); let fetcher = EngineArtifactFetcher::new(&core_engine); - let mut installed: Vec = Vec::new(); - for index in 0..dependency_plan.nodes.len() { - if cancel.load(Ordering::SeqCst) { - return Err(local("已取消(下载中途)")); - } - let node = dependency_plan.nodes[index].clone(); - progress.message = node_label(&node); - let _ = tx.send(HostTask::Install { - progress: Box::new(progress.clone()), - epoch, - }); - // M162:走 `online::download_verified`——sha1 交给引擎流式校验、sha512 落盘后复核, - // 失败重试且不留半截文件(内容库安装与整合包导入用**同一段**校验,不各写一份)。 - let dest = target_dir.join(safe_file_name(&node.file.filename)); - 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, - )); - 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()), - epoch, - }); - } + // 下载阶段在 `downloads.rs`:可测(假下载器能驱动),失败进名单而不中断。 + let installed = downloads::download_stage( + &mut dependency_plan, + &target_dir, + &fetcher, + cancel, + progress, + tx, + epoch, + )?; // 解析与下载两段的失败在这里一起收口:两档分成两句话(不合并成「下载失败」), // 并列出「可只重试这 N 个」。 @@ -263,7 +222,7 @@ pub(super) fn finish(progress: &mut InstallProgressView) { } /// 进度面板上的那一行文件名(前置标出来,用户才知道多出来的文件是什么)。 -fn node_label(node: &PlannedNode) -> String { +pub(super) fn node_label(node: &PlannedNode) -> String { if node.is_root() { node.file.filename.clone() } else { @@ -272,7 +231,7 @@ fn node_label(node: &PlannedNode) -> String { } /// 本地失败(找实例 / 建目录 / 取消)——不是网络问题,归到 `Other`。 -fn local(reason: impl Into) -> ContentFailure { +pub(super) fn local(reason: impl Into) -> ContentFailure { ContentFailure::new(ContentFailureKind::Other, reason) }