fix(addon): 复审 r1 —— 失败去重、null 载荷、完成态进度、顺序守门、下载失败归并
- P2-1 失败分支不再「直接 continue」:`fail()` 记一条失败(`record_failure` 按 `requested_as` 去重)并把请求键写进 `alias`,后续同键走现成的 `fold_parents` 折父边 ⇒ 同一项目只取一次版本、失败只一条、重试名单不重复; - P2-2 `RawDependency.dependency_type` 改 `Option<String>`:`#[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 另一个版本在目录里仍要下载」的负向用例。
This commit is contained in:
1 parent
011d525e8d
commit
aa33210bb6
12 files changed
+686
-77
No files matched your search
@@ -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<String>,
|
||||
/// 站点给的文件名
|
||||
pub file_name: Option<String>,
|
||||
}
|
||||
|
||||
/// 一次 BFS 的完整结果(成功、跳过、失败三份名单,都**不丢**)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DependencyPlan {
|
||||
@@ -233,6 +247,8 @@ pub struct DependencyPlan {
|
||||
pub failures: Vec<DependencyFailure>,
|
||||
/// 撞到 `max_depth` 而没展开的项目键(排序去重)
|
||||
pub truncated: Vec<String>,
|
||||
/// 有 `required` 但没有项目 id、解析不了的前置(排序去重)
|
||||
pub unresolved_required: Vec<UnresolvedRequired>,
|
||||
}
|
||||
|
||||
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<String> = 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -127,9 +127,11 @@ struct RawDependency {
|
||||
version_id: Option<String>,
|
||||
#[serde(default)]
|
||||
file_name: Option<String>,
|
||||
// 站点给的是 `required` / `optional` / `incompatible` / `embedded`;认不出 ⇒ `Unknown`
|
||||
// 站点给的是 `required` / `optional` / `incompatible` / `embedded`。
|
||||
// 必须是 `Option<String>`:`#[serde(default)]` 只兜「字段缺失」,兜不住 `null`
|
||||
// (`"dependency_type": null` 用 `String` 会整份响应报 Malformed,于是整个项目都取不到版本)。
|
||||
#[serde(default)]
|
||||
dependency_type: String,
|
||||
dependency_type: Option<String>,
|
||||
}
|
||||
|
||||
#[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(),
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本里的一个文件。
|
||||
|
||||
@@ -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<T: Transport + ?Sized>(
|
||||
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<T: Transport + ?Sized>(
|
||||
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<T: Transport + ?Sized>(
|
||||
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<String, String>,
|
||||
item: Wave,
|
||||
kind: FailureKind,
|
||||
cause: Option<OnlineErrorKind>,
|
||||
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<Wave>, key: &str, depth: u8, parent: Option<&str>) {
|
||||
if let Some(existing) = layer.iter_mut().find(|item| item.key == key) {
|
||||
|
||||
@@ -12,4 +12,6 @@ mod dependency;
|
||||
mod download;
|
||||
mod fixtures;
|
||||
mod fixtures_deps;
|
||||
mod order;
|
||||
mod regressions;
|
||||
mod support;
|
||||
@@ -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), "两次结果必须一样");
|
||||
}
|
||||
@@ -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"],
|
||||
"既要去重(非相邻)也要排序"
|
||||
);
|
||||
}
|
||||
@@ -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<String> = 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(",")
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user