- 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(光影的加载器); - 离线夹具覆盖:嵌套两层、环、扇入、深度截断、两档失败、已存在跳过、光影加载器。
305 lines
10 KiB
Rust
305 lines
10 KiB
Rust
//! 内容库宿主的守门测试(M109):编码、文件名穿越、退避判定、两个站点的方言映射。
|
||
|
||
use std::time::Duration;
|
||
|
||
use super::http::{encode, retry_after, retryable};
|
||
use super::install::safe_file_name;
|
||
use super::sources::{all_kinds, curseforge_loader, date_part, failure_of};
|
||
use crate::dto::{ContentFailureKind, ContentKind};
|
||
use unlucky_core::addon::online::{OnlineError, OnlineErrorKind};
|
||
|
||
#[test]
|
||
fn percent_encoding_keeps_the_query_readable_and_escapes_the_rest() {
|
||
assert_eq!(encode("1.20.1"), "1.20.1");
|
||
assert_eq!(
|
||
encode("[\"versions:1.20.1\"]"),
|
||
"%5B%22versions%3A1.20.1%22%5D"
|
||
);
|
||
assert_eq!(encode("中文"), "%E4%B8%AD%E6%96%87");
|
||
assert_eq!(encode("a b"), "a%20b");
|
||
}
|
||
|
||
#[test]
|
||
fn file_names_never_carry_a_path() {
|
||
// 抓得住:站点给的 `../../evil.jar` 落到实例目录之外(穿越)。
|
||
assert_eq!(safe_file_name("sodium.jar"), "sodium.jar");
|
||
assert_eq!(safe_file_name("../../evil.jar"), "evil.jar");
|
||
assert_eq!(safe_file_name("mods\\jei.jar"), "jei.jar");
|
||
assert_eq!(safe_file_name(".."), "download.bin");
|
||
assert_eq!(safe_file_name(""), "download.bin");
|
||
}
|
||
|
||
#[test]
|
||
fn dates_are_sliced_not_guessed() {
|
||
assert_eq!(date_part("2024-05-01T12:00:00Z"), "2024-05-01");
|
||
assert_eq!(date_part("2024-05"), "2024-05");
|
||
assert_eq!(date_part(""), "");
|
||
}
|
||
|
||
#[test]
|
||
fn only_rate_limited_and_transient_failures_are_retried() {
|
||
assert!(retryable("HTTP 429 (retry_after=Some(3))"));
|
||
assert!(retryable("HTTP 503 (retry_after=None)"));
|
||
assert!(!retryable("HTTP 404 (retry_after=None)"));
|
||
assert!(
|
||
!retryable("HTTP 401 (retry_after=None)"),
|
||
"密钥错重试只会更糟"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn retry_after_prefers_the_server_and_falls_back_to_exponential() {
|
||
assert_eq!(
|
||
retry_after("HTTP 429 (retry_after=Some(7))", 0),
|
||
Duration::from_secs(7)
|
||
);
|
||
assert_eq!(
|
||
retry_after("HTTP 429 (retry_after=None)", 0),
|
||
Duration::from_secs(1)
|
||
);
|
||
assert_eq!(
|
||
retry_after("HTTP 429 (retry_after=None)", 2),
|
||
Duration::from_secs(4)
|
||
);
|
||
// 服务端要得再多也不等超过 30s(交互与截图不能被一次退避挂死)
|
||
assert_eq!(
|
||
retry_after("HTTP 429 (retry_after=Some(900))", 0),
|
||
Duration::from_secs(30)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn curseforge_loader_ids_follow_the_official_enum() {
|
||
assert_eq!(curseforge_loader(1), Some("forge"));
|
||
assert_eq!(curseforge_loader(4), Some("fabric"));
|
||
assert_eq!(curseforge_loader(5), Some("quilt"));
|
||
assert_eq!(curseforge_loader(6), Some("neoforge"));
|
||
assert_eq!(curseforge_loader(99), None);
|
||
}
|
||
|
||
#[test]
|
||
fn the_four_content_kinds_are_the_four_side_entries() {
|
||
assert_eq!(
|
||
all_kinds().map(ContentKind::slug),
|
||
["modpack", "mod", "resourcepack", "shader"]
|
||
);
|
||
}
|
||
|
||
// ------------------------------------------------ M162:失败分类(core → DTO)
|
||
|
||
/// core 的 `OnlineErrorKind` → DTO 的 `ContentFailureKind`:**一一映射,不重新判**。
|
||
///
|
||
/// 「没联网」是单独一类:页面据此画离线态(说「检查网络」),而不是画「换个关键词」。
|
||
#[test]
|
||
fn core_errors_map_to_dto_failure_kinds() {
|
||
let cases = [
|
||
(OnlineErrorKind::NotConnected, ContentFailureKind::Offline),
|
||
(OnlineErrorKind::NotFound, ContentFailureKind::NotFound),
|
||
(OnlineErrorKind::Server, ContentFailureKind::Server),
|
||
(
|
||
OnlineErrorKind::Unsupported,
|
||
ContentFailureKind::Unsupported,
|
||
),
|
||
(OnlineErrorKind::Malformed, ContentFailureKind::Other),
|
||
(OnlineErrorKind::Io, ContentFailureKind::Other),
|
||
(OnlineErrorKind::Integrity, ContentFailureKind::Other),
|
||
];
|
||
for (core, expected) in cases {
|
||
let failure = failure_of(OnlineError::new(core, "x"));
|
||
assert_eq!(failure.kind, expected, "{} 映射错了", core.as_str());
|
||
}
|
||
assert!(failure_of(OnlineError::offline("connect timed out")).is_offline());
|
||
// 详情/版本列表的文案要带出 core 的原话(不是二次包装的「出错了」)
|
||
assert_eq!(
|
||
failure_of(OnlineError::offline("connect timed out")).detail,
|
||
"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);
|
||
}
|