feat(addon): Modrinth 前置依赖 BFS 解析与两档失败名单

- 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(光影的加载器);
- 离线夹具覆盖:嵌套两层、环、扇入、深度截断、两档失败、已存在跳过、光影加载器。
This commit is contained in:
AskaEth committed 2026-11-08 20:23:55 +08:00
1 parent 94f792e978
commit 011d525e8d
14 files changed
+1787 -97

No files matched your search

+187
View File
@@ -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<OnlineErrorKind>,
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<DependencyFailure>,
nodes: Vec<PlannedNode>,
skipped: Vec<SkippedNode>,
) -> 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<String> = 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);
}