Files
Unlucky-Launcher/crates/presenter/src/host/addon/tests.rs
T
AskaEth 011d525e8d 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(光影的加载器);
- 离线夹具覆盖:嵌套两层、环、扇入、深度截断、两档失败、已存在跳过、光影加载器。
2026-11-08 20:23:55 +08:00

305 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 内容库宿主的守门测试(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);
}