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:
1 parent
94f792e978
commit
011d525e8d
14 files changed
+1787
-97
No files matched your search
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user