fix(addon): 复审 r2 —— 下载阶段抽出可测边界并补守门

- 阻塞项:下载循环从 `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`。
This commit is contained in:
AskaEth committed 2026-11-08 20:23:57 +08:00
1 parent aa33210bb6
commit ff2f229b80
5 files changed
+383 -56

No files matched your search

+8
View File
@@ -196,6 +196,14 @@ pub async fn resolve<T: Transport + ?Sized>(
/// 标键这一步是**失败侧的去重网**:成功路径靠 `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<String, String>,
@@ -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 仍要被跟"
);
}