release: v0.2.5
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run

This commit is contained in:
AskaEth committed 2026-11-12 12:06:59 +08:00
commit 0522a41a52
1587 files changed
+323674

No files matched your search

+221
View File
@@ -0,0 +1,221 @@
//! 运行目录**碰撞**:slug 同名 / 大小写不敏感 / 「空目录已被实例占着」/ 「认领目录还不存在」。
//!
//! 复审 r1 的 R1:旧实现只比展示名,第二次登记看到「目录是空的」就复用了 ⇒ 两个从未选择
//! shared/custom 的实例共用一个运行目录(`mods`/`saves` 互相污染)。
//! 判据必须是「有没有实例占着这个**真实**目录」,而不是「目录空不空」。
use unlucky_core::game::adopt::{self, AdoptTarget};
use unlucky_core::game::install::instance::read_instance_config;
use unlucky_core::game::install::pack_dir;
use crate::fixture::{foreign_minecraft, request, TempRoot};
/// **slug 碰撞**:`Better MC` 与 `Better-MC` 派生到同一个目录名 —— 两个实例必须落在不同目录。
///
/// 复审 r1 的 R1:旧实现只比展示名,第二次登记看到「目录是空的」就复用了,于是两个从未选择
/// shared/custom 的实例共用一个运行目录(`mods`/`saves` 互相污染)。
///
/// 抓得住:
/// - 判据只看目录空不空、不看已有实例占没占(两条 `run_dir` 不同的断言会红);
/// - 顺延目录没建出来 / 提示里没点出撞的是谁。
///
/// 抓不住:`pack_dir::instance_dir_name` 本身把两个名字折成同一个目录(那是既有行为,
/// 本 mission 不改它,只保证**登记侧**不共用)。
#[test]
fn slug_colliding_names_do_not_share_a_run_dir() {
let root = TempRoot::new("slug-collide");
foreign_minecraft(&root.game_root());
let mut first = request("1.20.1", AdoptTarget::InstanceDir);
first.name = Some("Better MC".to_string());
let a = adopt::adopt(&root.game(), &root.launcher(), &first).expect("第一次登记");
let mut second = request("1.20.1", AdoptTarget::InstanceDir);
second.name = Some("Better-MC".to_string());
let b = adopt::adopt(&root.game(), &root.launcher(), &second).expect("第二次登记");
assert_ne!(a.run_dir, b.run_dir, "两个实例不许共用同一个运行目录");
assert!(
a.run_dir.is_dir() && b.run_dir.is_dir(),
"两个目录都要真实存在"
);
let bumped = b
.run_dir
.file_name()
.map(|name| name.to_string_lossy().into_owned());
assert!(
bumped.as_deref().is_some_and(|name| name.ends_with("-2")),
"撞车后应顺延成 -2:{:?}",
b.run_dir
);
assert!(
b.notes.iter().any(|note| note.contains("同一个物理目录")),
"必须把「撞了谁」说出来:{:?}",
b.notes
);
// 两份实例设置各自指向自己的目录(不是都指第一个)。
let config_a = read_instance_config(&root.launcher(), &a.instance_id)
.expect("读设置")
.expect("有设置文件");
let config_b = read_instance_config(&root.launcher(), &b.instance_id)
.expect("读设置")
.expect("有设置文件");
assert_eq!(config_a.game_dir.as_deref(), Some(a.run_dir.as_path()));
assert_eq!(config_b.game_dir.as_deref(), Some(b.run_dir.as_path()));
}
/// **大小写碰撞**(Windows/macOS):`Foo` 与 `foo` 是同一个物理目录 ⇒ 同样必须顺延。
///
/// 抓得住:`dir_key` 不做大小写归一(Windows 上第二次登记会复用同一个目录)。
/// 抓不住:Linux(大小写敏感,两条目录本来就不同——那里这条不适用,见 `cfg`)。
#[cfg(windows)]
#[test]
fn names_differing_only_in_case_do_not_share_a_run_dir() {
let root = TempRoot::new("case-collide");
foreign_minecraft(&root.game_root());
let mut first = request("1.20.1", AdoptTarget::InstanceDir);
first.name = Some("Foo".to_string());
let a = adopt::adopt(&root.game(), &root.launcher(), &first).expect("第一次登记");
let mut second = request("1.20.1", AdoptTarget::InstanceDir);
second.name = Some("foo".to_string());
let b = adopt::adopt(&root.game(), &root.launcher(), &second).expect("第二次登记");
assert_ne!(a.run_dir, b.run_dir, "大小写不同不是「不同目录」的理由");
let a_real = a.run_dir.canonicalize().expect("真实路径 A");
let b_real = b.run_dir.canonicalize().expect("真实路径 B");
assert_ne!(a_real, b_real, "两个运行目录的真实路径必须不同");
}
/// 空目录**已被别的实例占着** ⇒ 也不能当「失败残留」复用,要顺延。
///
/// 这是 R1 的另一半:判据必须是「有没有实例占着」,而不是「目录空不空」。
///
/// 抓得住:只按 `directory_occupied` 判(第一次登记建出的空目录会被第二次白白复用)。
/// 抓不住:目录非空且无人认领的情形(那条保持 `TargetNotEmpty`,见下一条用例)。
#[test]
fn an_empty_dir_owned_by_another_instance_is_not_reused() {
let root = TempRoot::new("owned-empty");
foreign_minecraft(&root.game_root());
let mut first = request("1.20.1", AdoptTarget::InstanceDir);
first.name = Some("Better MC".to_string());
let a = adopt::adopt(&root.game(), &root.launcher(), &first).expect("第一次登记");
// 目录此刻是空的(新建、还没启动过游戏)——旧判据会认为「可复用」。
let entries_before = std::fs::read_dir(&a.run_dir).expect("读目录").count();
assert_eq!(entries_before, 0, "夹具前提:这个目录此刻是空的");
let mut second = request("1.20.1", AdoptTarget::InstanceDir);
second.name = Some("Better-MC".to_string());
let b = adopt::adopt(&root.game(), &root.launcher(), &second).expect("第二次登记");
assert_ne!(b.run_dir, a.run_dir);
}
/// **认领的目录还不存在**时也必须能比对出碰撞。
///
/// 场景:第一个实例用 `--isolation custom --game-dir <某路径>` 登记(custom 档**不创建**目录),
/// 那条路径恰好等于第二个实例缺省派生出来的目录名。第二个实例看到的目录「不存在、非空判据为假」,
/// 但它已经被第一个实例认领了。
///
/// 抓得住:`claims`/`dir_key` 只在「路径存在」时才比较(那样第二条 `run_dir` 相同的断言会红);
/// 抓不住:跨卷/大小写之外的别名(例如 8.3 短名),本判据不覆盖。
#[test]
fn a_claim_on_a_not_yet_existing_dir_still_collides() {
let root = TempRoot::new("claim-missing");
foreign_minecraft(&root.game_root());
let default_dir = pack_dir::instance_dir(&root.launcher(), "Better MC");
assert!(!default_dir.exists(), "夹具前提:这个目录还不存在");
let mut first = request("1.20.1", AdoptTarget::Custom(default_dir.clone()));
first.name = Some("Better MC".to_string());
let a = adopt::adopt(&root.game(), &root.launcher(), &first).expect("custom 登记成功");
assert_eq!(a.run_dir, default_dir);
assert!(!default_dir.exists(), "custom 目标不该被创建");
let mut second = request("1.20.1", AdoptTarget::InstanceDir);
second.name = Some("Better-MC".to_string());
let b = adopt::adopt(&root.game(), &root.launcher(), &second).expect("第二次登记成功");
assert_ne!(b.run_dir, default_dir, "认领侧不存在也要能比对出碰撞");
assert!(
b.notes.iter().any(|note| note.contains("同一个物理目录")),
"碰撞要写进 notes:{:?}",
b.notes
);
}
/// **Win32 尾点/尾空格别名**:`custom` 认领 `Foo.`(目录还没建)与缺省派生 `Foo` 是**同一个物理目录**。
///
/// 复审 r2 的 R2.1:普通路径的尾点/尾空格会被 Win32 折叠,旧实现把尚不存在的尾段原样拼进比较键,
/// 于是第二次默认登记仍拿到 `Foo`(两次都 exit0、两个 UUID、同一物理目录)。
///
/// 抓得住:`dir_key` 只做分隔符/大小写归一(`Foo.` 与 `Foo` 键不等,第二条断言红)。
///
/// 抓不住:verbatim 路径(`\\?\…`)——那种写法**不**折叠,是另一个物理目录,见下一条用例。
#[cfg(windows)]
#[test]
fn a_trailing_dot_claim_still_collides_with_the_derived_dir() {
for suffix in [".", " "] {
let root = TempRoot::new("win32-alias");
foreign_minecraft(&root.game_root());
let claimed = pack_dir::instance_dir(&root.launcher(), &format!("Foo{suffix}"))
.with_file_name(format!("Foo{suffix}"));
assert!(!claimed.exists(), "夹具前提:认领目录还不存在");
let mut first = request("1.20.1", AdoptTarget::Custom(claimed.clone()));
first.name = Some("First".to_string());
let a = adopt::adopt(&root.game(), &root.launcher(), &first).expect("custom 登记成功");
let mut second = request("1.20.1", AdoptTarget::InstanceDir);
second.name = Some("Foo".to_string());
let b = adopt::adopt(&root.game(), &root.launcher(), &second)
.expect("第二次默认登记应成功(顺延到 -2,而不是复用同一目录)");
assert_ne!(
b.run_dir, a.run_dir,
"尾点/尾空格别名不许让两个实例共用一个运行目录(suffix={suffix:?})"
);
assert!(
b.run_dir
.file_name()
.map(|name| name.to_string_lossy().ends_with("-2"))
.unwrap_or(false),
"应顺延成 -2:{}",
b.run_dir.display()
);
assert!(
!a.run_dir.exists(),
"custom 认领的目录不该被创建:{}",
a.run_dir.display()
);
}
}
/// 正向:**verbatim** 路径(`\\?\…`)不折叠 —— 它是另一个物理目录,必须允许并存。
///
/// 抓得住:归一化时盲目 trim(verbatim 的 `Foo.` 会被折叠成 `Foo`,这条第二次登记会顺延,
/// `-2` 断言红)。
///
/// 抓不住:别的重解析点形态;也不验 8.3 短名。
#[cfg(windows)]
#[test]
fn a_verbatim_trailing_dot_path_is_not_folded() {
let root = TempRoot::new("verbatim");
foreign_minecraft(&root.game_root());
let base = pack_dir::instance_dir(&root.launcher(), "Bar");
let verbatim =
std::path::PathBuf::from(format!(r"\\?\{}", base.with_file_name("Bar.").display()));
let mut first = request("1.20.1", AdoptTarget::Custom(verbatim.clone()));
first.name = Some("First".to_string());
adopt::adopt(&root.game(), &root.launcher(), &first).expect("verbatim custom 登记成功");
let mut second = request("1.20.1", AdoptTarget::InstanceDir);
second.name = Some("Bar".to_string());
let b = adopt::adopt(&root.game(), &root.launcher(), &second).expect("默认登记成功");
assert_eq!(
b.run_dir
.file_name()
.map(|n| n.to_string_lossy().into_owned()),
Some("Bar".to_string()),
"verbatim 的 Bar. 与普通 Bar 是两个目录,不该顺延"
);
}
+349
View File
@@ -0,0 +1,349 @@
//! 真实磁盘夹具:一份「别的启动器铺好的」`.minecraft` + 全量快照工具。
//!
//! # 为什么必须是真盘
//!
//! 本 mission 的核心判据是**「登记前后外部文件一个字节都不变」**。内存里的假目录证明不了这件事
//! ——mtime、目录项、符号链接都只有真文件系统才存在。所以每个用例都在 `std::env::temp_dir()`
//! 下建一棵真目录树,`Drop` 时整棵删掉。
//!
//! # 夹具里放了什么(逐项都有用意)
//!
//! | 路径 | 用意 |
//! | --- | --- |
//! | `versions/1.20.1/` | 正常版本(清单 + jar) |
//! | `versions/1.20.1-fabric-0.15.0/` | 正常版本,`inheritsFrom` 指向 1.20.1(父链闭合) |
//! | `versions/1.20.1-broken/` | 清单不是合法 JSON |
//! | `versions/1.20.1-orphan/` | `inheritsFrom` 指向不存在的父 |
//! | `versions/cycle-a/` `versions/cycle-b/` | 互相继承(成环) |
//! | `versions/1.20.1-nomanifest/` | 只有目录、没有同名清单 |
//! | `libraries/` `assets/` | 恒共享层(登记不该碰) |
//! | `mods/` `saves/` `config/` | 玩家数据(登记不该碰) |
//! | `launcher_profiles.json` | 别的启动器的元数据(连读都不该读,更不该写) |
//!
//! 快照工具在 `snapshot.rs`(**全节点**:目录/文件/链接各记形状与 mtime,读盘失败即失败),
//! 本模块只做 `pub use`,测试统一从 `crate::fixture::{snapshot, assert_unchanged, …}` 取。
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use unlucky_core::game::adopt::{AdoptRequest, AdoptTarget};
use unlucky_core::game::GameLayout;
pub use super::snapshot::{assert_unchanged, snapshot, snapshot_opt, Node};
use unlucky_core::platform::layout::LauncherLayout;
/// 唯一临时目录守卫(`drop` 时递归删除)。
pub struct TempRoot {
path: PathBuf,
}
impl TempRoot {
/// 建一个唯一临时根。
pub fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"ull-adopt-{tag}-{}-{nanos}-{seq}",
std::process::id()
));
std::fs::create_dir_all(&path).expect("临时根应可创建");
Self { path }
}
/// 临时根路径。
pub fn path(&self) -> &Path {
&self.path
}
/// `.minecraft`(游戏根)。
pub fn game_root(&self) -> PathBuf {
self.path.join(".minecraft")
}
/// 游戏布局。
pub fn game(&self) -> GameLayout {
GameLayout::new(self.game_root())
}
/// 启动器数据布局(`<root>/.ULL`)。
pub fn launcher(&self) -> LauncherLayout {
LauncherLayout::new(self.path.join(".ULL"))
}
}
impl Drop for TempRoot {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
/// 造一份「别的启动器已经铺好的」`.minecraft`(见模块文档的表)。
pub fn foreign_minecraft(root: &Path) {
let versions = root.join("versions");
// 正常版本:清单 + jar。
version_with(&versions, "1.20.1", MANIFEST_VANILLA, true);
// 正常版本:带父链(fabric 形态:只有清单、没有自己的 jar)。
version_with(&versions, "1.20.1-fabric-0.15.0", MANIFEST_FABRIC, false);
// 清单坏掉。
let broken = versions.join("1.20.1-broken");
std::fs::create_dir_all(&broken).expect("建坏清单目录");
std::fs::write(broken.join("1.20.1-broken.json"), b"{ this is not json").expect("写坏清单");
// 缺父版本。
version_with(&versions, "1.20.1-orphan", MANIFEST_ORPHAN, true);
// 成环。
version_with(&versions, "cycle-a", MANIFEST_CYCLE_A, true);
version_with(&versions, "cycle-b", MANIFEST_CYCLE_B, true);
// 只有目录、没有清单。
std::fs::create_dir_all(versions.join("1.20.1-nomanifest")).expect("建无清单目录");
// 恒共享层:库与资源(登记一个字节都不该动)。
write_file(
&root.join("libraries/com/mojang/brigadier/1.0.18/brigadier-1.0.18.jar"),
b"library-bytes",
);
write_file(&root.join("assets/indexes/5.json"), b"{\"objects\":{}}");
write_file(
&root.join("assets/objects/ab/abcdef0123456789abcdef0123456789abcdef01"),
b"asset-bytes",
);
// 玩家数据。
write_file(&root.join("mods/jei.jar"), b"mod-bytes");
write_file(&root.join("saves/World/level.dat"), b"save-bytes");
write_file(&root.join("config/example.toml"), b"config = 1\n");
// 别的启动器的元数据(登记不该读、更不该写)。
write_file(
&root.join("launcher_profiles.json"),
br#"{"profiles":{"1.20.1":{"name":"1.20.1","lastVersionId":"1.20.1"}}}"#,
);
}
fn version_with(versions: &Path, id: &str, manifest: &str, jar: bool) {
let dir = versions.join(id);
std::fs::create_dir_all(&dir).expect("建版本目录");
std::fs::write(dir.join(format!("{id}.json")), manifest.as_bytes()).expect("写清单");
if jar {
std::fs::write(dir.join(format!("{id}.jar")), b"jar-bytes").expect("写 jar");
}
}
fn write_file(path: &Path, bytes: &[u8]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("建父目录");
}
std::fs::write(path, bytes).expect("写文件");
}
/// 原版形态的最小清单(`downloads.client` 有值、无继承)。
const MANIFEST_VANILLA: &str = r#"{
"id": "1.20.1",
"type": "release",
"mainClass": "net.minecraft.client.main.Main",
"assets": "5",
"assetIndex": { "id": "5" },
"arguments": {
"game": ["--username", "${auth_player_name}", "--version", "${version_name}",
"--gameDir", "${game_directory}"],
"jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"]
},
"libraries": [],
"downloads": { "client": { "size": 9, "url": "http://example.invalid/client.jar" } }
}"#;
/// Fabric 形态:`inheritsFrom` 指 1.20.1(父链闭合)。
const MANIFEST_FABRIC: &str = r#"{
"id": "1.20.1-fabric-0.15.0",
"type": "release",
"inheritsFrom": "1.20.1",
"mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient",
"libraries": []
}"#;
/// 缺父版本(父 id 在盘上不存在)。
const MANIFEST_ORPHAN: &str = r#"{
"id": "1.20.1-orphan",
"type": "release",
"inheritsFrom": "1.20.1-not-on-disk",
"mainClass": "net.minecraft.client.main.Main",
"libraries": []
}"#;
/// 环的一侧。
const MANIFEST_CYCLE_A: &str = r#"{
"id": "cycle-a",
"type": "release",
"inheritsFrom": "cycle-b",
"mainClass": "net.minecraft.client.main.Main",
"libraries": []
}"#;
/// 环的另一侧。
const MANIFEST_CYCLE_B: &str = r#"{
"id": "cycle-b",
"type": "release",
"inheritsFrom": "cycle-a",
"mainClass": "net.minecraft.client.main.Main",
"libraries": []
}"#;
/// 一次登记请求(测试用:固定 `created_at`,名字缺省 = 版本 id)。
pub fn request(version_id: &str, target: AdoptTarget) -> AdoptRequest {
AdoptRequest {
version_id: version_id.to_string(),
name: None,
target,
created_at: Some("2026-11-09T00:00:00.000Z".to_string()),
}
}
/// 建目录符号链接(`true` = 建成)。Windows 上未开开发者模式/非管理员时可能失败。
pub fn try_symlink_dir(target: &Path, link: &Path) -> bool {
#[cfg(unix)]
let created = std::os::unix::fs::symlink(target, link).is_ok();
#[cfg(windows)]
let created = std::os::windows::fs::symlink_dir(target, link).is_ok();
created
}
/// 用 `mklink /J` 建目录联接(junction 不需要管理员权限;建不出来返回 `false`)。
///
/// 只在 Windows 上有意义:junction 不是 `is_symlink`,是把别人版本目录挂进 `versions/` 最常见的
/// 形态,所以越界判据必须能看见它——这条夹具就是用来证明这点的。
#[cfg(windows)]
pub fn try_junction(target: &Path, link: &Path) -> bool {
// `cmd /c mklink /J <link> <target>`:三个参数分开传,别拼成一整条(拼成一串会被 cmd 当语法错),
// 路径也必须**逐段 join**(`cmd` 把正斜杠当开关)。
let output = std::process::Command::new("cmd")
.args(["/c", "mklink", "/J"])
.arg(link)
.arg(target)
.output();
match output {
Ok(output) if output.status.success() && link.is_dir() => true,
Ok(output) => {
eprintln!(
"[junction] mklink /J {} {} 失败({:?}):{}{}",
link.display(),
target.display(),
output.status.code(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
false
}
Err(error) => {
eprintln!("[junction] 调不起 cmd:{error}");
false
}
}
}
/// 建目录链接:Windows 先试符号链接、再试联接;其它平台只有符号链接。
pub fn try_link_dir(target: &Path, link: &Path) -> bool {
if try_symlink_dir(target, link) {
return true;
}
#[cfg(windows)]
{
try_junction(target, link)
}
#[cfg(not(windows))]
{
false
}
}
/// 链接夹具建不出来时怎么走(**真值表**,复审 r1 的 R4 抓的就是这张表被写反)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinkGate {
/// 建成了:照常跑断言。
Run,
/// 没建成且**没要求强制**:打印一行记录后跳过(不静默)。
Skip,
/// 没建成且 `ULL_ADOPT_TEST_SYMLINK_REQUIRE=1`:**必红**,不许空跑。
Fail,
}
/// 纯判据:`(建成了吗, 是否强制)` → 怎么走。
#[must_use]
pub fn link_gate(created: bool, required: bool) -> LinkGate {
match (created, required) {
(true, _) => LinkGate::Run,
(false, true) => LinkGate::Fail,
(false, false) => LinkGate::Skip,
}
}
/// `ULL_ADOPT_TEST_SYMLINK_REQUIRE=1` 时链接夹具**不许**空跑。
#[must_use]
pub fn links_required() -> bool {
std::env::var("ULL_ADOPT_TEST_SYMLINK_REQUIRE")
.as_deref()
.map(str::trim)
== Ok("1")
}
/// 链接夹具的守卫:返回 `true` = 继续跑断言;`false` = 已显式跳过(调用方 `return`)。
///
/// `required` 由环境变量决定([`links_required`]);判据本身是 [`link_gate`]。
pub fn link_guard(created: bool, what: &str) -> bool {
link_guard_with(created, links_required(), what)
}
/// 同 [`link_guard`],但 `required` 显式传入(测试要能**执行**强制分支,而不靠改进程环境)。
///
/// # Panics
///
/// `required` 且没建成链接/联接:**红**(这是复审 r1 的 R4:旧写法把方向写反了,
/// 强制模式下建不出来反而直接「成功」返回)。
pub fn link_guard_with(created: bool, required: bool, what: &str) -> bool {
match link_gate(created, required) {
LinkGate::Run => true,
LinkGate::Skip => {
eprintln!(
"[link-guard] {what}:本机建不出符号链接/目录联接,已显式跳过这条用例; 设 ULL_ADOPT_TEST_SYMLINK_REQUIRE=1 可让它必跑(建不出来即红)"
);
false
}
LinkGate::Fail => panic!(
"[link-guard] {what}:ULL_ADOPT_TEST_SYMLINK_REQUIRE=1,但本机建不出符号链接/目录联接 ——这条用例不许空跑,判红"
),
}
}
/// 真值表的回归:**强制模式建不出来必须致命**(R4 的反转就是这张表写错)。
///
/// 抓得住:把 `(false, true)` 判成 `Skip`(旧实现的写法)、或把 `(false, false)` 判成 `Fail`。
/// 抓不住:真实建链接是否成功(那由各链接用例自己走 [`link_guard`])。
#[test]
fn link_gate_truth_table() {
assert_eq!(link_gate(true, false), LinkGate::Run);
assert_eq!(link_gate(true, true), LinkGate::Run);
assert_eq!(link_gate(false, false), LinkGate::Skip);
assert_eq!(
link_gate(false, true),
LinkGate::Fail,
"强制模式下建不出链接必须红"
);
}
/// **执行**守卫的三个分支(不只测纯判据):Run 继续、Skip 返回 false、Fail panic。
///
/// 抓得住:把 `Fail` 分支写成 `return false`(`catch_unwind` 捕不到 panic,最后一条断言红);
/// 把 `Skip` 写成 panic(第一条 `catch_unwind` 不再 ok)。
/// 抓不住:真实建链接是否成功(各链接用例自己走 [`link_guard`]);也不依赖进程级环境变量,
/// 因此可与并行用例共存。
#[test]
fn link_guard_executes_all_three_branches() {
assert!(link_guard_with(true, false, "已建成"));
assert!(
std::panic::catch_unwind(|| link_guard_with(false, false, "非强制模式 · 建不出")).is_ok(),
"非强制模式不许 panic"
);
assert!(!link_guard_with(false, false, "非强制模式 · 建不出"));
let forced = std::panic::catch_unwind(|| link_guard_with(false, true, "强制模式 · 建不出"));
assert!(forced.is_err(), "强制模式下建不出来必须 panic(R4)");
}
+231
View File
@@ -0,0 +1,231 @@
//! 快照工具自身的回归:它必须抓得住「凭空多出一个空目录」(复审 r1 的 R5)。
//!
//! 早先的快照只记文件:`scan` 或 `--dry-run` 若在外部树里建了一个空目录(例如
//! `<game>/versions/<v>/mods`),快照完全不变,于是「零写入」这条承诺可以是假绿。
//! 这里用**同一份** `snapshot.rs` 证明:目录节点、链接节点都进快照,读盘失败不吞。
use std::path::Path;
use std::time::{Duration, SystemTime};
use crate::fixture::{
link_guard, snapshot, snapshot_opt, try_link_dir, try_symlink_dir, Node, TempRoot,
};
/// 目录 / 文件 / 链接三类节点都记,且链接**不跟随**(目标内容不会挂到链接路径下)。
///
/// 抓得住:
/// - 目录节点缺失(`dir` 断言红);
/// - 跟随链接(`link/inside.txt` 会同时出现在链接路径下,`is_none` 断言红);
/// - 链接被当成普通目录(`Node::Link` 断言红)。
///
/// 抓不住:链接目标本身的内容变化(不跟随就看不见——这正是本工具要的语义)。
#[test]
fn snapshot_records_dirs_files_and_links_without_following() {
let root = TempRoot::new("snap-shapes");
let real = root.path().join("real");
std::fs::create_dir_all(&real).expect("建真实目录");
std::fs::write(real.join("inside.txt"), b"x").expect("写文件");
let link = root.path().join("link");
if !link_guard(try_symlink_dir(&real, &link), "快照链接夹具") {
// 链接建不出来时仍要验目录/文件两类(guard 已显式记录跳过原因)。
let snap = snapshot(root.path());
assert!(matches!(snap.get("real"), Some(Node::Dir { .. })));
assert!(matches!(
snap.get("real/inside.txt"),
Some(Node::File { .. })
));
return;
}
let snap = snapshot(root.path());
assert!(
matches!(snap.get("."), Some(Node::Dir { .. })),
"根自身也要记"
);
assert!(matches!(snap.get("real"), Some(Node::Dir { .. })));
assert!(matches!(
snap.get("real/inside.txt"),
Some(Node::File { .. })
));
assert!(
matches!(snap.get("link"), Some(Node::Link { .. })),
"链接要记成 Link:{:?}",
snap.get("link")
);
assert!(
!snap.contains_key("link/inside.txt"),
"不许跟随链接(否则根外/别处内容会被算进这棵树)"
);
}
/// **新增一个空目录**必须让快照变化(R5 的核心判据)。
///
/// 抓得住:只记文件的旧快照(`before == after` 会让第一条断言红)。
/// 抓不住:目录 mtime 的精度(这条只比路径集合与节点形状;mtime 由 `assert_unchanged` 另外守)。
#[test]
fn snapshot_catches_a_new_empty_directory() {
let root = TempRoot::new("snap-newdir");
let game = root.game_root().join("versions").join("1.20.1");
std::fs::create_dir_all(&game).expect("建版本目录");
let before = snapshot(&root.game_root());
// 「登记过程凭空建了一个空目录」——正是旧快照看不见的那种改动。
let planted = game.join("mods");
std::fs::create_dir_all(&planted).expect("建空目录");
let after = snapshot(&root.game_root());
assert_ne!(before, after, "新增空目录必须改变快照");
assert!(
!before.contains_key("versions/1.20.1/mods"),
"改动前不该有这个节点"
);
assert!(
matches!(after.get("versions/1.20.1/mods"), Some(Node::Dir { .. })),
"新增的空目录要作为目录节点出现:{:?}",
after.get("versions/1.20.1/mods")
);
// 反向:删掉之后路径集合回到原样(目录 mtime 会变,所以只比键集合)。
std::fs::remove_dir(&planted).expect("删空目录");
let again = snapshot(&root.game_root());
assert_eq!(
before.keys().collect::<Vec<_>>(),
again.keys().collect::<Vec<_>>(),
"删掉空目录后路径集合应回到原样"
);
}
/// 目录的 mtime 变化也要能看见(条目增删会改父目录 mtime)。
///
/// ## 为什么不再「写个文件然后赌 mtime 变了」(M262)
///
/// 上一版只做「建目录 → 拍快照 → 写文件 → 再拍快照 → `assert_ne!(node)`」,靠的是**文件系统
/// 恰好把两次操作记成不同的 mtime**。这一条在 windows CI 上真红(写文件与建目录落在同一 tick),
/// 而它自己的注释还写着「真粗到同一 tick 时会软过」——既不可靠也不软过,是个真红。
///
/// 现在两个 mtime 都由本用例**显式给定**([`set_dir_mtime`]),不来自时钟:t0 = 1_000_000_000s,
/// t1 = t0 + 3600s,两者都是整秒、都对任何常见粒度(NTFS 100ns / ext4 1ns / HFS+ 1s / FAT 2s)
/// 无余数,于是「快照读到的目录 mtime」与「我们钉的那个值」可以逐 ns 比对。
///
/// 抓得住:
/// - `Node::Dir` 不再携带 mtime(记 0 / 记常量)—— 逐 ns 断言当场红;
/// - 快照不再重新读元数据(缓存 / 只记路径)—— 同上;
/// - 目录节点整个丢失(`expect` 红)。
///
/// 抓不住:
/// - **文件系统会不会自己在条目增删时抬高目录 mtime**:本用例把这件事绕开了(两次 mtime 都是我们
/// 钉的),所以「NTFS 真机上目录 mtime 会不会被延时更新」这件事依然**没有**用例守——它有自己
/// 的用例(`snapshot_catches_a_new_empty_directory` 比的是路径集合,不靠 mtime);
/// - 粒度比 3600s 还粗的文件系统(常见 FS 里没有;真遇到会红在「逐 ns 相等」那句,不会软过);
/// - 「目录里多了一个文件」这件事本身(那由 [`snapshot_catches_a_new_empty_directory`] 与
/// 下面 `watched/new.txt` 那一条单独守,不靠 mtime)。
#[test]
fn snapshot_notices_a_directory_mtime_change() {
let root = TempRoot::new("snap-dirmtime");
let dir = root.path().join("watched");
std::fs::create_dir_all(&dir).expect("建目录");
// 两个明确的时刻(整秒,任何常见 filetime 粒度都能原样存下)。
let t0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000_000);
let t1 = t0 + Duration::from_secs(3_600);
set_dir_mtime(&dir, t0);
let before = snapshot(root.path());
// 场景照旧是真的:往目录里加了一个文件(条目增删是 mtime 变的**真实**来源)。
std::fs::write(dir.join("new.txt"), b"x").expect("写文件");
// 但差异由本用例给出,而不是「期望这次写恰好落在另一个 tick」。
set_dir_mtime(&dir, t1);
let after = snapshot(root.path());
// 场景本身要成立(否则下面比的是一个没变的世界)。
assert!(
!before.contains_key("watched/new.txt")
&& matches!(after.get("watched/new.txt"), Some(Node::File { .. })),
"先把「目录里真多了一个文件」这条落实:before={:?} after={:?}",
before.get("watched/new.txt"),
after.get("watched/new.txt")
);
let before_node = before.get("watched").expect("有该目录节点");
let after_node = after.get("watched").expect("有该目录节点");
assert_ne!(
before_node, after_node,
"目录 mtime 变了,目录节点必须变(mtime 是我们钉的,不看时钟)"
);
assert_eq!(
dir_mtime_ns(after_node),
t1.duration_since(SystemTime::UNIX_EPOCH)
.expect("t1 在纪元之后")
.as_nanos(),
"快照里的目录 mtime 必须就是我们钉的那个值(逐 ns)"
);
assert_eq!(
dir_mtime_ns(before_node),
t0.duration_since(SystemTime::UNIX_EPOCH)
.expect("t0 在纪元之后")
.as_nanos()
);
}
/// 给**目录**显式钉一个 mtime(std-only;两个平台的句柄打开方式不同)。
#[cfg(windows)]
fn set_dir_mtime(dir: &Path, at: SystemTime) {
use std::os::windows::fs::OpenOptionsExt;
// 0x0100 = FILE_WRITE_ATTRIBUTES:`SetFileTime` 要的就是这个权限,而 GENERIC_WRITE
// (`OpenOptions::write(true)`)在**目录**上会被拒(os error 5)。
// 0x0200_0000 = FILE_FLAG_BACKUP_SEMANTICS:不开它 `CreateFile` 根本打不开目录句柄。
let handle = std::fs::OpenOptions::new()
.access_mode(0x0100)
.custom_flags(0x0200_0000)
.open(dir)
.unwrap_or_else(|error| panic!("打不开目录句柄 {}:{error}", dir.display()));
handle
.set_modified(at)
.unwrap_or_else(|error| panic!("给目录设 mtime 失败 {}:{error}", dir.display()));
}
/// 同 [`set_dir_mtime`](`futimens` 走普通目录句柄,不需要额外权限)。
#[cfg(not(windows))]
fn set_dir_mtime(dir: &Path, at: SystemTime) {
let handle = std::fs::File::open(dir)
.unwrap_or_else(|error| panic!("打不开目录句柄 {}:{error}", dir.display()));
handle
.set_modified(at)
.unwrap_or_else(|error| panic!("给目录设 mtime 失败 {}:{error}", dir.display()));
}
/// 目录节点的 mtime(纳秒)。节点不是目录就直接红:本用例只谈目录。
fn dir_mtime_ns(node: &Node) -> u128 {
match node {
Node::Dir { mtime_ns } => *mtime_ns,
other => panic!("watched 应当是目录节点:{other:?}"),
}
}
/// 目标根本不存在 ⇒ `snapshot_opt` 给 `None`(与「空树」区分开)。
///
/// 抓得住:把「不存在」当成空快照(那样「dry-run 建了 .ULL」就看不出来)。
/// 抓不住:存在但权限不足(那一支直接 panic,见模块文档)。
#[test]
fn a_missing_target_is_none_not_an_empty_snapshot() {
let root = TempRoot::new("snap-missing");
let absent: &Path = &root.path().join("nope");
assert!(snapshot_opt(absent).is_none());
std::fs::create_dir_all(absent).expect("建空目录");
assert!(snapshot_opt(absent).is_some(), "空目录也算存在");
}
/// 链接建不出来时 `try_link_dir` 会退回联接(Windows);这条只验它不 panic。
#[test]
fn link_fixtures_are_total() {
let root = TempRoot::new("snap-link-total");
let target = root.path().join("target");
std::fs::create_dir_all(&target).expect("建目标");
let link = root.path().join("link");
let created = try_link_dir(&target, &link);
if created {
assert!(link.is_dir());
}
}
+24
View File
@@ -0,0 +1,24 @@
//! M221 core 侧集成测试入口:从**别人的** `.minecraft` 只读登记版本(adopt)。
//!
//! 分工:
//! - `fixture`:临时真实磁盘目录 + 一份「别的启动器铺好的」`.minecraft`;
//! - `snapshot`:**全节点**快照(目录/文件/链接 + mtime),core 与 CLI 测试共用这一份;
//! - `scan_cases`:`scan` / `inspect` 的判据(正常 / 坏 JSON / 缺父 / 成环 / 缺清单 / 越界符号链接);
//! - `plan_cases`:还没登记时的**只读**判据与拒绝理由(dry-run 零写盘 / 缺父 / 不存在 / 非法目标);
//! - `collision_cases`:缺省运行目录**不许被两个实例共用**(slug / 大小写 / 已被占 / 认领但未创建);
//! - `register_cases`:`adopt` 的落点与写盘面(缺省独立目录 / shared / isolated / custom /
//! 重名 / 重复 / 非空目录 / 数据区联接 / **slug 与大小写碰撞不许共用运行目录** / 外部树全节点不变);
//! - `surface_cases`:配置写面被链接走出去必须拒绝(R2)、`versions` 入口断链必须报错(R3);
//! - `fixture_cases`:快照工具自身的回归(它得抓得住「凭空多一个空目录」,R5)。
//!
//! 这些用例**不启动真实 Minecraft**:验的是「登记这一步做了什么、没做什么」,启动链路由
//! `tests/game/*` 与 CLI 的 `launch --dry-run` 各自覆盖。
mod collision_cases;
mod fixture;
mod fixture_cases;
mod plan_cases;
mod register_cases;
mod scan_cases;
mod snapshot;
mod surface_cases;
+118
View File
@@ -0,0 +1,118 @@
//! `plan` / `inspect` 的**只读**判据与拒绝理由:零写盘、缺父/不存在/非法目标的报错形状。
//!
//! 与 `register_cases` 的分工:那边验「登记写出了什么」,这边验「还没登记时算出了什么、拒绝得对不对」。
use std::path::PathBuf;
use unlucky_core::game::adopt::{self, AdoptError, AdoptTarget};
use unlucky_core::game::install::instance::Isolation;
use crate::fixture::{
assert_unchanged, foreign_minecraft, request, snapshot, snapshot_opt, TempRoot,
};
/// 缺省档位本身就是「数据区独立目录」,而且**不是** `isolated`(判据的单一真相源)。
///
/// 抓得住:把 `#[default]` 挪到 `VersionDir`/`Shared`(第一条断言红),或让缺省档映射到
/// `Isolated`(第二条断言红)——后者正是「导入别人的版本后第一次启动就在他的 `versions/<id>/`
/// 里长 mods/saves」的那个坑。
/// 抓不住:CLI 是否真的用 `AdoptTarget::default()`(那条由 cli/adopt_test 的行为断言钉着)。
#[test]
fn the_default_target_is_an_own_dir_not_the_global_isolated_mode() {
let target = AdoptTarget::default();
assert_eq!(target, AdoptTarget::InstanceDir);
assert_eq!(target.as_str(), "instance_dir");
assert_eq!(target.isolation(), Isolation::Custom);
assert_ne!(target.isolation(), Isolation::Isolated);
}
/// `--isolation custom --game-dir <相对路径>` ⇒ 明确报错(不猜、不悄悄改成绝对路径)。
///
/// 抓得住:相对路径被默默接受(`TargetInvalid` 变成成功)。
/// 抓不住:Windows 上 `C:foo` 这种「带盘符但非绝对」的形状,只看 `is_absolute`。
#[test]
fn custom_target_requires_an_absolute_directory() {
let root = TempRoot::new("custom-rel");
foreign_minecraft(&root.game_root());
let error = adopt::adopt(
&root.game(),
&root.launcher(),
&request("1.20.1", AdoptTarget::Custom(PathBuf::from("relative/dir"))),
)
.expect_err("相对路径应被拒");
assert_eq!(error.code(), "adopt_target_invalid");
assert!(error.to_string().contains("绝对路径"), "{error}");
}
/// `--dry-run` 的 core 形态(`plan`):一个文件都不写、目录也不建。
///
/// 抓得住:`plan` 顺手建了运行目录或写了档案(两种断言都会红)。
/// 抓不住:CLI 的 `--dry-run` 是否真的只调 `plan`(那条在 cli 侧用例里)。
#[test]
fn plan_writes_nothing_at_all() {
let root = TempRoot::new("plan");
foreign_minecraft(&root.game_root());
let before = snapshot(root.path());
let plan = adopt::plan(
&root.game(),
&root.launcher(),
&request("1.20.1", AdoptTarget::InstanceDir),
)
.expect("计划应成功");
let after = snapshot(root.path());
assert_unchanged(&before, &after, "plan 之后(含 .ULL)");
assert!(plan.candidate.adoptable());
assert!(!plan.run_dir.exists(), "plan 不该建目录");
// `.ULL` 连**空目录**都不许出现(存在性用 `snapshot_opt`,与「空树」区分开)。
assert!(
snapshot_opt(&root.path().join(".ULL")).is_none(),
"plan 不该建配置目录(连空的也不行)"
);
}
/// 不可登记的版本(缺父)⇒ 一条都不登记,问题清单原样带出。
///
/// 抓得住:缺父的版本被登记(启动必失败却显示「导入成功」)、问题被折叠成一句空话。
/// 抓不住:用户在原来的启动器里修好之后重试(那条由重试路径自然覆盖)。
#[test]
fn not_adoptable_versions_are_refused_with_their_reasons() {
let root = TempRoot::new("refuse");
foreign_minecraft(&root.game_root());
let error = adopt::adopt(
&root.game(),
&root.launcher(),
&request("1.20.1-orphan", AdoptTarget::InstanceDir),
)
.expect_err("缺父版本应被拒");
assert_eq!(error.code(), "adopt_not_adoptable");
let codes: Vec<&str> = error
.problems()
.iter()
.map(|problem| problem.code())
.collect();
assert_eq!(codes, vec!["missing_parent"]);
assert!(
!root.path().join(".ULL/config/profiles.json").exists(),
"拒绝时不许留档案"
);
}
/// 不存在的版本 ⇒ `adopt_version_not_found`(不是「帮你在网上下一个」)。
///
/// 抓得住:不存在的 id 被当成可登记(档案指向空版本)。
/// 抓不住:`versions/` 整个不存在的情形(上一条扫描用例覆盖)。
#[test]
fn an_unknown_version_is_reported_as_not_found() {
let root = TempRoot::new("unknown");
foreign_minecraft(&root.game_root());
let error = adopt::adopt(
&root.game(),
&root.launcher(),
&request("9.9.9", AdoptTarget::InstanceDir),
)
.expect_err("不存在的版本应报错");
assert!(matches!(error, AdoptError::UnknownVersion { .. }));
assert_eq!(error.code(), "adopt_version_not_found");
}
+298
View File
@@ -0,0 +1,298 @@
//! `plan` / `adopt` 的落点与写盘面:登记只写自己的配置,外部 `.minecraft` 一个字节都不动。
//!
//! 每条用例都带「抓得住 / 抓不住」,并且**外部快照断言**是每条的核心:`versions/`
//! `libraries/` `assets/` `mods/` `saves/` `config/` `launcher_profiles.json` 的路径集合、
//! 字节数、内容哈希、mtime 全都不许变(`fixture::assert_unchanged`)。
use unlucky_core::game::adopt::{self, AdoptTarget};
use unlucky_core::game::install::instance::{read_instance_config, read_profiles, Isolation};
use unlucky_core::game::isolation::{derive_game_dir, IsolationMode};
use crate::fixture::{assert_unchanged, foreign_minecraft, request, snapshot, TempRoot};
/// 缺省(不给 `--isolation`):运行目录落 ULL 数据区的独立目录,隔离档写 `custom`。
///
/// 抓得住:缺省沿用全局 `isolated`(那样 `run_dir` 会变成 `versions/1.20.1`)、
/// `gameDir` 没落盘、外部文件被写(哈希/mtime 断言)、profiles 没登记。
/// 抓不住:真实启动是否真的把 `--gameDir` 用上(core 侧只验派生结果,CLI 侧用 `launch --dry-run` 验)。
#[test]
fn default_target_lands_in_the_launcher_data_area() {
let root = TempRoot::new("default");
foreign_minecraft(&root.game_root());
let before = snapshot(&root.game_root());
let outcome = adopt::adopt(
&root.game(),
&root.launcher(),
&request("1.20.1", AdoptTarget::InstanceDir),
)
.expect("登记应成功");
let after = snapshot(&root.game_root());
assert_unchanged(&before, &after, "缺省登记之后");
assert_eq!(outcome.isolation, Isolation::Custom);
assert!(outcome.created);
assert!(outcome.dir_created, "缺省目录应被新建");
let instances_dir = root.path().join(".ULL/data/instances");
assert!(
outcome.run_dir.starts_with(&instances_dir),
"运行目录必须在 ULL 数据区:{}",
outcome.run_dir.display()
);
assert!(outcome.run_dir.is_dir());
assert!(
!outcome.run_dir.starts_with(root.game_root()),
"缺省运行目录不许落在别人的 .minecraft 里"
);
// 落盘的三样东西:profiles.json、实例设置、新建的空运行目录。
let profiles = read_profiles(&root.launcher()).expect("读档案");
assert_eq!(profiles.profiles.len(), 1);
assert_eq!(profiles.profiles[0].name, "1.20.1");
assert_eq!(profiles.profiles[0].version_id, "1.20.1");
let config = read_instance_config(&root.launcher(), &outcome.instance_id)
.expect("读设置")
.expect("设置文件存在");
assert_eq!(config.isolation, Some(Isolation::Custom));
assert_eq!(config.game_dir.as_deref(), Some(outcome.run_dir.as_path()));
// 启动时 `--gameDir` 真的会取这个目录(D20 覆盖链的权威求值)。
let derivation = derive_game_dir(
&root.game(),
"1.20.1",
IsolationMode::Isolated,
Some(&config),
None,
)
.expect("派生运行目录");
assert_eq!(derivation.game_dir, outcome.run_dir);
assert_eq!(derivation.isolation, Isolation::Custom);
}
/// `--isolation shared`:运行目录 = 别人的游戏根,且**人读提示必须说出后果**。
///
/// 抓得住:shared 档被当成 custom 写 `game_dir`、提示里没提「共用/别人的 .minecraft」、
/// 登记过程往游戏根写了东西(哈希断言)。
/// 抓不住:用户是否真的读到了这句提示(那属于 UI 验收)。
#[test]
fn shared_target_points_at_the_game_root_and_says_what_it_means() {
let root = TempRoot::new("shared");
foreign_minecraft(&root.game_root());
let before = snapshot(&root.game_root());
let outcome = adopt::adopt(
&root.game(),
&root.launcher(),
&request("1.20.1", AdoptTarget::Shared),
)
.expect("登记应成功");
let after = snapshot(&root.game_root());
assert_unchanged(&before, &after, "shared 登记之后");
assert_eq!(outcome.isolation, Isolation::Shared);
assert_eq!(outcome.run_dir, root.game_root());
assert!(
outcome.notes.iter().any(|note| note.contains("共用")),
"必须告知「共用别人的 .minecraft」:{:?}",
outcome.notes
);
let config = read_instance_config(&root.launcher(), &outcome.instance_id)
.expect("读设置")
.expect("设置文件存在");
assert_eq!(config.isolation, Some(Isolation::Shared));
assert_eq!(config.game_dir, None, "shared 档不该固化成 custom");
}
/// `--isolation isolated`:运行目录 = `versions/<id>`,**登记这一刻仍不往那里写任何东西**。
///
/// 抓得住:`VersionDir` 被漏做(`isolation` 不是 `Isolated`)、提示里没提「会新增玩家数据」、
/// 登记当场就在版本目录里建了 `mods/`(下面的目录内容断言会红)。
/// 抓不住:首次启动之后游戏自己往里写(那是启动链路的事,本 mission 不跑真游戏)。
#[test]
fn version_dir_target_is_explicit_and_writes_nothing_yet() {
let root = TempRoot::new("isolated");
foreign_minecraft(&root.game_root());
let before = snapshot(&root.game_root());
let outcome = adopt::adopt(
&root.game(),
&root.launcher(),
&request("1.20.1", AdoptTarget::VersionDir),
)
.expect("登记应成功");
let after = snapshot(&root.game_root());
assert_unchanged(&before, &after, "isolated 登记之后");
assert_eq!(outcome.isolation, Isolation::Isolated);
assert_eq!(outcome.run_dir, root.game_root().join("versions/1.20.1"));
assert!(
outcome.notes.iter().any(|note| note.contains("新增")),
"必须告知「第一次启动会在这里新增玩家数据」:{:?}",
outcome.notes
);
assert!(!outcome.dir_created, "版本目录已存在,不该新建");
let mut entries: Vec<String> = std::fs::read_dir(&outcome.run_dir)
.expect("读版本目录")
.filter_map(|entry| Some(entry.ok()?.file_name().to_string_lossy().into_owned()))
.collect();
entries.sort();
assert_eq!(
entries,
vec!["1.20.1.jar".to_string(), "1.20.1.json".to_string()],
"版本目录里不该多出任何东西"
);
}
/// 展示名已被别的实例占用 ⇒ 显式报错,不改任何档案(不静默复用别的版本)。
///
/// 抓得住:`register` 的幂等复用路径被当成「导入成功」(那样第二个版本会指向第一个实例的档案)。
/// 抓不住:CLI 的 hint 文案(那条在 cli 侧用例里)。
#[test]
fn a_taken_name_is_an_explicit_error() {
let root = TempRoot::new("dupname");
foreign_minecraft(&root.game_root());
adopt::adopt(
&root.game(),
&root.launcher(),
&request("1.20.1", AdoptTarget::InstanceDir),
)
.expect("第一个登记成功");
let profiles_before =
std::fs::read(root.path().join(".ULL/config/profiles.json")).expect("读档案");
let mut second = request("1.20.1-fabric-0.15.0", AdoptTarget::InstanceDir);
second.name = Some("1.20.1".to_string());
let error = adopt::adopt(&root.game(), &root.launcher(), &second).expect_err("重名应被拒");
assert_eq!(error.code(), "adopt_duplicate_name");
let profiles_after =
std::fs::read(root.path().join(".ULL/config/profiles.json")).expect("读档案");
assert_eq!(profiles_before, profiles_after, "失败不许改档案");
}
/// 同一个版本重复登记 ⇒ 无副作用失败(`adopt_already_registered`),档案逐字节不变。
///
/// 抓得住:重复登记变成「静默成功」(第二条断言会红),或失败时仍改写了档案。
/// 抓不住:并发两次 adopt 的竞态(没有锁;本命令不是并发入口,如实记在这里)。
#[test]
fn repeating_the_same_adopt_fails_without_side_effects() {
let root = TempRoot::new("repeat");
foreign_minecraft(&root.game_root());
adopt::adopt(
&root.game(),
&root.launcher(),
&request("1.20.1", AdoptTarget::InstanceDir),
)
.expect("第一次登记成功");
let config_dir = root.path().join(".ULL/config");
let before = snapshot(&config_dir);
let error = adopt::adopt(
&root.game(),
&root.launcher(),
&request("1.20.1", AdoptTarget::InstanceDir),
)
.expect_err("重复登记应报错");
assert_eq!(error.code(), "adopt_already_registered");
let after = snapshot(&config_dir);
assert_unchanged(&before, &after, "重复登记失败之后");
}
/// 缺省运行目录已存在且非空 ⇒ 拒绝(不猜「合并进已有实例」,不覆盖用户内容)。
///
/// 抓得住:非空目录被复用(内容被游戏写进去只是时间问题)、拒绝时仍写了档案。
/// 抓不住:空目录复用那条正向路径(见下一条用例)。
#[test]
fn a_non_empty_default_run_dir_is_refused() {
let root = TempRoot::new("occupied");
foreign_minecraft(&root.game_root());
let plan = adopt::plan(
&root.game(),
&root.launcher(),
&request("1.20.1", AdoptTarget::InstanceDir),
)
.expect("先算一次落点");
std::fs::create_dir_all(&plan.run_dir).expect("建目录");
std::fs::write(plan.run_dir.join("someone-elses-file.txt"), b"keep me").expect("写文件");
let error = adopt::adopt(
&root.game(),
&root.launcher(),
&request("1.20.1", AdoptTarget::InstanceDir),
)
.expect_err("非空目录应被拒");
assert_eq!(error.code(), "adopt_target_not_empty");
assert_eq!(
std::fs::read(plan.run_dir.join("someone-elses-file.txt")).expect("读文件"),
b"keep me"
);
assert!(
!root.path().join(".ULL/config/profiles.json").exists(),
"拒绝时不许留档案"
);
}
/// 缺省运行目录存在但**为空** ⇒ 复用(上次失败的残留不该让用户永远重试不了)。
///
/// 抓得住:空目录也被拒(那就把「先建目录再登记」这条路堵死了)。
/// 抓不住:目录里的隐藏文件(`read_dir` 能看见,但平台差异在这里没覆盖)。
#[test]
fn an_empty_default_run_dir_is_reused() {
let root = TempRoot::new("empty-dir");
foreign_minecraft(&root.game_root());
let plan = adopt::plan(
&root.game(),
&root.launcher(),
&request("1.20.1", AdoptTarget::InstanceDir),
)
.expect("先算一次落点");
std::fs::create_dir_all(&plan.run_dir).expect("建空目录");
let outcome = adopt::adopt(
&root.game(),
&root.launcher(),
&request("1.20.1", AdoptTarget::InstanceDir),
)
.expect("空目录可复用");
assert!(!outcome.dir_created, "已存在的空目录不算新建");
assert_eq!(outcome.run_dir, plan.run_dir);
}
/// 数据区里预先放了指向外部的目录联接 ⇒ 缺省运行目录拒绝沿它写盘。
///
/// 抓得住:`create_dir_all` 直接沿联接在数据根之外建目录(外部会多出条目,最后一条断言红),
/// 或错误码不是 `adopt_target_invalid`。
/// 抓不住:数据根**自己**是链接的情形——那是用户的数据目录迁移手法,允许(判据是落点仍在
/// 解析后的数据根内);也抓不住检查与建目录之间的 TOCTOU。
#[cfg(windows)]
#[test]
fn a_junction_inside_the_data_area_does_not_get_written_through() {
let root = TempRoot::new("datajunction");
foreign_minecraft(&root.game_root());
let outside = root.path().join("outside-instances");
std::fs::create_dir_all(&outside).expect("建外部目录");
// 逐段 join:`mklink` 把正斜杠当开关,拼成一串带 `/` 的路径会被判「无效参数」。
let data_instances = root.path().join(".ULL").join("data").join("instances");
std::fs::create_dir_all(data_instances.parent().expect("父目录")).expect("建 data 目录");
if !crate::fixture::link_guard(
crate::fixture::try_junction(&outside, &data_instances),
"数据区目录联接",
) {
return;
}
let error = adopt::adopt(
&root.game(),
&root.launcher(),
&request("1.20.1", AdoptTarget::InstanceDir),
)
.expect_err("数据区里的联接必须被拒");
assert_eq!(error.code(), "adopt_target_invalid", "{error}");
let entries: Vec<String> = std::fs::read_dir(&outside)
.expect("读外部目录")
.filter_map(|entry| Some(entry.ok()?.file_name().to_string_lossy().into_owned()))
.collect();
assert!(
entries.is_empty(),
"不许沿联接在数据根之外建目录:{entries:?}"
);
}
+347
View File
@@ -0,0 +1,347 @@
//! `scan` / `inspect` 的判据:正常版本能不能过、坏东西是不是**逐条**说清楚。
//!
//! 每条断言下面都标了「抓得住 / 抓不住」:抓得住的是变异实现会变红的具体结论,
//! 抓不住的是只能靠人读的东西(措辞、是否还有别的静默吞掉路径)。
use unlucky_core::game::adopt::{self, AdoptCandidate};
use crate::fixture::{assert_unchanged, foreign_minecraft, snapshot, TempRoot};
/// 扫一次并按 id 取候选(`inspect` 对不合格候选返回 `Err`,扫描则逐项列出,断言更好写)。
fn candidate_of(root: &TempRoot, version_id: &str) -> AdoptCandidate {
let scan = adopt::scan(&root.game(), &root.launcher()).expect("扫描应成功");
scan.candidates
.into_iter()
.find(|candidate| candidate.version_id == version_id)
.unwrap_or_else(|| panic!("扫描里没有 {version_id}"))
}
/// 正常 / 缺清单 / 坏 JSON / 缺父 / 成环:一次扫描把每个候选的结论都说出来。
///
/// 抓得住:`scan` 把坏东西当空列表(`candidates` 少项就红)、漏掉父链(`inherits` 长度变)、
/// 缺父被当成可登记(`adoptable` 变真)、成环没被检出(`cycle-*` 会变可登记)。
/// 抓不住:问题文案的措辞,以及 `versions/` 之外的地方有没有被读写(由下面的哈希断言覆盖)。
#[test]
fn scan_reports_every_version_with_its_reason() {
let root = TempRoot::new("scan");
foreign_minecraft(&root.game_root());
let before = snapshot(root.path());
let scan = adopt::scan(&root.game(), &root.launcher()).expect("扫描应成功");
let after = snapshot(root.path());
assert_unchanged(&before, &after, "scan 之后");
let ids: Vec<&str> = scan
.candidates
.iter()
.map(|candidate| candidate.version_id.as_str())
.collect();
assert_eq!(
ids,
vec![
"1.20.1",
"1.20.1-broken",
"1.20.1-fabric-0.15.0",
"1.20.1-nomanifest",
"1.20.1-orphan",
"cycle-a",
"cycle-b",
],
"扫描必须列出每一个目录(含不可登记的),不能少一行"
);
let by_id = |id: &str| {
scan.candidates
.iter()
.find(|candidate| candidate.version_id == id)
.unwrap_or_else(|| panic!("缺候选 {id}"))
};
let vanilla = by_id("1.20.1");
assert!(
vanilla.adoptable(),
"正常版本应可登记:{:?}",
vanilla.problems
);
assert!(vanilla.jar_present);
assert_eq!(vanilla.inherits, vec!["1.20.1"]);
assert!(!vanilla.is_registered());
let fabric = by_id("1.20.1-fabric-0.15.0");
assert!(fabric.adoptable(), "{:?}", fabric.problems);
assert!(!fabric.jar_present, "fabric 版本自己没有 jar");
assert_eq!(
fabric.inherits,
vec!["1.20.1-fabric-0.15.0", "1.20.1"],
"父链必须走到底"
);
let codes = |id: &str| -> Vec<&'static str> {
by_id(id)
.problems
.iter()
.map(|problem| problem.code())
.collect()
};
assert_eq!(codes("1.20.1-broken"), vec!["manifest_invalid_json"]);
assert_eq!(codes("1.20.1-nomanifest"), vec!["manifest_missing"]);
assert_eq!(codes("1.20.1-orphan"), vec!["missing_parent"]);
assert_eq!(codes("cycle-a"), vec!["circular_inheritance"]);
assert_eq!(codes("cycle-b"), vec!["circular_inheritance"]);
for id in ["1.20.1-broken", "1.20.1-orphan", "cycle-a"] {
assert!(!by_id(id).adoptable(), "{id} 不该被判成可登记");
}
}
/// 缺父要说清**缺哪个**、成环要说清**环长什么样**(不是一句「继承有问题」)。
///
/// 抓得住:`MissingParent` 丢了 `parent` 字段、环链只剩最后一个 id(两个都会被下面的 contains 抓到)。
/// 抓不住:错误码是否被 CLI 原样透出(那条在 cli/tests/adopt_test 里)。
#[test]
fn missing_parent_and_cycle_name_what_is_wrong() {
let root = TempRoot::new("chain");
foreign_minecraft(&root.game_root());
let scan = adopt::scan(&root.game(), &root.launcher()).expect("扫描应成功");
let problems = |id: &str| {
scan.candidates
.iter()
.find(|candidate| candidate.version_id == id)
.expect("有候选")
.problems
.clone()
};
let orphan = problems("1.20.1-orphan");
let message = orphan.first().expect("一条问题").to_string();
assert!(message.contains("1.20.1-not-on-disk"), "{message}");
assert!(message.contains("1.20.1-orphan"), "{message}");
let cycle = problems("cycle-a");
let message = cycle.first().expect("一条问题").to_string();
assert!(message.contains("cycle-a"), "{message}");
assert!(message.contains("cycle-b"), "{message}");
}
/// 坏 JSON 要带**行列号**(用户才知道去改哪一行)。
///
/// 抓得住:把 serde 的错误文本换成一句「解析失败」(`line` 就没了)。
/// 抓不住:具体行列号对不对(不同 serde_json 版本的措辞会变,只断言关键字)。
#[test]
fn broken_json_message_carries_a_line_number() {
let root = TempRoot::new("badjson");
foreign_minecraft(&root.game_root());
let candidate = candidate_of(&root, "1.20.1-broken");
let message = candidate.problems.first().expect("一条问题").to_string();
assert!(message.contains("line"), "{message}");
assert!(message.contains("1.20.1-broken"), "{message}");
}
/// 清单 `id` 与目录名不一致 ⇒ 拒绝(启动时会按清单 id 找 jar,必然找不到)。
///
/// 抓得住:去掉 `ManifestIdMismatch` 这条判据后,这个版本会变成「可登记」。
/// 抓不住:真实启动是否真的失败(那需要真跑游戏,本 mission 不做)。
#[test]
fn manifest_id_must_match_the_directory_name() {
let root = TempRoot::new("idmismatch");
foreign_minecraft(&root.game_root());
let dir = root.game_root().join("versions/1.20.1-copy");
std::fs::create_dir_all(&dir).expect("建目录");
std::fs::write(
dir.join("1.20.1-copy.json"),
br#"{"id":"1.20.1","type":"release","mainClass":"x","libraries":[]}"#,
)
.expect("写清单");
std::fs::write(dir.join("1.20.1-copy.jar"), b"jar").expect("写 jar");
let candidate = candidate_of(&root, "1.20.1-copy");
let codes: Vec<&str> = candidate
.problems
.iter()
.map(|problem| problem.code())
.collect();
assert_eq!(codes, vec!["manifest_id_mismatch"]);
}
/// 版本 id 不能当路径用(`../` / 盘符 / 结尾点号)⇒ 一条结构化问题,不去碰盘。
///
/// 抓得住:删掉 id 校验后 `inspect("../escape")` 会去读游戏根之外的路径(下面断言问题码)。
/// 抓不住:Windows 大小写不敏感之类的平台细节(与判据无关)。
#[test]
fn version_ids_that_cannot_be_paths_are_rejected() {
let root = TempRoot::new("badid");
foreign_minecraft(&root.game_root());
let escape = root.path().join("outside");
std::fs::create_dir_all(escape.join("1.20.1")).expect("建外部同名目录");
std::fs::write(
escape.join("1.20.1/1.20.1.json"),
br#"{"id":"1.20.1","type":"release","mainClass":"x","libraries":[]}"#,
)
.expect("写外部清单");
// `versions/../../outside/1.20.1` 这种形状:目录真的存在(能解析出去),但 id 里含分隔符。
let error = adopt::inspect(&root.game(), &root.launcher(), "../../outside/1.20.1")
.expect_err("非法 id 应报错,而不是去读游戏根之外的目录");
assert_eq!(error.code(), "adopt_not_adoptable");
let codes: Vec<&str> = error
.problems()
.iter()
.map(|problem| problem.code())
.collect();
assert_eq!(codes, vec!["version_id_invalid"], "{error}");
for bad in ["1.20.1.", "1.20.1 ", "C:", "a/b"] {
let error =
adopt::inspect(&root.game(), &root.launcher(), bad).expect_err("非法 id 应报错");
assert!(
error
.problems()
.iter()
.any(|problem| problem.code() == "version_id_invalid"),
"{bad:?} 应被当成非法 id:{error}"
);
}
}
/// 版本目录是指向游戏根**之外**的符号链接 ⇒ 拒绝不跟随(也不读它)。
///
/// 抓得住:`ensure_inside` 不比较解析后的真实路径(那样 `1.20.1-link` 会变成「可登记」)。
/// 抓不住 / 已知局限:
/// - 本机不允许建符号链接(Windows 未开开发者模式、或非管理员)时这条会打印 `[link-guard]`
/// 并**跳过**——显式记录而不是静静通过;设 `ULL_ADOPT_TEST_SYMLINK_REQUIRE=1` 则建不出来**必红**;
/// - 目录联接(junction)由下面单独那条用例覆盖(不需要管理员权限,是更常见的形态)。
#[test]
fn a_symlinked_version_pointing_outside_the_game_root_is_rejected() {
let root = TempRoot::new("symlink");
foreign_minecraft(&root.game_root());
let outside = root.path().join("other-launcher");
std::fs::create_dir_all(outside.join("1.20.1")).expect("建外部版本目录");
std::fs::write(
outside.join("1.20.1/1.20.1.json"),
br#"{"id":"1.20.1","type":"release","mainClass":"x","libraries":[]}"#,
)
.expect("写外部清单");
let link = root.game_root().join("versions/1.20.1-link");
// 建不出来时:非强制模式打印一行并跳过;`ULL_ADOPT_TEST_SYMLINK_REQUIRE=1` 必红(R4)。
if !crate::fixture::link_guard(
crate::fixture::try_symlink_dir(&outside.join("1.20.1"), &link),
"根外符号链接",
) {
return;
}
let candidate = candidate_of(&root, "1.20.1-link");
let codes: Vec<&str> = candidate
.problems
.iter()
.map(|problem| problem.code())
.collect();
assert!(
codes.contains(&"outside_game_root"),
"越界链接必须被点名:{codes:?}"
);
assert!(!candidate.adoptable(), "越界链接的版本不许登记");
}
/// 游戏根内的符号链接(指向同一棵树里的真实版本目录)**不**拒绝:那是合法的省盘做法。
///
/// 抓得住:`check_symlink` 一刀切拒绝所有符号链接(这条会红)。
/// 抓不住:本机建不出链接时同上跳过(打印 `[link-guard]`,强制模式下必红)。
#[test]
fn a_symlink_inside_the_game_root_is_allowed() {
let root = TempRoot::new("symlink-in");
foreign_minecraft(&root.game_root());
// 真实版本目录放在根内的 `hidden/` 下(清单 id 与目录名一致),再在 `versions/` 里挂一条链接。
let real = root.game_root().join("hidden/1.20.1-thin");
std::fs::create_dir_all(&real).expect("建真实版本目录");
std::fs::write(
real.join("1.20.1-thin.json"),
br#"{"id":"1.20.1-thin","type":"release","mainClass":"x","libraries":[]}"#,
)
.expect("写清单");
let link = root.game_root().join("versions/1.20.1-thin");
if !crate::fixture::link_guard(
crate::fixture::try_symlink_dir(&real, &link),
"根内符号链接",
) {
return;
}
let candidate = candidate_of(&root, "1.20.1-thin");
assert!(
candidate.adoptable(),
"根内链接不该被拒:{:?}",
candidate.problems
);
}
/// `versions/` 不存在 = 空结果(新数据根不是错误),但**不存在的游戏根不会被建出来**。
///
/// 抓得住:`scan` 顺手 `create_dir_all(versions)`(下面的断言会红),或把「不存在」当错误。
/// 抓不住:读目录失败(ENOTDIR/EACCES)那条分支——真造一个不可读目录在 Windows 上不可靠,
/// 那一条由代码审读把关。
#[test]
fn a_missing_versions_dir_is_empty_not_an_error() {
let root = TempRoot::new("noversions");
let scan = adopt::scan(&root.game(), &root.launcher()).expect("应成功");
assert!(scan.candidates.is_empty());
assert!(
!root.game_root().exists(),
"扫描不该顺手建游戏根目录:{}",
root.game_root().display()
);
}
/// Windows 目录联接(junction)指向根外 ⇒ **同样**拒绝,而且不去读那个目录。
///
/// 为什么要单列:junction 不是 `is_symlink`,只看链接类型的实现会放行它——而 junction
/// **不需要管理员权限**就能建,是把别人版本目录挂进 `versions/` 最常见的形态。
///
/// 抓得住:
/// - 判据退回 `is_symlink`(除 Windows junction 外全都看不到,此时候选会变成「可登记」,第一条断言红);
/// - 判据排到**解析清单之后**:根外那份清单是合法 JSON 且 id 与目录名一致,一旦走到解析,
/// `manifest_id` 就会被填上(第二条断言红)——所以它至少能证明「没解析过根外清单」;
/// - 建不出链接时走 `fixture::link_guard`:非强制模式打印 `[link-guard]` 并跳过,
/// 强制模式(`ULL_ADOPT_TEST_SYMLINK_REQUIRE=1`)**必红**(真值表见 `fixture::link_gate_truth_table`)。
///
/// 抓不住(证据边界,别把它读大):**只 stat/只 open 不解析**的读取这里看不出来
/// (`manifest_id` 为 `None` 只说明没解析过,不说明没碰过文件);「边界检查排在
/// `read_manifest` 之前」这条由 `inspect.rs::check` 的代码顺序保证,靠人读。
/// 也抓不住检查与读取之间的 TOCTOU(本模块不是并发/对抗入口)。
#[cfg(windows)]
#[test]
fn a_junction_pointing_outside_is_rejected_without_reading_it() {
let root = TempRoot::new("junction");
foreign_minecraft(&root.game_root());
// 逐段 join:`cmd` / `mklink` 会把正斜杠当开关,拼成一串带 `/` 的路径会被判「无效参数」。
let outside = root.path().join("other-launcher").join("1.20.1-junction");
std::fs::create_dir_all(&outside).expect("建外部版本目录");
std::fs::write(
outside.join("1.20.1-junction.json"),
br#"{"id":"1.20.1-junction","type":"release","mainClass":"x","libraries":[]}"#,
)
.expect("写外部清单");
let link = root.game_root().join("versions").join("1.20.1-junction");
if !crate::fixture::link_guard(
crate::fixture::try_junction(&outside, &link),
"根外目录联接",
) {
return;
}
let candidate = candidate_of(&root, "1.20.1-junction");
let codes: Vec<&str> = candidate
.problems
.iter()
.map(|problem| problem.code())
.collect();
assert!(
codes.contains(&"outside_game_root"),
"联接越界必须被点名:{codes:?} / {:?}",
candidate.problems
);
assert!(!candidate.adoptable(), "越界联接的版本不许登记");
assert!(
candidate.manifest_id.is_none() && candidate.inherits.is_empty(),
"拒绝之后不许再读根外那份清单:{:?}",
candidate.manifest_id
);
}
+180
View File
@@ -0,0 +1,180 @@
//! 目录树**全节点**快照:目录 / 文件 / 链接各记自己的类型与适用 mtime。
//!
//! # 为什么不是「只记文件」
//!
//! 复审 r1 的 R5:早先版本遇到目录只递归、不记录,于是「登记过程凭空建了一个空目录」
//! (例如错误地在 `<game>/versions/<v>/mods` 里 `create_dir_all`)在快照上**完全看不出来**。
//! 「只读外部」这条承诺必须能抓住新增/删除的**目录节点**,所以这里:
//!
//! - 根自身、每个目录、每个文件、每个链接都进快照(相对路径 → [`Node`]);
//! - 目录记 mtime(条目增删会改它),文件记字节数 + mtime + 内容哈希;
//! - **链接不跟随**:只记目标路径(跟随会把根外的内容算进「外部树」,让越界写入看起来像「没变化」);
//! Windows 的目录联接(junction)不是 `is_symlink`,这里额外用重解析点属性识别它;
//! - 任何读盘失败都**直接失败**(`panic`),不吞成「这项没变」——吞错就是假绿。
//!
//! # 为什么 CLI 测试也用这一份
//!
//! 以前 core 与 CLI 各写一份 walk,两份都漏了目录节点(同一处缺口两遍)。现在 CLI 的
//! `adopt_test/fixture.rs` 用 `#[path = "../../../core/tests/adopt/snapshot.rs"]` 复用本文件:
//! **判据只有一份**,不会再次漂移。
use std::collections::hash_map::DefaultHasher;
use std::collections::BTreeMap;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
/// 快照里的一个节点。
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum Node {
/// 目录(只记 mtime:条目增删会改它)。
Dir {
/// mtime(纳秒;取不到记 0)。
mtime_ns: u128,
},
/// 文件(字节数 + mtime + 内容哈希)。
File {
/// 字节数。
len: u64,
/// mtime(纳秒)。
mtime_ns: u128,
/// 内容哈希(稳定即可,不要密码学强度)。
hash: u64,
},
/// 符号链接 / Windows 目录联接:只记目标,**不跟随**。
Link {
/// 链接目标(原样,可能是相对路径;读不出来时记 `<unreadable-link>`)。
target: PathBuf,
},
}
/// 一次快照:相对路径(根自身是 `.`)→ 节点。
pub type Snapshot = BTreeMap<String, Node>;
/// 拍一次全节点快照(目标不存在 = 空快照,见 [`snapshot_opt`] 的区分)。
///
/// # Panics
///
/// 任何目录/元数据/文件读不出来都 panic:读不出来不能当成「没变化」。
pub fn snapshot(root: &Path) -> Snapshot {
snapshot_opt(root).unwrap_or_default()
}
/// 同 [`snapshot`],但目标**不存在**时返回 `None`(用于区分「空树」与「根本没有」)。
///
/// # Panics
///
/// 同上(存在但读不出来即 panic)。
pub fn snapshot_opt(root: &Path) -> Option<Snapshot> {
match std::fs::symlink_metadata(root) {
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None,
Err(error) => panic!("快照根读不出来 {}:{error}", root.display()),
}
let mut out = Snapshot::new();
out.insert(".".to_string(), node_of(root));
walk(root, root, &mut out);
Some(out)
}
fn walk(root: &Path, dir: &Path, out: &mut Snapshot) {
let entries = std::fs::read_dir(dir)
.unwrap_or_else(|error| panic!("目录应可读 {}:{error}", dir.display()));
for entry in entries {
let entry = entry.unwrap_or_else(|error| panic!("目录项应可读 {}:{error}", dir.display()));
let path = entry.path();
let relative = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
let node = node_of(&path);
let is_dir = matches!(node, Node::Dir { .. });
out.insert(relative, node);
if is_dir {
walk(root, &path, out);
}
}
}
/// 单个节点的形状(链接/联接**不跟随**)。
fn node_of(path: &Path) -> Node {
let meta = std::fs::symlink_metadata(path)
.unwrap_or_else(|error| panic!("元数据应可读 {}:{error}", path.display()));
if link_like(&meta) {
let target =
std::fs::read_link(path).unwrap_or_else(|_| PathBuf::from("<unreadable-link>"));
return Node::Link { target };
}
let mtime_ns = meta
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map_or(0, |duration| duration.as_nanos());
if meta.file_type().is_dir() {
return Node::Dir { mtime_ns };
}
let bytes = std::fs::read(path)
.unwrap_or_else(|error| panic!("文件应可读 {}:{error}", path.display()));
Node::File {
len: bytes.len() as u64,
mtime_ns,
hash: hash(&bytes),
}
}
/// 符号链接(所有平台)或 Windows 重解析点(junction / mount point)。
///
/// `FileType::is_symlink` 对 junction 返回 `false`,所以 Windows 上另看
/// `FILE_ATTRIBUTE_REPARSE_POINT`:不识别它就会把 junction 当普通目录**递归进去**,
/// 等于跟随链接读根外内容。
fn link_like(meta: &std::fs::Metadata) -> bool {
if meta.file_type().is_symlink() {
return true;
}
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
}
#[cfg(not(windows))]
{
false
}
}
/// 内容哈希(只要稳定、不需要密码学强度)。
fn hash(bytes: &[u8]) -> u64 {
let mut hasher = DefaultHasher::new();
bytes.hash(&mut hasher);
hasher.finish()
}
/// 断言两次快照**完全一致**:路径集合(含目录/链接)与每个节点的形状都不许变。
///
/// 抓得住:
/// - 新增/删除**空目录**(目录节点进快照)、目录条目变化(目录 mtime 变);
/// - 文件被改写(哈希变、mtime 变)或被删(路径集合变);
/// - 链接目标被改(`Link.target` 变)。
///
/// 抓不住:只读打开(无可观测痕迹)、文件内容相同且 mtime 未被改写的「伪写」、
/// 以及检查期间的 TOCTOU。
///
/// # Panics
///
/// 快照不一致即 panic(消息里点名第一个不同的路径)。
pub fn assert_unchanged(before: &Snapshot, after: &Snapshot, what: &str) {
assert_eq!(
before.keys().collect::<Vec<_>>(),
after.keys().collect::<Vec<_>>(),
"{what}:路径集合(含目录/链接节点)变了"
);
for (path, node) in before {
assert_eq!(
node,
after.get(path).expect("同一路径"),
"{what}:节点被改动 -> {path}"
);
}
}
+325
View File
@@ -0,0 +1,325 @@
//! 写盘面与入口可用性:**配置目录被链出去**必须拒绝(R2)、**versions 入口断链**必须具名报错(R3)。
//!
//! 两类都属于「本 mission 承诺的东西被链接绕过」:前者是「只写自己的配置」被
//! `config/instances` 的目录联接打穿(会替别人的启动器在 `.minecraft/config` 里造 UUID 文件),
//! 后者是「坏东西不能当空列表」被 `read_dir` 的 NotFound 打穿(断链 junction 与「真没有」同态)。
//!
//! 两条都用**真链接夹具**;建不出链接时走 `fixture::link_guard`(强制模式下必红,见 R4)。
use unlucky_core::game::adopt::{self, AdoptTarget};
use crate::fixture::{
assert_unchanged, foreign_minecraft, link_guard, request, snapshot, try_link_dir, TempRoot,
};
/// `config/instances` 被目录联接指到游戏根的 `config/`:adopt 必须先拒绝,且游戏根零写入。
///
/// 抓得住:
/// - 只核运行目录、不核配置写面(那样这次登记会 exit0,并在 `.minecraft/config/<uuid>.json`
/// 多出一个文件——`before/after` 全节点快照与「外部 config 目录为空」两条都会红);
/// - `plan`(dry-run)也放行(第四条断言红:预检说「可以」,真写才失败);
/// - 拒绝时先写了目录/配置(快照与目录列举都会红)。
///
/// 抓不住:检查与写入之间的 TOCTOU(本模块不是并发/对抗入口)。
#[cfg(windows)]
#[test]
fn a_junction_on_the_instances_dir_must_not_write_into_the_game_root() {
use crate::fixture::try_junction;
let root = TempRoot::new("cfg-instances-junction");
foreign_minecraft(&root.game_root());
let game_config = root.game_root().join("config");
std::fs::create_dir_all(&game_config).expect("建别人的 config 目录");
let instances_dir = root.path().join(".ULL").join("config").join("instances");
std::fs::create_dir_all(instances_dir.parent().expect("config 目录")).expect("建 .ULL/config");
if !link_guard(
try_junction(&game_config, &instances_dir),
"config/instances 目录联接",
) {
return;
}
let before = snapshot(&root.game_root());
// 别人的 `config/` 里本来就有文件(`example.toml` 是玩家配置):判据是**没有新增条目**。
let config_before = listing(&game_config);
let plan_error = adopt::plan(
&root.game(),
&root.launcher(),
&request("1.20.1", AdoptTarget::InstanceDir),
)
.expect_err("配置写面被链走时,dry-run 也必须报错");
assert_eq!(plan_error.code(), "adopt_write_surface_in_game_tree");
let error = adopt::adopt(
&root.game(),
&root.launcher(),
&request("1.20.1", AdoptTarget::InstanceDir),
)
.expect_err("配置写面被链走时必须拒绝登记");
assert_eq!(error.code(), "adopt_write_surface_in_game_tree");
let after = snapshot(&root.game_root());
assert_unchanged(&before, &after, "拒绝之后(游戏根零写入)");
assert_eq!(
config_before,
listing(&game_config),
"绝不许在别人的 .minecraft/config 里新增文件(尤其 UUID 配置)"
);
assert!(
!listing(&game_config)
.iter()
.any(|name| name.ends_with(".json") && name.len() == 41),
"不该出现 UUID 形状的 json:{:?}",
listing(&game_config)
);
}
/// `config/` 整层被链到游戏根:同样拒绝(判定粒度到**每一个**写盘目标,不只看 `instances`)。
///
/// 抓得住:只检查 `config/instances` 而不检查 `config/` 与 `profiles.json`(那样这条会 exit0)。
/// 抓不住:跨卷/跨盘的整体迁移(`<.ULL>` 自己是联接)——那是允许的,见 `run_dir` 模块文档。
#[test]
fn a_linked_config_dir_must_not_write_into_the_game_root() {
let root = TempRoot::new("cfg-dir-link");
foreign_minecraft(&root.game_root());
let game_config = root.game_root().join("config");
std::fs::create_dir_all(&game_config).expect("建别人的 config 目录");
let ull = root.path().join(".ULL");
std::fs::create_dir_all(&ull).expect("建 .ULL");
let config_dir = ull.join("config");
if !link_guard(try_link_dir(&game_config, &config_dir), "config 目录链接") {
return;
}
let before = snapshot(&root.game_root());
let error = adopt::adopt(
&root.game(),
&root.launcher(),
&request("1.20.1", AdoptTarget::InstanceDir),
)
.expect_err("config/ 被链走时必须拒绝登记");
assert_eq!(error.code(), "adopt_write_surface_in_game_tree");
let after = snapshot(&root.game_root());
assert_unchanged(&before, &after, "拒绝之后(游戏根零写入)");
}
/// `versions/` 入口**存在但断链**(目标被移走)⇒ 具名报错,而不是「这个数据根还没有版本」。
///
/// 抓得住:`read_dir` 的 `NotFound` 一律当空列表(旧实现;Windows 断链 junction 正是这个形状)
/// ——第一条断言会红成「成功空列表」。
/// 抓不住:`versions/` 是普通文件的情形(走 `GameError::io`,错误码不同,靠人读)。
#[test]
fn a_dangling_versions_entry_is_an_error_not_an_empty_scan() {
let root = TempRoot::new("dangling-versions");
std::fs::create_dir_all(root.game_root()).expect("建游戏根");
let moved = root.path().join("moved-versions");
std::fs::create_dir_all(moved.join("1.20.1")).expect("建将被移走的 versions");
let versions = root.game_root().join("versions");
if !link_guard(try_link_dir(&moved, &versions), "versions 入口链接") {
return;
}
// 目标被移走 ⇒ 入口成为断链;lstat 仍然看得见它。
std::fs::remove_dir_all(&moved).expect("移走目标");
assert!(
std::fs::symlink_metadata(&versions).is_ok(),
"夹具有问题:断链入口应当仍能被 lstat 看到"
);
let error = adopt::scan(&root.game(), &root.launcher())
.expect_err("断链的 versions 入口必须报错,不能当空列表");
assert_eq!(error.code(), "adopt_versions_unreadable", "{error}");
}
/// `versions` 真不存在 ⇒ 仍然是空结果(别把「新数据根」误报成故障)。
///
/// 抓得住:为了修 R3 把所有 NotFound 都当错误(这条会红)。
/// 抓不住:真正不可读(EACCES)那一支——Windows 上造不可读目录不可靠。
#[test]
fn a_truly_missing_versions_dir_stays_empty() {
let root = TempRoot::new("no-versions");
std::fs::create_dir_all(root.game_root()).expect("建空游戏根");
let scan = adopt::scan(&root.game(), &root.launcher()).expect("真不存在 ⇒ 空结果");
assert!(scan.candidates.is_empty());
}
/// 目录里直接子项的名字(排序后,用于「有没有新增」的判据)。
///
/// cfg 必须与唯一的调用点(`a_junction_on_the_instances_dir_must_not_write_into_the_game_root`)
/// 对齐:那条用例是 `#[cfg(windows)]`(目录联接夹具只在 Windows 上建得出来),Linux 上调用点
/// 整块编掉,未 gate 的 helper 就成了 `dead_code`(CI 的 `-D warnings` 会让 clippy 退出 101)。
#[cfg(windows)]
fn listing(dir: &std::path::Path) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(dir)
.expect("读目录")
.filter_map(|entry| Some(entry.ok()?.file_name().to_string_lossy().into_owned()))
.collect();
names.sort();
names
}
/// `.ULL/data` 被预先联接到**游戏树**上 ⇒ 默认登记必须拒绝(r2 R2.2),而不是写完再说「隔离」。
///
/// 复审 r2 的实跑:`data -> .minecraft` 时 dry-run 与 adopt 都 exit0,外部多出
/// `instances/<slug>` 且游戏根 mtime 变化,输出仍称「与别人的 .minecraft 隔离」。
///
/// 抓得住:写面重叠不核(那样这次登记会成功,游戏树快照与「外部零新增」断言都会红);
/// 只核配置不核 data(`data` 面上的缺省运行目录仍会落进游戏树)。
///
/// 抓不住:检查与写入之间的 TOCTOU;也不把「后续游戏自己写盘」算作本判据的证据。
#[cfg(windows)]
#[test]
fn data_linked_into_the_game_tree_is_refused_before_any_write() {
use crate::fixture::try_junction;
let root = TempRoot::new("data-to-game");
foreign_minecraft(&root.game_root());
let data = root.path().join(".ULL").join("data");
std::fs::create_dir_all(data.parent().expect(".ULL")).expect("建 .ULL");
if !link_guard(try_junction(&root.game_root(), &data), "data -> 游戏树联接") {
return;
}
let before = snapshot(&root.game_root());
let plan_error = adopt::plan(
&root.game(),
&root.launcher(),
&request("1.20.1", AdoptTarget::InstanceDir),
)
.expect_err("dry-run 也必须拒绝与游戏树重叠的写盘面");
assert_eq!(plan_error.code(), "adopt_write_surface_in_game_tree");
let error = adopt::adopt(
&root.game(),
&root.launcher(),
&request("1.20.1", AdoptTarget::InstanceDir),
)
.expect_err("默认登记必须拒绝与游戏树重叠的 data 写面");
assert_eq!(error.code(), "adopt_write_surface_in_game_tree");
assert_unchanged(
&before,
&snapshot(&root.game_root()),
"拒绝之后(游戏树零写入)",
);
assert!(
!root.game_root().join("instances").exists(),
"绝不许在别人的 .minecraft 里建 instances 目录"
);
}
/// `.ULL` 整棵树被预先联接到游戏树 ⇒ 同样拒绝(配置 + data 一起落进游戏树)。
///
/// 抓得住:只核 `data` 或只核 `config/instances`(那样 `profiles.json` 与 `data/` 仍会写穿)。
///
/// 抓不住:`<.ULL>` 自己是链接但指向**游戏树之外**的情形——那是合法迁移,见下一条正向对照。
#[cfg(windows)]
#[test]
fn home_linked_into_the_game_tree_is_refused_before_any_write() {
use crate::fixture::try_junction;
let root = TempRoot::new("home-to-game");
foreign_minecraft(&root.game_root());
let home = root.path().join(".ULL");
if !link_guard(try_junction(&root.game_root(), &home), ".ULL -> 游戏树联接") {
return;
}
let before = snapshot(&root.game_root());
let error = adopt::adopt(
&root.game(),
&root.launcher(),
&request("1.20.1", AdoptTarget::InstanceDir),
)
.expect_err("默认登记必须拒绝与游戏树重叠的 .ULL 写面");
assert_eq!(error.code(), "adopt_write_surface_in_game_tree");
assert_unchanged(
&before,
&snapshot(&root.game_root()),
"拒绝之后(游戏树零写入)",
);
}
/// **正向对照**:把 `data` 整体迁到游戏树**之外**仍然允许(迁移是合法的,重叠才拒)。
///
/// 抓得住:把「与游戏树重叠」写成「任何链接都拒」(这条正向用例会红)。
/// 抓不住:迁移后的数据目录内容(只断言登记成功且游戏树不变)。
#[test]
fn data_migrated_outside_the_game_tree_still_registers() {
let root = TempRoot::new("data-outside");
foreign_minecraft(&root.game_root());
let external = root.path().join("external-data");
std::fs::create_dir_all(&external).expect("建外部数据目录");
let data = root.path().join(".ULL").join("data");
std::fs::create_dir_all(data.parent().expect(".ULL")).expect("建 .ULL");
if !link_guard(try_link_dir(&external, &data), "data -> 游戏树之外的联接") {
return;
}
let before = snapshot(&root.game_root());
let outcome = adopt::adopt(
&root.game(),
&root.launcher(),
&request("1.20.1", AdoptTarget::InstanceDir),
)
.expect("迁到游戏树之外的 data 应允许");
// 对外返回的是**逻辑**路径(`<.ULL>/data/instances/<slug>`),真实位置才是迁移后的目录。
let real = outcome.run_dir.canonicalize().expect("运行目录真实路径");
let external_real = external.canonicalize().expect("外部数据目录真实路径");
assert!(
real.starts_with(&external_real),
"运行目录的真实位置应落在迁移后的数据目录里:{}",
real.display()
);
assert_unchanged(
&before,
&snapshot(&root.game_root()),
"登记之后(游戏树零写入)",
);
}
/// **更高祖先断链**(r2 R2.3):完整版本树挂在迁移联接下面,联接目标被搬走后 scan 不许报空。
///
/// 复审 r2 的对照:`--data-dir link/nested` 断开前 total=1;把 `link` 的目标改名后,
/// `versions` 与游戏根本身都 NotFound,旧实现返回 `ok:true / total:0`。
///
/// 抓得住:只在 `versions` 与游戏根两层 lstat(更高层断链会漏,第二条断言红);
/// 或反过来把「真正的新数据根」也判成错误(断开**之前**那条 total==1 的断言与
/// `a_truly_missing_versions_dir_stays_empty` 会红)。
#[test]
fn a_dangling_ancestor_above_the_game_root_is_not_an_empty_scan() {
let root = TempRoot::new("ancestor-dangling");
let real = root.path().join("target");
let game = real.join("nested").join(".minecraft");
std::fs::create_dir_all(game.join("versions").join("1.20.1")).expect("建完整版本树");
std::fs::write(
game.join("versions/1.20.1/1.20.1.json"),
br#"{"id":"1.20.1","type":"release","mainClass":"x","libraries":[]}"#,
)
.expect("写清单");
let link = root.path().join("game-parent-link");
if !link_guard(try_link_dir(&real, &link), "游戏根上层联接") {
return;
}
let data_root = root.path().join("data");
std::fs::create_dir_all(&data_root).expect("建数据根");
let minecraft = link.join("nested").join(".minecraft");
let game_layout = unlucky_core::game::GameLayout::new(&minecraft);
let launcher = unlucky_core::platform::layout::LauncherLayout::new(data_root.join(".ULL"));
// 断开之前:完整版本树看得见。
let scan = adopt::scan(&game_layout, &launcher).expect("联接完好的树应可扫");
assert_eq!(scan.candidates.len(), 1, "完整版本树应扫到 1 个候选");
// 断开:把联接目标改名,入口还在但解析不出来(versions 与游戏根本身都 NotFound)。
std::fs::rename(&real, root.path().join("moved-target")).expect("移走联接目标");
assert!(
std::fs::symlink_metadata(&link).is_ok(),
"夹具有问题:断链入口应仍能被 lstat 看到"
);
let error =
adopt::scan(&game_layout, &launcher).expect_err("祖先断链必须具名报错,不能当空结果");
assert_eq!(error.code(), "adopt_versions_unreadable", "{error}");
}
@@ -0,0 +1,151 @@
//! M38:认证失败事件落盘(`.ULL/logs/auth.jsonl`)与凭据红线。
//!
//! 验收场景:`ull accounts add msa` 失败后,终端文案会滚走;日志区里要留下
//! 一行结构化记录(`code` / `detail_code` / `status` / `content_type` / `body_kind` /
//! 已脱敏摘要),且**不含任何 token**。便携数据根还没初始化时先补建 `logs/`。
use serde_json::json;
use unlucky_core::auth::{AuthError, AuthFailureEvent, AuthJournal, BodyKind};
use crate::support::{
assert_no_credentials, auth_for, expired_session, full_routes, routes_until_login, temp_dir,
AuthMock, MockReply, HTML_BLOCK_PAGE,
};
const JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVlLXRoYXQtaXMtbG9uZy1lbm91Z2g";
#[tokio::test]
async fn body_digest_never_carries_credentials() {
let mut routes = routes_until_login();
routes.push((
"/login_with_xbox",
vec![MockReply::json_status(
403,
json!({ "error": "DENIED", "echo": "xsts-token-value", "jwt": JWT }),
)],
));
let mock = AuthMock::start(routes).await;
let error = auth_for(&mock.base())
.device_code_login(|_| {})
.await
.expect_err("应失败");
let message = error.message();
assert_no_credentials(&message);
// 长不透明串(JWT)必须打码,别把 token 回显抄进摘要。
assert!(!message.contains("eyJhbGci"), "JWT 必须脱敏:{message}");
assert!(message.contains("redacted"), "{message}");
// 事件层同样不落凭据。
let event = AuthFailureEvent::of("device_code_login", &error);
let json = event.to_json().to_string();
assert_no_credentials(&json);
assert!(!json.contains("eyJhbGci"), "{json}");
assert!(json.contains("login_with_xbox_403"), "{json}");
mock.stop();
}
#[tokio::test]
async fn failure_journal_initializes_logs_and_records_the_403() {
let root = temp_dir("diag-journal");
let logs = root.join(".ULL").join("logs");
let journal = AuthJournal::in_logs_dir(&logs);
assert!(!logs.exists(), "模拟未初始化的便携数据根");
let mut routes = routes_until_login();
routes.push((
"/login_with_xbox",
vec![MockReply::html(403, HTML_BLOCK_PAGE)],
));
let mock = AuthMock::start(routes).await;
let error = auth_for(&mock.base())
.with_journal(Some(journal.clone()))
.device_code_login(|_| {})
.await
.expect_err("应失败");
let text = std::fs::read_to_string(journal.path()).expect("失败日志必须落盘");
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 1, "一次失败一条记录:{text}");
let record: serde_json::Value = serde_json::from_str(lines[0]).expect("合法 JSONL");
assert_eq!(record["event"], "auth_failure");
assert_eq!(record["level"], "error");
assert_eq!(record["scope"], "device_code_login");
assert_eq!(record["step"], "login_with_xbox");
assert_eq!(record["detail_code"], "login_with_xbox_non_json");
assert_eq!(record["status"], 403);
assert_eq!(record["body_kind"], "html");
assert_eq!(record["transient"], true);
assert_eq!(
record["message"],
error.message(),
"事件里的文案要与返回的错误一致"
);
assert_no_credentials(&text);
mock.stop();
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn journal_event_shape_covers_non_chain_errors() {
let event = AuthFailureEvent::of("refresh", &AuthError::Canceled);
assert_eq!(event.detail_code, "canceled");
assert_eq!(event.step, None);
assert_eq!(event.status, None);
assert_eq!(event.body_kind, None);
assert!(!event.transient);
assert_eq!(event.to_json()["scope"], "refresh");
let detail = unlucky_core::auth::ResponseDetail {
step: "profile".to_string(),
what: "拉取 Minecraft 档案被拒".to_string(),
status: 401,
content_type: Some("application/json".to_string()),
kind: BodyKind::Json,
server_message: Some("Unauthorized".to_string()),
digest: "{\"error\":\"Unauthorized\"}".to_string(),
};
let chain = AuthFailureEvent::of("device_code_login", &AuthError::Response(Box::new(detail)));
assert_eq!(chain.step.as_deref(), Some("profile"));
assert_eq!(chain.detail_code, "profile_401");
assert_eq!(chain.status, Some(401));
assert!(!chain.transient, "JSON 拒绝不是可重试的网络问题");
}
#[tokio::test]
async fn successful_login_writes_no_journal_and_refresh_failures_do() {
let root = temp_dir("diag-journal-ok");
let journal = AuthJournal::in_logs_dir(root.join("logs"));
// 成功:不落盘。
let mock = AuthMock::start(full_routes()).await;
auth_for(&mock.base())
.with_journal(Some(journal.clone()))
.device_code_login(|_| {})
.await
.expect("应登录成功");
assert!(!journal.path().exists(), "成功路径不写失败日志");
mock.stop();
// 刷新失败:落一条(凭据被撤销 = 要人重新登录)。
let failing = AuthMock::start(vec![(
"/token",
vec![MockReply::json_status(
400,
json!({ "error": "invalid_grant", "error_description": "AADSTS70000" }),
)],
)])
.await;
let error = auth_for(&failing.base())
.with_journal(Some(journal.clone()))
.refresh(&expired_session())
.await
.expect_err("应失败");
assert!(error.is_credential_expired());
let text = std::fs::read_to_string(journal.path()).expect("刷新失败也要落盘");
assert_eq!(text.lines().count(), 1, "{text}");
assert!(text.contains("refresh"), "{text}");
assert!(text.contains("credential_expired"), "{text}");
failing.stop();
let _ = std::fs::remove_dir_all(&root);
}
+307
View File
@@ -0,0 +1,307 @@
//! Yggdrasil(authlib-injector)四动作的报文级对接:登录 / 刷新 / 校验 / 注销。
use serde_json::json;
use unlucky_core::auth::authlib::{
AuthlibAuth, AuthlibEndpoints, AuthlibProfile, AuthlibServerMeta, AuthlibSession,
};
use unlucky_core::auth::{AuthError, Secret};
use crate::support::{AuthMock, MockReply};
const API_ROOT: &str = "https://example.invalid/api/yggdrasil/";
const PROFILE_ID: &str = "0123456789abcdef0123456789abcdef";
fn paths() -> (&'static str, &'static str, &'static str, &'static str) {
(
"/api/yggdrasil/authserver/authenticate",
"/api/yggdrasil/authserver/refresh",
"/api/yggdrasil/authserver/validate",
"/api/yggdrasil/authserver/invalidate",
)
}
/// 把 API 根改指到 mock(`base` 代替 https://example.invalid)。
fn mock_root(mock: &AuthMock) -> String {
format!("{}/api/yggdrasil", mock.base())
}
fn session() -> AuthlibSession {
AuthlibSession {
access_token: Secret::new("access-token-value"),
client_token: Secret::new("client-token-value"),
selected_profile: Some(AuthlibProfile {
id: PROFILE_ID.to_string(),
name: "Steve".to_string(),
}),
}
}
#[tokio::test]
async fn authenticate_sends_the_canonical_body() {
let (authenticate, _, _, _) = paths();
let mock = AuthMock::start(vec![(
authenticate,
vec![MockReply::json(json!({
"accessToken": "access-token-value",
"clientToken": "client-token-value",
"selectedProfile": { "id": PROFILE_ID, "name": "Steve" }
}))],
)])
.await;
let auth = AuthlibAuth::new().expect("构造");
let login = auth
.authenticate(
&mock_root(&mock),
"steve@example.com",
&Secret::new("hunter2-password"),
&Secret::new("client-token-value"),
)
.await
.expect("登录成功");
assert_eq!(login.access_token.expose(), "access-token-value");
assert_eq!(login.client_token.expose(), "client-token-value");
assert!(login.has_profile());
assert_eq!(
login.selected_profile.as_ref().map(|p| p.name.as_str()),
Some("Steve")
);
let body = &mock.bodies_on(authenticate)[0];
assert!(
body.contains("\"agent\":{\"name\":\"Minecraft\",\"version\":1}"),
"{body}"
);
assert!(body.contains("\"requestUser\":true"), "{body}");
assert!(
body.contains("\"username\":\"steve@example.com\""),
"{body}"
);
assert!(
body.contains("\"clientToken\":\"client-token-value\""),
"{body}"
);
// 密码确实发了(否则登录不了),但会话 Debug 里看不到它。
assert!(body.contains("hunter2-password"));
assert!(!format!("{login:?}").contains("hunter2-password"));
mock.stop();
}
#[tokio::test]
async fn wrong_password_is_denied_with_server_message() {
let (authenticate, _, _, _) = paths();
let mock = AuthMock::start(vec![(
authenticate,
vec![MockReply::json_status(
403,
json!({
"error": "ForbiddenOperationException",
"errorMessage": "Invalid credentials. Invalid username or password."
}),
)],
)])
.await;
let auth = AuthlibAuth::new().expect("构造");
let error = auth
.authenticate(
&mock_root(&mock),
"steve",
&Secret::new("wrong"),
&Secret::new("client-token-value"),
)
.await
.expect_err("应失败");
match error {
AuthError::Denied { code, description } => {
assert_eq!(code, "ForbiddenOperationException");
assert!(description.contains("Invalid credentials"));
}
other => panic!("应是 Denied,实际 {other:?}"),
}
mock.stop();
}
#[tokio::test]
async fn server_must_echo_the_client_token() {
let (authenticate, _, _, _) = paths();
let mock = AuthMock::start(vec![(
authenticate,
vec![MockReply::json(json!({
"accessToken": "access-token-value",
"clientToken": "someone-elses-token",
"selectedProfile": { "id": PROFILE_ID, "name": "Steve" }
}))],
)])
.await;
let auth = AuthlibAuth::new().expect("构造");
let error = auth
.authenticate(
&mock_root(&mock),
"steve",
&Secret::new("pw"),
&Secret::new("client-token-value"),
)
.await
.expect_err("应拒绝");
assert_eq!(error.code(), "malformed");
assert!(
error.message().contains("clientToken"),
"{}",
error.message()
);
mock.stop();
}
#[tokio::test]
async fn refresh_can_select_a_character() {
let (_, refresh, _, _) = paths();
let mock = AuthMock::start(vec![(
refresh,
vec![MockReply::json(json!({
"accessToken": "access-token-2",
"clientToken": "client-token-value",
"selectedProfile": { "id": PROFILE_ID, "name": "Alex" }
}))],
)])
.await;
let auth = AuthlibAuth::new().expect("构造");
let wanted = AuthlibProfile {
id: PROFILE_ID.to_string(),
name: "Alex".to_string(),
};
let refreshed = auth
.refresh(&mock_root(&mock), &session(), Some(&wanted))
.await
.expect("刷新成功");
assert_eq!(refreshed.access_token.expose(), "access-token-2");
assert_eq!(refreshed.selected_profile.expect("角色").name, "Alex");
// 新的 refresh_token 不存在于 Yggdrasil:clientToken 保持不变。
assert_eq!(refreshed.client_token.expose(), "client-token-value");
let body = &mock.bodies_on(refresh)[0];
assert!(body.contains("\"selectedProfile\":{\"id\":\"0123456789abcdef0123456789abcdef\""));
assert!(body.contains("\"accessToken\":\"access-token-value\""));
mock.stop();
}
#[tokio::test]
async fn refresh_that_did_not_switch_character_is_malformed() {
let (_, refresh, _, _) = paths();
let mock = AuthMock::start(vec![(
refresh,
vec![MockReply::json(json!({
"accessToken": "access-token-2",
"clientToken": "client-token-value",
"selectedProfile": { "id": PROFILE_ID, "name": "Steve" }
}))],
)])
.await;
let auth = AuthlibAuth::new().expect("构造");
let wanted = AuthlibProfile {
id: "ffffffffffffffffffffffffffffffff".to_string(),
name: "Ghost".to_string(),
};
let error = auth
.refresh(&mock_root(&mock), &session(), Some(&wanted))
.await
.expect_err("应失败");
assert_eq!(error.code(), "malformed");
mock.stop();
}
#[tokio::test]
async fn forbidden_on_refresh_means_credential_expired() {
let (_, refresh, _, _) = paths();
let mock = AuthMock::start(vec![(
refresh,
vec![MockReply::json_status(
403,
json!({ "error": "ForbiddenOperationException", "errorMessage": "Invalid token." }),
)],
)])
.await;
let auth = AuthlibAuth::new().expect("构造");
let error = auth
.refresh(&mock_root(&mock), &session(), None)
.await
.expect_err("应失败");
assert!(error.is_credential_expired(), "{error:?}");
mock.stop();
}
#[tokio::test]
async fn validate_distinguishes_invalid_token_from_server_trouble() {
let (_, _, validate, _) = paths();
// 空体 200 → 有效
let ok = AuthMock::start(vec![(validate, vec![MockReply::status(200)])]).await;
let auth = AuthlibAuth::new().expect("构造");
assert!(auth
.validate(&mock_root(&ok), &session())
.await
.expect("有效"));
ok.stop();
// 403 ForbiddenOperationException → 无效(返回值 false,不抛错)
let invalid = AuthMock::start(vec![(
validate,
vec![MockReply::json_status(
403,
json!({ "error": "ForbiddenOperationException", "errorMessage": "Invalid token." }),
)],
)])
.await;
assert!(!auth
.validate(&mock_root(&invalid), &session())
.await
.expect("无效"));
invalid.stop();
// 500 → 服务器问题,必须抛错(调用方才会重试而不是刷新)
let broken = AuthMock::start(vec![(validate, vec![MockReply::status(500)])]).await;
let error = auth
.validate(&mock_root(&broken), &session())
.await
.expect_err("应失败");
assert_eq!(error.code(), "network");
broken.stop();
}
#[tokio::test]
async fn invalidate_tolerates_already_invalid_tokens() {
let (_, _, _, invalidate) = paths();
let auth = AuthlibAuth::new().expect("构造");
let ok = AuthMock::start(vec![(invalidate, vec![MockReply::status(200)])]).await;
auth.invalidate(&mock_root(&ok), &session())
.await
.expect("注销成功");
ok.stop();
let gone = AuthMock::start(vec![(
invalidate,
vec![MockReply::json_status(
403,
json!({ "error": "ForbiddenOperationException" }),
)],
)])
.await;
auth.invalidate(&mock_root(&gone), &session())
.await
.expect("已失效也算成功");
gone.stop();
}
#[test]
fn endpoints_and_metadata_stay_in_sync_with_the_protocol() {
let endpoints = AuthlibEndpoints::from_api_root(API_ROOT).expect("端点");
assert_eq!(
endpoints.authenticate,
format!("{API_ROOT}authserver/authenticate")
);
let meta = AuthlibServerMeta::from_value(&json!({
"meta": { "serverName": "Example", "feature.non_email_login": true },
"skinDomains": ["example.invalid"]
}));
assert_eq!(meta.name.as_deref(), Some("Example"));
assert!(meta.non_email_login);
assert_eq!(meta.skin_domains, vec!["example.invalid".to_string()]);
}
+23
View File
@@ -0,0 +1,23 @@
//! `core::auth` 的集成测试入口。
//!
//! 每个子模块对应 mission 的一项验收:
//! - `offline_uuid`:离线 UUID 与 JDK(= HMCL 算法)**逐字节对拍**的已知向量;
//! - `store_separation`:元数据/凭据分文件、原子写、写序、容错与孤儿清理;
//! - `microsoft_flow`:device-code 全链(含 pending / slow_down / 取消 / 超时 / XErr);
//! - `microsoft_authcode`:授权码流(本地回调服务器 / 授权 URL 向量 / code 换链 / 403 诊断);
//! - `microsoft_diagnostics`:非 2xx 正文诊断(JSON/HTML/空体分流)+ 失败日志落盘(M38);
//! - `microsoft_refresh`:refresh 轮换、profile 变脸防线、validate 语义、404 二义;
//! - `microsoft_msalogin`:回环重定向 URI 没登记时的回退(M178:默认设备码、不白开监听口);
//! - `authlib_api`:Yggdrasil 四动作的报文级对接。
mod support;
mod auth_failure_journal;
mod authlib_api;
mod microsoft_authcode;
mod microsoft_diagnostics;
mod microsoft_flow;
mod microsoft_msalogin;
mod microsoft_refresh;
mod offline_uuid;
mod store_separation;
@@ -0,0 +1,295 @@
//! 授权码流(`research/08` §3 方案 A):本地回调服务器 + 授权 URL + code 换链。
//!
//! 覆盖验收口径:授权码换到 MS token → XBL → XSTS 全通;白名单未批前
//! `login_with_xbox` 的 403 只要求**诊断文案正确**(M38 版),不绕过。
use std::time::Duration;
use serde_json::json;
use unlucky_core::auth::microsoft::{
AuthCodePolicy, AuthCodePrompt, CallbackServer, MicrosoftAuth, MicrosoftEndpoints,
};
use unlucky_core::auth::AuthError;
use crate::support::{full_routes, response_of, AuthMock, MockReply};
const CLIENT_ID: &str = "test-client-id";
fn policy(state: &str, timeout_ms: u64) -> AuthCodePolicy {
AuthCodePolicy {
callback_timeout: Duration::from_millis(timeout_ms),
bind_attempts: 3,
port: None,
state: Some(state.to_string()),
}
}
/// 模拟浏览器:把 `code` 送到本地回调地址(真发一次 HTTP,走 localhost 解析)。
async fn deliver(redirect_uri: &str, query: &str) {
let url = format!("{redirect_uri}?{query}");
// 回调服务器可能在 accept 中,给一拍让绑定先落地。
tokio::time::sleep(Duration::from_millis(10)).await;
let response = reqwest::get(&url).await.expect("浏览器回调");
assert_eq!(response.status(), 200, "回调页应回 200");
let page = response.text().await.expect("回调页正文");
assert!(page.contains("登录完成"), "回调页应是给用户看的完成页");
}
#[tokio::test]
async fn authcode_login_completes_the_chain() {
let mock = AuthMock::start(full_routes()).await;
let auth = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(MicrosoftEndpoints::all_on(&mock.base()))
.with_authcode_policy(policy("state-abc", 5_000));
let mut prompted: Option<AuthCodePrompt> = None;
let session = auth
.authcode_login(|prompt| {
prompted = Some(prompt.clone());
let redirect = prompt.redirect_uri.clone();
tokio::spawn(
async move { deliver(&redirect, "code=the-auth-code&state=state-abc").await },
);
})
.await
.expect("授权码登录成功");
// 1. 宿主拿到的是「授权 URL + 本地回调地址」,两者端口必须一致。
let prompt = prompted.expect("必须回调展示授权 URL");
let redirect = prompt.redirect_uri.clone();
assert!(redirect.starts_with("http://localhost:"), "{redirect}");
assert!(redirect.ends_with("/auth"), "{redirect}");
assert!(prompt.authorization_url.contains("prompt=select_account"));
// 端点表在测试里整体指向 mock,所以授权页的路径是 `/authorize`(租户口径见向量单测)。
assert!(
prompt.authorization_url.starts_with(&mock.base())
&& prompt
.authorization_url
.contains("/authorize?client_id=test-client-id"),
"{}",
prompt.authorization_url
);
assert!(prompt.authorization_url.contains("state=state-abc"));
assert_eq!(prompt.timeout, Duration::from_millis(5_000));
// 2. code 换 token 的表单:grant_type + 原样重定向 URI(URI 严格一致那条坑)。
let token_bodies = mock.bodies_on("/token");
assert_eq!(token_bodies.len(), 1, "只该换一次 token");
let body = &token_bodies[0];
assert!(body.contains("grant_type=authorization_code"), "{body}");
assert!(body.contains("code=the-auth-code"), "{body}");
assert!(
body.contains("redirect_uri=http%3A%2F%2Flocalhost%3A") && !body.contains(&redirect),
"redirect_uri 必须是编码后的同一字面量:{body}"
);
assert!(
body.contains("scope=XboxLive.signin+offline_access")
|| body.contains("scope=XboxLive.signin%20offline_access"),
"{body}"
);
// 授权 URL 里的 redirect_uri 与 token 请求里的必须是同一个端口。
let encoded = redirect.replace(':', "%3A").replace('/', "%2F");
assert!(
prompt.authorization_url.contains(&encoded),
"{}",
prompt.authorization_url
);
assert!(body.contains(&encoded), "{body}");
// 3. 后链与会话:与设备码流同一套字段语义。
assert_eq!(session.profile_name, "Steve");
assert_eq!(session.access_token.expose(), "mc-access-token-value");
assert_eq!(session.refresh_token.expose(), "live-refresh-token-value");
assert!(!format!("{session:?}").contains("mc-access-token-value"));
assert_eq!(mock.bodies_on("/xbl").len(), 1);
assert_eq!(mock.bodies_on("/xsts").len(), 1);
mock.stop();
}
#[tokio::test]
async fn callback_server_ignores_unrelated_requests_and_bad_state() {
let server = CallbackServer::bind(None, 1, "state-ok")
.await
.expect("绑定");
let port = server.port();
let browser = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(20)).await;
let base = format!("http://localhost:{port}");
// favicon / 浏览器预热:404 但绝不能结束等待。
let favicon = reqwest::get(format!("{base}/favicon.ico"))
.await
.expect("favicon");
assert_eq!(favicon.status(), 404);
// state 不匹配(旧标签页的迟到回调):400,继续等。
let stale = reqwest::get(format!("{base}/auth?code=stale-code&state=state-old"))
.await
.expect("迟到的回调");
assert_eq!(stale.status(), 400);
let good = reqwest::get(format!("{base}/auth?code=good-code&state=state-ok"))
.await
.expect("真回调");
assert_eq!(good.status(), 200);
});
let code = server
.wait_for_code(Duration::from_secs(5))
.await
.expect("应拿到 code");
assert_eq!(code, "good-code");
browser.await.expect("模拟浏览器任务");
}
#[tokio::test]
async fn foreign_state_cannot_cancel_the_login() {
// P2 回归:回调先校验 state,再处理 error(RFC 6749 §4.1.2.1 / RFC 9207)。
// 修之前 `GET /auth?error=access_denied`(state 随便给或不给)就能把登录判成 Canceled。
let server = CallbackServer::bind(None, 1, "state-ok")
.await
.expect("绑定");
let port = server.port();
let browser = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(20)).await;
let base = format!("http://localhost:{port}/auth");
// 1. 连 state 都不给:与本次登录无关 → 400,继续等。
let bare = reqwest::get(format!("{base}?error=access_denied"))
.await
.expect("伪造取消");
assert_eq!(bare.status(), 400);
// 2. state 明显不匹配:同样忽略,绝不当成用户取消。
let foreign = reqwest::get(format!("{base}?error=access_denied&state=not-our-state"))
.await
.expect("伪造取消带错 state");
assert_eq!(foreign.status(), 400);
// 3. 真正带对 state 的回调仍然能完成登录。
let good = reqwest::get(format!("{base}?code=good-code&state=state-ok"))
.await
.expect("真回调");
assert_eq!(good.status(), 200);
});
let code = server
.wait_for_code(Duration::from_secs(5))
.await
.expect("伪造的 access_denied 不能中止登录");
assert_eq!(code, "good-code");
browser.await.expect("模拟浏览器任务");
}
#[tokio::test]
async fn access_denied_with_our_state_is_still_canceled() {
// 另一半语义:用户真的在授权页点了取消(带本机 state)→ 仍然是 Canceled。
let server = CallbackServer::bind(None, 1, "state-ok")
.await
.expect("绑定");
let port = server.port();
let browser = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(20)).await;
let url = format!("http://localhost:{port}/auth?error=access_denied&state=state-ok");
let _ = reqwest::get(url).await;
});
let error = server
.wait_for_code(Duration::from_secs(5))
.await
.expect_err("本机 state 的取消要生效");
assert_eq!(error, AuthError::Canceled);
browser.await.expect("模拟浏览器任务");
}
#[tokio::test]
async fn callback_timeout_is_reported() {
let auth = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_authcode_policy(policy("state-abc", 30));
let error = auth
.authcode_login(|_prompt| {})
.await
.expect_err("没人回调就该超时");
assert_eq!(error.code(), "timeout");
assert!(
error.message().contains("浏览器回调"),
"{}",
error.message()
);
}
#[tokio::test]
async fn access_denied_on_the_callback_page_is_canceled() {
let auth = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_authcode_policy(policy("state-abc", 5_000));
let error = auth
.authcode_login(|prompt| {
let redirect = prompt.redirect_uri.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(10)).await;
let url = format!("{redirect}?error=access_denied&state=state-abc");
let _ = reqwest::get(&url).await;
});
})
.await
.expect_err("用户取消");
assert_eq!(error, AuthError::Canceled);
}
#[tokio::test]
async fn busy_port_retries_then_reports_which_port() {
// 先占住一个端口,再让回调服务器去绑同一个:必须失败并说清是哪个端口。
let occupied = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.expect("占端口");
let port = occupied.local_addr().expect("地址").port();
let error = CallbackServer::bind(Some(port), 2, "s")
.await
.expect_err("端口已被占");
assert_eq!(error.code(), "network");
assert!(
error.message().contains(&port.to_string()),
"文案要指出端口:{}",
error.message()
);
assert!(error.message().contains("重试 2 次"), "{}", error.message());
// 同一台机器上换个端口就能绑上(重试机制不是把错误吞掉)。
let ok = CallbackServer::bind(None, 1, "s").await.expect("内核分配");
assert_ne!(ok.port(), port);
}
#[tokio::test]
async fn login_with_xbox_403_keeps_the_m38_diagnostic() {
// 白名单未批的真机状态:授权码换到 token、XBL/XSTS 全通,最后一步 403。
let mut routes = full_routes();
let (_, login) = routes
.iter_mut()
.find(|(path, _)| *path == "/login_with_xbox")
.expect("路由表里有 login_with_xbox");
*login = vec![MockReply::json_status(
403,
json!({ "path": "/authentication/login_with_xbox", "errorType": "FORBIDDEN",
"error": "Invalid app registration" }),
)];
let mock = AuthMock::start(routes).await;
let auth = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(MicrosoftEndpoints::all_on(&mock.base()))
.with_authcode_policy(policy("state-abc", 5_000));
let error = auth
.authcode_login(|prompt| {
let redirect = prompt.redirect_uri.clone();
tokio::spawn(async move { deliver(&redirect, "code=c&state=state-abc").await });
})
.await
.expect_err("403 必须冒出来");
let detail = response_of(&error);
assert_eq!(detail.status, 403);
assert_eq!(detail.detail_code(), "login_with_xbox_403");
// 「授权码这条流本身是通的」——这正是验收止步的位置。
assert_eq!(mock.bodies_on("/token").len(), 1);
assert_eq!(mock.bodies_on("/xbl").len(), 1);
assert_eq!(mock.bodies_on("/xsts").len(), 1);
let message = error.message();
assert!(message.contains("Minecraft 登录被拒"), "{message}");
assert!(message.contains("Invalid app registration"), "{message}");
mock.stop();
}
@@ -0,0 +1,231 @@
//! M38:非 2xx 应答正文诊断(`AuthError::Response`)。
//!
//! 覆盖 MSA 真机验收里分不开的那几类:
//! - **JSON 403**:真实服务拒绝(服务端文案直出,引导查许可/区域);
//! - **HTML 403**:网关/风控拦截页(引导查网络/代理,独立细分码 `*_non_json`);
//! - **空体 403**:同样按「不确定」分流(`*_empty_body`);
//! - 4xx / 5xx 各一,外加 XErr 优先级与 JSON 码的向后兼容。
//!
//! 失败日志(JSONL)与凭据红线在同一目录的 `auth_failure_journal.rs`。
use serde_json::json;
use unlucky_core::auth::{AuthError, BodyKind};
use crate::support::{
assert_no_credentials, auth_for, full_routes, response_of, routes_until_login, AuthMock,
MockReply, HTML_BLOCK_PAGE,
};
#[tokio::test]
async fn json_403_surfaces_server_message_and_points_at_license() {
let mut routes = routes_until_login();
routes.push((
"/login_with_xbox",
vec![MockReply::json_status(
403,
json!({
"path": "/authentication/login_with_xbox",
"error": "UNAUTHORIZED",
"errorMessage": "You do not own Minecraft Java Edition"
}),
)],
));
let mock = AuthMock::start(routes).await;
let error = auth_for(&mock.base())
.device_code_login(|_| {})
.await
.expect_err("应失败");
assert_eq!(error.code(), "denied", "粗分流层保持 M31 的 denied");
assert_eq!(error.detail_code(), "login_with_xbox_403");
assert!(!error.is_transient(), "服务端明确拒绝,重试无意义");
let detail = response_of(&error);
assert_eq!(detail.status, 403);
assert_eq!(detail.kind, BodyKind::Json);
assert_eq!(detail.content_type.as_deref(), Some("application/json"));
let message = error.message();
// 服务端文案直出(验收要看到真实原因)。
assert!(message.contains("You do not own"), "{message}");
assert!(message.contains("UNAUTHORIZED"), "{message}");
assert!(message.contains("许可"), "应引导查许可:{message}");
assert!(
!message.contains("代理"),
"JSON 拒绝不该往网络引:{message}"
);
assert!(message.contains("响应摘要"), "{message}");
assert_no_credentials(&message);
mock.stop();
}
#[tokio::test]
async fn html_403_is_routed_to_network_and_proxy() {
let mut routes = routes_until_login();
routes.push((
"/login_with_xbox",
vec![MockReply::html(403, HTML_BLOCK_PAGE)],
));
let mock = AuthMock::start(routes).await;
let error = auth_for(&mock.base())
.device_code_login(|_| {})
.await
.expect_err("应失败");
assert_eq!(error.code(), "denied");
assert_eq!(
error.detail_code(),
"login_with_xbox_non_json",
"拦截页要有独立细分码"
);
assert!(error.is_transient(), "拦截页可以换代理重试");
let detail = response_of(&error);
assert_eq!(detail.kind, BodyKind::Html);
assert!(detail.is_interception());
assert_eq!(detail.status, 403);
assert!(
detail
.content_type
.as_deref()
.is_some_and(|value| value.contains("text/html")),
"{:?}",
detail.content_type
);
let message = error.message();
assert!(message.contains("响应非 JSON"), "{message}");
assert!(message.contains("网络"), "{message}");
assert!(message.contains("代理"), "{message}");
assert!(
message.contains("blocked by gateway"),
"摘要要留下证据:{message}"
);
assert!(
!message.contains("许可"),
"拦截页不该把用户引向许可:{message}"
);
assert_no_credentials(&message);
mock.stop();
}
#[tokio::test]
async fn empty_403_reports_the_empty_body() {
let mut routes = routes_until_login();
routes.push(("/login_with_xbox", vec![MockReply::status(403)]));
let mock = AuthMock::start(routes).await;
let error = auth_for(&mock.base())
.device_code_login(|_| {})
.await
.expect_err("应失败");
assert_eq!(error.detail_code(), "login_with_xbox_empty_body");
let detail = response_of(&error);
assert_eq!(detail.kind, BodyKind::Empty);
assert!(detail.digest.is_empty());
assert!(error.message().contains("为空"), "{}", error.message());
mock.stop();
}
#[tokio::test]
async fn profile_4xx_keeps_the_step_status_code() {
let mut routes = full_routes();
routes[6].1 = vec![MockReply::json_status(
401,
json!({ "path": "/minecraft/profile", "error": "Unauthorized" }),
)];
let mock = AuthMock::start(routes).await;
let error = auth_for(&mock.base())
.device_code_login(|_| {})
.await
.expect_err("应失败");
assert_eq!(error.detail_code(), "profile_401");
assert!(error.message().contains("档案"), "{}", error.message());
assert_no_credentials(&error.message());
mock.stop();
}
#[tokio::test]
async fn entitlements_5xx_text_is_an_interception_not_a_license_verdict() {
let mut routes = full_routes();
routes[5].1 = vec![MockReply::text(503, "Service Unavailable")];
let mock = AuthMock::start(routes).await;
let error = auth_for(&mock.base())
.device_code_login(|_| {})
.await
.expect_err("应失败");
// 关键:网关页不能报成「你没买游戏」(M31 的 no_entitlement 只留给 JSON/空体)。
assert_eq!(error.detail_code(), "entitlements_non_json");
let detail = response_of(&error);
assert_eq!(detail.kind, BodyKind::Text);
assert_eq!(detail.status, 503);
assert!(
error.message().contains("Service Unavailable"),
"{}",
error.message()
);
mock.stop();
}
#[tokio::test]
async fn entitlements_404_with_empty_body_still_reports_no_entitlement() {
let mut routes = full_routes();
routes[5].1 = vec![MockReply::status(404)];
let mock = AuthMock::start(routes).await;
let error = auth_for(&mock.base())
.device_code_login(|_| {})
.await
.expect_err("应失败");
// 向后兼容:M31 的稳定码 `no_entitlement` 不动。
match error {
AuthError::Denied { code, description } => {
assert_eq!(code, "no_entitlement");
assert!(description.contains("404"), "{description}");
}
other => panic!("应是 Denied,实际 {other:?}"),
}
mock.stop();
}
#[tokio::test]
async fn entitlements_403_with_empty_body_is_not_a_license_verdict() {
let mut routes = full_routes();
routes[5].1 = vec![MockReply::status(403)];
let mock = AuthMock::start(routes).await;
let error = auth_for(&mock.base())
.device_code_login(|_| {})
.await
.expect_err("应失败");
// 403 且没正文:说不清是许可还是拦截,按「不确定」报,不下「没买游戏」的结论。
assert_eq!(error.detail_code(), "entitlements_empty_body");
mock.stop();
}
#[tokio::test]
async fn xsts_401_without_xerr_still_reports_body_context() {
let mut routes = routes_until_login();
routes[3].1 = vec![MockReply::html(401, HTML_BLOCK_PAGE)];
let mock = AuthMock::start(routes).await;
let error = auth_for(&mock.base())
.device_code_login(|_| {})
.await
.expect_err("应失败");
assert_eq!(error.detail_code(), "xsts_non_json");
assert!(error.message().contains("XSTS"), "{}", error.message());
mock.stop();
}
#[tokio::test]
async fn xerr_still_wins_over_the_body_diagnostic() {
let mut routes = routes_until_login();
routes[3].1 = vec![MockReply::json_status(
401,
json!({ "XErr": 2_148_916_235_u32, "Message": "country unavailable" }),
)];
let mock = AuthMock::start(routes).await;
let error = auth_for(&mock.base())
.device_code_login(|_| {})
.await
.expect_err("应失败");
match error {
AuthError::Xbox { code } => assert_eq!(code, 2_148_916_235),
other => panic!("XErr 判定必须优先,实际 {other:?}"),
}
mock.stop();
}
+274
View File
@@ -0,0 +1,274 @@
//! device-code 全链(打到进程内 mock 服务器):报文形状 + 轮询状态机 + 错误分流。
use std::time::Duration;
use serde_json::json;
use unlucky_core::auth::microsoft::{
DeviceCodePrompt, MicrosoftAuth, MicrosoftEndpoints, PollPolicy,
};
use unlucky_core::auth::AuthError;
use crate::support::{AuthMock, MockReply};
const PROFILE_ID: &str = "0f8fad5bd9cb469fa16570867728950e";
const CLIENT_ID: &str = "test-client-id";
fn fast_poll() -> PollPolicy {
PollPolicy {
min_interval: Duration::from_millis(1),
slow_down_step: Duration::from_millis(1),
max_wait: Duration::from_secs(5),
}
}
fn device_code_reply() -> MockReply {
MockReply::json(json!({
"user_code": "ABCD-1234",
"device_code": "device-code-secret-value",
"verification_uri": "https://www.microsoft.com/link",
"expires_in": 900,
"interval": 0,
"message": "go to the url"
}))
}
fn chain_replies() -> Vec<(&'static str, Vec<MockReply>)> {
vec![
("/devicecode", vec![device_code_reply()]),
(
"/token",
vec![
MockReply::json(json!({ "error": "authorization_pending" })),
MockReply::json(json!({
"access_token": "live-access-token-value",
"refresh_token": "live-refresh-token-value"
})),
],
),
(
"/xbl",
vec![MockReply::json(json!({
"Token": "xbl-token-value",
"DisplayClaims": { "xui": [{ "uhs": "uhs-1" }] }
}))],
),
(
"/xsts",
vec![MockReply::json(json!({
"Token": "xsts-token-value",
"DisplayClaims": { "xui": [{ "uhs": "uhs-1" }] }
}))],
),
(
"/login_with_xbox",
vec![MockReply::json(json!({
"username": "Steve",
"access_token": "mc-access-token-value",
"token_type": "Bearer",
"expires_in": 86_400
}))],
),
(
"/entitlements/mcstore",
vec![MockReply::json(json!({ "items": [] }))],
),
(
"/profile",
vec![MockReply::json(
json!({ "id": PROFILE_ID, "name": "Steve" }),
)],
),
]
}
#[tokio::test]
async fn device_code_login_runs_the_whole_chain() {
let mock = AuthMock::start(chain_replies()).await;
let auth = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(MicrosoftEndpoints::all_on(&mock.base()))
.with_poll_policy(fast_poll());
let mut prompted: Option<DeviceCodePrompt> = None;
let session = auth
.device_code_login(|prompt| prompted = Some(prompt.clone()))
.await
.expect("登录成功");
// 1. 用户码/URL 在轮询前就交给宿主了(CLI 才能先打印再等)。
let prompt = prompted.expect("必须回调展示用户码");
assert_eq!(prompt.user_code, "ABCD-1234");
assert_eq!(prompt.verification_uri, "https://www.microsoft.com/link");
assert_eq!(prompt.expires_in, Duration::from_secs(900));
assert_eq!(prompt.interval, Duration::ZERO, "服务端 interval 原样收下");
let (line1, line2) = prompt.lines();
assert!(line1.contains("https://www.microsoft.com/link"));
assert!(line2.contains("ABCD-1234"));
// 2. 会话字段:MC token 落 access_token,Live refresh 落 refresh_token。
assert_eq!(session.profile_id, PROFILE_ID);
assert_eq!(session.profile_name, "Steve");
assert_eq!(session.access_token.expose(), "mc-access-token-value");
assert_eq!(session.refresh_token.expose(), "live-refresh-token-value");
assert!(session.not_after_ms > 1_577_836_800_000);
assert!(session.has_profile_name());
// Debug 里不能出现任何 token。
let debug = format!("{session:?}");
assert!(!debug.contains("mc-access-token-value"), "{debug}");
assert!(!debug.contains("live-refresh-token-value"), "{debug}");
// 3. 报文形状(这几处抄错就会 400,必须逐条盯住)。
let device_bodies = mock.bodies_on("/devicecode");
assert_eq!(device_bodies.len(), 1);
assert!(device_bodies[0].contains("client_id=test-client-id"));
assert!(
device_bodies[0].contains("scope=XboxLive.signin+offline_access")
|| device_bodies[0].contains("scope=XboxLive.signin%20offline_access")
);
let token_bodies = mock.bodies_on("/token");
assert_eq!(token_bodies.len(), 2, "pending 一次后成功");
for body in &token_bodies {
assert!(body.contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"));
assert!(body.contains("code=device-code-secret-value"));
assert!(!body.contains("{"), "表单体不该是 JSON:{body}");
}
let xbl = &mock.bodies_on("/xbl")[0];
// `d=` 前缀是 MSA 消费版票据标记(漏了就 400)。
assert!(
xbl.contains("\"RpsTicket\":\"d=live-access-token-value\""),
"{xbl}"
);
assert!(xbl.contains("user.auth.xboxlive.com"));
let xsts = &mock.bodies_on("/xsts")[0];
assert!(xsts.contains("rp://api.minecraftservices.com/"), "{xsts}");
assert!(xsts.contains("\"SandboxId\":\"RETAIL\""), "{xsts}");
assert!(xsts.contains("xbl-token-value"), "{xsts}");
let login = &mock.bodies_on("/login_with_xbox")[0];
assert!(
login.contains("XBL3.0 x=uhs-1;xsts-token-value"),
"identityToken 拼接错:{login}"
);
mock.stop();
}
#[tokio::test]
async fn slow_down_backs_off_instead_of_failing() {
let mut routes = chain_replies();
// 把 /token 换成 pending → slow_down → 成功。
routes[1].1 = vec![
MockReply::json(json!({ "error": "authorization_pending" })),
MockReply::json(json!({ "error": "slow_down" })),
MockReply::json(json!({
"access_token": "live-access-token-value",
"refresh_token": "live-refresh-token-value"
})),
];
let mock = AuthMock::start(routes).await;
let auth = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(MicrosoftEndpoints::all_on(&mock.base()))
.with_poll_policy(fast_poll());
let session = auth.device_code_login(|_| {}).await.expect("登录成功");
assert_eq!(session.profile_name, "Steve");
assert_eq!(mock.bodies_on("/token").len(), 3, "三次轮询");
mock.stop();
}
#[tokio::test]
async fn access_denied_is_reported_as_canceled() {
let mut routes = chain_replies();
routes[1].1 = vec![MockReply::json(json!({ "error": "access_denied" }))];
let mock = AuthMock::start(routes).await;
let auth = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(MicrosoftEndpoints::all_on(&mock.base()))
.with_poll_policy(fast_poll());
let error = auth.device_code_login(|_| {}).await.expect_err("应取消");
assert_eq!(error, AuthError::Canceled);
assert_eq!(error.code(), "canceled");
mock.stop();
}
#[tokio::test]
async fn expired_device_code_is_a_timeout() {
let mut routes = chain_replies();
routes[1].1 = vec![MockReply::json(json!({ "error": "expired_token" }))];
let mock = AuthMock::start(routes).await;
let auth = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(MicrosoftEndpoints::all_on(&mock.base()))
.with_poll_policy(fast_poll());
let error = auth.device_code_login(|_| {}).await.expect_err("应超时");
assert_eq!(error.code(), "timeout");
mock.stop();
}
#[tokio::test]
async fn polling_gives_up_after_the_wait_budget() {
let mut routes = chain_replies();
let pending = MockReply::json(json!({ "error": "authorization_pending" }));
routes[1].1 = vec![pending; 64];
let mock = AuthMock::start(routes).await;
let auth = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(MicrosoftEndpoints::all_on(&mock.base()))
.with_poll_policy(PollPolicy {
min_interval: Duration::from_millis(1),
slow_down_step: Duration::from_millis(1),
max_wait: Duration::from_millis(20),
});
let error = auth.device_code_login(|_| {}).await.expect_err("应超时");
assert_eq!(error.code(), "timeout");
assert!(mock.bodies_on("/token").len() < 64, "超时后必须停止轮询");
mock.stop();
}
#[tokio::test]
async fn xsts_401_with_xerr_is_mapped() {
let mut routes = chain_replies();
routes[2].1 = vec![MockReply::json_status(
401,
json!({ "Identity": "0", "XErr": 2_148_916_233_u32, "Message": "no xbox account" }),
)];
let mock = AuthMock::start(routes).await;
let auth = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(MicrosoftEndpoints::all_on(&mock.base()))
.with_poll_policy(fast_poll());
let error = auth.device_code_login(|_| {}).await.expect_err("应失败");
match error {
AuthError::Xbox { code } => {
assert_eq!(code, 2_148_916_233);
assert!(unlucky_core::auth::error::xbox_hint(code).contains("Xbox 档案"));
}
other => panic!("应是 Xbox 错误,实际 {other:?}"),
}
mock.stop();
}
#[tokio::test]
async fn device_code_endpoint_failure_is_denied() {
let mock = AuthMock::start(vec![(
"/devicecode",
vec![MockReply::json_status(
400,
json!({ "error": "invalid_client", "error_description": "bad client id" }),
)],
)])
.await;
let auth = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(MicrosoftEndpoints::all_on(&mock.base()))
.with_poll_policy(fast_poll());
let error = auth.device_code_login(|_| {}).await.expect_err("应失败");
match error {
AuthError::Denied { code, .. } => assert_eq!(code, "invalid_client"),
other => panic!("应是 Denied,实际 {other:?}"),
}
mock.stop();
}
@@ -0,0 +1,274 @@
//! M178 守卫:微软登录在**官方产物**(应用注册里没有任何重定向 URI)上的行为。
//!
//! 用户 2026-11-08 实测的现象:GUI 直接走授权码流 → 授权端点回
//! `invalid_request ... 'redirect_uri' is not valid` → 浏览器里一张 AAD 原始错误页,
//! 启动器干等超时 ⇒ 官方产物上「微软登录」整体不可用。
//!
//! 本文件钉四件事(都是能给答案「能不能变红」的):
//!
//! | 守卫 | 用例 | 抓到什么 |
//! |---|---|---|
//! | ① 回退且提示可读 | [`unregistered_redirect_falls_back_to_the_device_code`] | 回退没发生 / 原因码丢了(页面就只能画原文) |
//! | ② 不白开监听口 | 同上 + [`device_code_default_never_opens_a_listening_port`] | 预检被挪到绑定之后(`listeners_opened` 会变 1) |
//! | ③ 注册过就仍能走浏览器 | [`registered_loopback_still_runs_the_auth_code_flow`] | 把加速路彻底焊死(一刀切回退) |
//! | ④ 取消不被偷偷换流 | [`user_cancel_on_the_browser_page_does_not_fall_back`] | 取消被当成失败的后退理由 |
//!
//! 另:**真微软登录没有在这里验**(要真人拿自己账号点一次),这里只证错误处理与回退路径。
use std::sync::{Arc, Mutex};
use std::time::Duration;
use unlucky_core::auth::microsoft::{
AuthCodePolicy, BrowserFallbackReason, MicrosoftAuth, MicrosoftEndpoints,
};
use unlucky_core::auth::AuthError;
use crate::support::{full_routes, AuthMock, MockReply};
const CLIENT_ID: &str = "test-client-id";
/// 授权端点对**没登记**的回环 redirect_uri 的应答(2026-11-08 实测那张页面的形状)。
const INVALID_REDIRECT_PAGE: &str =
"error=invalid_request&error_description=The+provided+value+for+the+input+\
parameter+%27redirect_uri%27+is+not+valid.+The+expected+value+is+a+URI+which+matches+a+\
+redirect+URI+registered+for+this+client+application.";
/// 授权端点对**已登记**回环 URI 的正常应答(登录页,无拒绝字样)。
const SIGN_IN_PAGE: &str = "<!DOCTYPE html><html><head><title>Sign in to your account</title>\
</head><body><form id=\"loginForm\"></form></body></html>";
/// 设备码面板里看到的 `(用户码, 回退原因码)`(跨闭包共享)。
type Seen = Arc<Mutex<Option<(String, Option<BrowserFallbackReason>)>>>;
fn policy(state: &str) -> AuthCodePolicy {
AuthCodePolicy {
callback_timeout: Duration::from_millis(2_000),
bind_attempts: 1,
port: None,
state: Some(state.to_string()),
}
}
/// 路由表 + 定向的 `/authorize` 应答(预检打的就是这个 path)。
fn routes_with_authorize(reply: MockReply) -> Vec<(&'static str, Vec<MockReply>)> {
let mut routes = full_routes();
routes.push(("/authorize", vec![reply]));
routes
}
fn auth(base: &str, browser_first: bool) -> MicrosoftAuth {
MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(MicrosoftEndpoints::all_on(base))
.with_poll_policy(crate::support::microsoft::fast_poll())
.with_authcode_policy(policy("state-abc"))
.with_browser_first(browser_first)
}
/// 守卫 ① + ②:没登记回环 → 回退设备码、原因码是「没登记」、**监听口一次都不开**。
#[tokio::test]
async fn unregistered_redirect_falls_back_to_the_device_code() {
let mock = AuthMock::start(routes_with_authorize(MockReply::text(
400,
INVALID_REDIRECT_PAGE,
)))
.await;
let seen: Seen = Arc::new(Mutex::new(None));
let seen_for_device = Arc::clone(&seen);
let outcome = auth(&mock.base(), true)
.browser_then_device_login(
|_prompt| panic!("没登记回环时**不许**弹浏览器(用户只会看到 AAD 错误页)"),
move |prompt, fallback| {
*seen_for_device.lock().unwrap() =
Some((prompt.user_code.clone(), fallback.map(|note| note.reason)));
},
)
.await
.expect("必须回退到设备码并成功");
// ① 回退确实发生,且宿主拿到的是**结构化原因码**(页面据此出可读文案,而不是画原文)
let (user_code, reason) = seen.lock().unwrap().clone().expect("设备码必须被展示");
assert_eq!(user_code, "ABCD-1234");
assert_eq!(reason, Some(BrowserFallbackReason::RedirectNotRegistered));
assert_eq!(
reason.map(BrowserFallbackReason::code),
Some("redirect_not_registered")
);
assert_eq!(outcome.session.profile_name, "Steve");
let note = outcome.browser.expect("必须带回退说明");
assert_eq!(note.reason, BrowserFallbackReason::RedirectNotRegistered);
// 原文只进日志:说明里的 detail 有内容,但页面只拿码(i18n 在 app-iced 侧)。
assert!(!note.detail.is_empty());
// ② 预检在绑定之前:这次登录一个监听口都没开(用户机上不会出现「占了端口却没人回调」)
assert_eq!(
outcome.listeners_opened, 0,
"没登记回环时不许绑本地端口(预检必须在绑定之前)"
);
// ③ **报文级证据**:预检真的发出去了(授权端点收到 1 次 GET),而不是被跳过。
// 这条也是复审 P1-1 的回执:只要生产路径把 `browser_first` 置位,这条 GET 就会发生。
assert_eq!(
mock.bodies_on("/authorize").len(),
1,
"浏览器优先路必须真的发一次回环注册预检"
);
mock.stop();
}
/// 守卫 ②(默认路那一半):设备码是默认,浏览器流根本没被启用 ⇒ 监听计数不涨。
#[tokio::test]
async fn device_code_default_never_opens_a_listening_port() {
let mock = AuthMock::start(full_routes()).await;
let device_seen = Arc::new(Mutex::new(None));
let device_flag = Arc::clone(&device_seen);
let outcome = auth(&mock.base(), false)
.browser_then_device_login(
|_| panic!("默认路不许碰浏览器回调"),
move |prompt, fallback| {
*device_flag.lock().unwrap() = Some(prompt.user_code.clone());
assert!(fallback.is_none(), "没走浏览器就不该有回退说明");
},
)
.await
.expect("设备码登录成功");
assert_eq!(
device_seen.lock().unwrap().clone().as_deref(),
Some("ABCD-1234")
);
assert!(outcome.browser.is_none());
assert_eq!(
outcome.session.profile_id,
crate::support::microsoft::PROFILE_ID
);
assert_eq!(outcome.listeners_opened, 0, "设备码流不许开任何本地监听口");
// 授权端点一次都没被请求(那正是会返回 invalid_request 的那一跳)
assert!(mock.bodies_on("/authorize").is_empty());
mock.stop();
}
/// 守卫 ③:回环**真的登记了**的时候,加速路必须还能走(否则就是把功能一刀切焊死)。
#[tokio::test]
async fn registered_loopback_still_runs_the_auth_code_flow() {
let mock = AuthMock::start(routes_with_authorize(MockReply::html(200, SIGN_IN_PAGE))).await;
let outcome = auth(&mock.base(), true)
.browser_then_device_login(
|prompt| {
let redirect = prompt.redirect_uri.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(20)).await;
let url = format!("{redirect}?code=the-auth-code&state=state-abc");
let response = reqwest::get(&url).await.expect("浏览器回调");
assert_eq!(response.status(), 200);
});
},
|_prompt, _note| panic!("登记了回环就不该回退设备码"),
)
.await
.expect("授权码流应当走通");
assert_eq!(outcome.session.profile_name, "Steve");
assert!(outcome.browser.is_none(), "走通了就不该有回退说明");
assert_eq!(
outcome.listeners_opened, 1,
"登记了回环就应当真的绑端口(否则加速路名存实亡)"
);
// 换 token 用的是**授权码** grant(不是设备码那条)。
let token_bodies = mock.bodies_on("/token");
assert_eq!(token_bodies.len(), 1, "{token_bodies:?}");
assert!(token_bodies[0].contains("grant_type=authorization_code"));
mock.stop();
}
/// 守卫 ④:用户自己取消**不算**失败——不许偷偷换一条流继续要码。
#[tokio::test]
async fn user_cancel_on_the_browser_page_does_not_fall_back() {
let mock = AuthMock::start(routes_with_authorize(MockReply::html(200, SIGN_IN_PAGE))).await;
let error = auth(&mock.base(), true)
.browser_then_device_login(
|prompt| {
let redirect = prompt.redirect_uri.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(20)).await;
let url = format!("{redirect}?error=access_denied&state=state-abc");
let _ = reqwest::get(&url).await;
});
},
|_prompt, _note| panic!("用户取消后不许自动换流"),
)
.await
.expect_err("取消应当是最终结果");
assert_eq!(error, AuthError::Canceled);
assert!(
mock.bodies_on("/devicecode").is_empty(),
"取消之后不该再去要设备码"
);
mock.stop();
}
/// 预检问不出结论(连不上)也要回退,且原因码是「没确认」而不是「没登记」。
#[tokio::test]
async fn an_unreachable_precheck_falls_back_with_the_unverified_reason() {
// 只有授权端点指到一个必然关闭的端口:预检必然失败,而设备码那条链照常打 mock。
let mock = AuthMock::start(full_routes()).await;
let mut endpoints = MicrosoftEndpoints::all_on(&mock.base());
endpoints.authorize = "http://127.0.0.1:1/authorize".to_string();
let reason = Arc::new(Mutex::new(None));
let reason_flag = Arc::clone(&reason);
let outcome = MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(endpoints)
.with_poll_policy(crate::support::microsoft::fast_poll())
.with_authcode_policy(policy("state-abc"))
.with_browser_first(true)
.browser_then_device_login(
|_prompt| panic!("预检失败时不许弹浏览器"),
move |_prompt, fallback| {
*reason_flag.lock().unwrap() = fallback.map(|note| note.reason);
},
)
.await
.expect("预检失败不该拦住设备码");
assert_eq!(
*reason.lock().unwrap(),
Some(BrowserFallbackReason::PreflightUnavailable)
);
assert_eq!(
outcome.browser.map(|note| note.reason),
Some(BrowserFallbackReason::PreflightUnavailable)
);
assert_eq!(outcome.session.profile_name, "Steve");
assert_eq!(outcome.listeners_opened, 0, "预检失败时不该绑端口");
mock.stop();
}
/// 与上两例配对:`/authorize` 回 200 但正文是 AADSTS50011 的拒绝页,也不许当「已登记」。
#[tokio::test]
async fn aadsts50011_page_is_not_treated_as_registered() {
let mock = AuthMock::start(routes_with_authorize(MockReply::text(
200,
"AADSTS50011: The redirect URI specified in the request does not match the redirect URIs configured",
)))
.await;
let note = Arc::new(Mutex::new(None));
let note_flag = Arc::clone(&note);
let outcome = auth(&mock.base(), true)
.browser_then_device_login(
|_| panic!("AADSTS50011 的页面不许当已登记"),
move |_prompt, fallback| {
*note_flag.lock().unwrap() = fallback.map(|note| note.reason);
},
)
.await
.expect("回退设备码后应当成功");
assert_eq!(
*note.lock().unwrap(),
Some(BrowserFallbackReason::RedirectNotRegistered)
);
assert_eq!(
outcome.browser.map(|note| note.reason),
Some(BrowserFallbackReason::RedirectNotRegistered)
);
mock.stop();
}
+283
View File
@@ -0,0 +1,283 @@
//! refresh 语义与 validate 分支:轮换、换号防线、404 二义、拥有权门禁。
use std::time::Duration;
use serde_json::json;
use unlucky_core::auth::microsoft::{
MicrosoftAuth, MicrosoftEndpoints, MicrosoftSession, PollPolicy, RefreshDecision, RefreshReason,
};
use unlucky_core::auth::Secret;
use crate::support::{AuthMock, MockReply};
const PROFILE_ID: &str = "0f8fad5bd9cb469fa16570867728950e";
const OTHER_PROFILE_ID: &str = "11111111111111111111111111111111";
const CLIENT_ID: &str = "test-client-id";
fn auth_for(base: &str) -> MicrosoftAuth {
MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(MicrosoftEndpoints::all_on(base))
.with_poll_policy(PollPolicy {
min_interval: Duration::from_millis(1),
slow_down_step: Duration::from_millis(1),
max_wait: Duration::from_secs(5),
})
}
/// 一个「已过期」的旧会话(`not_after` 早于当前时刻)。
fn expired_session() -> MicrosoftSession {
MicrosoftSession::new(
PROFILE_ID,
"Steve",
Secret::new("mc-access-token-1"),
Secret::new("live-refresh-token-1"),
3_600,
0,
None,
)
}
/// 一个「新鲜」的会话。
fn fresh_session() -> MicrosoftSession {
MicrosoftSession::new(
PROFILE_ID,
"Steve",
Secret::new("mc-access-token-1"),
Secret::new("live-refresh-token-1"),
86_400,
unlucky_core::auth::microsoft::now_ms(),
None,
)
}
/// 刷新链(Live refresh + 五步)。
fn refresh_routes(profile_id: &str, license: bool) -> Vec<(&'static str, Vec<MockReply>)> {
vec![
(
"/token",
vec![MockReply::json(json!({
"access_token": "live-access-token-2",
"refresh_token": "live-refresh-token-2"
}))],
),
(
"/xbl",
vec![MockReply::json(json!({
"Token": "xbl-token-2",
"DisplayClaims": { "xui": [{ "uhs": "uhs-2" }] }
}))],
),
(
"/xsts",
vec![MockReply::json(json!({
"Token": "xsts-token-2",
"DisplayClaims": { "xui": [{ "uhs": "uhs-2" }] }
}))],
),
(
"/login_with_xbox",
vec![MockReply::json(json!({
"username": "Steve",
"access_token": "mc-access-token-2",
"expires_in": 86_400
}))],
),
(
"/entitlements/mcstore",
vec![MockReply::json(json!({ "items": [] }))],
),
(
"/entitlements/license",
vec![MockReply::json(if license {
json!({ "items": [{ "name": "game_minecraft" }] })
} else {
json!({ "items": [{ "name": "product_minecraft" }] })
})],
),
(
"/profile",
vec![MockReply::json(
json!({ "id": profile_id, "name": "Steve" }),
)],
),
]
}
#[test]
fn refresh_decision_table() {
let expired = expired_session();
assert_eq!(
expired.refresh_decision(unlucky_core::auth::microsoft::now_ms()),
RefreshDecision::Refresh(RefreshReason::Expired)
);
let fresh = fresh_session();
assert_eq!(
fresh.refresh_decision(unlucky_core::auth::microsoft::now_ms()),
RefreshDecision::Reuse
);
}
#[tokio::test]
async fn refresh_rotates_tokens_and_keeps_profile() {
let mock = AuthMock::start(refresh_routes(PROFILE_ID, true)).await;
let auth = auth_for(&mock.base());
let old = expired_session();
let (refreshed, reason) = auth.ensure_fresh(&old).await.expect("刷新成功");
assert_eq!(reason, Some(RefreshReason::Expired));
assert_eq!(refreshed.profile_id, PROFILE_ID);
// 轮换:access 与 refresh **都**换新(refresh 返回新 refresh_token)。
assert_eq!(refreshed.access_token.expose(), "mc-access-token-2");
assert_eq!(refreshed.refresh_token.expose(), "live-refresh-token-2");
assert_ne!(refreshed.access_token, old.access_token);
assert_ne!(refreshed.refresh_token, old.refresh_token);
assert!(refreshed.not_after_ms > old.not_after_ms);
// 刷新报文:拿旧 refresh_token 走 refresh_token grant。
let body = &mock.bodies_on("/token")[0];
assert!(body.contains("grant_type=refresh_token"), "{body}");
assert!(
body.contains("refresh_token=live-refresh-token-1"),
"{body}"
);
// 整链重跑:XBL 用的是**新的** Live access token。
let xbl = &mock.bodies_on("/xbl")[0];
assert!(xbl.contains("d=live-access-token-2"), "{xbl}");
mock.stop();
}
#[tokio::test]
async fn fresh_session_needs_no_network() {
let mock = AuthMock::start(vec![("/token", vec![MockReply::status(500)])]).await;
let auth = auth_for(&mock.base());
let (session, reason) = auth.ensure_fresh(&fresh_session()).await.expect("直接复用");
assert!(reason.is_none());
assert_eq!(session.access_token.expose(), "mc-access-token-1");
assert!(mock.requests().is_empty(), "不该打网络");
mock.stop();
}
#[tokio::test]
async fn refresh_that_changes_profile_is_rejected() {
let mock = AuthMock::start(refresh_routes(OTHER_PROFILE_ID, true)).await;
let auth = auth_for(&mock.base());
let error = auth.refresh(&expired_session()).await.expect_err("应拒绝");
assert_eq!(error.code(), "malformed");
assert!(error.message().contains("档案变了"), "{}", error.message());
mock.stop();
}
#[tokio::test]
async fn revoked_refresh_token_requires_relogin() {
let mock = AuthMock::start(vec![(
"/token",
vec![MockReply::json_status(
400,
json!({
"error": "invalid_grant",
"error_description": "AADSTS70000: The grant is expired or revoked"
}),
)],
)])
.await;
let auth = auth_for(&mock.base());
let error = auth.refresh(&expired_session()).await.expect_err("应失败");
assert!(error.is_credential_expired(), "{error:?}");
mock.stop();
}
#[tokio::test]
async fn validate_reports_token_state_without_swallowing_profile_errors() {
// 200 → 有效
let ok = AuthMock::start(vec![(
"/profile",
vec![MockReply::json(
json!({ "id": PROFILE_ID, "name": "Steve" }),
)],
)])
.await;
assert!(auth_for(&ok.base())
.validate(&fresh_session())
.await
.expect("有效"));
ok.stop();
// 401 → token 失效(返回值,不是错误)
let unauthorized = AuthMock::start(vec![("/profile", vec![MockReply::status(401)])]).await;
assert!(!auth_for(&unauthorized.base())
.validate(&fresh_session())
.await
.expect("失效"));
unauthorized.stop();
// 404 + 没有 game_minecraft → 「没买游戏」是真实错误,不当 token 失效
let no_license = AuthMock::start(vec![
("/profile", vec![MockReply::status(404)]),
(
"/entitlements/license",
vec![MockReply::json(
json!({ "items": [{ "name": "product_minecraft" }] }),
)],
),
])
.await;
let error = auth_for(&no_license.base())
.validate(&fresh_session())
.await
.expect_err("应上抛");
assert_eq!(error.code(), "denied");
assert!(error.message().contains("没有购买"), "{}", error.message());
no_license.stop();
// 404 + 有 game_minecraft → 「买了没建档案」
let no_profile = AuthMock::start(vec![
("/profile", vec![MockReply::status(404)]),
(
"/entitlements/license",
vec![MockReply::json(
json!({ "items": [{ "name": "game_minecraft" }] }),
)],
),
])
.await;
let error = auth_for(&no_profile.base())
.validate(&fresh_session())
.await
.expect_err("应上抛");
assert!(
error.message().contains("还没有创建档案"),
"{}",
error.message()
);
no_profile.stop();
}
#[tokio::test]
async fn missing_entitlement_fails_the_chain_early() {
let mut routes = refresh_routes(PROFILE_ID, true);
routes[4].1 = vec![MockReply::status(404)];
let mock = AuthMock::start(routes).await;
let auth = auth_for(&mock.base());
let error = auth.refresh(&expired_session()).await.expect_err("应失败");
match error {
unlucky_core::auth::AuthError::Denied { code, .. } => assert_eq!(code, "no_entitlement"),
other => panic!("应是 Denied,实际 {other:?}"),
}
mock.stop();
}
#[tokio::test]
async fn uhs_mismatch_between_xbl_and_xsts_is_malformed() {
let mut routes = refresh_routes(PROFILE_ID, true);
routes[2].1 = vec![MockReply::json(json!({
"Token": "xsts-token-2",
"DisplayClaims": { "xui": [{ "uhs": "uhs-diFFERENT" }] }
}))];
let mock = AuthMock::start(routes).await;
let auth = auth_for(&mock.base());
let error = auth.refresh(&expired_session()).await.expect_err("应失败");
assert_eq!(error.code(), "malformed");
assert!(error.message().contains("userhash"), "{}", error.message());
mock.stop();
}
+105
View File
@@ -0,0 +1,105 @@
//! 离线 UUID 与 **JDK 对拍**(这就是「HMCL 兼容」的定义)。
//!
//! 期望值不是我们算出来再抄回来的,而是用 JDK 25 跑
//! `UUID.nameUUIDFromBytes(("OfflinePlayer:" + name).getBytes(UTF_8))`
//! 生成的(驱动源码见下方注释里的等价 Java)。HMCL 的
//! `OfflineAccountFactory.getUUIDFromUserName` 就是这一行,因此对拍 JDK 即对拍 HMCL。
//!
//! ```java
//! UUID id = UUID.nameUUIDFromBytes(("OfflinePlayer:" + name).getBytes(StandardCharsets.UTF_8));
//! System.out.println(id.toString().replace("-", ""));
//! ```
use unlucky_core::auth::offline::{offline_login, offline_meta, offline_uuid, offline_uuid_dashed};
use unlucky_core::auth::AccountKind;
/// `(名字, JDK 输出 32 位无连字符)`。
const JDK_VECTORS: [(&str, &str); 7] = [
("Notch", "b50ad385829d3141a2167e7d7539ba7f"),
("jeb_", "a762f5604fce3236812ab80efff0b62b"),
("Player", "a01e3843e5213998958af459800e4d11"),
("Steve", "5627dd98e6be3c21b8a8e92344183641"),
// 非 ASCII:UTF-8 字节参与 MD5(中文名离线服常见)。
("小明", "e1fa999fd9e93419bc012f51157a00e5"),
("name with space", "46c02d33ff53313b813aa95508518305"),
("AskaEth", "5c5918fb61303d6eb8d8eb2d1da6e439"),
];
#[test]
fn matches_jdk_name_uuid_from_bytes() {
for (name, expected) in JDK_VECTORS {
assert_eq!(
offline_uuid(name),
expected,
"离线 UUID 与 JDK/HMCL 不一致:{name}"
);
}
}
#[test]
fn dashed_form_is_the_same_uuid() {
for (name, expected) in JDK_VECTORS {
let dashed = offline_uuid_dashed(name);
assert_eq!(dashed.replace('-', ""), expected, "{name} 的连字符形式");
assert_eq!(dashed.len(), 36);
assert_eq!(dashed.matches('-').count(), 4);
}
}
#[test]
fn version_is_3_and_variant_is_ietf() {
for (name, raw) in JDK_VECTORS {
let version_nibble = &raw[12..13];
assert_eq!(version_nibble, "3", "{name} 必须是 v3");
let variant = u8::from_str_radix(&raw[16..17], 16).expect("变体位");
assert!((0x8..=0xb).contains(&variant), "{name} 变体必须是 10xx");
}
}
#[test]
fn uuid_depends_on_exact_bytes_not_case() {
// 大小写不同 → 不同身份(服务端就是按字节算的,别做「友好」归一化)。
assert_ne!(offline_uuid("Steve"), offline_uuid("steve"));
// 前缀也参与哈希:直接哈希名字会得到另一个 UUID。
assert_ne!(offline_uuid("Notch"), offline_uuid("OfflinePlayer:Notch"));
}
#[test]
fn offline_login_matches_hmcl_behaviour() {
let meta = offline_meta("小明", 1).expect("建元数据");
assert_eq!(meta.kind, AccountKind::Offline);
assert_eq!(meta.profile_id, "e1fa999fd9e93419bc012f51157a00e5");
assert_eq!(meta.profile_name, "小明");
let placeholders = offline_login(&meta).expect("占位符");
// uuid 必须是**无连字符** 32 位(HMCL 的 UUIDs.toCompactString)。
assert_eq!(placeholders.uuid, meta.profile_id);
assert_eq!(placeholders.uuid.len(), 32);
assert!(!placeholders.uuid.contains('-'));
// accessToken = 随机 32 位十六进制(每次不同、非空:游戏不接受空 token)。
assert_eq!(placeholders.access_token.len(), 32);
assert!(placeholders
.access_token
.chars()
.all(|ch| ch.is_ascii_hexdigit()));
assert_ne!(
placeholders.access_token,
offline_login(&meta).expect("再来一次").access_token
);
// userType=msa、userProperties={}(承 OfflineAccount.logInWithoutSkin)。
assert_eq!(placeholders.user_type, "msa");
assert_eq!(placeholders.user_properties, "{}");
}
#[test]
fn names_are_validated_before_hashing() {
assert!(offline_meta(" ", 1).is_err());
assert!(offline_meta("0123456789abcdefg", 1).is_err());
// 16 字符刚好合法。
assert!(offline_meta("0123456789abcdef", 1).is_ok());
// 首尾空白被去掉后哈希(避免「名字带空格」算出两套 UUID)。
assert_eq!(
offline_meta(" Steve ", 1).expect("合法").profile_id,
offline_uuid("Steve")
);
}
+240
View File
@@ -0,0 +1,240 @@
//! 存储契约:**分文件 / 原子写 / 写序 / 容错 / 孤儿清理**。
//!
//! 这些是「凭据红线」的落盘侧证据:元数据文件里永远搜不到 token(可安全备份/分享),
//! 凭据文件只在需要发请求时才读,且写坏了也不会让账户列表整体打不开。
use std::path::Path;
use unlucky_core::auth::account::{AccountKind, AccountMeta, Credentials};
use unlucky_core::auth::microsoft::MicrosoftSession;
use unlucky_core::auth::offline::offline_meta;
use unlucky_core::auth::{AccountStore, Secret};
use crate::support::temp_dir;
const MC_TOKEN: &str = "mc-access-token-value";
const LIVE_REFRESH: &str = "live-refresh-token-value";
fn microsoft_entry(created_at_ms: u64) -> (AccountMeta, Credentials) {
let meta = AccountMeta::new(
AccountKind::Microsoft,
"0f8fad5bd9cb469fa16570867728950e",
"Steve",
created_at_ms,
);
let session = MicrosoftSession::new(
"0f8fad5bd9cb469fa16570867728950e",
"Steve",
Secret::new(MC_TOKEN),
Secret::new(LIVE_REFRESH),
86_400,
1_000,
None,
);
(meta, Credentials::Microsoft(session))
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).expect("读文件")
}
fn tmp_leftovers(dir: &Path) -> Vec<String> {
std::fs::read_dir(dir)
.expect("列目录")
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| name.contains(".tmp"))
.collect()
}
#[test]
fn metadata_and_credentials_live_in_separate_files() {
let dir = temp_dir("sep");
let mut store = AccountStore::open(&dir);
let (meta, credentials) = microsoft_entry(1);
store.insert(meta, credentials).expect("插入");
store.save().expect("落盘");
let accounts_text = read(&store.paths().accounts_file());
let credentials_text = read(&store.paths().credentials_file());
// 元数据:有身份、有轨道,但**没有任何 token**。
assert!(accounts_text.contains("0f8fad5bd9cb469fa16570867728950e"));
assert!(accounts_text.contains("\"kind\": \"microsoft\""));
assert!(!accounts_text.contains(MC_TOKEN), "{accounts_text}");
assert!(!accounts_text.contains(LIVE_REFRESH), "{accounts_text}");
// 凭据:有 token、有保护级别标记,但**没有**可公开的展示字段。
assert!(credentials_text.contains(MC_TOKEN));
assert!(credentials_text.contains(LIVE_REFRESH));
assert!(credentials_text.contains("\"protection\": \"plain-v1\""));
// 凭据文件里没有元数据那份「账户列表」结构(两个文件的 schema 各管各的)。
assert!(
!credentials_text.contains("\"accounts\""),
"{credentials_text}"
);
// 原子写不留 tmp 垃圾。
assert!(tmp_leftovers(&dir).is_empty(), "残留临时文件");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn roundtrip_keeps_credentials_intact() {
let dir = temp_dir("roundtrip");
let mut store = AccountStore::open(&dir);
let (meta, credentials) = microsoft_entry(1);
let id = meta.id.clone();
store.insert(meta, credentials.clone()).expect("插入");
store.save().expect("落盘");
let reopened = AccountStore::open(&dir);
assert_eq!(reopened.accounts().len(), 1);
assert!(reopened.warnings().is_empty(), "{:?}", reopened.warnings());
assert_eq!(reopened.credentials(&id), Some(&credentials));
let placeholders = match reopened.credentials(&id).expect("凭据") {
Credentials::Microsoft(session) => session.to_placeholders().expect("占位符"),
other => panic!("应是微软凭据,实际 {other:?}"),
};
assert_eq!(placeholders.player_name, "Steve");
assert_eq!(placeholders.access_token, MC_TOKEN);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn credentials_are_written_before_metadata() {
let dir = temp_dir("order");
let mut store = AccountStore::open(&dir);
store
.insert(offline_meta("First", 1).expect("甲"), Credentials::Offline)
.expect("插入甲");
store.save().expect("首次落盘");
// 把凭据文件的位置换成目录 → 凭据写必定失败。
let credentials_path = store.paths().credentials_file();
std::fs::remove_file(&credentials_path).expect("删凭据文件");
std::fs::create_dir_all(&credentials_path).expect("建同名目录");
store
.insert(offline_meta("Second", 2).expect("乙"), Credentials::Offline)
.expect("插入乙");
let error = store.save().expect_err("凭据写不进去时 save 必须失败");
assert_eq!(error.code(), "store");
// 关键断言:元数据没有被先写(写序正确才有的结果)。
let accounts_text = read(&store.paths().accounts_file());
assert!(!accounts_text.contains("Second"), "{accounts_text}");
assert!(tmp_leftovers(&dir).is_empty(), "残留临时文件");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn corrupt_record_is_skipped_not_fatal() {
let dir = temp_dir("corrupt");
let store = AccountStore::open(&dir);
let good = offline_meta("Good", 1).expect("合法元数据");
let payload = serde_json::json!({
"schemaVersion": 1,
"accounts": [
{ "id": good.id.as_str(), "kind": "offline",
"profileId": good.profile_id, "profileName": "Good", "createdAtMs": 1 },
{ "id": "account:zzzz", "kind": "offline" },
{ "id": "account:0123456789abcdef0123456789abcdef", "kind": "unknown-track" }
]
});
std::fs::write(
store.paths().accounts_file(),
serde_json::to_string_pretty(&payload).expect("序列化"),
)
.expect("写元数据");
let reopened = AccountStore::open(&dir);
assert_eq!(reopened.accounts().len(), 1, "坏记录必须被跳过");
assert_eq!(reopened.accounts()[0].display_name(), "Good");
assert_eq!(reopened.warnings().len(), 2, "{:?}", reopened.warnings());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn broken_metadata_file_still_allows_empty_store() {
let dir = temp_dir("broken");
let store = AccountStore::open(&dir);
std::fs::write(store.paths().accounts_file(), "{ this is not json").expect("写坏文件");
let reopened = AccountStore::open(&dir);
assert!(reopened.is_empty());
assert_eq!(reopened.warnings().len(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn orphan_credentials_are_ignored_and_cleaned_on_save() {
let dir = temp_dir("orphan");
let store = AccountStore::open(&dir);
let orphan_id = "account:00000000000000000000000000000000";
let payload = serde_json::json!({
"schemaVersion": 1,
"protection": "plain-v1",
"entries": [{ "accountId": orphan_id, "data": { "kind": "offline" } }]
});
std::fs::write(
store.paths().credentials_file(),
serde_json::to_string_pretty(&payload).expect("序列化"),
)
.expect("写凭据");
let reopened = AccountStore::open(&dir);
assert!(reopened
.warnings()
.iter()
.any(|line| line.contains("没有对应账户")));
let unknown = unlucky_core::auth::AccountId::parse(orphan_id).expect("id");
assert!(reopened.credentials(&unknown).is_none());
reopened.save().expect("落盘");
let text = read(&reopened.paths().credentials_file());
assert!(!text.contains(orphan_id), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn unknown_protection_level_is_reported() {
let dir = temp_dir("protection");
let store = AccountStore::open(&dir);
std::fs::write(
store.paths().credentials_file(),
serde_json::to_string_pretty(&serde_json::json!({
"schemaVersion": 1,
"protection": "hmcl-obfuscated-v1",
"entries": []
}))
.expect("序列化"),
)
.expect("写凭据");
let reopened = AccountStore::open(&dir);
assert!(reopened
.warnings()
.iter()
.any(|line| line.contains("保护级别")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn remove_drops_credential_entry_on_disk() {
let dir = temp_dir("remove");
let mut store = AccountStore::open(&dir);
store
.insert(offline_meta("Keep", 1).expect("甲"), Credentials::Offline)
.expect("甲");
let (meta, credentials) = microsoft_entry(2);
let id = meta.id.clone();
store.insert(meta, credentials).expect("乙");
store.save().expect("落盘");
store.remove("Steve").expect("删乙");
store.save().expect("再落盘");
let text = read(&store.paths().credentials_file());
assert!(!text.contains(id.as_str()), "{text}");
let reopened = AccountStore::open(&dir);
assert_eq!(reopened.accounts().len(), 1);
assert_eq!(reopened.accounts()[0].display_name(), "Keep");
let _ = std::fs::remove_dir_all(&dir);
}
+136
View File
@@ -0,0 +1,136 @@
//! 微软轨的共用夹具:`MicrosoftAuth` 构造、五步链路由表、诊断断言助手。
//!
//! 从 `support/mod.rs` 拆出来(M38):mock 服务器本体与「业务路由/断言」各自长大,
//! 分文件后两边都不顶 400 行。路由表默认用 `[0]/[3]/[5]/[6]` 下标对应
//! `xsts` / `login_with_xbox` / `entitlements/mcstore` / `profile`,用例按需替换。
use serde_json::json;
use unlucky_core::auth::microsoft::{
MicrosoftAuth, MicrosoftEndpoints, MicrosoftSession, PollPolicy,
};
use unlucky_core::auth::{AuthError, ResponseDetail, Secret};
use super::MockReply;
/// 测试用档案 UUID(32 位无连字符)。
pub const PROFILE_ID: &str = "0f8fad5bd9cb469fa16570867728950e";
/// 测试用 client id。
pub const CLIENT_ID: &str = "test-client-id";
/// 网关/风控拦截页(HTML 403 的典型正文)。
pub const HTML_BLOCK_PAGE: &str = "<!DOCTYPE html><html><head><title>Service unavailable</title></head><body>blocked by gateway</body></html>";
/// 小间隔轮询策略(设备码轮询在测试里要秒级跑完)。
pub fn fast_poll() -> PollPolicy {
PollPolicy {
min_interval: std::time::Duration::from_millis(1),
slow_down_step: std::time::Duration::from_millis(1),
max_wait: std::time::Duration::from_secs(5),
}
}
/// 一台整表指向 mock 的 `MicrosoftAuth`。
pub fn auth_for(base: &str) -> MicrosoftAuth {
MicrosoftAuth::new(CLIENT_ID)
.expect("构造")
.with_endpoints(MicrosoftEndpoints::all_on(base))
.with_poll_policy(fast_poll())
}
/// 一路通到 `login_with_xbox` 的路由表(后续步骤留给用例替换)。
pub fn routes_until_login() -> Vec<(&'static str, Vec<MockReply>)> {
vec![
(
"/devicecode",
vec![MockReply::json(json!({
"user_code": "ABCD-1234",
"device_code": "device-code-secret-value",
"verification_uri": "https://www.microsoft.com/link",
"expires_in": 900,
"interval": 0
}))],
),
(
"/token",
vec![MockReply::json(json!({
"access_token": "live-access-token-value",
"refresh_token": "live-refresh-token-value"
}))],
),
(
"/xbl",
vec![MockReply::json(json!({
"Token": "xbl-token-value",
"DisplayClaims": { "xui": [{ "uhs": "uhs-1" }] }
}))],
),
(
"/xsts",
vec![MockReply::json(json!({
"Token": "xsts-token-value",
"DisplayClaims": { "xui": [{ "uhs": "uhs-1" }] }
}))],
),
]
}
/// 整链(含 entitlements 与 profile),便于替换中间任意一步。
pub fn full_routes() -> Vec<(&'static str, Vec<MockReply>)> {
let mut routes = routes_until_login();
routes.push((
"/login_with_xbox",
vec![MockReply::json(json!({
"username": "Steve",
"access_token": "mc-access-token-value",
"expires_in": 86_400
}))],
));
routes.push((
"/entitlements/mcstore",
vec![MockReply::json(json!({ "items": [] }))],
));
routes.push((
"/profile",
vec![MockReply::json(
json!({ "id": PROFILE_ID, "name": "Steve" }),
)],
));
routes
}
/// 一个已过期的旧会话(刷新/校验用例用)。
pub fn expired_session() -> MicrosoftSession {
MicrosoftSession::new(
PROFILE_ID,
"Steve",
Secret::new("mc-access-token-1"),
Secret::new("live-refresh-token-1"),
3_600,
0,
None,
)
}
/// 取 `AuthError::Response` 的诊断(不是这个变体就让用例炸掉,报出真实值)。
pub fn response_of(error: &AuthError) -> &ResponseDetail {
match error {
AuthError::Response(detail) => detail,
other => panic!("应是 Response,实际 {other:?}"),
}
}
/// 断言一段文案/事件里没有任何测试 token。
pub fn assert_no_credentials(text: &str) {
for forbidden in [
"xsts-token-value",
"xbl-token-value",
"live-access-token-value",
"live-refresh-token-value",
"device-code-secret-value",
"mc-access-token-value",
] {
assert!(
!text.contains(forbidden),
"文案泄漏了凭据 {forbidden}:{text}"
);
}
}
+279
View File
@@ -0,0 +1,279 @@
//! `core::auth` 集成测试夹具:临时目录 + 进程内 HTTP 服务器(零额外依赖)。
//!
//! 为什么要 mock 服务器:微软/authlib 两条轨的正确性全在**报文级**(`XBL3.0 x=<uhs>;<xsts>`
//! 的拼接、XSTS 401 带 `XErr`、`/minecraft/profile` 404 的二义复判、Yggdrasil 的
//! `clientToken` 回传校验)——只有把整条链打到一个真实 socket 上,这些分支才被真的跑到。
//!
//! 服务器按 **path + 顺序队列**回应答:同一 path 的第 N 次请求用第 N 个应答
//! (设备码轮询要靠这个:`authorization_pending` → `slow_down` → 成功)。
//! 微软轨的 `MicrosoftAuth` / 路由表 / 断言助手拆在 [`microsoft`] 子模块里(M38)。
#![allow(dead_code)]
use std::collections::{HashMap, VecDeque};
use std::net::SocketAddr;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
pub mod microsoft;
pub use microsoft::{
assert_no_credentials, auth_for, expired_session, full_routes, response_of, routes_until_login,
HTML_BLOCK_PAGE,
};
/// 唯一临时目录(进程内计数 + 时间戳,避免并行测试互踩)。
pub fn temp_dir(tag: &str) -> std::path::PathBuf {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |since| since.as_nanos());
let dir = std::env::temp_dir().join(format!("ull-auth-test-{tag}-{stamp}-{unique}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("创建临时目录失败");
dir
}
/// 一次 mock 应答。
#[derive(Debug, Clone)]
pub struct MockReply {
/// HTTP 状态码。
pub status: u16,
/// 响应体(JSON 文本或空)。
pub body: String,
/// `Content-Type`(默认 `application/json`;拦截页场景可改成 `text/html`)。
pub content_type: &'static str,
}
impl MockReply {
/// 200 + JSON。
pub fn json(value: serde_json::Value) -> Self {
Self {
status: 200,
body: value.to_string(),
content_type: "application/json",
}
}
/// 指定状态码 + JSON。
pub fn json_status(status: u16, value: serde_json::Value) -> Self {
Self {
status,
body: value.to_string(),
content_type: "application/json",
}
}
/// 指定状态码 + 空体。
pub fn status(status: u16) -> Self {
Self {
status,
body: String::new(),
content_type: "application/json",
}
}
/// 指定状态码 + HTML 拦截页(`Content-Type: text/html`)——M38 的网络分流用。
pub fn html(status: u16, body: &str) -> Self {
Self {
status,
body: body.to_string(),
content_type: "text/html; charset=utf-8",
}
}
/// 指定状态码 + 纯文本(网关页的另一种常见形状)。
pub fn text(status: u16, body: &str) -> Self {
Self {
status,
body: body.to_string(),
content_type: "text/plain",
}
}
}
/// 收到的一次请求(断言报文形状用)。
#[derive(Debug, Clone)]
pub struct Recorded {
/// 方法(大写)。
pub method: String,
/// 路径(不含 query)。
pub path: String,
/// 请求体(表单或 JSON 原文)。
pub body: String,
}
/// 进程内 HTTP/1.1 服务器(按 path 排队回应答)。
pub struct AuthMock {
addr: SocketAddr,
requests: Arc<Mutex<Vec<Recorded>>>,
handle: tokio::task::JoinHandle<()>,
}
impl AuthMock {
/// 启动服务器。`routes` 是 `(path, 该 path 依次使用的应答队列)`。
pub async fn start(routes: Vec<(&str, Vec<MockReply>)>) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("绑定端口");
let addr = listener.local_addr().expect("取地址");
let requests: Arc<Mutex<Vec<Recorded>>> = Arc::new(Mutex::new(Vec::new()));
let queues: Arc<Mutex<HashMap<String, VecDeque<MockReply>>>> = Arc::new(Mutex::new(
routes
.into_iter()
.map(|(path, replies)| (path.to_string(), replies.into()))
.collect(),
));
let recorded = Arc::clone(&requests);
let queues_for_task = Arc::clone(&queues);
let handle = tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
break;
};
let recorded = Arc::clone(&recorded);
let queues = Arc::clone(&queues_for_task);
tokio::spawn(async move {
let _ = serve(stream, recorded, queues).await;
});
}
});
Self {
addr,
requests,
handle,
}
}
/// 基址(喂给 `MicrosoftEndpoints::all_on` / `AuthlibEndpoints` 的根)。
pub fn base(&self) -> String {
format!("http://{}", self.addr)
}
/// 端口。
pub fn port(&self) -> u16 {
self.addr.port()
}
/// 已收到的请求(按到达顺序)。
pub fn requests(&self) -> Vec<Recorded> {
self.requests
.lock()
.unwrap_or_else(|error| error.into_inner())
.clone()
}
/// 找某 path 的请求体列表。
pub fn bodies_on(&self, path: &str) -> Vec<String> {
self.requests()
.into_iter()
.filter(|request| request.path == path)
.map(|request| request.body)
.collect()
}
/// 关停。
pub fn stop(self) {
self.handle.abort();
}
}
async fn serve(
mut stream: TcpStream,
recorded: Arc<Mutex<Vec<Recorded>>>,
queues: Arc<Mutex<HashMap<String, VecDeque<MockReply>>>>,
) -> std::io::Result<()> {
let mut buffer = Vec::new();
let mut chunk = [0_u8; 2048];
// 读头
let header_end = loop {
let read = stream.read(&mut chunk).await?;
if read == 0 {
return Ok(());
}
buffer.extend_from_slice(&chunk[..read]);
if let Some(position) = find_headers_end(&buffer) {
break position;
}
};
let head = String::from_utf8_lossy(&buffer[..header_end]).to_string();
let content_length = head
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
if name.trim().eq_ignore_ascii_case("content-length") {
value.trim().parse::<usize>().ok()
} else {
None
}
})
.unwrap_or(0);
while buffer.len() < header_end + content_length {
let read = stream.read(&mut chunk).await?;
if read == 0 {
break;
}
buffer.extend_from_slice(&chunk[..read]);
}
let body = String::from_utf8_lossy(
&buffer[header_end..(header_end + content_length).min(buffer.len())],
)
.to_string();
let mut first_line = head.lines().next().unwrap_or_default().split(' ');
let method = first_line.next().unwrap_or_default().to_ascii_uppercase();
let raw_path = first_line.next().unwrap_or("/");
let path = raw_path.split('?').next().unwrap_or("/").to_string();
recorded
.lock()
.unwrap_or_else(|error| error.into_inner())
.push(Recorded {
method,
path: path.clone(),
body,
});
let reply = queues
.lock()
.unwrap_or_else(|error| error.into_inner())
.get_mut(&path)
.and_then(VecDeque::pop_front)
.unwrap_or_else(|| MockReply::status(500));
let mut response = format!(
"HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
reply.status,
reason(reply.status),
reply.content_type,
reply.body.len()
)
.into_bytes();
response.extend_from_slice(reply.body.as_bytes());
stream.write_all(&response).await?;
stream.flush().await?;
Ok(())
}
fn find_headers_end(buffer: &[u8]) -> Option<usize> {
buffer
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|position| position + 4)
}
fn reason(status: u16) -> &'static str {
match status {
200 => "OK",
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
500 => "Internal Server Error",
_ => "Status",
}
}
+85
View File
@@ -0,0 +1,85 @@
//! M213:`platform::child::quiet` 的**行为**测试(它仍是一个普通 `Command`)。
//!
//! 抑制窗口最容易顺手搞坏的是「`.output()` 的管道、退出码、env、错误语义」,
//! 所以这一组用真程序把这四件事各钉一条。
//!
//! 源码扫描型守卫(所有 spawn 是否都经 `quiet`)在 `child_quiet_guard.rs`,
//! 与这里分开是为了让两边都留在 400 行以内,不靠压缩论证换行数。
use std::process::Command;
#[test]
fn quiet_is_a_plain_command_that_still_captures_output() {
// 抑制窗口不能顺手把输出吞了:`.output()` 的管道与退出码必须照旧可用。
let output = shell(&["echo hi"]).output().expect("spawn 失败");
assert!(output.status.success(), "退出码:{}", output.status);
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "hi");
}
#[test]
fn quiet_keeps_stderr_and_nonzero_exit_code() {
let output = shell(&["echo oops 1>&2", "exit 3"])
.output()
.expect("spawn 失败");
assert_eq!(output.status.code(), Some(3), "退出码没透传");
assert!(
String::from_utf8_lossy(&output.stderr).contains("oops"),
"stderr 没被捕获:{:?}",
String::from_utf8_lossy(&output.stderr),
);
}
#[test]
fn quiet_keeps_env_and_args() {
// 走 argv 与 env 两条通道,证明 `quiet` 没把 Command 包装成别的东西。
let script = if cfg!(windows) {
"echo %ULL_QUIET_PROBE%"
} else {
"echo $ULL_QUIET_PROBE"
};
let output = shell(&[script])
.env("ULL_QUIET_PROBE", "m213")
.output()
.expect("spawn 失败");
assert!(output.status.success(), "退出码:{}", output.status);
assert_eq!(
String::from_utf8_lossy(&output.stdout).trim(),
"m213",
"参数/环境变量在构造器里丢了",
);
}
#[test]
fn quiet_does_not_swallow_spawn_errors() {
// 找不到程序时仍然是 Err:构造器不加任何「失败即静默」的逻辑。
let error = unlucky_core::platform::child::quiet("ull-no-such-program-m213")
.output()
.expect_err("不存在的程序居然 spawn 成功了?");
assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
}
/// 构造器的返回值必须能直接当 `std::process::Command` 用(下游 `run_with_timeout` 就是这么接的)。
#[test]
fn quiet_returns_a_std_command() {
fn takes_command(_: Command) {}
takes_command(unlucky_core::platform::child::quiet("ull-type-check-only"));
}
/// 走构造器起一个 shell,拼上本平台的「跑一段脚本」参数。
fn shell(scripts: &[&str]) -> Command {
let (program, flag) = if cfg!(windows) {
("cmd", "/C")
} else {
("sh", "-c")
};
let mut command = unlucky_core::platform::child::quiet(program);
command.arg(flag);
// Windows 的 `cmd /C` 只吃一条命令行;POSIX 的 `sh -c` 同理,所以用 `&` / `;` 串起来。
let joined = if cfg!(windows) {
scripts.join(" & ")
} else {
scripts.join("; ")
};
command.arg(joined);
command
}
+400
View File
@@ -0,0 +1,400 @@
//! M217 守卫:**整棵 `crates/` 树**里所有子进程都必须经 `platform::child::quiet` 起。
//!
//! M213 建它时射程只有 `crates/core/src`;用户随后报「CI 构建版启动时底下闪一个控制台」,
//! 定位到 `app-iced/src/platform/locale.rs` 的 `reg.exe`——射程之外的漏网。M217 把根抬到
//! `crates/`,角色路径也重写成相对 `crates/`。
//!
//! ## 判据(按角色,不按次数)
//!
//! 每一处直接构造的**文件**必须命中 [`ALLOWED_ROLES`] 里的某条角色,否则就是绕开了构造器;红的
//! 信息是「某文件某行不属于任何角色」而不是「总数对不上 N」,所以它咬得住「随手再 spawn 一个
//! 命令」。文件角色还钉**精确处数**;目录角色(`core/tests/` 这类)只要求至少一处,因为测试
//! 夹具的数量是活的,钉死会变成假红。
//!
//! ## 抓不住什么(别当完备保证)
//!
//! - **别名**:只认 `Command as <别名>` 与 `type <别名> = …Command`;两跳别名、从别的模块
//! `pub use` 进来的别名看不见。`Command` 没有 `From`,非 `::new` 的构造暂不存在。
//! - **注释与空白**:只剥整行 `//` 与**行首** `/*`;行尾注释里写 `Command::new`、行中开始的
//! 跨行块注释之后那几行会**误报**(方向「宁可红」,与 P1-1 的假绿相反)。判据是字面子串,
//! `Command :: new(..)` 看不见(`rustfmt` 会规范回去)。
//! - **射程**:根是 `crates/`;`packaging/*.py`、`.github/workflows/*` 里的 shell 不在内(父进程
//! 是控制台,GUI 下不闪窗),`crates/app`(Slint 退役树,非 workspace 成员)**在**内。
//! - **行为**:只证明「代码里没有绕开构造器」,不证明「桌面上真的没闪窗口」——本机无前台桌面,
//! 做不到目视验证(见 M217 报告)。
//!
//! ## 变异验证(全部实测过并已还原,只改实现侧)
//!
//! M213(射程还是 `crates/core/src`):①`download/region_system.rs` 塞裸 `tzutil` ⇒ 红并点名该
//! 行;②同一处改写成别名 `Spawn::new(..)` ⇒ 同样红;③删 `platform/child.rs` 的
//! `creation_flags(CREATE_NO_WINDOW)` ⇒ [`the_constructor_actually_passes_create_no_window`] 红;
//! ④`game/java/discover.rs` 的 `quiet("reg")` 改回裸构造 ⇒ 必须红(见 P1-1)。
//!
//! M217(落在**此前不在射程内**的 crate 上):⑤`presenter/src/host/env.rs` 塞裸
//! `Command::new("tzutil")` ⇒ 红并点名 `crates/presenter/src/host/env.rs:<行>`;⑥
//! `app-iced/src/platform/locale.rs` 的 `quiet(program)` 改回裸构造 ⇒ 红(用户报的那处
//! `reg.exe`);⑦`export.rs` 三个 opener 留裸 + 清单写明理由 ⇒ **不**红。
//!
//! ## P1-1:守卫自己被变异验了两遍才对的
//!
//! ③第一次**没红**:断言搜整份源码,而 `quiet` 的文档注释里就写着
//! `.creation_flags(CREATE_NO_WINDOW)`——注释喂饱了断言,改成先剥注释([`code_lines`])再判。
//! ④(复审判的变异 D)**又没红**:那版 `code_lines` 对每行找 `/*`,而注释与字符串里到处是
//! glob(`/usr/lib/jvm/*`、`"mods/*.jar"`),这些 `/*` 没有配对的 `*/` ⇒ `in_block` 一旦为真就
//! 不再变假,**该文件从那一行到 EOF 全被吞掉**(`crates/core/src` 里 8/371 个中招,一个正是
//! `game/java/discover.rs`)。现在只在**行首**认 `/*`,并由
//! [`the_scan_reaches_the_end_of_every_file`] 逐文件核对最后一行代码必须被扫到。
use std::fs;
use std::path::{Path, PathBuf};
/// 允许直接构造子进程的一条**角色**。
struct Role {
/// 相对 `crates/` 的路径。以 `/` 结尾 = 该目录下全部文件(目录角色)。
path: &'static str,
/// 期望的裸构造处数:`Some(n)` 精确核对;目录角色 `None` = 只要求至少一处。
sites: Option<usize>,
/// 为什么这一处不能走 `quiet`。文件角色写这一处,目录角色写这一整类。
why: &'static str,
}
impl Role {
/// 目录角色覆盖该目录下全部文件;文件角色按整条路径精确比。
fn covers(&self, rel: &str) -> bool {
(self.path.ends_with('/') && rel.starts_with(self.path)) || rel == self.path
}
}
/// 这张表是**角色**清单,不是「白名单文件」清单:新增一行必须写清「为什么不能走 `quiet`」。
const ALLOWED_ROLES: &[Role] = &[
Role {
path: "core/src/platform/child.rs",
sites: Some(1),
why: "构造器自身:`child::quiet` 内部就是这一处 `Command::new`",
},
Role {
path: "core/src/launch/process.rs",
sites: Some(1),
why: "游戏进程:要 CREATE_NEW_PROCESS_GROUP 撑 taskkill /T 回收进程树;\
游戏要不要带自己的控制台窗口是产品决定,不是顺手抑制(M213 明确不动)",
},
Role {
path: "app-iced/src/app/dialog/export.rs",
sites: Some(3),
why: "目录打开器三兄弟(explorer / open / xdg-open,各平台一个):用户点「打开文件夹」\
要的就是那个文件管理器窗口,窗口就是产品。这三者自己都是 GUI 侧程序,由无控制台的\
GUI 父进程 spawn 时本来就不分控制台;改错的代价是用户点按钮没反应(M217 要求留裸)",
},
Role {
path: "presenter/src/host/opener.rs",
sites: Some(1),
why: "浏览器打开器(rundll32 / open / xdg-open):与上一行同一角色——打开一个可见的东西\
是这次 spawn 的**目的**。M217 不改它(不在本 mission 的改动范围),只登记成角色,\
免得守卫把正确的实现判成漏网",
},
Role {
path: "core/tests/",
sites: None,
why: "测试夹具:父进程是控制台(`cargo test`),子进程继承同一块控制台,不会新建窗口;\
而且夹具要起 shell / tasklist / kill 之类的真程序做行为验证",
},
Role {
path: "cli/tests/",
sites: None,
why: "测试夹具(同上;这里还要起真二进制验 CLI 的退出码与 update 签名)",
},
];
#[test]
fn no_spawn_site_bypasses_the_quiet_constructor() {
let root = crates_root();
let files = rust_files(&root);
// 兜底一:扫不到任何文件 ⇒ 根路径错了。兜底二:根扫到「非空但不是 crates/」也红——拿角色
// 文件当锚点。
assert!(
!files.is_empty(),
"没扫到任何 .rs:`crates/` 路径不对({})",
root.display()
);
for anchor in ["core/src/platform/child.rs", "core/src/launch/process.rs"] {
assert!(
files.iter().any(|rel| rel == anchor),
"扫描根不是 `crates/`:里面找不到 `{anchor}`(实际根:{})",
root.display()
);
}
// 一次扫面:`(相对 crates/ 的路径, 行号, 该行代码)`。
let mut sites: Vec<(String, usize, String)> = Vec::new();
for rel in &files {
let text = read_source(&root, rel);
let aliases = command_aliases(&text);
for (number, line) in code_lines(&text) {
if is_spawn_site(line, &aliases) {
sites.push((rel.clone(), number, line.trim().to_string()));
}
}
}
let illegal: Vec<String> = sites
.iter()
.filter(|(rel, ..)| !ALLOWED_ROLES.iter().any(|role| role.covers(rel)))
.map(|(rel, number, line)| format!(" crates/{rel}:{number} {line}"))
.collect();
assert!(
illegal.is_empty(),
"发现绕开 `platform::child::quiet` 的子进程构造({} 处):\n{}\n\
在 Windows 上这会为每个子进程新建一块控制台(GUI 父进程下就是桌面上「黑框闪一下」)。\
改走 `unlucky_core::platform::child::quiet(program)`;若这一处确实不该抑制窗口,\
把它的**角色**加进 ALLOWED_ROLES 并写清理由。",
illegal.len(),
illegal.join("\n"),
);
// 角色核对:允许的角色必须真的存在、处数对得上,否则这张表会悄悄变成摆设。
for role in ALLOWED_ROLES {
let hits: Vec<usize> = sites
.iter()
.filter(|(rel, ..)| role.covers(rel))
.map(|(.., number, _)| *number)
.collect();
match role.sites {
Some(expected) => assert_eq!(
hits.len(),
expected,
"角色 `crates/{}`({})应当恰好 {expected} 处直接构造,实测行号 {hits:?}",
role.path,
role.why,
),
None => assert!(
!hits.is_empty(),
"目录角色 `crates/{}`({})一处都没命中——这条清单条目该删了",
role.path,
role.why,
),
}
}
}
#[test]
fn the_constructor_actually_passes_create_no_window() {
let text = read_source(&crates_root(), "core/src/platform/child.rs");
// **只看代码行**:`quiet` 的文档注释里就写着 `.creation_flags(CREATE_NO_WINDOW)`,
// 对着整份源码搜会被那句注释喂饱——删掉真正的设置行也照样绿(实测踩过)。
let code: Vec<&str> = code_lines(&text)
.into_iter()
.map(|(_, line)| line)
.collect();
// 只去空白,避免格式调整造成假红(下划线是标识符的一部分,不能去)。
let no_space = code.concat().split_whitespace().collect::<String>();
assert!(
no_space.contains("creation_flags(CREATE_NO_WINDOW)"),
"`platform::child::quiet` 不再设置 creation_flags —— 抑制窗口的那一行被删了?\n\
注意 `.stdout(Stdio::null())` 不能替代它:那只改句柄,控制台窗口照样会被创建。",
);
// 常量值单独比:数字里的下划线是排版自由,去掉再比。
let definition = code
.iter()
.find(|line| line.trim_start().starts_with("const CREATE_NO_WINDOW"))
.expect("找不到 `CREATE_NO_WINDOW` 的常量定义");
let (_, value) = definition.split_once('=').expect("常量定义里没有 `=`");
let digits = value.replace('_', "");
let literal = digits.split_whitespace().collect::<String>();
assert_eq!(
literal.trim_end_matches(';'),
"0x08000000",
"`CREATE_NO_WINDOW` 的取值不对(`{definition}`)。必须是 0x0800_0000,\
不是 CREATE_NEW_CONSOLE(0x0000_0010,意思正好相反)。",
);
}
#[test]
fn code_lines_is_not_fooled_by_glob_slashes_in_strings_or_docs() {
// M213 复审 P1-1 的最小复现:**字符串**里的 `/*` 不许把后一行吞掉。
let in_string = code_lines("let a = \"mods/*.jar\";\nlet b = 1;\n");
assert!(
in_string
.iter()
.any(|(number, line)| *number == 2 && line.contains("let b")),
"字符串里的 `/*` 吞掉了后一行:{in_string:?}",
);
// 同一份故障在**文档注释的 glob** 上的形态(`crates/core/src` 里 8 个文件都是这一种)。
let in_doc_glob = code_lines("//! | Linux | `/usr/lib/jvm/*` |\nfn real_code() {}\n");
assert_eq!(
in_doc_glob.len(),
1,
"注释里的 glob 吞掉了代码:{in_doc_glob:?}"
);
assert!(in_doc_glob[0].1.contains("fn real_code"));
// 真的块注释还是要被剥掉(别把上面两条修成「块注释一律不认」)。
let block = code_lines("/* a\n b\n*/\nfn after() {}\n");
assert_eq!(block.len(), 1, "块注释没被剥:{block:?}");
assert!(block[0].1.contains("fn after"), "剥错了行:{block:?}");
// 同一行闭合的块注释,收尾后的残段是代码。
let inline_close = code_lines("/* c */ fn inline() {}\n");
assert_eq!(
inline_close.len(),
1,
"同行闭合的块注释丢了残段:{inline_close:?}"
);
assert!(inline_close[0].1.contains("fn inline"));
}
#[test]
fn the_scan_reaches_the_end_of_every_file() {
// 「整份文件被吞掉」这类故障自己红:逐文件核对**最后一行代码的行号**必须出现在扫面结果里。
// 这条比「数总行数」尖锐——文件普遍以 `}` 结尾,光比内容会被别处的 `}` 蒙混过去。
// 只有 `//!` 的结论页(`app-iced/src/platform/linux/mod.rs` 这类)没有代码行,跳过是对的。
let root = crates_root();
let files = rust_files(&root);
assert!(!files.is_empty(), "没扫到任何 .rs:{}", root.display());
let (mut visible_total, mut line_total) = (0usize, 0usize);
let mut starved: Vec<String> = Vec::new();
for rel in &files {
let text = read_source(&root, rel);
let visible = code_lines(&text);
visible_total += visible.len();
line_total += text.lines().count();
let mut last_code = None;
for (index, line) in text.lines().enumerate() {
if looks_like_code(line) {
last_code = Some((index + 1, line));
}
}
let Some((number, line)) = last_code else {
continue;
};
if !visible.iter().any(|(seen, _)| *seen == number) {
starved.push(format!(
"crates/{rel}:最后一行代码 `{}`(第 {number} 行)没被扫到 ⇒ 被吞到 EOF",
line.trim()
));
}
}
assert!(starved.is_empty(), "扫面没走到底:\n{}", starved.join("\n"));
// 兜底:可见行占比实测 81%(1219 个 .rs),门槛取 60% 留足余量。它只抓「大面积吞行」。
assert!(
visible_total * 10 >= line_total * 6,
"可见行只有 {visible_total}/{line_total}(<60%),扫面异常缩水",
);
}
/// 「这一行看起来是代码」:非空,且不是以 `/`(`//`、`/*`)或 `*`(`*/`、续行)开头。
fn looks_like_code(line: &str) -> bool {
let trimmed = line.trim();
!trimmed.is_empty() && !trimmed.starts_with(['/', '*'])
}
/// `crates/`(`CARGO_MANIFEST_DIR` 的上一级)。守卫的射程就是它,不多不少。
fn crates_root() -> PathBuf {
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
let crates = manifest.parent().expect("`crates/core` 没有上一级");
crates.to_path_buf()
}
fn read_source(root: &Path, rel: &str) -> String {
fs::read_to_string(root.join(rel))
.unwrap_or_else(|error| panic!("读不到 crates/{rel}:{error}"))
}
fn rust_files(root: &Path) -> Vec<String> {
fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) {
let entries =
fs::read_dir(dir).unwrap_or_else(|error| panic!("读不到 {}:{error}", dir.display()));
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk(root, &path, out);
} else if path.extension().is_some_and(|ext| ext == "rs") {
let rel = path.strip_prefix(root).expect("strip_prefix 失败");
out.push(rel.to_string_lossy().replace('\\', "/"));
}
}
}
let mut files = Vec::new();
walk(root, root, &mut files);
files.sort();
files
}
/// 去掉注释后的可见行(行号保留,报错能指到原文件)。
///
/// **`/*` 只在行首才算块注释开头**(M213 复审 P1-1):本仓库的文档注释与字符串里到处是 glob
/// (`/usr/lib/jvm/*`、`"mods/*.jar"`),行中认 `/*` 会让 `in_block` 一去不回,把该文件从那
/// 里到 EOF 全吞掉。代价是行中开始的跨行块注释之后那几行会被当成代码(方向:误报)。
fn code_lines(text: &str) -> Vec<(usize, &str)> {
let mut visible: Vec<(usize, &str)> = Vec::new();
let mut in_block = false;
for (index, raw) in text.lines().enumerate() {
let number = index + 1;
let trimmed = raw.trim_start();
if in_block {
// 块内只认 `*/`:它后面剩的残段可能是代码(`*/ let x = 1;`)。
if let Some(end) = trimmed.find("*/") {
in_block = false;
push_tail(&mut visible, number, &trimmed[end + 2..]);
}
continue;
}
if trimmed.starts_with("//") {
continue;
}
if trimmed.starts_with("/*") {
match trimmed.find("*/") {
Some(end) => push_tail(&mut visible, number, &trimmed[end + 2..]),
None => in_block = true,
}
continue;
}
visible.push((number, raw));
}
visible
}
/// 块注释收尾后的残段:不是空、也不是新的一条整行注释,才算可见代码。
fn push_tail<'a>(visible: &mut Vec<(usize, &'a str)>, number: usize, tail: &'a str) {
let tail = tail.trim_start();
if !tail.is_empty() && !tail.starts_with("//") {
visible.push((number, tail));
}
}
/// 本文件里由 `std::process::Command` 导出的别名(`use ... Command as X;` / `type X = …Command;`)。
fn command_aliases(text: &str) -> Vec<String> {
let mut aliases = Vec::new();
for (_, line) in code_lines(text) {
// `use std::process::Command as Spawn;` / `use std::process::{Command as Spawn};`
if let Some(at) = line.find("Command as ") {
let ident: String = line[at + "Command as ".len()..]
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
if !ident.is_empty() {
aliases.push(ident);
}
continue;
}
// `type Cmd = std::process::Command;` —— 别名在 `=` 的**左边**。
let trimmed = line.trim_start();
if trimmed.starts_with("type ") && line.ends_with("Command;") {
if let Some(ident) = trimmed
.split_whitespace()
.find(|word| *word != "type" && !word.contains('='))
{
aliases.push(ident.to_string());
}
}
}
aliases
}
fn is_spawn_site(line: &str, aliases: &[String]) -> bool {
line.contains("Command::new(")
|| aliases
.iter()
.any(|alias| line.contains(&format!("{alias}::new(")))
}
@@ -0,0 +1,233 @@
//! 五类信号采集:环形缓冲语义 / 退出码分类 / 磁盘采集器 / 引擎输入拼装。
use std::fs;
use std::time::{Duration, SystemTime};
use crate::prelude::*;
#[test]
fn log_buffer_keeps_key_lines_when_evicted() {
let mut buffer = LogBuffer::new(2);
buffer.push(SignalOrigin::Stdout, "java.lang.Exception: boom");
buffer.push(SignalOrigin::Stdout, "line 2");
buffer.push(SignalOrigin::Stdout, "line 3");
buffer.push(SignalOrigin::Stderr, "line 4");
assert_eq!(buffer.pushed(), 4);
assert_eq!(buffer.dropped(), 2);
assert_eq!(buffer.kept_key_lines(), 1, "关键行被淘汰后仍应保留");
assert_eq!(buffer.len(), 3, "保留 1 行关键历史 + 环形区 2 行");
let text = buffer.text();
assert!(text.contains("boom"), "根因行不该被挤出缓冲区");
assert!(text.contains("line 4"));
assert!(!text.contains("line 2"));
assert!(!buffer.is_empty());
}
#[test]
fn exit_types_match_launch_contract() {
// 字符串值与 launch-接口约定.md §3 exit.exit_type 完全一致
let kill = ExitSignal::classify(Some(137), Some(60_000), 0, "");
assert_eq!(kill.exit_type, ExitType::Sigkill);
assert_eq!(kill.exit_type.as_str(), "sigkill");
assert_eq!(ExitType::from_name("sigkill"), ExitType::Sigkill);
let term = ExitSignal::classify(Some(143), Some(60_000), 1, "");
assert_eq!(term.exit_type, ExitType::Sigterm);
let jvm = ExitSignal::classify(
Some(1),
Some(800),
2,
"Error occurred during initialization of VM\nCould not create the Java Virtual Machine",
);
assert_eq!(jvm.exit_type, ExitType::JvmError);
assert!(!jvm.silent_failure);
let normal = ExitSignal::classify(Some(0), Some(600_000), 0, "Game started fine");
assert_eq!(normal.exit_type, ExitType::Normal);
assert!(!normal.silent_failure);
assert!(normal.status_hex().is_none());
// exit 0 + 短命 + 有错误行 → 静默失败(HMCL 会判 NORMAL 放过)
let silent = ExitSignal::classify(Some(0), Some(5_000), 4, "java.lang.Exception: boom");
assert_eq!(silent.exit_type, ExitType::Normal);
assert!(silent.silent_failure);
// Windows 访问违例:负的 i32 → 十六进制状态码
let access = ExitSignal::classify(Some(-1_073_741_819), Some(90_000), 0, "");
assert!(access.is_access_violation());
assert_eq!(access.status_hex().as_deref(), Some("0xc0000005"));
for exit_type in [
ExitType::Normal,
ExitType::ApplicationError,
ExitType::JvmError,
ExitType::Sigkill,
ExitType::Sigterm,
ExitType::Killed,
ExitType::Interrupted,
] {
assert_eq!(ExitType::from_name(exit_type.as_str()), exit_type);
}
}
#[test]
fn crash_report_collector_reads_newest_and_honours_started_at() {
let run_dir = temp_dir("collect-crash");
let reports = run_dir.join("crash-reports");
fs::create_dir_all(&reports).expect("建目录");
fs::write(
reports.join("crash-2026-10-29_12.00.00-client.txt"),
"---- Minecraft Crash Report ----\nDescription: Example\n",
)
.expect("写崩溃报告");
let collector = CrashReportCollector::new();
let collectors: [&dyn SignalCollector; 1] = [&collector];
let (bundle, errors) = collect_all(&collectors, &CollectionContext::new(&run_dir));
assert!(errors.is_empty());
let captured = bundle.crash_report.expect("应采到崩溃报告");
assert!(captured.text.contains("Description: Example"));
assert_eq!(bundle.sources.len(), 1);
// 只要「本次会话之后」的文件:把下界设到未来 → 不采
let future =
CollectionContext::new(&run_dir).since(SystemTime::now() + Duration::from_secs(3_600));
let (bundle, errors) = collect_all(&collectors, &future);
assert!(errors.is_empty());
assert!(bundle.crash_report.is_none(), "历史崩溃不该算进本次");
}
#[test]
fn hs_err_collector_parses_newest_file() {
let run_dir = temp_dir("collect-hs-err");
fs::write(
run_dir.join("hs_err_pid4242.log"),
"#\n# A fatal error has been detected by the Java Runtime Environment:\n#\n# EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x1, pid=4242, tid=0x2\n# Problematic frame:\n# C [nvoglv64.dll+0x10]\n",
)
.expect("写 hs_err");
let collector = HsErrCollector::new();
let collectors: [&dyn SignalCollector; 1] = [&collector];
let (bundle, errors) = collect_all(&collectors, &CollectionContext::new(&run_dir));
assert!(errors.is_empty());
let report = bundle.hs_err.expect("应解析 hs_err");
assert_eq!(report.kind, HsErrKind::AccessViolation);
assert_eq!(report.pid, Some(4242));
assert_eq!(bundle.sources.len(), 1);
}
#[test]
fn latest_log_collector_tails_n_lines() {
let run_dir = temp_dir("collect-latest");
let logs = run_dir.join("logs");
fs::create_dir_all(&logs).expect("建目录");
let mut text = String::new();
for index in 0..10 {
text.push_str(&format!("line {index}\n"));
}
fs::write(logs.join("latest.log"), text).expect("写日志");
let collector = LatestLogCollector::new().with_max_lines(4);
let collectors: [&dyn SignalCollector; 1] = [&collector];
let (bundle, errors) = collect_all(&collectors, &CollectionContext::new(&run_dir));
assert!(errors.is_empty());
assert_eq!(bundle.log.pushed(), 4);
assert!(bundle.log.text().contains("line 9"));
assert!(!bundle.log.text().contains("line 5"));
}
#[test]
fn collect_all_aggregates_pushed_and_disk_signals() {
let run_dir = temp_dir("collect-all");
let reports = run_dir.join("crash-reports");
fs::create_dir_all(&reports).expect("建目录");
fs::write(
reports.join("crash-a.txt"),
"---- Minecraft Crash Report ----\n",
)
.expect("写报告");
let snapshot = EnvironmentSnapshot {
java_major: Some(17),
java_bits: Some(64),
xmx_mb: Some(4096),
physical_memory_mb: Some(16_384),
mods: vec![ModRecord::from_file_name("sodium-fabric-0.5.8.jar")],
components: vec![unlucky_core::diagnose::collectors::ComponentVersion::new(
"fabric", "0.15.0",
)],
..EnvironmentSnapshot::default()
};
let exit = ExitCollector::new(ExitSignal::classify(Some(1), Some(9_000), 1, "boom"));
let environment = EnvironmentCollector::new(snapshot);
let crash = CrashReportCollector::new();
let hs_err = HsErrCollector::new();
let collectors: [&dyn SignalCollector; 4] = [&exit, &environment, &crash, &hs_err];
let (bundle, errors) = collect_all(&collectors, &CollectionContext::new(&run_dir));
assert!(errors.is_empty(), "单源缺失不应报错:{errors:?}");
assert!(bundle.exit.is_some());
assert!(bundle.crash_report.is_some());
assert!(
bundle.hs_err.is_none(),
"没有 hs_err 文件时是 None(不是错误)"
);
let snapshot = bundle.environment.as_ref().expect("环境快照");
assert_eq!(snapshot.java_major, Some(17));
assert!(snapshot.summary().contains("Java 17"));
assert_eq!(bundle.mods.len(), 1);
assert!(bundle.mods[0].enabled);
assert!(!bundle.is_empty());
}
#[test]
fn mod_record_parses_file_name() {
let record = ModRecord::from_file_name("jei-1.20.1-15.2.0.27.jar");
assert_eq!(record.id, "jei-1.20.1");
assert_eq!(record.version.as_deref(), Some("15.2.0.27"));
assert!(record.enabled);
let disabled = ModRecord::from_file_name("optifine.jar.disabled");
assert!(!disabled.enabled);
assert_eq!(disabled.id, "optifine");
}
#[test]
fn bundle_input_groups_log_by_origin_and_carries_hs_err() {
let mut bundle = SignalBundle::new();
bundle.log.push(SignalOrigin::Stdout, "stdout line");
bundle.log.push(SignalOrigin::Stderr, "stderr line");
bundle.exit = Some(ExitSignal::classify(Some(1), Some(1_000), 1, "boom"));
let input = bundle.diagnosis_input();
assert!(input.exit.is_some());
let origins = input
.haystacks
.iter()
.map(|haystack| haystack.origin)
.collect::<Vec<_>>();
assert!(origins.contains(&SignalOrigin::Stdout));
assert!(origins.contains(&SignalOrigin::Stderr));
assert!(!input.is_empty());
let mut with_hs_err = SignalBundle::new();
with_hs_err.hs_err = Some(hs_err::parse(&read_sample(
"samples/hs_err/access_violation.log",
)));
let input = with_hs_err.diagnosis_input();
assert!(input.hs_err.is_some());
assert!(input
.haystacks
.iter()
.any(|haystack| haystack.origin == SignalOrigin::HsErr));
}
#[test]
fn empty_bundle_produces_empty_input() {
let bundle = SignalBundle::new();
assert!(bundle.is_empty());
assert!(bundle.diagnosis_input().is_empty());
}
+233
View File
@@ -0,0 +1,233 @@
//! 引擎行为:主因排序 / 抑制关系 / 去重 / 内置结论 / 动作物化。
use crate::prelude::*;
#[test]
fn root_cause_outranks_secondary_error() {
// 一条日志里既 OOM 又 NoClassDefFoundError:OOM 是根因(priority 80 vs 40)。
let text = "\
java.lang.OutOfMemoryError: Java heap space
java.lang.NoClassDefFoundError: net/minecraftforge/fml/loading/FMLEnvironment
";
let report = analyze_text(SignalOrigin::Stdout, text);
assert_eq!(
report.primary().expect("命中").rule_id,
"memory.out_of_memory"
);
assert!(
ids(&report).contains(&"integrity.no_class_def_found"),
"低级错误也应给出(哪怕是次生):{:?}",
ids(&report)
);
}
#[test]
fn suppress_moves_rule_into_secondary() {
// 32 位 Java 分配失败 + 次生 OutOfMemoryError
let text = "\
Could not reserve enough space for 4194304KB object heap
java.lang.OutOfMemoryError: Java heap space
";
let report = analyze_text(SignalOrigin::Stdout, text);
assert_eq!(report.primary().expect("命中").rule_id, "jvm.32bit");
let secondary = report
.secondary
.iter()
.find(|finding| finding.rule_id == "memory.out_of_memory")
.expect("OOM 应被降级为次生结论而不是丢弃");
assert_eq!(secondary.suppressed_by.as_deref(), Some("jvm.32bit"));
assert!(
!ids(&report).contains(&"memory.out_of_memory"),
"被抑制的结论不应出现在主因列表"
);
}
#[test]
fn same_rule_keeps_best_evidence_source() {
// 同一条文本在两个来源出现:只留一条结论,且优先崩溃报告。
let mut input = DiagnosisInput::new();
input.push_text(
SignalOrigin::Stdout,
"java.lang.OutOfMemoryError: Java heap space",
);
input.push_text(
SignalOrigin::CrashReport,
"---- Minecraft Crash Report ----\njava.lang.OutOfMemoryError: Java heap space",
);
let report = diagnoser().analyze(&input);
let hits = report
.findings
.iter()
.filter(|finding| finding.rule_id == "memory.out_of_memory")
.count();
assert_eq!(hits, 1, "同一规则只应报一次");
let finding = &report.findings[0];
assert_eq!(finding.evidence[0].origin, SignalOrigin::CrashReport);
}
#[test]
fn access_violation_exit_is_diagnosed() {
let exit = ExitSignal::classify(Some(-1_073_741_819), Some(90_000), 0, "");
let mut input = DiagnosisInput::new();
input.exit = Some(exit);
let report = diagnoser().analyze(&input);
let finding = report
.findings
.iter()
.find(|finding| finding.rule_id == "signal.access_violation")
.expect("0xC0000005 应产出内置结论");
assert_eq!(finding.category, RuleCategory::Graphics);
let action = &finding.materialized_actions()[0];
assert_eq!(action.kind, FixActionKind::ChangeGraphics);
assert_eq!(action.tool, "graphics.set");
assert_eq!(
action.args.get("update_driver").map(String::as_str),
Some("true")
);
}
#[test]
fn silent_failure_is_marked_on_exit_zero() {
// analyse/02 §7.10-10:exit 0 + 存活 <30s + 有错误行 → 静默失败,HMCL 会判 NORMAL 放过。
let exit = ExitSignal::classify(Some(0), Some(4_200), 3, "java.lang.Exception: boom");
assert_eq!(exit.exit_type, ExitType::Normal);
assert!(exit.silent_failure);
let mut input = DiagnosisInput::new();
input.exit = Some(exit);
let report = diagnoser().analyze(&input);
assert!(
ids(&report).contains(&"signal.silent_failure"),
"静默失败应单独打标:{:?}",
ids(&report)
);
// 低置信 → 交 AI 深化(PlanDoc/06 §2.3)
assert!(report.needs_more_info());
}
#[test]
fn empty_input_asks_for_more_information() {
let report = diagnoser().analyze(&DiagnosisInput::new());
assert!(report.findings.is_empty());
assert!(report.needs_more_info());
}
#[test]
fn conflict_rule_materializes_capture_groups() {
let text = read_sample("samples/fabric/mod_resolution_conflict.log");
let report = analyze_text(SignalOrigin::Stdout, &text);
let primary = report.primary().expect("命中");
assert_eq!(primary.rule_id, "fabric.mod_resolution_conflict");
let action = &primary.materialized_actions()[0];
assert_eq!(action.kind, FixActionKind::RemoveMod);
assert_eq!(action.tool, "mod.remove");
assert_eq!(
action.args.get("mod_ids").map(String::as_str),
Some("sodium,sodium-extra"),
"mod_ids 应已代入命名捕获组"
);
}
/// 回归(review P2-1):OptiFine/Forge 兼容规则最后一个 alternative 必须是**前缀语义**,
/// 既收裸 `(Reflector.java)` 也收真实的带行号形态 `(Reflector.java:455)`。
#[test]
fn optifine_forge_matches_both_frame_forms() {
for frame in [
"\tTRANSFORMER/net.optifine/net.optifine.reflect.Reflector.<clinit>(Reflector.java:455)",
"\tTRANSFORMER/net.optifine/net.optifine.reflect.Reflector.<clinit>(Reflector.java)",
] {
let report = analyze_text(SignalOrigin::Stdout, frame);
assert!(
ids(&report).contains(&"conflict.optifine_forge"),
"真实栈帧形态应命中:{frame}(实际命中:{:?})",
ids(&report)
);
}
}
#[test]
fn too_old_java_converts_class_file_version() {
let text = read_sample("samples/jvm/too_old_java.log");
let report = analyze_text(SignalOrigin::Stdout, &text);
let primary = report.primary().expect("命中");
assert_eq!(primary.rule_id, "jvm.too_old_java");
let action = &primary.materialized_actions()[0];
assert_eq!(
action.args.get("required_major").map(String::as_str),
Some("17"),
"class file version 61 应换算成 Java 17"
);
assert_eq!(class_file_to_java_major(61), Some(17));
assert_eq!(class_file_to_java_major(52), Some(8));
assert_eq!(class_file_to_java_major(40), None);
}
#[test]
fn fix_actions_are_deduplicated_across_findings() {
// 一条日志同时命中 OOM(adjust_memory)与 32bit(adjust_memory + change_java)
let text = "\
Could not reserve enough space for 4194304KB object heap
java.lang.OutOfMemoryError: Java heap space
";
let report = analyze_text(SignalOrigin::Stdout, text);
let actions = report.fix_actions();
assert!(!actions.is_empty());
let mut unique = actions.clone();
unique.sort_by(|a, b| a.tool.cmp(b.tool).then(a.args.len().cmp(&b.args.len())));
unique.dedup();
assert_eq!(unique.len(), actions.len(), "fix_actions 应当已去重");
}
#[test]
fn record_matches_launch_diagnosis_payload() {
let text = read_sample("samples/memory/out_of_memory.log");
let report = analyze_text(SignalOrigin::Stdout, &text);
let record = report.to_record();
assert_eq!(record.source, "rules");
assert!(!record.candidates.is_empty());
let candidate = &record.candidates[0];
assert_eq!(candidate.id, "memory.out_of_memory");
assert_eq!(candidate.severity, "critical");
assert_eq!(candidate.category, "memory");
assert_eq!(candidate.message_key, "diagnose.cause.memory.out_of_memory");
assert!(!candidate.message.is_empty(), "M16 需要渲染后的中文结论");
assert!(!candidate.evidence.is_empty());
assert_eq!(candidate.fix_actions[0].tool, "memory.set");
}
#[test]
fn hs_err_report_feeds_builtin_findings() {
let report = hs_err::parse(&read_sample("samples/hs_err/access_violation.log"));
let mut input = DiagnosisInput::new();
input.push_hs_err(report);
let analysis = diagnoser().analyze(&input);
assert_eq!(
analysis.primary().expect("命中").rule_id,
"hs_err.access_violation"
);
// graphics.graphics_driver 会被内置结论 suppress 成次生(同一条证据、不同粒度)
assert!(
analysis
.secondary
.iter()
.any(|finding| finding.rule_id == "graphics.graphics_driver"),
"hs_err 原文也应命中图形规则,但被降级:{:?}",
analysis
.secondary
.iter()
.map(|finding| finding.rule_id.as_str())
.collect::<Vec<_>>()
);
}
#[test]
fn file_scope_rules_survive_very_long_logs() {
// >1 MiB:首尾窗口扫描,尾部窗口里的签名仍应命中。
let mut text = String::from("noise line\n").repeat(120_000);
text.push_str("java.lang.OutOfMemoryError: Java heap space\n");
assert!(text.len() > 1024 * 1024);
let report = analyze_text(SignalOrigin::LatestLog, &text);
assert!(
ids(&report).contains(&"memory.out_of_memory"),
"超长日志的尾部窗口应被扫描"
);
}
+143
View File
@@ -0,0 +1,143 @@
//! `hs_err_pid*.log` 解析器:标题行 / 线程段 / 内存段 / 失败帧。
use crate::prelude::*;
#[test]
fn parses_access_violation_report() {
let text = read_sample("samples/hs_err/access_violation.log");
let report = hs_err::parse(&text);
assert_eq!(report.kind, HsErrKind::AccessViolation);
assert_eq!(
report.header.as_deref(),
Some("A fatal error has been detected by the Java Runtime Environment:")
);
assert_eq!(
report.exception.as_deref(),
Some("EXCEPTION_ACCESS_VIOLATION (0xc0000005)")
);
assert_eq!(report.pc.as_deref(), Some("0x00007ffb1a2b3c4d"));
assert_eq!(report.pid, Some(12344));
assert_eq!(report.tid.as_deref(), Some("0x0000000000002a1c"));
let frame = report.failed_frame.as_ref().expect("失败帧");
assert_eq!(frame.kind, FrameKind::Native);
assert_eq!(frame.module.as_deref(), Some("ig8d64.dll"));
assert_eq!(frame.offset.as_deref(), Some("0x9b3c4d"));
let thread = report.current_thread.as_deref().expect("当前线程");
assert!(thread.contains("Render thread"), "{thread}");
assert!(report
.jre_version
.as_deref()
.is_some_and(|line| line.contains("8.0_51")));
assert!(report
.java_vm
.as_deref()
.is_some_and(|line| line.contains("HotSpot")));
let memory = report.memory.as_ref().expect("内存段");
assert_eq!(memory.page_size.as_deref(), Some("4k"));
assert_eq!(memory.physical_kb, Some(16_741_480));
assert_eq!(memory.physical_free_kb, Some(4_716_856));
assert_eq!(memory.swap_kb, Some(18_672_384));
assert_eq!(memory.swap_free_kb, Some(9_433_608));
assert!(memory.summary().contains("GB"));
assert!(!report.heap.is_empty(), "Heap 段应被抽取");
assert!(report
.vm_arguments
.iter()
.any(|arg| arg.contains("-Xmx4096m")));
assert!(report
.command_line
.as_deref()
.is_some_and(|line| line.contains("net.minecraft.launchwrapper.Launch")));
}
#[test]
fn digest_keeps_header_block_for_cross_line_rules() {
let report = hs_err::parse(&read_sample("samples/hs_err/access_violation.log"));
let digest = report.digest();
// 跨行图形规则依赖「EXCEPTION_ACCESS_VIOLATION … # C [ig…」的相对位置
assert!(digest.contains("EXCEPTION_ACCESS_VIOLATION"));
assert!(digest.contains("# C [ig8d64.dll"));
assert!(digest.contains("Current thread"));
assert!(digest.contains("Memory:"));
}
#[test]
fn parses_out_of_memory_report() {
let report = hs_err::parse(&read_sample("samples/hs_err/out_of_memory.log"));
assert_eq!(report.kind, HsErrKind::OutOfMemory);
assert_eq!(report.pid, Some(9876));
assert_eq!(
report.exception, None,
"OOM 型 hs_err 没有 EXCEPTION_* 行,不该瞎猜"
);
assert!(report
.header
.as_deref()
.is_some_and(|header| header.contains("insufficient memory")));
let memory = report.memory.as_ref().expect("内存段");
assert_eq!(memory.physical_kb, Some(8_134_564));
assert!(report.summary().contains("out_of_memory"));
}
#[test]
fn parse_never_fails_on_garbage() {
for text in ["", "\n\n", "随便一段中文", "# EXCEPTION_ACCESS_VIOLATION"] {
let report = hs_err::parse(text);
// 不 panic 即可;字段可以全空
let _ = report.digest();
let _ = report.summary();
}
assert_eq!(hs_err::parse("").kind, HsErrKind::Other);
}
#[test]
fn parse_file_attaches_path() {
let path = rules_dir().join("samples/hs_err/access_violation.log");
let report = hs_err::parse_file(&path).expect("读文件");
assert_eq!(report.path.as_deref(), Some(path.as_path()));
assert_eq!(report.kind, HsErrKind::AccessViolation);
}
#[test]
fn missing_file_is_an_io_error() {
let path = temp_dir("hs-err-missing").join("hs_err_pid1.log");
assert!(hs_err::parse_file(&path).is_err());
}
#[test]
fn java_frame_is_classified() {
let text = "\
# EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x0000000000000000, pid=1, tid=0x2
# Problematic frame:
# J 123 c1 com.example.mod.Foo.bar(Ljava/lang/String;)V (12 bytes) @ 0x00007ffb
";
let report = hs_err::parse(text);
let frame = report.failed_frame.as_ref().expect("失败帧");
assert_eq!(frame.kind, FrameKind::Java);
assert_eq!(frame.module, None);
}
#[test]
fn engine_reports_hs_err_out_of_memory() {
let report = hs_err::parse(&read_sample("samples/hs_err/out_of_memory.log"));
let mut input = DiagnosisInput::new();
input.push_hs_err(report);
let analysis = diagnoser().analyze(&input);
let finding = analysis
.findings
.iter()
.find(|finding| finding.rule_id == "hs_err.out_of_memory")
.expect("hs_err OOM 应产出内置结论");
assert_eq!(finding.category, RuleCategory::Memory);
assert!(finding.message.contains("内存不足"));
assert_eq!(
finding.materialized_actions()[0].tool,
"memory.set",
"M16 需要能直接执行的工具 id"
);
}
+171
View File
@@ -0,0 +1,171 @@
//! 规则库加载 / 热重载 / 坏文件的降级行为。
use std::fs;
use crate::prelude::*;
const CUSTOM_MEMORY: &str = "\
version: 1
category: memory
rules:
- id: memory.custom_only
severity: warning
pattern: 'CUSTOM TEST SIGNATURE'
confidence: 0.9
priority: 70
cause:
key: diagnose.cause.memory.custom_only
summary: '测试用自定义规则'
fix_action:
- kind: seek_help
sample: samples/memory/out_of_memory.log
";
#[test]
fn embedded_library_is_complete() {
let loader = RuleLoader::embedded();
assert_eq!(EMBEDDED_RULES.len(), 10, "10 个根因分组各一个 YAML");
// 64 = HMCL 59 条全量平移 + 5 条 Vulkan 组(graphics.yaml 末尾的 4 个原因码
// + no_graphics_backend)。加规则要同时加样本并同步这个数。
assert_eq!(loader.rules().len(), 64);
assert!(loader.last_errors().is_empty());
assert!(loader.dir().is_none());
}
#[test]
fn missing_directory_falls_back_to_embedded() {
let dir = temp_dir("loader-missing").join("nope");
let loader = RuleLoader::from_dir(&dir);
assert_eq!(loader.rules().len(), 64);
assert!(loader.last_errors().is_empty());
}
#[test]
fn disk_file_replaces_embedded_group() {
let dir = temp_dir("loader-override");
fs::write(dir.join("memory.yaml"), CUSTOM_MEMORY).expect("写规则文件");
let loader = RuleLoader::from_dir(&dir);
assert_eq!(loader.rules().len(), 63, "memory.yaml 的 2 条被 1 条替换");
assert!(loader.last_errors().is_empty());
assert!(loader.rule_set().get("memory.custom_only").is_some());
assert!(
loader.rule_set().get("memory.out_of_memory").is_none(),
"同组文件被整份替换"
);
}
#[test]
fn refresh_picks_up_edits() {
let dir = temp_dir("loader-refresh");
let mut loader = RuleLoader::from_dir(&dir);
assert_eq!(
loader.refresh(),
ReloadOutcome::Unchanged,
"没变就不该重解析"
);
fs::write(dir.join("memory.yaml"), CUSTOM_MEMORY).expect("写规则文件");
assert_eq!(
loader.refresh(),
ReloadOutcome::Reloaded { rules: 63 },
"指纹变了应重新加载"
);
assert!(loader.rule_set().get("memory.custom_only").is_some());
}
#[test]
fn broken_yaml_keeps_last_good_rules() {
let dir = temp_dir("loader-broken");
fs::write(dir.join("memory.yaml"), CUSTOM_MEMORY).expect("写规则文件");
let mut loader = RuleLoader::from_dir(&dir);
assert_eq!(loader.rules().len(), 63);
// 用户手改坏了 YAML:保留上一份好规则 + 报错,而不是让诊断能力归零
fs::write(
dir.join("memory.yaml"),
"version: 1\ncategory: memory\nrules: [ oops\n",
)
.expect("写坏文件");
match loader.refresh() {
ReloadOutcome::Rejected { errors } => {
assert!(!errors.is_empty());
assert_eq!(
errors[0].kind,
unlucky_core::diagnose::loader::LoadErrorKind::Yaml
);
}
other => panic!("应被拒绝:{other:?}"),
}
assert_eq!(loader.rules().len(), 63, "坏文件不该清空规则库");
assert!(loader.rule_set().get("memory.custom_only").is_some());
assert!(!loader.last_errors().is_empty());
}
#[test]
fn duplicate_rule_id_across_files_is_rejected() {
let dir = temp_dir("loader-duplicate");
let duplicated = "\
version: 1
category: other
rules:
- id: memory.out_of_memory
pattern: 'dup'
cause:
key: diagnose.cause.dup
summary: '重复 id'
";
fs::write(dir.join("other.yaml"), duplicated).expect("写规则文件");
let loader = RuleLoader::from_dir(&dir);
assert_eq!(loader.rules().len(), 64, "整体失败后回退内嵌规则集");
assert!(loader
.last_errors()
.iter()
.any(|error| error.kind == unlucky_core::diagnose::loader::LoadErrorKind::DuplicateId));
}
#[test]
fn unknown_field_is_rejected() {
let dir = temp_dir("loader-typo");
let typo = "\
version: 1
category: other
rules:
- id: other.typo
patternn: 'typo in field name'
cause:
key: diagnose.cause.typo
summary: '字段拼错'
";
fs::write(dir.join("other.yaml"), typo).expect("写规则文件");
let loader = RuleLoader::from_dir(&dir);
assert_eq!(loader.rules().len(), 64);
assert!(
!loader.last_errors().is_empty(),
"deny_unknown_fields 应把拼错的字段报出来"
);
}
#[test]
fn rule_set_rejects_invalid_regex() {
let sources = vec![(
"bad.yaml".to_owned(),
"\
version: 1
category: other
rules:
- id: other.bad_regex
pattern: '(unclosed'
cause:
key: diagnose.cause.bad
summary: '坏正则'
"
.to_owned(),
)];
let errors = RuleSet::from_sources(&sources).expect_err("坏正则应失败");
assert_eq!(
errors[0].kind,
unlucky_core::diagnose::loader::LoadErrorKind::Regex
);
}
+83
View File
@@ -0,0 +1,83 @@
//! `core::diagnose` 集成测试。
//!
//! 结构对齐 HMCL 的 `CrashReportAnalyzerTest` + `resources/logs`(analyse/02 §7.9):
//! **每条规则一个样本**(`src/diagnose/rules.d/samples/`),断言「样本命中它自己」。
//! 规则库是数据不是代码,样本就是规则的验收标准——新增规则必须同时提交样本。
//!
//! 跑法:`cargo test -p unlucky-core --test diagnose`
mod collectors_probe;
mod engine_rank;
mod hs_err_parse;
mod loader_reload;
mod rules_samples;
/// 测试共用导入与辅助函数。
pub mod prelude {
use std::path::{Path, PathBuf};
pub use unlucky_core::diagnose::collectors::{
collect_all, CollectionContext, CrashReportCollector, EnvironmentCollector,
EnvironmentSnapshot, ExitCollector, ExitSignal, ExitType, HsErrCollector,
LatestLogCollector, LogBuffer, ModRecord, SignalBundle, SignalCollector, SignalKind,
SignalOrigin,
};
pub use unlucky_core::diagnose::engine::{
Diagnoser, DiagnosisInput, DiagnosisReport, Evidence, Finding, CONCLUSIVE_CONFIDENCE,
};
pub use unlucky_core::diagnose::hs_err::{self, FrameKind, HsErrKind, HsErrReport};
pub use unlucky_core::diagnose::loader::{ReloadOutcome, RuleLoader, RuleSet, EMBEDDED_RULES};
pub use unlucky_core::diagnose::rules::{
class_file_to_java_major, ComponentKind, FixAction, FixActionKind, GraphicsSetting,
JavaVendor, ModAction, RuleCategory, Severity,
};
/// 规则目录(`crates/core/src/diagnose/rules.d`)。
#[must_use]
pub fn rules_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("src/diagnose/rules.d")
}
/// 读样本(路径相对 `rules.d/`)。
pub fn read_sample(relative: &str) -> String {
let path = rules_dir().join(relative);
std::fs::read_to_string(&path)
.unwrap_or_else(|error| panic!("样本 {} 读不到:{error}", path.display()))
}
/// 用内嵌规则库跑一段文本。
pub fn analyze_text(origin: SignalOrigin, text: &str) -> DiagnosisReport {
let mut input = DiagnosisInput::new();
input.push_text(origin, text);
diagnoser().analyze(&input)
}
/// 内嵌规则库构造的引擎。
#[must_use]
pub fn diagnoser() -> Diagnoser {
Diagnoser::new(RuleLoader::embedded().rules().to_vec())
}
/// 结论 id 列表(断言失败时打印用)。
#[must_use]
pub fn ids(report: &DiagnosisReport) -> Vec<&str> {
report
.findings
.iter()
.map(|finding| finding.rule_id.as_str())
.collect()
}
/// 临时目录(进程 id + 计数器,不引 tempfile 依赖)。
#[must_use]
pub fn temp_dir(tag: &str) -> PathBuf {
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
let index = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir =
std::env::temp_dir().join(format!("ull-diagnose-{}-{tag}-{index}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("创建临时目录");
dir
}
}
+267
View File
@@ -0,0 +1,267 @@
//! 规则库完整性 + 每条规则的样本回归(HMCL `CrashReportAnalyzerTest` 同构)。
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use crate::prelude::*;
#[test]
fn every_rule_has_a_sample_that_hits_itself() {
let loader = RuleLoader::embedded();
let rules = loader.rules();
// 64 = HMCL 59 条全量平移 + 5 条 Vulkan 组(rules.d/graphics.yaml 末尾:
// vulkan_loader_missing / vulkan_device_version_too_low / vulkan_missing_extension /
// vulkan_missing_feature / no_graphics_backend)。加规则记得同时加样本并改这个数。
assert_eq!(rules.len(), 64, "HMCL 59 条 + Vulkan 5 条规则应全量在位");
for rule in rules {
let sample = rule
.rule()
.sample
.as_deref()
.unwrap_or_else(|| panic!("规则 {} 没有 sample 字段", rule.id()));
let text = read_sample(sample);
let report = analyze_text(SignalOrigin::Stdout, &text);
let hit = report
.findings
.iter()
.chain(report.secondary.iter())
.any(|finding| finding.rule_id == rule.id());
assert!(
hit,
"规则 {} 的样本 {} 没有命中它自己(实际命中:{:?})",
rule.id(),
sample,
ids(&report)
);
}
}
#[test]
fn rule_ids_are_unique_and_namespaced() {
let loader = RuleLoader::embedded();
let mut seen = BTreeSet::new();
for rule in loader.rules() {
assert!(seen.insert(rule.id()), "规则 id 重复:{}", rule.id());
let (namespace, name) = rule
.id()
.split_once('.')
.unwrap_or_else(|| panic!("规则 id 必须带命名空间:{}", rule.id()));
assert!(
!namespace.is_empty() && !name.is_empty(),
"规则 id 不合法:{}",
rule.id()
);
}
}
#[test]
fn rules_carry_cause_actions_and_provenance() {
for rule in RuleLoader::embedded().rules() {
let rule = rule.rule();
assert!(
rule.cause.key.starts_with("diagnose.cause."),
"规则 {} 的 cause.key 应以 diagnose.cause. 开头",
rule.id
);
assert!(
!rule.cause.summary.is_empty(),
"规则 {} 缺结论文案",
rule.id
);
assert!(
!rule.fix_action.is_empty(),
"规则 {} 没有绑定六维修复动作(M16 一键修复要靠它)",
rule.id
);
assert!(
rule.source.is_some(),
"规则 {} 缺 source(HMCL 出处)",
rule.id
);
assert!(
(0.0..=1.0).contains(&rule.confidence),
"规则 {} 的 confidence 超出 0~1",
rule.id
);
}
}
#[test]
fn every_reported_category_has_rules() {
let mut categories = BTreeSet::new();
for rule in RuleLoader::embedded().rules() {
categories.insert(rule.category());
}
for category in RuleCategory::all() {
assert!(
categories.contains(&category),
"分类 {category} 一条规则都没有(rules.d 里缺这个文件或为空)"
);
}
}
#[test]
fn no_orphan_samples() {
let samples_root = rules_dir().join("samples");
let declared = RuleLoader::embedded()
.rules()
.iter()
.filter_map(|rule| rule.rule().sample.clone())
.collect::<BTreeSet<_>>();
let mut on_disk = BTreeSet::new();
for group in std::fs::read_dir(&samples_root).expect("samples 目录") {
let group = group.expect("目录项").path();
if !group.is_dir() {
continue;
}
collect(&samples_root, &group, &mut on_disk);
}
for path in &on_disk {
assert!(
declared.contains(path),
"样本 {path} 没有规则引用它(孤儿样本,或 sample: 路径写错)"
);
}
}
/// 递归收集 `.log` 样本,返回相对 `rules.d/` 的路径。
fn collect(root: &Path, dir: &Path, out: &mut BTreeSet<String>) {
for entry in std::fs::read_dir(dir).expect("目录项") {
let path = entry.expect("目录项").path();
if path.is_dir() {
collect(root, &path, out);
} else if path.extension().is_some_and(|ext| ext == "log") {
let relative = path
.strip_prefix(root)
.expect("相对路径")
.to_string_lossy()
.replace('\\', "/");
// hs_err 样本是解析器测试素材,不对应规则。
if !relative.starts_with("hs_err/") {
out.insert(format!("samples/{relative}"));
}
}
}
}
#[test]
fn decisive_rule_gives_evidence_message_and_actions() {
let text = read_sample("samples/integrity/mod_files_decompressed.log");
let report = analyze_text(SignalOrigin::Stdout, &text);
let primary = report.primary().expect("应当命中");
assert_eq!(primary.rule_id, "integrity.mod_files_decompressed");
assert!(!primary.message.is_empty());
assert_eq!(primary.severity, Severity::Critical);
let evidence = primary.evidence.first().expect("证据");
assert_eq!(evidence.origin, SignalOrigin::Stdout);
assert!(evidence.render().starts_with("stdout:"));
assert!(!primary.fix_actions.is_empty());
// 高置信结论不该再要求 AI 深化
assert!(
!report.needs_more_info(),
"0.95 置信度的结论被判成「需要更多信息」"
);
assert_eq!(report.rules_evaluated, 64);
}
/// Vulkan 组:语义是「Vulkan 后端起不来」,所以**成功的** Vulkan 日志不该命中任何一条。
#[test]
fn healthy_vulkan_startup_hits_no_vulkan_rule() {
let text = "\
[12:00:21] [Render thread/INFO]: Backend library: LWJGL version 3.4.3+13
[12:00:21] [Render thread/INFO]: Using graphics backend VULKAN, using drivers: AMD proprietary driver
[12:00:21] [Render thread/INFO]: OpenGL debug message callback registered
";
let report = analyze_text(SignalOrigin::Stdout, text);
let hit = report
.findings
.iter()
.chain(report.secondary.iter())
.map(|finding| finding.rule_id.as_str())
.filter(|id| id.starts_with("graphics.vulkan") || *id == "graphics.no_graphics_backend")
.collect::<Vec<_>>();
assert!(hit.is_empty(), "正常的 Vulkan 启动日志被判成故障:{hit:?}");
}
/// Vulkan 规则必须落在 `graphics_api` 这个新维度上(否则 agent 侧不知道该改哪一项)。
#[test]
fn vulkan_rules_point_at_the_graphics_api_setting() {
let loader = RuleLoader::embedded();
for id in [
"graphics.vulkan_loader_missing",
"graphics.vulkan_device_version_too_low",
"graphics.vulkan_missing_extension",
"graphics.vulkan_missing_feature",
"graphics.no_graphics_backend",
] {
let compiled = loader
.rule_set()
.get(id)
.unwrap_or_else(|| panic!("规则 {id} 不存在"));
let rule = compiled.rule();
let settings = rule
.fix_action
.iter()
.map(|action| action.materialize(&BTreeMap::new()))
.filter(|action| {
action
.args
.get("setting")
.is_some_and(|setting| setting == "graphics_api")
})
.count();
assert!(
settings > 0,
"规则 {id} 没有 change_graphics/graphics_api 动作"
);
assert!(
rule.fix_action
.iter()
.any(|action| action.kind().as_str() == "change_graphics"),
"规则 {id} 的修复动作应属于 change_graphics 维度"
);
}
}
/// 后端全挂(两个都失败)应当压过单条 vulkan_* 原因,避免同一个故障报五遍。
#[test]
fn no_graphics_backend_suppresses_single_reason_rules() {
let text = read_sample("samples/graphics/no_graphics_backend.log");
let report = analyze_text(SignalOrigin::Stdout, &text);
assert_eq!(
report.primary().expect("命中").rule_id,
"graphics.no_graphics_backend"
);
}
/// SDL 变体必须保持 macOS 特异性:`cocoa` 是 SDL 在 macOS 的视频驱动名,两者同现才算
/// 本规则命中。否则 Windows/Linux 的 SDL_Init 失败会被误归因成「macOS 显示器问题」。
#[test]
fn sdl_display_failure_is_attributed_to_macos_only_with_cocoa() {
let hit = |text: &str| {
let report = analyze_text(SignalOrigin::Stdout, text);
report
.findings
.iter()
.chain(report.secondary.iter())
.any(|finding| finding.rule_id == "graphics.macos_display_port")
};
let macos = "\
[12:00:21] [Render thread/ERROR]: java.lang.IllegalStateException: SDL_Init(SDL_INIT_VIDEO) failed: cocoa: Failed to create window
";
assert!(hit(macos), "macOS(cocoa)的 SDL 初始化失败应命中原规则");
for other in [
// Windows:SDL 的 windows 驱动,文本里没有 cocoa。
"[12:00:21] [Render thread/ERROR]: SDL_Init(SDL_INIT_VIDEO) failed: No available video device\n",
// Linux:x11/wayland 驱动。
"[12:00:21] [Render thread/ERROR]: SDL_Init(SDL_INIT_VIDEO) failed: x11: Could not connect to display\n",
] {
assert!(
!hit(other),
"非 macOS 的 SDL 初始化失败不该归因到 macOS 显示器规则:{other}"
);
}
}
@@ -0,0 +1,68 @@
//! 指数退避与抖动的单测(纯函数,无网络)。
use std::time::Duration;
use unlucky_core::download::backoff::{BackoffPolicy, Jitter};
#[test]
fn exponential_growth_doubles_and_caps() {
let policy = BackoffPolicy::default();
assert_eq!(policy.delay(0), Duration::from_millis(1_000));
assert_eq!(policy.delay(1), Duration::from_millis(2_000));
assert_eq!(policy.delay(2), Duration::from_millis(4_000));
assert_eq!(policy.delay(3), Duration::from_millis(8_000));
// 上限封顶,不会涨到分钟级。
assert_eq!(policy.delay(10), Duration::from_millis(30_000));
assert_eq!(policy.delay(u32::MAX), Duration::from_millis(30_000));
}
#[test]
fn no_retry_policy_waits_nothing() {
let policy = BackoffPolicy::none();
assert_eq!(policy.delay(0), Duration::ZERO);
assert_eq!(policy.max_attempts, 1);
assert_eq!(policy.delay_jittered(0, 0.5), Duration::ZERO);
}
#[test]
fn jitter_stays_within_ratio_bounds() {
let policy = BackoffPolicy::default();
// base 1000ms ± 30% → [700, 1300]
let low = policy.delay_jittered(0, 0.0);
let high = policy.delay_jittered(0, 1.0);
assert_eq!(low, Duration::from_millis(700));
assert_eq!(high, Duration::from_millis(1_300));
// 抖动永不超过 max_ms。
for rng in [0.0, 0.25, 0.5, 0.75, 1.0] {
assert!(policy.delay_jittered(9, rng) <= Duration::from_millis(30_000));
}
}
#[test]
fn jitter_without_ratio_is_deterministic() {
let policy = BackoffPolicy {
jitter_ratio: 0.0,
..BackoffPolicy::default()
};
assert_eq!(
policy.delay_jittered(2, 0.0),
policy.delay_jittered(2, 0.99)
);
}
#[test]
fn jitter_source_is_reproducible_and_in_range() {
let mut first = Jitter::new(42);
let mut second = Jitter::new(42);
for _ in 0..32 {
let a = first.next_fraction();
let b = second.next_fraction();
assert_eq!(a, b, "同种子的抖动序列必须一致");
assert!((0.0..1.0).contains(&a));
}
let mut other = Jitter::new(7);
assert_ne!(Jitter::new(1).next_u64(), other.next_u64());
// 种子 0 会被替换成固定非零种子,但仍然可用。
assert!((0.0..1.0).contains(&Jitter::new(0).next_fraction()));
}
+149
View File
@@ -0,0 +1,149 @@
//! 内容寻址缓存的单测:两级分片、记录信任(命中不重算)、损坏检测、索引持久化。
mod support;
use std::time::Duration;
use unlucky_core::download::cache::ContentCache;
use unlucky_core::download::types::{HashAlgo, Integrity};
const NOW: u64 = 5_000_000;
fn cache_dir(tag: &str) -> std::path::PathBuf {
support::temp_dir(tag)
}
#[test]
fn sharded_path_follows_layout() {
let dir = cache_dir("cache-path");
let cache = ContentCache::open(&dir);
let integrity = Integrity::sha1("abcdef0123456789abcdef0123456789abcdef01");
let path = cache.path_for(integrity.algo, &integrity.hex);
assert!(path.ends_with("sha1/ab/abcdef0123456789abcdef0123456789abcdef01"));
assert!(path.starts_with(cache.root()));
// SHA-256 单独一棵子树。
let sha256_path = cache.path_for(HashAlgo::Sha256, "00112233445566778899aabbccddeeff");
assert!(sha256_path.ends_with("sha256/00/00112233445566778899aabbccddeeff"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn insert_then_lookup_hits_without_rehashing() {
let dir = cache_dir("cache-hit");
let cache = ContentCache::open(&dir);
let payload = b"hello unlucky launcher".to_vec();
let integrity = Integrity::sha1(support::sha1_hex(&payload));
let source = dir.join("download.bin");
std::fs::write(&source, &payload).unwrap();
let cached = cache
.insert(&source, &integrity, NOW)
.expect("写入缓存失败");
assert!(cached.is_file());
assert_eq!(cache.len(), 1);
// 第二次命中:size/mtime 未变 → 记录信任,直接返回(不重算摘要)。
let hit = cache.lookup(&integrity, NOW + 10).expect("应命中缓存");
assert_eq!(hit, cached);
// 文件被删 → 记录作废。
std::fs::remove_file(&cached).unwrap();
assert!(cache.lookup(&integrity, NOW + 20).is_none());
assert_eq!(cache.len(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn truncated_cache_file_is_a_miss() {
let dir = cache_dir("cache-truncate");
let cache = ContentCache::open(&dir);
let payload = vec![7_u8; 256];
let integrity = Integrity::sha1(support::sha1_hex(&payload));
let source = dir.join("payload.bin");
std::fs::write(&source, &payload).unwrap();
let cached = cache.insert(&source, &integrity, NOW).unwrap();
// 半损坏(长度变了)必须立刻视为未命中。
std::fs::write(&cached, &payload[..128]).unwrap();
assert!(cache.lookup(&integrity, NOW + 1).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn metadata_change_forces_a_rehash() {
let dir = cache_dir("cache-rehash");
let cache = ContentCache::open(&dir);
let payload = vec![3_u8; 128];
let integrity = Integrity::sha1(support::sha1_hex(&payload));
let source = dir.join("data.bin");
std::fs::write(&source, &payload).unwrap();
let cached = cache.insert(&source, &integrity, NOW).unwrap();
// 硬链接/复制都可能让源文件与缓存同 inode,先断开再改缓存内容。
let _ = std::fs::remove_file(&source);
// 等 mtime 变化(部分文件系统时间戳精度 10ms)。
std::thread::sleep(Duration::from_millis(30));
let mut tampered = payload.clone();
tampered[0] = 9;
std::fs::write(&cached, &tampered).unwrap();
// 内容变了 → 摘要不符 → 记录作废。
assert!(cache.lookup(&integrity, NOW + 100).is_none());
assert_eq!(cache.len(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn lookup_verified_rejects_wrong_content_with_same_size() {
let dir = cache_dir("cache-verified");
let cache = ContentCache::open(&dir);
let payload = vec![1_u8; 64];
let integrity = Integrity::sha1(support::sha1_hex(&payload));
// 直接放到缓存位但内容是错的(模拟外部篡改 / 镜像写坏)。
let path = cache.path_for(integrity.algo, &integrity.hex);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, vec![2_u8; 64]).unwrap();
cache.register(&path, &integrity, NOW).unwrap();
assert!(
cache.lookup_verified(&integrity, NOW + 1).is_none(),
"强制校验必须发现内容不符"
);
// 内容对了就能通过。
std::fs::write(&path, &payload).unwrap();
cache.register(&path, &integrity, NOW).unwrap();
assert!(cache.lookup_verified(&integrity, NOW + 2).is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn index_persists_across_reopen() {
let dir = cache_dir("cache-persist");
let payload = vec![5_u8; 32];
let integrity = Integrity::sha1(support::sha1_hex(&payload));
{
let cache = ContentCache::open(&dir);
let source = dir.join("src.bin");
std::fs::write(&source, &payload).unwrap();
cache.insert(&source, &integrity, NOW).unwrap();
cache.persist().expect("持久化索引失败");
assert_eq!(cache.stats().entries, 1);
assert_eq!(cache.stats().total_bytes, 32);
}
let reopened = ContentCache::open(&dir);
assert_eq!(reopened.len(), 1);
assert!(reopened.lookup(&integrity, NOW + 1).is_some());
let _ = std::fs::remove_dir_all(&dir);
}
+375
View File
@@ -0,0 +1,375 @@
//! 分块规划与 Range 语义的单测(纯函数)+ 真跑 4 连接的端到端用例(mock 源)。
mod support;
use std::sync::Arc;
use std::time::Duration;
use unlucky_core::download::backoff::BackoffPolicy;
use unlucky_core::download::cache::ContentCache;
use unlucky_core::download::chunked::{
content_range_matches, parse_content_range, plan, range_support, ByteRange, ChunkedConfig,
ContentRange, RangeSupport, MAX_CONNECTIONS,
};
use unlucky_core::download::config::EngineConfig;
use unlucky_core::download::engine::DownloadEngine;
use unlucky_core::download::health::HealthRegistry;
use unlucky_core::download::mirrors::MirrorTable;
use unlucky_core::download::region::RegionProfile;
use unlucky_core::download::types::{DownloadRequest, ResourceKind};
use support::{sha1_hex, temp_dir, MockResponse, MockServer};
#[test]
fn plan_partitions_whole_file_without_gaps() {
let cases = [
(1_u64, 4_usize),
(3, 4),
(100, 4),
(1000, 7),
(123_456_789, 4),
];
for (total, connections) in cases {
let ranges = plan(total, connections);
assert!(!ranges.is_empty(), "total={total} 不能切出空集");
assert_eq!(ranges[0].start, 0, "必须从 0 开始");
assert_eq!(ranges.last().unwrap().end, total - 1, "必须覆盖到末尾");
let mut next = 0_u64;
for range in &ranges {
assert_eq!(range.start, next, "段之间不能有空洞或重叠");
assert!(!range.is_empty());
next = range.end + 1;
}
assert_eq!(next, total);
assert_eq!(ranges.iter().map(ByteRange::len).sum::<u64>(), total);
}
}
#[test]
fn plan_caps_connections_and_handles_edge_cases() {
assert!(plan(0, 4).is_empty());
// 文件比连接数还小:每段 1 字节。
let tiny = plan(3, 4);
assert_eq!(tiny.len(), 3);
assert!(tiny.iter().all(|range| range.len() == 1));
// 连接数被夹到上限。
let many = plan(1_000_000, 100);
assert_eq!(many.len(), MAX_CONNECTIONS);
// 单连接。
assert_eq!(plan(120, 1), vec![ByteRange::new(0, 119)]);
}
#[test]
fn byte_range_headers_are_closed_intervals() {
let range = ByteRange::new(100, 199);
assert_eq!(range.len(), 100);
assert_eq!(range.header_value(), "bytes=100-199");
assert!(ByteRange::new(10, 9).is_empty());
assert_eq!(ByteRange::new(10, 9).len(), 0);
}
#[test]
fn parse_content_range_accepts_servers_forms() {
assert_eq!(
parse_content_range("bytes 0-99/1000"),
Some(ContentRange {
start: 0,
end: 99,
total: Some(1000)
})
);
assert_eq!(
parse_content_range(" bytes 100-199/* "),
Some(ContentRange {
start: 100,
end: 199,
total: None
})
);
// 畸形一律 None:调用方据此退化。
assert_eq!(parse_content_range("bytes 100-99/1000"), None);
assert_eq!(parse_content_range("items 0-9/10"), None);
assert_eq!(parse_content_range("bytes 0-9"), None);
assert_eq!(parse_content_range(""), None);
}
#[test]
fn range_support_matrix() {
assert_eq!(range_support(Some("bytes"), 200), RangeSupport::Supported);
assert_eq!(range_support(None, 206), RangeSupport::Supported);
assert_eq!(range_support(Some("none"), 200), RangeSupport::Unsupported);
assert_eq!(
range_support(Some("crumbs"), 200),
RangeSupport::Unsupported
);
assert_eq!(range_support(None, 200), RangeSupport::Unknown);
}
#[test]
fn chunked_config_gate() {
let config = ChunkedConfig::default();
assert!(config.enabled);
assert_eq!(config.connections, 4);
// 只有大文件 + 大资源类型才分块。
assert!(config.applies_to(ResourceKind::JavaRuntime, Some(200 * 1024 * 1024)));
assert!(!config.applies_to(ResourceKind::JavaRuntime, Some(1024)));
assert!(!config.applies_to(ResourceKind::ClientJar, Some(200 * 1024 * 1024)));
assert!(!config.applies_to(ResourceKind::JavaRuntime, None));
// 设置项关掉后一律不分块。
assert!(!ChunkedConfig::disabled().applies_to(ResourceKind::JavaRuntime, Some(u64::MAX)));
assert_eq!(
ChunkedConfig {
connections: 999,
..ChunkedConfig::default()
}
.effective_connections(),
MAX_CONNECTIONS
);
}
/// 端到端:`min_size` 调到 1KiB 后,20KiB 的 JDK 类资源真的被切成 4 段并行取。
///
/// 断言三件事:①mock 源收到 4 次带 `Range` 的请求(不是 1 次)②拼装后的文件逐字节正确
/// ③流式 sha1 校验通过(证明分块乱序写没有错位)。
#[tokio::test]
async fn downloads_large_file_over_four_parallel_ranges() {
let payload: Vec<u8> = (0..20 * 1024).map(|index| (index % 251) as u8).collect();
let server = MockServer::start(vec![
MockResponse::ok(payload.clone()),
MockResponse::ok(payload.clone()),
MockResponse::ok(payload.clone()),
MockResponse::ok(payload.clone()),
])
.await;
let url = server.url("/jdk.zip");
let dir = temp_dir("chunked-e2e");
let engine = DownloadEngine::new(
EngineConfig {
max_attempts_per_source: 1,
backoff: BackoffPolicy {
base_ms: 1,
max_ms: 2,
jitter_ratio: 0.0,
max_attempts: 1,
},
read_timeout: Duration::from_secs(5),
connect_timeout: Duration::from_secs(5),
// 真开分块:4 条连接,1KiB 以上就切。
chunked: ChunkedConfig {
enabled: true,
connections: 4,
min_size: 1024,
},
..EngineConfig::default()
},
Arc::new(MirrorTable::builtin()),
Arc::new(RegionProfile::direct(0)),
Arc::new(HealthRegistry::in_memory()),
Arc::new(ContentCache::open(dir.join("cache"))),
);
let dest = dir.join("jdk.zip");
let outcome = engine
.download(
DownloadRequest::new(ResourceKind::JavaRuntime, url.as_str(), &dest)
.with_sha1(sha1_hex(&payload))
.with_size(payload.len() as u64),
)
.await
.expect("分块下载失败");
assert_eq!(outcome.bytes, payload.len() as u64);
assert_eq!(outcome.source, "official");
assert_eq!(
server.hits(),
4,
"4 条连接应各发一次带 Range 的请求(退化单连接会只有 1 次)"
);
assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload);
assert!(
support::part_files(&dir).is_empty(),
"中间文件必须在提交后消失:{:?}",
support::part_files(&dir)
);
server.stop();
}
/// 端到端:目标目录**还不存在**时分块下载也要能落盘。
///
/// 回归用例。分块路径先 `OpenOptions` 预分配整文件(不走 `PartFile::open_append`
/// 那层建目录),所以「下到新建目录」曾让所有候选源瞬间全部失败
/// (现场:`data/java-managed/.cache/` 未创建时装 JDK 六个源全挂)。
#[tokio::test]
async fn chunked_download_creates_the_destination_directory() {
let payload: Vec<u8> = (0..8 * 1024).map(|index| (index % 31) as u8).collect();
let responses = vec![
MockResponse::ok(payload.clone()),
MockResponse::ok(payload.clone()),
MockResponse::ok(payload.clone()),
MockResponse::ok(payload.clone()),
];
let server = MockServer::start(responses).await;
let url = server.url("/jdk.zip");
let dir = temp_dir("chunked-mkdir");
let engine = DownloadEngine::new(
EngineConfig {
max_attempts_per_source: 1,
backoff: BackoffPolicy {
base_ms: 1,
max_ms: 2,
jitter_ratio: 0.0,
max_attempts: 1,
},
read_timeout: Duration::from_secs(5),
connect_timeout: Duration::from_secs(5),
chunked: ChunkedConfig {
enabled: true,
connections: 4,
min_size: 1024,
},
..EngineConfig::default()
},
Arc::new(MirrorTable::builtin()),
Arc::new(RegionProfile::direct(0)),
Arc::new(HealthRegistry::in_memory()),
Arc::new(ContentCache::open(dir.join("cache"))),
);
let dest = dir.join("java-managed/.cache/jdk.zip");
assert!(
!dest.parent().expect("父目录").exists(),
"前置条件:目录不存在"
);
let outcome = engine
.download(
DownloadRequest::new(ResourceKind::JavaRuntime, url.as_str(), &dest)
.with_sha1(sha1_hex(&payload))
.with_size(payload.len() as u64),
)
.await
.expect("分块下载应成功");
assert_eq!(outcome.bytes, payload.len() as u64);
assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload);
server.stop();
}
/// 端到端:服务器声明支持 Range 但实际无视(返回 200)→ 自动退化单连接,结果仍然正确。
#[tokio::test]
async fn falls_back_to_single_connection_when_server_ignores_range() {
let payload: Vec<u8> = (0..8 * 1024).map(|index| (index % 97) as u8).collect();
// 每个请求都会拿到 200 + 整份内容(range_support = false)。
// 响应条数给足:4 个分块请求可能都发出去才被 abort,退化后的整份请求是第 5 条。
let no_range = || MockResponse::ok(payload.clone()).without_range();
let server = MockServer::start(vec![
no_range(),
no_range(),
no_range(),
no_range(),
no_range(),
no_range(),
])
.await;
let url = server.url("/client.jar");
let dir = temp_dir("chunked-fallback");
let engine = DownloadEngine::new(
EngineConfig {
max_attempts_per_source: 1,
backoff: BackoffPolicy::none(),
chunked: ChunkedConfig {
enabled: true,
connections: 4,
min_size: 1024,
},
..EngineConfig::default()
},
Arc::new(MirrorTable::builtin()),
Arc::new(RegionProfile::direct(0)),
Arc::new(HealthRegistry::in_memory()),
Arc::new(ContentCache::open(dir.join("cache"))),
);
let dest = dir.join("client.jar");
let outcome = engine
.download(
DownloadRequest::new(ResourceKind::JavaRuntime, url.as_str(), &dest)
.with_sha1(sha1_hex(&payload))
.with_size(payload.len() as u64),
)
.await
.expect("退化后应仍能下载成功");
assert_eq!(outcome.bytes, payload.len() as u64);
assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload);
server.stop();
}
#[test]
fn content_range_must_match_requested_segment() {
let requested = ByteRange::new(5120, 10239);
// 严格一致才放行。
assert!(content_range_matches(
requested,
Some("bytes 5120-10239/20480")
));
// 起止任一不符 → 判失败(否则「长度恰好但位置错」的响应会拼出坏文件)。
assert!(!content_range_matches(
requested,
Some("bytes 0-5119/20480")
));
assert!(!content_range_matches(
requested,
Some("bytes 5120-20479/20480")
));
// 畸形头判失败。
assert!(!content_range_matches(requested, Some("garbage")));
// 缺头从宽(长度校验仍兜底)。
assert!(content_range_matches(requested, None));
}
/// 端到端:服务器回 206 但 `Content-Range` 谎报段位置 → 分块路径判不符、丢弃中间文件、
/// 退化单连接重下,最终文件仍逐字节正确(不会交付拼错的文件)。
#[tokio::test]
async fn rejects_lying_content_range_and_falls_back() {
let payload: Vec<u8> = (0..8 * 1024).map(|index| (index % 89) as u8).collect();
let lying = || MockResponse::ok(payload.clone()).with_wrong_content_range();
let server = MockServer::start(vec![
lying(),
lying(),
lying(),
lying(),
MockResponse::ok(payload.clone()),
])
.await;
let url = server.url("/jdk.zip");
let dir = temp_dir("chunked-lying-range");
let engine = DownloadEngine::new(
EngineConfig {
max_attempts_per_source: 1,
backoff: BackoffPolicy::none(),
chunked: ChunkedConfig {
enabled: true,
connections: 4,
min_size: 1024,
},
..EngineConfig::default()
},
Arc::new(MirrorTable::builtin()),
Arc::new(RegionProfile::direct(0)),
Arc::new(HealthRegistry::in_memory()),
Arc::new(ContentCache::open(dir.join("cache"))),
);
let dest = dir.join("jdk.zip");
let outcome = engine
.download(
DownloadRequest::new(ResourceKind::JavaRuntime, url.as_str(), &dest)
.with_sha1(sha1_hex(&payload))
.with_size(payload.len() as u64),
)
.await
.expect("谎报 Content-Range 后应退化单连接成功");
assert_eq!(outcome.bytes, payload.len() as u64);
assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload);
server.stop();
}
@@ -0,0 +1,67 @@
//! `SourceFailed.kind` 的另一半(M197 复审 P2-2):**404 是「该源没有这个文件」**,不是「源坏了」。
//!
//! 两档在引擎里的后果不同:`NotFound` 走 `record_missing`(不冷却源),`ServerError` 走
//! `record_failure`。院校镜像只留最新补丁,请求旧补丁必然 404——判成「源坏了」会把唯一可用镜像
//! 冷却掉(M33/M34 实测)。
//!
//! 变异证明:把 `engine.rs` / `bytes.rs` 的 `kind: err.kind` 改成常量 `Network` ⇒ 本条与
//! `reports_progress_events_and_errors_when_all_sources_fail` 一起红。
//!
//! 拆成子模块:`engine_mock_test.rs` 加了这条之后 407 行,越过 400 行硬线(`guards.yml` 的
//! `file-size`),于是把整条用例搬出来。
use std::sync::Arc;
use unlucky_core::download::cache::ContentCache;
use unlucky_core::download::engine::DownloadEngine;
use unlucky_core::download::error::DownloadErrorKind;
use unlucky_core::download::health::HealthRegistry;
use unlucky_core::download::mirrors::MirrorTable;
use unlucky_core::download::progress::{ProgressEmitter, ProgressEvent};
use unlucky_core::download::types::{DownloadRequest, ResourceKind};
use super::support::{temp_dir, MockResponse, MockServer};
use super::{direct_region, fast_config};
#[tokio::test]
async fn a_404_is_classified_as_not_found() {
let server = MockServer::start(vec![
MockResponse::status(404),
MockResponse::status(404),
MockResponse::status(404),
MockResponse::status(404),
])
.await;
let url = server.url("/gone.jar");
let dir = temp_dir("engine-404");
let (emitter, mut events) = ProgressEmitter::channel();
let engine = DownloadEngine::new(
fast_config(1),
Arc::new(MirrorTable::builtin()),
Arc::new(direct_region()),
Arc::new(HealthRegistry::in_memory()),
Arc::new(ContentCache::open(dir.join("cache"))),
)
.with_progress(emitter);
engine
.download(DownloadRequest::new(
ResourceKind::Library,
url.as_str(),
dir.join("gone.jar"),
))
.await
.expect_err("只有 404 的源必须失败");
let mut seen: Vec<DownloadErrorKind> = Vec::new();
while let Ok(event) = events.try_recv() {
if let ProgressEvent::SourceFailed { kind, .. } = event {
seen.push(kind);
}
}
assert!(!seen.is_empty(), "至少要有一条 SourceFailed");
assert!(
seen.iter().all(|kind| *kind == DownloadErrorKind::NotFound),
"404 必须分类成 NotFound,实际:{seen:?}"
);
server.stop();
}
@@ -0,0 +1,364 @@
//! 引擎端到端(mock 源):成功 / 退避重试 / 换源 / 校验失败 / 缓存命中 / 断点续传。
//!
//! 用 `tests/download/support` 里的进程内 HTTP 服务器,不发真实网络请求。
mod support;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use unlucky_core::download::backoff::BackoffPolicy;
use unlucky_core::download::cache::ContentCache;
use unlucky_core::download::chunked::ChunkedConfig;
use unlucky_core::download::config::EngineConfig;
use unlucky_core::download::engine::DownloadEngine;
use unlucky_core::download::error::DownloadErrorKind;
use unlucky_core::download::health::HealthRegistry;
use unlucky_core::download::mirrors::{MirrorTable, PrefixRule};
use unlucky_core::download::progress::{ProgressEmitter, ProgressEvent};
use unlucky_core::download::region::RegionProfile;
use unlucky_core::download::resume::ResumeMeta;
use unlucky_core::download::types::{DownloadRequest, HashAlgo, ResourceKind};
use support::{sha1_hex, temp_dir, MockResponse, MockServer};
fn fast_config(attempts: u32) -> EngineConfig {
EngineConfig {
max_attempts_per_source: attempts,
backoff: BackoffPolicy {
base_ms: 1,
max_ms: 2,
jitter_ratio: 0.0,
max_attempts: attempts,
},
connect_timeout: Duration::from_secs(5),
read_timeout: Duration::from_secs(5),
chunked: ChunkedConfig::disabled(),
..EngineConfig::default()
}
}
struct Harness {
engine: DownloadEngine,
dir: PathBuf,
}
fn harness(cfg: EngineConfig, table: MirrorTable, region: RegionProfile, tag: &str) -> Harness {
let dir = temp_dir(tag);
let cache = Arc::new(ContentCache::open(dir.join("cache")));
let health = Arc::new(HealthRegistry::in_memory());
let engine = DownloadEngine::new(cfg, Arc::new(table), Arc::new(region), health, cache);
Harness { engine, dir }
}
fn direct_region() -> RegionProfile {
RegionProfile::direct(0)
}
#[tokio::test]
async fn downloads_verifies_and_leaves_no_part_file() {
let payload = b"unlucky launcher payload".to_vec();
let server = MockServer::start(vec![MockResponse::ok(payload.clone())]).await;
let url = server.url("/client.jar");
let harness = harness(
fast_config(2),
MirrorTable::builtin(),
direct_region(),
"engine-ok",
);
let dest = harness.dir.join("versions/1.20.1/client.jar");
let request = DownloadRequest::new(ResourceKind::ClientJar, url.as_str(), &dest)
.with_sha1(sha1_hex(&payload))
.with_size(payload.len() as u64);
let outcome = harness.engine.download(request).await.expect("下载应成功");
assert_eq!(outcome.bytes, payload.len() as u64);
assert!(!outcome.from_cache);
assert_eq!(outcome.resumed_from, 0);
assert_eq!(std::fs::read(&dest).unwrap(), payload);
let version_dir = harness.dir.join("versions/1.20.1");
assert!(
support::part_files(&version_dir).is_empty(),
"中间文件必须被提交掉:{:?}",
support::part_files(&version_dir)
);
server.stop();
}
#[tokio::test]
async fn retries_same_source_after_server_error() {
let payload = b"retry me".to_vec();
let server = MockServer::start(vec![
MockResponse::status(503),
MockResponse::ok(payload.clone()),
])
.await;
let url = server.url("/lib.jar");
let harness = harness(
fast_config(3),
MirrorTable::builtin(),
direct_region(),
"engine-retry",
);
let dest = harness.dir.join("lib.jar");
let outcome = harness
.engine
.download(
DownloadRequest::new(ResourceKind::Library, url.as_str(), &dest)
.with_sha1(sha1_hex(&payload)),
)
.await
.expect("退避后应成功");
assert_eq!(outcome.bytes, payload.len() as u64);
assert_eq!(server.hits(), 2, "第一次 503 之后应重试一次");
server.stop();
}
#[tokio::test]
async fn switches_to_official_when_mirror_keeps_failing() {
let payload = b"official wins".to_vec();
let official = MockServer::start(vec![MockResponse::ok(payload.clone())]).await;
let mirror = MockServer::start(vec![
MockResponse::status(500),
MockResponse::status(500),
MockResponse::status(500),
])
.await;
let mut table = MirrorTable::builtin();
let official_prefix = format!("http://127.0.0.1:{}", official.port());
let mirror_prefix = format!("http://localhost:{}", mirror.port());
table.add_custom_rule(PrefixRule::new(
"mock-mirror",
official_prefix,
mirror_prefix,
));
let harness = harness(
fast_config(2),
table,
RegionProfile::mirror_first(0),
"engine-switch",
);
let dest = harness.dir.join("client.jar");
let url = official.url("/client.jar");
let outcome = harness
.engine
.download(
DownloadRequest::new(ResourceKind::ClientJar, url.as_str(), &dest)
.with_sha1(sha1_hex(&payload)),
)
.await
.expect("镜像全挂后应切到官方");
assert_eq!(outcome.source, "official");
assert_eq!(std::fs::read(&dest).unwrap(), payload);
assert_eq!(mirror.hits(), 2, "每个源最多尝试 max_attempts 次");
official.stop();
mirror.stop();
}
#[tokio::test]
async fn rejects_wrong_checksum_and_falls_back() {
let payload = b"the real jar".to_vec();
let official = MockServer::start(vec![MockResponse::ok(payload.clone())]).await;
let mirror = MockServer::start(vec![MockResponse::ok(b"tampered by mirror".to_vec())]).await;
let mut table = MirrorTable::builtin();
table.add_custom_rule(PrefixRule::new(
"mock-mirror",
format!("http://127.0.0.1:{}", official.port()),
format!("http://localhost:{}", mirror.port()),
));
let harness = harness(
fast_config(1),
table,
RegionProfile::mirror_first(0),
"engine-checksum",
);
let dest = harness.dir.join("client.jar");
let url = official.url("/client.jar");
let outcome = harness
.engine
.download(
DownloadRequest::new(ResourceKind::ClientJar, url.as_str(), &dest)
.with_sha1(sha1_hex(&payload))
.with_size(payload.len() as u64),
)
.await
.expect("校验失败应换源而不是交付坏文件");
assert_eq!(outcome.source, "official");
assert_eq!(std::fs::read(&dest).unwrap(), payload);
assert!(mirror.hits() >= 1);
official.stop();
mirror.stop();
}
#[tokio::test]
async fn second_download_hits_content_cache_without_network() {
let payload = b"cached payload".to_vec();
let server = MockServer::start(vec![
MockResponse::ok(payload.clone()),
MockResponse::ok(payload.clone()),
])
.await;
let url = server.url("/lib.jar");
let harness = harness(
fast_config(2),
MirrorTable::builtin(),
direct_region(),
"engine-cache",
);
let integrity = sha1_hex(&payload);
let first = harness
.engine
.download(
DownloadRequest::new(
ResourceKind::Library,
url.as_str(),
harness.dir.join("a/lib.jar"),
)
.with_sha1(integrity.as_str()),
)
.await
.expect("首次下载失败");
assert!(!first.from_cache);
let hits_after_first = server.hits();
let second = harness
.engine
.download(
DownloadRequest::new(
ResourceKind::Library,
url.as_str(),
harness.dir.join("b/lib.jar"),
)
.with_sha1(integrity.as_str()),
)
.await
.expect("缓存命中失败");
assert!(second.from_cache);
assert_eq!(second.bytes, payload.len() as u64);
assert_eq!(server.hits(), hits_after_first, "缓存命中不能再发请求");
assert_eq!(
std::fs::read(harness.dir.join("b/lib.jar")).unwrap(),
payload
);
server.stop();
}
#[tokio::test]
async fn resumes_from_existing_part_file() {
let payload = b"0123456789abcdefghij".to_vec();
let split = 8_usize;
let server = MockServer::start(vec![MockResponse::ok(payload.clone())]).await;
let url = server.url("/client.jar");
let harness = harness(
fast_config(2),
MirrorTable::builtin(),
direct_region(),
"engine-resume",
);
let dest = harness.dir.join("client.jar");
let part = harness.dir.join("client.jar.ullpart");
let meta_path = harness.dir.join("client.jar.ullpart.ullmeta.json");
std::fs::write(&part, &payload[..split]).unwrap();
ResumeMeta {
url: url.clone(),
etag: Some("\"v1\"".to_string()),
last_modified: None,
total: Some(payload.len() as u64),
algo: HashAlgo::Sha1,
hash: sha1_hex(&payload),
saved_at_ms: 1,
}
.save(&meta_path)
.expect("写续传元数据失败");
let outcome = harness
.engine
.download(
DownloadRequest::new(ResourceKind::ClientJar, url.as_str(), &dest)
.with_sha1(sha1_hex(&payload))
.with_size(payload.len() as u64),
)
.await
.expect("续传失败");
assert_eq!(outcome.resumed_from, split as u64, "应从已有偏移续传");
assert_eq!(std::fs::read(&dest).unwrap(), payload);
assert_eq!(server.hits(), 1);
server.stop();
}
#[tokio::test]
async fn reports_progress_events_and_errors_when_all_sources_fail() {
let server = MockServer::start(vec![
MockResponse::status(500),
MockResponse::status(500),
MockResponse::status(500),
MockResponse::status(500),
])
.await;
let url = server.url("/dead.jar");
let dir = temp_dir("engine-fail");
let cache = Arc::new(ContentCache::open(dir.join("cache")));
let (emitter, mut events) = ProgressEmitter::channel();
let engine = DownloadEngine::new(
fast_config(1),
Arc::new(MirrorTable::builtin()),
Arc::new(direct_region()),
Arc::new(HealthRegistry::in_memory()),
cache,
)
.with_progress(emitter);
let error = engine
.download(DownloadRequest::new(
ResourceKind::Library,
url.as_str(),
dir.join("dead.jar"),
))
.await
.expect_err("所有源都 500 时必须失败");
assert_eq!(error.kind, DownloadErrorKind::AllSourcesFailed);
let mut started = false;
let mut failed_source = false;
while let Ok(event) = events.try_recv() {
match event {
ProgressEvent::Started { candidates, .. } => {
started = true;
assert!(candidates >= 1);
}
ProgressEvent::SourceFailed {
will_retry, kind, ..
} => {
failed_source = true;
assert!(!will_retry, "单次尝试用尽后不应再声明重试");
// M197:`kind` 是诊断字段,必须来自**真实**的失败分类(HTTP 500 → ServerError)。
// 变异证明:把 `engine.rs` 的 `kind: err.kind` 写成常量
// `DownloadErrorKind::Network` ⇒ 本断言红。
assert_eq!(
kind,
DownloadErrorKind::ServerError,
"HTTP 500 必须被分类成 ServerError(诊断字段不许是常量)"
);
}
_ => {}
}
}
assert!(started && failed_source, "进度事件缺失");
server.stop();
}
/// 「404 不是源坏了」的守卫(M197 P2-2 的第二档):拆到子模块守 400 行硬线。
#[path = "engine_mock/not_found.rs"]
mod not_found;
+268
View File
@@ -0,0 +1,268 @@
//! 健康度记忆的单测:冷却、恢复探测、排序、持久化 + 引擎驱动的落盘接线。
mod support;
use std::sync::Arc;
use std::time::Duration;
use unlucky_core::download::backoff::BackoffPolicy;
use unlucky_core::download::cache::{now_ms, ContentCache};
use unlucky_core::download::config::EngineConfig;
use unlucky_core::download::engine::DownloadEngine;
use unlucky_core::download::error::DownloadErrorKind;
use unlucky_core::download::health::{
HealthRegistry, SourceHealth, COOLDOWN_MS, FAILURE_THRESHOLD,
};
use unlucky_core::download::mirrors::MirrorTable;
use unlucky_core::download::region::RegionProfile;
use unlucky_core::download::types::{Candidate, DownloadRequest, ResourceKind, SOURCE_OFFICIAL};
use support::{MockResponse, MockServer};
const NOW: u64 = 1_000_000;
fn registry() -> HealthRegistry {
HealthRegistry::in_memory()
}
#[test]
fn fresh_source_is_neutral_not_penalized() {
let health = SourceHealth::default();
assert!(!health.is_cooling_down(NOW));
assert_eq!(health.failure_rate(), 0.0);
// 未知源必须是中间分,不能比刚成功的源更优先。
let healthy = SourceHealth {
samples: 3,
successes: 3,
ema_rtt_ms: 120.0,
..SourceHealth::default()
};
assert!(healthy.score(NOW) < health.score(NOW));
}
#[test]
fn three_consecutive_failures_trigger_cooldown() {
let mut health = SourceHealth::default();
for _ in 0..FAILURE_THRESHOLD {
health.record_failure("HTTP 502", NOW);
}
assert!(health.is_cooling_down(NOW));
assert!(health.is_cooling_down(NOW + COOLDOWN_MS - 1));
// 冷却到期后不再是冷却态,而是「可以探测」。
assert!(!health.is_cooling_down(NOW + COOLDOWN_MS));
assert!(health.is_probe(NOW + COOLDOWN_MS));
// 冷却中的源排序惩罚要明显高于健康源。
assert!(health.score(NOW) >= 1_000.0);
}
#[test]
fn missing_artifact_is_recorded_without_cooling_down() {
// 院校镜像只留最新补丁:请求旧补丁必然 404,不能把源判成「坏」。
let mut health = SourceHealth::default();
for _ in 0..(FAILURE_THRESHOLD * 2) {
health.record_missing("HTTP 404", NOW);
}
assert!(!health.is_cooling_down(NOW), "4xx 不进冷却");
assert!(!health.is_probe(NOW), "4xx 不该产生恢复探测状态");
assert_eq!(health.failures, 0, "4xx 不计失败次数");
assert_eq!(health.not_found, u64::from(FAILURE_THRESHOLD * 2));
assert_eq!(health.failure_rate(), 0.0);
assert!(health.last_error.is_some(), "诊断仍要留痕");
// 打分不应被 404 拉高(仍是未知源的中位分)。
assert_eq!(health.score(NOW), SourceHealth::default().score(NOW));
assert_eq!(
health.score(NOW),
unlucky_core::download::health::SCORE_UNKNOWN
);
}
#[test]
fn not_found_ageing_does_not_persist_a_cooldown() {
let registry = HealthRegistry::in_memory();
for _ in 0..10 {
registry.record_missing("tuna", ResourceKind::JavaRuntime, "HTTP 404", NOW);
}
let health = registry.health_of("tuna", ResourceKind::JavaRuntime);
assert!(!health.is_cooling_down(NOW));
assert_eq!(health.not_found, 10);
assert_eq!(health.consecutive_failures, 0);
}
#[test]
fn probe_failure_re_arms_cooldown_and_success_clears_it() {
let mut health = SourceHealth::default();
for _ in 0..FAILURE_THRESHOLD {
health.record_failure("timeout", NOW);
}
let after_cooldown = NOW + COOLDOWN_MS + 1;
assert!(!health.is_cooling_down(after_cooldown));
// 探测再一次失败 → 立即重新冷却。
health.record_failure("timeout again", after_cooldown);
assert!(health.is_cooling_down(after_cooldown));
// 成功则全部清零。
health.record_success(90, after_cooldown + 1);
assert!(!health.is_cooling_down(after_cooldown + 1));
assert!(!health.is_probe(after_cooldown + 1));
assert_eq!(health.consecutive_failures, 0);
assert_eq!(health.cooldown_until_ms, 0);
assert_eq!(health.last_error, None);
}
#[test]
fn ema_smooths_latency_and_counts_samples() {
let mut health = SourceHealth::default();
health.record_success(100, NOW);
assert_eq!(health.ema_rtt_ms, 100.0);
health.record_success(200, NOW);
// 0.3 * 200 + 0.7 * 100 = 130
assert!((health.ema_rtt_ms - 130.0).abs() < 1e-9);
assert_eq!(health.samples, 2);
assert_eq!(health.successes, 2);
}
#[test]
fn failure_rate_counts_both_sides() {
let mut health = SourceHealth::default();
health.record_success(50, NOW);
health.record_failure("boom", NOW);
assert!((health.failure_rate() - 0.5).abs() < 1e-9);
}
#[test]
fn registry_ranks_healthy_first_and_cooling_last() {
let registry = registry();
for _ in 0..FAILURE_THRESHOLD {
registry.record_failure("mirror-b", ResourceKind::ClientJar, "HTTP 500", NOW);
}
registry.record_success(
"mirror-a",
ResourceKind::ClientJar,
Duration::from_millis(80),
NOW,
);
let candidates = vec![
Candidate::new("mirror-b", "https://b.example/file"),
Candidate::new("unknown", "https://u.example/file"),
Candidate::new("mirror-a", "https://a.example/file"),
];
let ranked = registry.rank(&candidates, ResourceKind::ClientJar, NOW);
assert_eq!(ranked[0].source, "mirror-a");
assert_eq!(ranked[1].source, "unknown");
assert_eq!(
ranked[2].source, "mirror-b",
"冷却中的源排最后但仍保留为兜底"
);
}
#[test]
fn health_is_tracked_per_resource_kind() {
let registry = registry();
for _ in 0..FAILURE_THRESHOLD {
registry.record_failure("bmclapi", ResourceKind::Asset, "404", NOW);
}
assert!(registry.is_cooling_down("bmclapi", ResourceKind::Asset, NOW));
// 另一种资源类型不受影响(资产挂了不代表 JDK 也挂)。
assert!(!registry.is_cooling_down("bmclapi", ResourceKind::JavaRuntime, NOW));
}
#[test]
fn persistence_roundtrip_and_age_pruning() {
let dir = std::env::temp_dir().join(format!("ull-health-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("health.json");
let registry = HealthRegistry::open(&path, 7 * 24 * 3600 * 1000, NOW);
registry.record_failure("bmclapi", ResourceKind::Library, "HTTP 503", NOW);
registry.record_success(
"official",
ResourceKind::Library,
Duration::from_millis(55),
NOW,
);
registry.save().expect("持久化失败");
let reloaded = HealthRegistry::open(&path, 7 * 24 * 3600 * 1000, NOW + 1_000);
assert_eq!(reloaded.snapshot().len(), 2);
assert_eq!(
reloaded
.health_of("bmclapi", ResourceKind::Library)
.failures,
1
);
assert!(
reloaded
.health_of("official", ResourceKind::Library)
.ema_rtt_ms
> 0.0
);
// 超出最大保留期 → 丢弃旧记录(缓存目录被长时间放着的情况)。
let pruned = HealthRegistry::open(&path, 10, NOW + 60_000);
assert!(pruned.snapshot().is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
/// P1 回归:健康度必须由**引擎的生产路径**落盘——下载→(模拟进程退出)→重开 registry,
/// 冷却状态与失败计数都要还在,否则「连续 3 败冷却 10 分钟」每轮启动从零学起。
#[tokio::test]
async fn engine_persists_health_so_a_restart_keeps_the_cooldown() {
let failed = || MockResponse::status(500);
let server = MockServer::start(vec![failed(), failed(), failed(), failed()]).await;
let url = server.url("/lib.jar");
let dir = support::temp_dir("health-engine-persist");
let path = dir.join("cache/health.json");
let now = now_ms();
let max_age_ms = 7 * 24 * 3600 * 1000;
let registry = Arc::new(HealthRegistry::open(&path, max_age_ms, now));
let engine = DownloadEngine::new(
EngineConfig {
max_attempts_per_source: FAILURE_THRESHOLD,
backoff: BackoffPolicy {
base_ms: 1,
max_ms: 2,
jitter_ratio: 0.0,
max_attempts: FAILURE_THRESHOLD,
},
..EngineConfig::default()
},
Arc::new(MirrorTable::builtin()),
Arc::new(RegionProfile::direct(0)),
Arc::clone(&registry),
Arc::new(ContentCache::open(dir.join("cache"))),
);
let error = engine
.download(DownloadRequest::new(
ResourceKind::Library,
url.as_str(),
dir.join("lib.jar"),
))
.await
.expect_err("全 500 必须失败");
assert_eq!(error.kind, DownloadErrorKind::AllSourcesFailed);
server.stop();
// 第 3 次连续失败进入冷却时就该落盘(不等收尾 flush)。
assert!(path.is_file(), "健康度必须已经落盘到 cache/health.json");
let before = registry.health_of(SOURCE_OFFICIAL, ResourceKind::Library);
assert!(before.consecutive_failures >= FAILURE_THRESHOLD);
assert!(before.is_cooling_down(now_ms()));
// 「进程退出后重开」:从同一个文件恢复,冷却状态与失败计数都还在。
let reopened = HealthRegistry::open(&path, max_age_ms, now_ms());
let restored = reopened.health_of(SOURCE_OFFICIAL, ResourceKind::Library);
assert_eq!(restored.consecutive_failures, before.consecutive_failures);
assert_eq!(restored.failures, before.failures);
assert!(
restored.is_cooling_down(now_ms()),
"重开后仍应处于冷却:{restored:?}"
);
assert!(restored.last_error.is_some(), "失败原因也要持久化");
let _ = std::fs::remove_dir_all(&dir);
}
+397
View File
@@ -0,0 +1,397 @@
//! 镜像清单与前缀重写的单测(纯字符串,无网络)。
use unlucky_core::download::adoptium::{parse_adoptium_artifact, JdkFlavor};
use unlucky_core::download::mirrors::{host_of, source_id_for, MirrorTable, PrefixRule};
use unlucky_core::download::region::RegionProfile;
use unlucky_core::download::types::ResourceKind;
const LIBRARIES_URL: &str =
"https://libraries.minecraft.net/com/google/guava/guava/32.0.0/guava-32.0.0.jar";
const ASSET_URL: &str = "https://resources.download.minecraft.net/ab/abcdef0123456789";
const GITHUB_URL: &str =
"https://github.com/AskaEth/Unlucky-Launcher/releases/download/v1.0.0/ull-windows-x64.exe";
const ADOPTIUM_URL: &str = "https://github.com/adoptium/temurin17-binaries/releases/download/JDK17.0.13%2B11/OpenJDK17U-jdk_x64_windows_hotspot_17.0.13_11.zip";
#[test]
fn builtin_table_is_embedded_and_complete() {
let table = MirrorTable::builtin();
assert!(table.version >= 1);
assert!(table.rules.len() >= 10, "内置规则太少");
assert!(table.adoptium.len() >= 3, "院校镜像至少要 TUNA/USTC/SJTU");
assert!(!table.gh_proxies.is_empty());
assert!(table.gh_proxies[0].prefix.starts_with("https://gh-proxy"));
}
#[test]
fn prefix_rule_preserves_sub_path() {
let table = MirrorTable::builtin();
let rule = table
.prefix_rule_for(LIBRARIES_URL, ResourceKind::Library)
.expect("libraries 规则缺失");
let candidate = rule.apply(LIBRARIES_URL).expect("重写失败");
assert_eq!(candidate.source, "bmclapi");
// 子路径逐字保留(`/maven` 是镜像侧的固定前缀,不属于子路径)。M210/D1:这条断言此前写成
// 「换成 `https://bmclapi2.bangbang93.com` 就完事」,把**丢 inject 的 bug 钉成了期望**——实测
// 无 `/maven` 是 70/70 个库 404 + 9 B,带 `/maven` 回 200 且 sha1 与官方逐字节一致。
let expected = LIBRARIES_URL.replace(
"https://libraries.minecraft.net",
"https://bmclapi2.bangbang93.com/maven",
);
assert_eq!(candidate.url, expected);
}
/// **守卫(M210/D1)**:每一条「给 libraries 用」的镜像规则,镜像前缀都必须带 `/maven`。官方库域名与
/// 各 loader 的 maven 在 BMCLAPI 上都挂在 `/maven` 下,而 `apply()` 是纯前缀替换——`to` 里少了这一段
/// 就会把 `com/google/guava/...` 直接拼到镜像根上。抓得住:删掉/改错任一条库镜像规则的 `inject`;
/// 抓不住:`/maven` **写歪**(如 `/maven2`)只能靠行为用例 [`library_urls_expand_to_the_maven_subpath_on_bmclapi`];
/// `/assets`、`/fabric-meta` 这类结构本就不同的镜像表,以及 `from` 不像 maven 仓库的新库镜像,都不在列。
#[test]
fn every_library_mirror_rule_carries_the_maven_segment() {
let table = MirrorTable::builtin();
// 判据:「给 libraries 用」= `from` 指向 maven 仓库(host/路径里带 maven,或就是 Mojang 库域名)。
// 新增一条库镜像规则时这条自动纳入,不必改测试。
fn serves_maven(from: &str) -> bool {
host_of(from).unwrap_or_default().contains("maven")
|| from.contains("maven")
|| host_of(from) == Some("libraries.minecraft.net")
}
// 判据覆盖的规则 id(内置表里就是这 6 条;`bmclapi-liteloader` 的 `from` 不像 maven 仓库,且已因 M225 关闭,不在列)。
const EXPECTED: [&str; 6] = [
"bmclapi-libraries",
"bmclapi-forge-maven",
"bmclapi-forge-maven-net",
"bmclapi-neoforge",
"bmclapi-fabric-maven",
"bmclapi-maven-central",
];
let mut checked: Vec<&str> = Vec::new();
for rule in table.rules.iter().filter(|rule| serves_maven(&rule.from)) {
let prefix = format!("{}{}", rule.to, rule.inject);
assert!(
prefix.ends_with("/maven"),
"规则 {} 的镜像前缀 `{prefix}` 少了 `/maven`:官方 `{}` 在镜像上挂在 `/maven/` 下,\
丢了它整张表 404(M210/D1)",
rule.id,
rule.from
);
checked.push(rule.id.as_str());
}
// 判据失效(规则被改名/改形/删掉)时这里红:期望的规则一条都不能漏。
for expected in EXPECTED {
assert!(
checked.contains(&expected),
"库镜像规则 {expected} 没被形态判据覆盖到(被改名/改形/删掉了?):{checked:?}"
);
}
// 多出来的(新增 maven 规则)也得登记进 EXPECTED,否则说明它绕过了这道判据的意图审查。
assert_eq!(
checked.len(),
EXPECTED.len(),
"库镜像规则数与预期不符(新增/删除了规则?):{checked:?}"
);
}
/// **行为守卫(M210/D1)**:真实库 URL 展开后必须落在镜像的 `/maven/<子路径>` 上。与上一条互为
/// 补充:那条看规则形态,这条看用户实际会请求到的 URL。抓得住:`inject` 丢失、`to` 里手写 `/maven`
/// 被删、或 `from`/`to` 被改成拼不出 `/maven` 的形态;抓不住:只覆盖这 5 个官方 maven 根(各处未必
/// 都写全),镜像侧真实可达性要网络(留给 e2e)。
#[test]
fn library_urls_expand_to_the_maven_subpath_on_bmclapi() {
let table = MirrorTable::builtin();
let region = RegionProfile::mirror_first(0);
let cases = [
(
LIBRARIES_URL,
"https://bmclapi2.bangbang93.com/maven/com/google/guava/guava/32.0.0/guava-32.0.0.jar",
),
(
"https://maven.fabricmc.net/net/fabricmc/fabric-loader/0.15.11/fabric-loader-0.15.11.jar",
"https://bmclapi2.bangbang93.com/maven/net/fabricmc/fabric-loader/0.15.11/fabric-loader-0.15.11.jar",
),
(
"https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-installer.jar",
"https://bmclapi2.bangbang93.com/maven/net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-installer.jar",
),
(
"https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.80/neoforge-21.1.80-installer.jar",
"https://bmclapi2.bangbang93.com/maven/net/neoforged/neoforge/21.1.80/neoforge-21.1.80-installer.jar",
),
// M225:LiteLoader 的库来自 Central(`net.minecraft:launchwrapper` 才是 Mojang 库仓)。
(
"https://repo1.maven.org/maven2/org/ow2/asm/asm-all/5.2/asm-all-5.2.jar",
"https://bmclapi2.bangbang93.com/maven/org/ow2/asm/asm-all/5.2/asm-all-5.2.jar",
),
];
for (official, expected) in cases {
let candidates = table.expand(official, ResourceKind::Library, &region);
let mirror = candidates
.iter()
.find(|candidate| candidate.source == "bmclapi")
.unwrap_or_else(|| panic!("{official} 没有 bmclapi 候选:{candidates:?}"));
assert_eq!(
mirror.url, expected,
"库镜像 URL 形状不对(官方 {official})"
);
}
}
/// **守卫(M225)**:LiteLoader 不套 bmclapi 改写——镜像侧 `versions.json` 与 artifact 实测 404 / 9 B,
/// 官方 200(curl 记录见 `mirrors.json` 的 notes)。抓得住:`bmclapi-liteloader.enabled` 被翻回 true、
/// 或有人给快照仓补来源不明的镜像规则;抓不住:镜像侧哪天真的补上了(那时改期望与 notes,别删测试)。
#[test]
fn liteloader_urls_stay_official_only() {
let (table, region) = (MirrorTable::builtin(), RegionProfile::mirror_first(0));
for url in [
"https://dl.liteloader.com/versions/versions.json",
"https://dl.liteloader.com/versions/com/mumfrey/liteloader/1.7.10/liteloader-1.7.10_04.jar",
"https://repo.mumfrey.com/content/repositories/snapshots/com/mumfrey/liteloader/1.12.2-SNAPSHOT/maven-metadata.xml",
] {
let got = table.expand(url, ResourceKind::Library, &region);
assert_eq!(got.len(), 1, "{url} 不该有镜像候选:{got:?}");
}
}
#[test]
fn asset_rule_injects_path_prefix() {
let table = MirrorTable::builtin();
let candidate = table
.prefix_rule_for(ASSET_URL, ResourceKind::Asset)
.and_then(|rule| rule.apply(ASSET_URL))
.expect("资源对象规则缺失");
assert_eq!(
candidate.url,
"https://bmclapi2.bangbang93.com/assets/ab/abcdef0123456789"
);
}
#[test]
fn direct_mode_puts_official_first() {
let table = MirrorTable::builtin();
let region = RegionProfile::direct(0);
let candidates = table.expand(LIBRARIES_URL, ResourceKind::Library, &region);
assert_eq!(candidates[0].source, "official");
assert_eq!(candidates[0].url, LIBRARIES_URL);
// 镜像仍然保留为兜底(官方失败时能救回来)。
assert!(candidates
.iter()
.any(|candidate| candidate.source == "bmclapi"));
}
#[test]
fn mirror_first_mode_puts_mirror_first() {
let table = MirrorTable::builtin();
let region = RegionProfile::mirror_first(0);
let candidates = table.expand(LIBRARIES_URL, ResourceKind::Library, &region);
assert_eq!(candidates[0].source, "bmclapi");
assert_eq!(
candidates.last().map(|c| c.source.as_str()),
Some("official")
);
}
#[test]
fn gh_proxy_only_for_github_hosts_in_domestic_mode() {
let table = MirrorTable::builtin();
let domestic = RegionProfile::mirror_first(0);
let direct = RegionProfile::direct(0);
let github = table.expand(GITHUB_URL, ResourceKind::GitHubRelease, &domestic);
assert!(
github.iter().any(|candidate| candidate.source == "gh_proxy"
&& candidate.url.starts_with("https://gh-proxy.com/")),
"国内模式下 GitHub 资源必须套 gh-proxy:{github:?}"
);
let non_github = table.expand(LIBRARIES_URL, ResourceKind::Library, &domestic);
assert!(
non_github
.iter()
.all(|candidate| candidate.source != "gh_proxy"),
"非 GitHub 资源不受 gh-proxy 影响"
);
let github_direct = table.expand(GITHUB_URL, ResourceKind::GitHubRelease, &direct);
assert!(github_direct
.iter()
.all(|candidate| candidate.source != "gh_proxy"));
}
#[test]
fn pinned_or_disabled_gh_proxy_is_respected() {
let table = MirrorTable::builtin();
// 用户关掉:地区档案显式不带前缀 → 一条 gh_proxy 候选都不出。
let off = RegionProfile::mirror_first(0).with_gh_proxy(None);
let candidates = table.expand(GITHUB_URL, ResourceKind::GitHubRelease, &off);
assert!(candidates.iter().all(|c| c.source != "gh_proxy"));
// 自定义前缀(设置项写进地区档案)。
let custom = RegionProfile::mirror_first(0).with_gh_proxy(Some("https://proxy.example/"));
let candidates = table.expand(GITHUB_URL, ResourceKind::GitHubRelease, &custom);
let proxy = candidates
.iter()
.find(|candidate| candidate.source == "gh_proxy")
.expect("自定义前缀未生效");
assert_eq!(proxy.url, format!("https://proxy.example/{GITHUB_URL}"));
// 「默认前缀」的唯一真相源是**地区档案**(M193:`MirrorTable::set_gh_proxy` / `default_gh_proxy`
// 已删除——它们改/读的是表里的前缀目录,而 `expand()` 读档案,属于「看起来能改、其实没人读」)。
assert_eq!(
RegionProfile::mirror_first(0).gh_proxy.as_deref(),
Some(unlucky_core::download::region::DEFAULT_GH_PROXY)
);
}
#[test]
fn adoptium_artifact_parsing_and_mirror_ordering() {
let parsed = parse_adoptium_artifact(ADOPTIUM_URL).expect("解析 Temurin 产物名失败");
assert_eq!(parsed.major, "17");
assert_eq!(parsed.flavor, JdkFlavor::Jdk);
assert_eq!(parsed.arch, "x64");
assert_eq!(parsed.os, "windows");
assert!(parsed.filename.starts_with("OpenJDK17U-jdk_x64_windows"));
let table = MirrorTable::builtin();
let region = RegionProfile::mirror_first(0);
let candidates = table.expand(ADOPTIUM_URL, ResourceKind::JavaRuntime, &region);
let first = &candidates[0];
assert_eq!(first.source, "tuna");
assert!(
first
.url
.starts_with("https://mirrors.tuna.tsinghua.edu.cn/Adoptium/17/jdk/x64/windows/"),
"院校镜像路径不符合模板:{}",
first.url
);
// 官方仍在候选里(全部镜像不可达时的兜底)。
assert!(candidates
.iter()
.any(|candidate| candidate.source == "official"));
}
/// JRE 产物必须落到镜像的 `/jre/` 子树(否则 4 个镜像全 404,加速白费)。
#[test]
fn adoptium_jre_uses_jre_path_segment() {
let jre_url = ADOPTIUM_URL.replace("OpenJDK17U-jdk_", "OpenJDK17U-jre_");
let parsed = parse_adoptium_artifact(&jre_url).expect("解析 JRE 产物名失败");
assert_eq!(parsed.flavor, JdkFlavor::Jre);
assert_eq!(
parsed.mirror_url("https://mirrors.tuna.tsinghua.edu.cn/Adoptium"),
format!(
"https://mirrors.tuna.tsinghua.edu.cn/Adoptium/17/jre/x64/windows/{}",
parsed.filename
)
);
let table = MirrorTable::builtin();
let candidates = table.expand(
&jre_url,
ResourceKind::JavaRuntime,
&RegionProfile::mirror_first(0),
);
let first = &candidates[0];
assert_eq!(first.source, "tuna");
assert!(
first.url.contains("/17/jre/x64/windows/"),
"JRE 必须走 /jre/ 子树:{}",
first.url
);
// `-jre-17…` 这种兜底形态同样识别。
let fallback = "https://github.com/adoptium/temurin21-binaries/releases/download/jre-21.0.5%2B11/OpenJDK21U-jre_x64_linux_hotspot_21.0.5_11.tar.gz";
assert_eq!(
parse_adoptium_artifact(fallback).map(|artifact| artifact.flavor),
Some(JdkFlavor::Jre)
);
}
#[test]
fn custom_rules_take_precedence() {
let mut table = MirrorTable::builtin();
table.add_custom_rule(PrefixRule::new(
"my-mirror",
"https://libraries.minecraft.net",
"https://mirror.example/libraries",
));
let candidate = table
.prefix_rule_for(LIBRARIES_URL, ResourceKind::Library)
.and_then(|rule| rule.apply(LIBRARIES_URL))
.expect("自定义规则未生效");
assert_eq!(candidate.source, "mirror.example");
assert_eq!(
candidate.url,
"https://mirror.example/libraries/com/google/guava/guava/32.0.0/guava-32.0.0.jar"
);
}
#[test]
fn fallback_rule_keeps_official_first_even_in_domestic_mode() {
let mut table = MirrorTable {
version: 1,
notes: Vec::new(),
rules: vec![PrefixRule::new(
"modrinth-accelerator",
"https://api.modrinth.com",
"https://cdn.example",
)
.fallback()],
// v3 新增的三张 JDK 源表在这张夹具里必须为空(否则会混进别的候选)。
adoptium: Vec::new(),
oracle_openjdk: Vec::new(),
microsoft_openjdk: None,
zulu: None,
gh_proxies: Vec::new(),
};
let region = RegionProfile::mirror_first(0);
let candidates = table.expand(
"https://api.modrinth.com/v2/project/sodium",
ResourceKind::ModFile,
&region,
);
assert_eq!(
candidates[0].source, "official",
"「仅加速」规则不能顶掉官方"
);
assert_eq!(candidates[1].source, "cdn.example");
// 关掉规则后只剩官方一条。
table.rules.clear();
let candidates = table.expand(
"https://api.modrinth.com/v2/project/sodium",
ResourceKind::ModFile,
&region,
);
assert_eq!(candidates.len(), 1);
}
#[test]
fn host_and_source_id_helpers() {
assert_eq!(
host_of("https://libraries.minecraft.net/a/b"),
Some("libraries.minecraft.net")
);
assert_eq!(
host_of("https://user:pass@mirror.example:8443/x/y"),
Some("mirror.example")
);
assert_eq!(host_of("http://127.0.0.1:8080/file"), Some("127.0.0.1"));
assert_eq!(host_of("not-a-url"), Some("not-a-url"));
assert_eq!(
source_id_for("https://bmclapi2.bangbang93.com/assets/ab/cd"),
"bmclapi"
);
assert_eq!(
source_id_for("https://mirrors.tuna.tsinghua.edu.cn/Adoptium/17/x.zip"),
"tuna"
);
assert_eq!(
source_id_for("https://mirrors.ustc.edu.cn/adoptium/17/x.zip"),
"ustc"
);
assert_eq!(
source_id_for("https://gh-proxy.com/https://github.com/a/b"),
"gh_proxy"
);
assert_eq!(source_id_for("https://example.org/file"), "example.org");
}
+233
View File
@@ -0,0 +1,233 @@
//! `download::atomic::PartFile`:中间文件的进程/实例隔离、续传收养与孤儿清理。
//!
//! 背景(M35 真机 finding):中间文件路径原先只由目标路径决定,两个启动器进程装同一
//! 数据根时共用一个 `.ullpart`,先提交的一方 `rename` 走,另一方 `os error 2`
//! (实测两次 `ull install` 各失败 819 / 1116 个资源)。这里守住三条:名字带会话标识、
//! 旧材料能收养、孤儿清理不许误删有人在用的中间文件。
mod support;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use unlucky_core::download::backoff::BackoffPolicy;
use unlucky_core::download::cache::ContentCache;
use unlucky_core::download::chunked::ChunkedConfig;
use unlucky_core::download::config::EngineConfig;
use unlucky_core::download::engine::DownloadEngine;
use unlucky_core::download::health::HealthRegistry;
use unlucky_core::download::mirrors::MirrorTable;
use unlucky_core::download::region::RegionProfile;
use unlucky_core::download::types::{DownloadRequest, ResourceKind};
use unlucky_core::download::{PartFile, ADOPT_MIN_AGE, META_SUFFIX, ORPHAN_MAX_AGE, PART_SUFFIX};
use support::{age_file, part_files, sha1_hex, temp_dir, MockResponse, MockServer};
/// 另一个会话号(保证与本进程不同)。
fn foreign_session() -> u32 {
std::process::id() ^ 1
}
fn part_name(name: &str, session: u32, seq: u64) -> String {
format!("{name}{PART_SUFFIX}-{session}-{seq}")
}
fn write_part(dir: &Path, name: &str) -> PathBuf {
let path = dir.join(name);
fs::write(&path, b"0123456789").expect("写中间文件失败");
path
}
#[test]
fn part_file_names_carry_session_and_sequence() {
let dir = temp_dir("partfile-identity");
let dest = dir.join("client.jar");
let first = PartFile::for_dest(&dest);
let second = PartFile::for_dest(&dest);
assert_ne!(
first.path(),
second.path(),
"同一 dest 的两个守卫不能共用文件"
);
assert_eq!(first.path().parent(), Some(dir.as_path()));
let name = first.path().file_name().unwrap().to_string_lossy();
assert!(
name.starts_with(&format!("client.jar{PART_SUFFIX}-")),
"中间文件名要带进程标识:{name}"
);
assert!(
name.contains(&std::process::id().to_string()),
"进程号要在名字里(跨进程隔离的依据):{name}"
);
}
#[test]
fn two_threads_writing_the_same_dest_commit_independently() {
let dir = temp_dir("partfile-threads");
let dest = dir.join("client.jar");
let handles: Vec<_> = (0..2_u8)
.map(|index| {
let dest = dest.clone();
std::thread::spawn(move || {
let payload = vec![b'a' + index; 4096];
let part = PartFile::for_dest(&dest);
part.open_truncate()
.expect("打开中间文件失败")
.write_all(&payload)
.expect("写中间文件失败");
part.commit(&dest).expect("原子落盘失败");
payload
})
})
.collect();
let payloads: Vec<Vec<u8>> = handles
.into_iter()
.map(|handle| handle.join().expect("下载线程 panic"))
.collect();
let written = fs::read(&dest).expect("读落盘文件失败");
assert!(
payloads.contains(&written),
"落盘内容必须是某个线程的完整产物"
);
assert!(
part_files(&dir).is_empty(),
"两线程各自的中间文件都要被提交掉:{:?}",
part_files(&dir)
);
}
#[test]
fn legacy_part_file_is_adopted_for_resume() {
let dir = temp_dir("partfile-legacy");
let dest = dir.join("client.jar");
let legacy = write_part(&dir, &format!("client.jar{PART_SUFFIX}"));
let legacy_meta = dir.join(format!("client.jar{PART_SUFFIX}{META_SUFFIX}"));
fs::write(&legacy_meta, b"{}").expect("写元数据失败");
let part = PartFile::for_dest(&dest);
assert_ne!(part.path(), legacy.as_path(), "新守卫要换自己的名字");
assert_eq!(part.len(), 10, "旧版中间文件必须被收养(续传语义不变)");
assert!(!legacy.exists(), "收养后不许留下旧名字");
assert!(!legacy_meta.exists(), "元数据要跟着中间文件一起搬");
assert!(part.meta_path().is_file(), "收养后的元数据路径要就位");
}
#[test]
fn foreign_part_file_is_adopted_only_after_it_cools_down() {
let dir = temp_dir("partfile-foreign");
let dest = dir.join("client.jar");
let foreign = write_part(&dir, &part_name("client.jar", foreign_session(), 1));
let fresh = PartFile::for_dest(&dest);
assert!(!fresh.exists(), "外进程刚写过的中间文件不能抢");
assert!(foreign.is_file(), "更不能删(可能正被那个进程写着)");
age_file(&foreign, ADOPT_MIN_AGE + Duration::from_secs(60));
let adopted = PartFile::for_dest(&dest);
assert_eq!(adopted.len(), 10, "静置够久的外来中间文件应被收养续传");
assert!(!foreign.exists(), "收养后旧名字应消失");
}
#[test]
fn sweeps_ownerless_orphans_but_keeps_fresh_and_live_files() {
let dir = temp_dir("partfile-sweep");
let stale = write_part(&dir, &format!("other.jar{PART_SUFFIX}"));
let stale_meta = dir.join(format!("other.jar{PART_SUFFIX}{META_SUFFIX}"));
fs::write(&stale_meta, b"{}").expect("写元数据失败");
age_file(&stale, ORPHAN_MAX_AGE + Duration::from_secs(60));
let fresh = write_part(&dir, &format!("fresh.jar{PART_SUFFIX}"));
let _guard = PartFile::for_dest(&dir.join("client.jar"));
assert!(!stale.exists(), "无主且超龄的中间文件应被清理");
assert!(!stale_meta.exists(), "它的元数据也要一起清");
assert_eq!(
fs::read(&fresh).unwrap().len(),
10,
"还新鲜的无主文件不能删"
);
}
#[test]
fn keeps_another_live_guard_of_the_same_dest() {
let dir = temp_dir("partfile-live");
let dest = dir.join("client.jar");
let running = PartFile::for_dest(&dest);
running
.open_truncate()
.expect("打开中间文件失败")
.write_all(&[b'x'; 1024])
.expect("写中间文件失败");
let other = PartFile::for_dest(&dest);
assert!(
running.path().is_file(),
"有人在用的中间文件不能被清理或收养"
);
assert_eq!(running.len(), 1024);
assert_ne!(other.path(), running.path());
}
fn fast_engine(cache: &Path) -> DownloadEngine {
DownloadEngine::new(
EngineConfig {
max_attempts_per_source: 1,
backoff: BackoffPolicy {
base_ms: 1,
max_ms: 2,
jitter_ratio: 0.0,
max_attempts: 1,
},
read_timeout: Duration::from_secs(5),
connect_timeout: Duration::from_secs(5),
chunked: ChunkedConfig::disabled(),
..EngineConfig::default()
},
Arc::new(MirrorTable::builtin()),
Arc::new(RegionProfile::direct(0)),
Arc::new(HealthRegistry::in_memory()),
Arc::new(ContentCache::open(cache)),
)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn two_engines_downloading_the_same_dest_do_not_share_parts() {
let payload = b"same destination payload".to_vec();
let server = MockServer::start(vec![
MockResponse::ok(payload.clone()),
MockResponse::ok(payload.clone()),
])
.await;
let url = server.url("/client.jar");
let dir = temp_dir("partfile-engine");
let dest = dir.join("client.jar");
// 两份独立的缓存目录:否则后到的那次会直接吃内容缓存,绕过中间文件路径。
let left = fast_engine(&dir.join("cache-a"));
let right = fast_engine(&dir.join("cache-b"));
let request = || {
DownloadRequest::new(ResourceKind::ClientJar, url.as_str(), &dest)
.with_sha1(sha1_hex(&payload))
.with_size(payload.len() as u64)
};
let (first, second) = tokio::join!(left.download(request()), right.download(request()));
first.expect("并行下载 A 失败");
second.expect("并行下载 B 失败");
assert_eq!(fs::read(&dest).expect("读落盘文件失败"), payload);
assert!(
part_files(&dir).is_empty(),
"并发下载不能留下中间文件:{:?}",
part_files(&dir)
);
server.stop();
}
+121
View File
@@ -0,0 +1,121 @@
//! 地区判定的单测:只测纯判定与缓存,不真发探测请求。
use unlucky_core::download::region::{
decide_from, load_cached, store_cached, RegionMode, RegionProfile, RegionTrigger, Signals,
DECISION_TTL_MS, DEFAULT_GH_PROXY,
};
fn signals(timezone: Option<&str>, language: Option<&str>, google: Option<bool>) -> Signals {
Signals {
timezone: timezone.map(str::to_string),
languages: language.map(str::to_string).into_iter().collect(),
google_reachable: google,
baidu_reachable: None,
}
}
#[test]
fn timezone_signal_detects_greater_china() {
assert!(signals(Some("Asia/Shanghai"), None, None).is_greater_china());
assert!(signals(Some("Asia/Taipei"), None, None).is_greater_china());
// 大小写/空白容错。
assert!(signals(Some(" asia/hong_kong "), None, None).is_greater_china());
assert!(!signals(Some("Europe/Berlin"), None, None).is_greater_china());
}
#[test]
fn language_signal_handles_posix_and_bcp47() {
assert!(signals(None, Some("zh_CN.UTF-8"), None).is_greater_china());
assert!(signals(None, Some("zh-Hant-TW"), None).is_greater_china());
assert!(!signals(None, Some("en_US.UTF-8"), None).is_greater_china());
// 时区不命中、语言命中也算。
assert!(signals(Some("UTC"), Some("zh_TW"), None).is_greater_china());
}
#[test]
fn domestic_without_google_goes_mirror_first() {
let profile = decide_from(&signals(Some("Asia/Shanghai"), None, Some(false)), 1);
assert_eq!(profile.mode, RegionMode::MirrorFirst);
assert!(profile.greater_china);
assert_eq!(profile.trigger, RegionTrigger::Auto);
assert_eq!(profile.gh_proxy.as_deref(), Some(DEFAULT_GH_PROXY));
assert!(profile.gh_proxy_enabled());
}
#[test]
fn domestic_with_google_reachable_stays_direct() {
// 已有全局代理:尊重用户代理,不再插镜像。
let profile = decide_from(&signals(Some("Asia/Shanghai"), None, Some(true)), 1);
assert_eq!(profile.mode, RegionMode::Direct);
assert!(profile.gh_proxy.is_none());
assert!(!profile.gh_proxy_enabled());
}
#[test]
fn non_greater_china_is_always_direct() {
for google in [Some(true), Some(false), None] {
let profile = decide_from(&signals(Some("America/New_York"), Some("en-US"), google), 1);
assert_eq!(profile.mode, RegionMode::Direct, "google={google:?}");
assert!(!profile.greater_china);
}
}
#[test]
fn probes_disabled_defaults_to_mirror_first_for_chinese_env() {
let profile = decide_from(&signals(None, Some("zh_CN.UTF-8"), None), 1);
assert_eq!(profile.mode, RegionMode::MirrorFirst);
assert!(!profile.probes_enabled);
}
#[test]
fn pinned_mode_never_goes_stale() {
let pinned = RegionProfile::pinned(RegionMode::Direct, 10);
assert_eq!(pinned.trigger, RegionTrigger::UserPinned);
assert!(!pinned.is_stale(10 + DECISION_TTL_MS * 10));
let auto = decide_from(&signals(Some("Asia/Shanghai"), None, Some(false)), 10);
assert!(!auto.is_stale(10 + 1_000));
assert!(auto.is_stale(10 + DECISION_TTL_MS + 1));
}
#[test]
fn gh_proxy_helpers() {
let mut profile = RegionProfile::mirror_first(0);
assert!(profile.gh_proxy_enabled());
profile = profile.with_gh_proxy(Some(" "));
assert!(!profile.gh_proxy_enabled(), "空白前缀视为不启用");
profile = profile.with_gh_proxy(Some("https://proxy.example/"));
assert!(profile.gh_proxy_enabled());
// 直连模式下即便配了前缀也不生效(gh-proxy 只在国内模式用)。
let direct = RegionProfile::direct(0).with_gh_proxy(Some("https://proxy.example/"));
assert!(!direct.gh_proxy_enabled());
assert_eq!(direct.label(), "直连");
assert_eq!(RegionProfile::mirror_first(0).label(), "镜像优先");
}
#[test]
fn cache_roundtrip_and_ttl_expiry() {
let dir = std::env::temp_dir().join(format!("ull-region-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("region.json");
let profile = decide_from(&signals(Some("Asia/Shanghai"), None, Some(false)), 1_000);
store_cached(&path, &profile).expect("写缓存失败");
let cached = load_cached(&path, 1_000 + 60_000).expect("应有缓存");
assert_eq!(cached, profile);
// 过期 → 视为没有缓存。
assert!(load_cached(&path, 1_000 + DECISION_TTL_MS + 1).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn missing_cache_file_is_not_an_error() {
let path = std::env::temp_dir().join("ull-region-does-not-exist.json");
let _ = std::fs::remove_file(&path);
assert!(load_cached(&path, 0).is_none());
}
+223
View File
@@ -0,0 +1,223 @@
//! 断点续传保守判定的单测:矩阵覆盖 + 416 处理。
use unlucky_core::download::resume::{
decide, on_range_unsatisfiable, RestartReason, ResumeDecision, ResumeMeta, Validator,
};
use unlucky_core::download::types::{HashAlgo, ResumePolicy};
const URL: &str = "https://bmclapi2.bangbang93.com/versions/1.20.1/client.jar";
const HASH: &str = "0123456789abcdef0123456789abcdef01234567";
fn meta(url: &str) -> ResumeMeta {
ResumeMeta {
url: url.to_string(),
etag: Some("\"abc\"".to_string()),
last_modified: None,
total: Some(1_000),
algo: HashAlgo::Sha1,
hash: HASH.to_string(),
saved_at_ms: 1,
}
}
fn resume_ok(part_len: u64, meta: &ResumeMeta) -> ResumeDecision {
decide(
ResumePolicy::Conservative,
part_len,
Some(meta),
URL,
Some((HashAlgo::Sha1, HASH)),
Some(1_000),
)
}
#[test]
fn resumes_when_everything_matches() {
let decision = resume_ok(400, &meta(URL));
assert_eq!(
decision,
ResumeDecision::Resume {
offset: 400,
validator: Validator {
etag: Some("\"abc\"".to_string()),
last_modified: None,
},
}
);
}
#[test]
fn never_policy_and_missing_files_restart() {
let meta = meta(URL);
assert_eq!(
decide(
ResumePolicy::Never,
400,
Some(&meta),
URL,
Some((HashAlgo::Sha1, HASH)),
Some(1_000)
),
ResumeDecision::Restart {
reason: RestartReason::PolicyNever
}
);
assert_eq!(
resume_ok(0, &meta),
ResumeDecision::Restart {
reason: RestartReason::EmptyPart
}
);
// 没有元数据:无法证明中间文件是同一次下载的产物。
assert_eq!(
decide(
ResumePolicy::Conservative,
400,
None,
URL,
Some((HashAlgo::Sha1, HASH)),
Some(1_000)
),
ResumeDecision::Restart {
reason: RestartReason::NoMeta
}
);
}
#[test]
fn changed_url_or_hash_restarts() {
assert_eq!(
decide(
ResumePolicy::Conservative,
400,
Some(&meta("https://other.example/client.jar")),
URL,
Some((HashAlgo::Sha1, HASH)),
Some(1_000)
),
ResumeDecision::Restart {
reason: RestartReason::UrlChanged
}
);
assert_eq!(
decide(
ResumePolicy::Conservative,
400,
Some(&meta(URL)),
URL,
Some((HashAlgo::Sha1, "ffffffffffffffffffffffffffffffffffffffff")),
Some(1_000)
),
ResumeDecision::Restart {
reason: RestartReason::HashChanged
}
);
}
#[test]
fn unknown_total_or_oversize_restarts() {
let mut unknown = meta(URL);
unknown.total = None;
assert_eq!(
decide(
ResumePolicy::Conservative,
400,
Some(&unknown),
URL,
None,
None
),
ResumeDecision::Restart {
reason: RestartReason::TotalUnknown
}
);
assert_eq!(
resume_ok(2_000, &meta(URL)),
ResumeDecision::Restart {
reason: RestartReason::Oversize
}
);
}
#[test]
fn complete_part_skips_transfer() {
assert_eq!(
resume_ok(1_000, &meta(URL)),
ResumeDecision::Complete { size: 1_000 }
);
}
#[test]
fn missing_validator_restarts() {
let mut weak = meta(URL);
weak.etag = None;
weak.last_modified = None;
assert_eq!(
resume_ok(400, &weak),
ResumeDecision::Restart {
reason: RestartReason::NoValidator
}
);
// Last-Modified 单独也能当校验器。
weak.last_modified = Some("Wed, 21 Oct 2015 07:28:00 GMT".to_string());
assert!(matches!(
resume_ok(400, &weak),
ResumeDecision::Resume { offset: 400, .. }
));
}
#[test]
fn expected_size_can_stand_in_for_missing_total() {
let mut unknown = meta(URL);
unknown.total = None;
let decision = decide(
ResumePolicy::Conservative,
400,
Some(&unknown),
URL,
Some((HashAlgo::Sha1, HASH)),
Some(1_000),
);
assert!(matches!(
decision,
ResumeDecision::Resume { offset: 400, .. }
));
}
#[test]
fn range_not_satisfiable_handling() {
// 已下满:直接进入校验,不要白删一个好文件。
assert_eq!(
on_range_unsatisfiable(1_000, Some(1_000)),
ResumeDecision::Complete { size: 1_000 }
);
// 没下满却 416:中间文件不可信,丢掉重来。
let decision = on_range_unsatisfiable(400, Some(1_000));
assert_eq!(
decision,
ResumeDecision::Restart {
reason: RestartReason::RangeUnsatisfiable
}
);
assert!(decision.should_drop_part());
// 不允许丢掉「已完成」的中间文件。
assert!(!ResumeDecision::Complete { size: 1 }.should_drop_part());
}
#[test]
fn resume_meta_roundtrip_and_removal() {
let dir = std::env::temp_dir().join(format!("ull-resume-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("client.jar.ullpart.ullmeta.json");
let original = meta(URL);
original.save(&path).expect("写元数据失败");
assert_eq!(ResumeMeta::load(&path), Some(original));
ResumeMeta::remove(&path);
assert!(ResumeMeta::load(&path).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
+278
View File
@@ -0,0 +1,278 @@
//! 测试支撑:临时目录、摘要工具、进程内极简 HTTP 服务器(零额外依赖)。
//!
//! 为什么自己写 mock server 而不用 wiremock:P0 阶段控制依赖面(Win7 Tier-3 构建 +
//! 单文件体积),一个 60 行的 `TcpListener` 足够表达「成功 / 500 / 校验不符 / 无视 Range」。
#![allow(dead_code)]
use std::net::SocketAddr;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
/// 断言用的小工具:唯一临时目录(进程内计数 + 时间戳,避免并行测试互相踩)。
pub fn temp_dir(tag: &str) -> std::path::PathBuf {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_nanos());
let dir = std::env::temp_dir().join(format!("ull-download-test-{tag}-{stamp}-{unique}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("创建临时目录失败");
dir
}
/// 算一段字节的 SHA-1(测试断言用)。
pub fn sha1_hex(bytes: &[u8]) -> String {
use sha1::Digest;
let digest = sha1::Sha1::digest(bytes);
let mut out = String::with_capacity(40);
for byte in digest {
out.push_str(&format!("{byte:02x}"));
}
out
}
/// 目录里(不含子目录)所有中间文件家族的名字(`.ullpart` 与 `.ullmeta.json`)。
pub fn part_files(dir: &std::path::Path) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut names: Vec<String> = entries
.flatten()
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| name.contains(unlucky_core::download::PART_SUFFIX))
.collect();
names.sort();
names
}
/// 把文件的修改时间挪到「`age` 之前」(清理/收养的年龄判定用)。
pub fn age_file(path: &std::path::Path, age: std::time::Duration) {
let times = std::fs::FileTimes::new().set_modified(std::time::SystemTime::now() - age);
std::fs::File::options()
.write(true)
.open(path)
.expect("打开待改 mtime 的文件失败")
.set_times(times)
.expect("改 mtime 失败");
}
/// mock 服务器给一次请求的应答。
#[derive(Debug, Clone)]
pub struct MockResponse {
/// HTTP 状态码。
pub status: u16,
/// 响应体。
pub body: Vec<u8>,
/// 是否声明 `Accept-Ranges: bytes` 并在收到 `Range` 时真的切段。
pub range_support: bool,
/// `ETag`(续传测试用)。
pub etag: Option<String>,
/// 返回 206 时故意把 `Content-Range` 写成从 0 开始(模拟段错位的坏服务器)。
pub lie_content_range: bool,
}
impl MockResponse {
/// 200 + 完整内容(支持 Range)。
pub fn ok(body: impl Into<Vec<u8>>) -> Self {
Self {
status: 200,
body: body.into(),
range_support: true,
etag: Some("\"v1\"".to_string()),
lie_content_range: false,
}
}
/// 固定状态码 + 空体。
pub fn status(status: u16) -> Self {
Self {
status,
body: Vec::new(),
range_support: false,
etag: None,
lie_content_range: false,
}
}
/// 声明不支持 Range(模拟部分镜像)。
pub fn without_range(mut self) -> Self {
self.range_support = false;
self
}
/// 206 但 `Content-Range` 谎报(模拟段错位的坏 CDN)。
pub fn with_wrong_content_range(mut self) -> Self {
self.lie_content_range = true;
self
}
}
/// 进程内极简 HTTP/1.1 服务器:按脚本顺序返回响应,脚本用尽后一律 500。
pub struct MockServer {
addr: SocketAddr,
hits: Arc<AtomicUsize>,
handle: tokio::task::JoinHandle<()>,
}
impl MockServer {
/// 启动服务器(脚本是「每次请求一个响应」的队列)。
pub async fn start(script: Vec<MockResponse>) -> Self {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("绑定端口失败");
let addr = listener.local_addr().expect("取本地地址失败");
let hits = Arc::new(AtomicUsize::new(0));
let script = Arc::new(std::sync::Mutex::new(script));
let counter = Arc::clone(&hits);
let handle = tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
break;
};
let script = Arc::clone(&script);
let counter = Arc::clone(&counter);
tokio::spawn(async move {
let index = counter.fetch_add(1, Ordering::Relaxed);
let response = {
let script = script.lock().unwrap_or_else(|err| err.into_inner());
script.get(index).cloned()
};
let response = response.unwrap_or_else(|| MockResponse::status(500));
let _ = serve(stream, response).await;
});
}
});
Self { addr, hits, handle }
}
/// 第 `index` 个请求该用的 URL(换成不同路径可模拟「不同源」)。
pub fn url(&self, path: &str) -> String {
format!("http://{}{}", self.addr, path)
}
/// 换 host 拼 URL(用 `localhost` vs `127.0.0.1` 造出两个不同源)。
pub fn url_with(&self, host: &str, path: &str) -> String {
format!("http://{host}:{}{}", self.addr.port(), path)
}
/// 端口号。
pub fn port(&self) -> u16 {
self.addr.port()
}
/// 已收到的请求数。
pub fn hits(&self) -> usize {
self.hits.load(Ordering::Relaxed)
}
/// 关停。
pub fn stop(self) {
self.handle.abort();
}
}
async fn serve(mut stream: TcpStream, response: MockResponse) -> std::io::Result<()> {
let mut buffer = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
let read = stream.read(&mut chunk).await?;
if read == 0 {
break;
}
buffer.extend_from_slice(&chunk[..read]);
if buffer.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
let request = String::from_utf8_lossy(&buffer).to_ascii_lowercase();
let range = request
.lines()
.find_map(|line| line.strip_prefix("range: bytes="))
.map(str::trim)
.and_then(parse_range_header);
let total = response.body.len() as u64;
let (status, headers, body) = match (range, response.range_support) {
(Some((start, requested_end)), true) if start < total => {
// 尊重请求里的结束偏移(分块下载必须逐段精确,否则组装后长度对不上)。
let end = requested_end.unwrap_or(total - 1).min(total - 1);
let slice = response.body[start as usize..=end as usize].to_vec();
// 谎报模式:body 仍是正确的那一段,但头部声称从 0 开始(长度恰好 → 只有
// 严格比对 Content-Range 才抓得住)。
let (claimed_start, claimed_end) = if response.lie_content_range {
(0, slice.len().saturating_sub(1) as u64)
} else {
(start, end)
};
(
206,
vec![
format!("Content-Range: bytes {claimed_start}-{claimed_end}/{total}"),
format!("Content-Length: {}", slice.len()),
"Accept-Ranges: bytes".to_string(),
],
slice,
)
}
(Some(_), true) => (
416,
vec![
format!("Content-Range: bytes */{total}"),
"Content-Length: 0".to_string(),
],
Vec::new(),
),
_ => {
let mut headers = vec![format!("Content-Length: {total}")];
if response.range_support {
headers.push("Accept-Ranges: bytes".to_string());
}
(response.status, headers, response.body.clone())
}
};
let mut head = format!("HTTP/1.1 {status} {}\r\n", reason(status));
if let Some(etag) = &response.etag {
head.push_str(&format!("ETag: {etag}\r\n"));
}
for header in headers {
head.push_str(&header);
head.push_str("\r\n");
}
head.push_str("Connection: close\r\n\r\n");
stream.write_all(head.as_bytes()).await?;
stream.write_all(&body).await?;
stream.flush().await?;
Ok(())
}
/// 解析 `Range: bytes=<start>-<end>`(`end` 可缺省 = 到文件末尾)。
fn parse_range_header(value: &str) -> Option<(u64, Option<u64>)> {
let (start, end) = value.split_once('-')?;
let start = start.trim().parse::<u64>().ok()?;
let end = end.trim();
let end = if end.is_empty() {
None
} else {
Some(end.parse::<u64>().ok()?)
};
Some((start, end))
}
fn reason(status: u16) -> &'static str {
match status {
200 => "OK",
206 => "Partial Content",
404 => "Not Found",
416 => "Range Not Satisfiable",
500 => "Internal Server Error",
503 => "Service Unavailable",
_ => "Status",
}
}
+256
View File
@@ -0,0 +1,256 @@
//! 规则求值、参数展开、natives 选择(launch 直接消费这些结果)。
use std::collections::BTreeMap;
use unlucky_core::game::arguments::{
matches_simple_pattern, rules_allow, Argument, ArgumentValue, ConditionalArgument, Environment,
OsRestriction, Rule, RuleAction,
};
use unlucky_core::game::library::Library;
use crate::helpers::parse;
fn env(os: &str, arch: &str) -> Environment {
Environment {
os: os.to_owned(),
os_version: "10.0".to_owned(),
arch: arch.to_owned(),
features: BTreeMap::new(),
}
}
fn allow_os(name: &str) -> Rule {
Rule {
action: RuleAction::Allow,
os: Some(OsRestriction {
name: Some(name.to_owned()),
..OsRestriction::default()
}),
features: None,
}
}
fn disallow_os(name: &str) -> Rule {
Rule {
action: RuleAction::Disallow,
..allow_os(name)
}
}
#[test]
fn empty_rules_always_allow() {
assert!(rules_allow(&[], &env("linux", "x86_64")));
}
#[test]
fn allow_rule_must_match_environment() {
let rules = [allow_os("windows")];
assert!(rules_allow(&rules, &env("windows", "x86_64")));
assert!(!rules_allow(&rules, &env("linux", "x86_64")));
}
#[test]
fn later_rules_override_earlier_ones() {
// 白名单语义:列表非空时从 DISALLOW 起步。
let allow_after_disallow = [disallow_os("windows"), allow_os("windows")];
assert!(rules_allow(
&allow_after_disallow,
&env("windows", "x86_64")
));
let disallow_after_allow = [allow_os("windows"), disallow_os("windows")];
assert!(!rules_allow(
&disallow_after_allow,
&env("windows", "x86_64")
));
}
#[test]
fn feature_gate_blocks_argument() {
let mut features = BTreeMap::new();
features.insert("has_custom_resolution".to_owned(), true);
let rule = Rule {
action: RuleAction::Allow,
os: None,
features: Some(features),
};
let enabled = env("windows", "x86_64").with_feature("has_custom_resolution", true);
let disabled = env("windows", "x86_64");
assert_eq!(rule.action_in(&enabled), Some(RuleAction::Allow));
assert_eq!(rule.action_in(&disabled), None);
}
#[test]
fn host_environment_is_mojang_shaped() {
let host = Environment::host();
assert!(matches!(host.os.as_str(), "windows" | "osx" | "linux"));
assert!(!host.arch.is_empty());
assert!(!host.feature("has_custom_resolution"));
// 空串是「没探」,真值来自 platform::os_info::current();只要探得到就不能再是空的。
let probed = unlucky_core::platform::os_info::current();
assert_eq!(host.os_version, probed.os_version());
assert_eq!(host.os, probed.family.manifest_name());
assert_eq!(host.arch, probed.arch);
}
#[test]
fn environment_from_an_injected_os_snapshot_carries_os_version() {
use unlucky_core::platform::linux_caps::SessionKind;
use unlucky_core::platform::os_info::{support_level_for, OsFamily, OsInfo};
use unlucky_core::platform::vk_probe::VkProbe;
let fake = OsInfo {
family: OsFamily::Windows,
arch: "x86_64".to_string(),
major_minor: Some((10, 0)),
build: Some(19_045),
product: "Windows 10".to_string(),
version_string: "10.0.19045".to_string(),
session: SessionKind::Unknown,
gl: unlucky_core::platform::linux_caps::GlProbe::Unknown,
vk: VkProbe::Available,
kernel: None,
glibc: None,
support_level: support_level_for(OsFamily::Windows, Some((10, 0)), None, None),
};
let environment = Environment::from_os_info(&fake);
assert_eq!(environment.os, "windows");
assert_eq!(environment.os_version, "10.0.19045");
assert_eq!(environment.arch, "x86_64");
assert!(environment.features.is_empty(), "注入快照不该带特性开关");
// 系统版本真的参与规则求值(这正是不再用空串的原因)。
let rule = Rule {
os: Some(OsRestriction {
name: None,
version: Some("^10\\.".to_string()),
arch: None,
}),
..Rule::default()
};
assert_eq!(rule.action_in(&environment), Some(RuleAction::Allow));
let mac_rule = Rule {
os: Some(OsRestriction {
name: None,
version: Some("^11\\.".to_string()),
arch: None,
}),
..Rule::default()
};
assert_eq!(mac_rule.action_in(&environment), None);
}
#[test]
fn os_version_patterns_match_conservatively() {
assert!(matches_simple_pattern("^10\\.", "10.0"));
assert!(!matches_simple_pattern("^10\\.", "11.0"));
assert!(matches_simple_pattern("x86_64", "x86_64"));
assert!(!matches_simple_pattern("x86_64", "x86"));
assert!(matches_simple_pattern("10\\.0$", "10.0"));
// 不认识的复杂正则放宽为匹配(宁可多带参数也不漏参数)。
assert!(matches_simple_pattern("^(10|11)\\.", "11.0"));
}
#[test]
fn expand_arguments_by_environment() {
let version = parse(
r#"{
"id": "x",
"arguments": {
"game": [
"--username",
{ "rules": [{ "action": "allow", "features": { "is_demo_user": true } }],
"value": "--demo" },
{ "value": ["--pair", "1"] }
],
"jvm": [ "-Dfoo=1" ]
}
}"#,
);
let arguments = version.arguments.as_ref().expect("arguments 应解析");
let plain = arguments.expand(&env("windows", "x86_64"));
assert_eq!(plain.game, vec!["--username", "--pair", "1"]);
assert_eq!(plain.jvm, vec!["-Dfoo=1"]);
let demo = env("windows", "x86_64").with_feature("is_demo_user", true);
assert!(arguments.expand(&demo).game.contains(&"--demo".to_owned()));
}
#[test]
fn conditional_argument_expands_single_value() {
let argument = Argument::Conditional(ConditionalArgument {
rules: vec![allow_os("windows")],
value: ArgumentValue::One("--single".to_owned()),
});
let mut out = Vec::new();
argument.expand_into(&env("windows", "x86_64"), &mut out);
assert_eq!(out, vec!["--single"]);
let mut out = Vec::new();
argument.expand_into(&env("linux", "x86_64"), &mut out);
assert!(out.is_empty());
}
#[test]
fn native_classifier_expands_arch() {
let version = parse(
r#"{ "id": "x",
"libraries": [{
"name": "org.lwjgl:lwjgl:3.3.2",
"natives": { "windows": "natives-windows-${arch}", "linux": "natives-linux" },
"downloads": {
"artifact": { "path": "org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2.jar" },
"classifiers": {
"natives-windows-64": { "path": "org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-windows-64.jar" }
}
}
}]
}"#,
);
let library = &version.libraries.as_ref().expect("libraries 应解析")[0];
assert_eq!(
library.native_classifier(&env("windows", "x86_64")),
Some("natives-windows-64".to_owned())
);
assert_eq!(
library.native_classifier(&env("windows", "x86")),
Some("natives-windows-".to_owned())
);
assert_eq!(
library.native_classifier(&env("linux", "x86_64")),
Some("natives-linux".to_owned())
);
assert_eq!(library.native_classifier(&env("osx", "arm64")), None);
let classifier = "natives-windows-64";
assert!(library.native_artifact(classifier).is_some());
assert_eq!(
library
.native_relative_path(classifier)
.expect("路径应可推导"),
std::path::PathBuf::from("org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-windows-64.jar")
);
}
#[test]
fn library_rules_and_local_hint() {
let library = Library {
name: "com.example:inst:1".to_owned(),
rules: vec![allow_os("linux")],
hint: Some("local".to_owned()),
..Library::default()
};
assert!(library.applies(&env("linux", "x86_64")));
assert!(!library.applies(&env("windows", "x86_64")));
assert!(library.is_local());
assert_eq!(
library.relative_path().expect("路径应可推导"),
std::path::PathBuf::from("com/example/inst/1/inst-1.jar")
);
}
+66
View File
@@ -0,0 +1,66 @@
//! 测试公用件:fixture 解析、内存父版本表、临时目录守卫。
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use unlucky_core::game::manifest::VersionJson;
/// 解析 fixture JSON(fixture 是测试常量,解析失败即 panic)。
pub fn parse(json: &str) -> VersionJson {
VersionJson::from_json_str(json).expect("fixture 必须可解析")
}
/// 用 `(id, json)` 对建内存父版本表,喂给 `inherit::resolve`。
pub fn source(pairs: &[(&str, &str)]) -> BTreeMap<String, VersionJson> {
pairs
.iter()
.map(|(id, json)| ((*id).to_owned(), parse(json)))
.collect()
}
/// 唯一临时目录守卫:`drop` 时递归删除。
pub struct TempDir {
path: PathBuf,
}
impl TempDir {
/// 建一个唯一临时目录。
pub fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"ull-game-{tag}-{}-{nanos}-{seq}",
std::process::id()
));
std::fs::create_dir_all(&path).expect("临时目录应可创建");
Self { path }
}
/// 目录路径。
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
/// 建一个游戏根布局(纯路径计算,不建目录)。
pub fn layout(root: &Path) -> unlucky_core::game::GameLayout {
unlucky_core::game::GameLayout::new(root)
}
/// 造 `dir/<name>` 并写一个文件进去(模拟玩家内容:`mods`/`saves`/`config` 的条目)。
pub fn seed(dir: &Path, name: &str) {
let target = dir.join(name);
std::fs::create_dir_all(&target).expect("建目录");
std::fs::write(target.join("entry.txt"), b"x").expect("写文件");
}
+265
View File
@@ -0,0 +1,265 @@
//! `inheritsFrom` 解析:逐字段 merge 语义、patch 优先级、环检测、缺失父版本。
use unlucky_core::game::error::GameError;
use unlucky_core::game::inherit::{apply_patches, merge, resolve, sorted_patches};
use unlucky_core::game::version::ReleaseType;
use crate::helpers::{parse, source};
const VANILLA: &str = r#"{
"id": "1.20.1",
"type": "release",
"releaseTime": "2023-06-12T13:25:51+00:00",
"mainClass": "net.minecraft.client.main.Main",
"minecraftArguments": "--username ${auth_player_name}",
"assets": "5",
"assetIndex": { "id": "5" },
"complianceLevel": 1,
"minimumLauncherVersion": 21,
"javaVersion": { "component": "java-runtime-gamma", "majorVersion": 17 },
"libraries": [ { "name": "lib:a:1" }, { "name": "lib:b:1" } ],
"downloads": {
"client": { "url": "https://example.invalid/client.jar", "sha1": "c", "size": 1 },
"server": { "url": "https://example.invalid/server.jar", "sha1": "s", "size": 1 }
},
"arguments": { "game": ["--game-parent"], "jvm": ["-Dparent=1"] },
"logging": { "client": { "argument": "-Dlog4j", "file": { "id": "client-1.12.xml" } } }
}"#;
const FORGE: &str = r#"{
"id": "1.20.1-forge-47.2.0",
"inheritsFrom": "1.20.1",
"mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher",
"time": "2024-01-01T00:00:00+00:00",
"minimumLauncherVersion": 14,
"javaVersion": { "component": "java-runtime-delta", "majorVersion": 21 },
"libraries": [ { "name": "net.minecraftforge:forge:1" } ],
"downloads": { "server": { "url": "https://example.invalid/server2.jar" } },
"arguments": { "game": ["--game-child"], "jvm": ["-Dchild=1"] }
}"#;
fn vanilla_and_forge(
) -> std::collections::BTreeMap<String, unlucky_core::game::manifest::VersionJson> {
source(&[("1.20.1", VANILLA), ("1.20.1-forge-47.2.0", FORGE)])
}
#[test]
fn merge_table_follows_analyse_03() {
let sources = vanilla_and_forge();
let child = parse(FORGE);
let resolved = resolve(&sources, &child).expect("继承链应可解析");
// 子覆盖父。
assert_eq!(
resolved.main_class.as_deref(),
Some("cpw.mods.bootstraplauncher.BootstrapLauncher")
);
// 子没写就继承父。
assert_eq!(
resolved.minecraft_arguments.as_deref(),
Some("--username ${auth_player_name}")
);
assert_eq!(resolved.assets.as_deref(), Some("5"));
assert_eq!(resolved.version_type, Some(ReleaseType::Release));
// arguments 父在前、子在后追加。
let arguments = resolved.arguments.as_ref().expect("arguments 应保留");
assert_eq!(arguments.game.len(), 2);
assert_eq!(arguments.jvm.len(), 2);
// libraries 父在前、子在后。
let libraries = resolved.libraries.as_ref().expect("libraries 应保留");
let names: Vec<&str> = libraries.iter().map(|l| l.name.as_str()).collect();
assert_eq!(
names,
vec!["lib:a:1", "lib:b:1", "net.minecraftforge:forge:1"]
);
// downloads 整体覆盖:子只写 server,client 就没了(非直觉点 1,必须显式守门)。
let downloads = resolved.downloads.as_ref().expect("downloads 应存在");
assert!(downloads.client().is_none(), "整体覆盖会让父层 client 消失");
assert_eq!(
downloads.server().and_then(|s| s.url.as_deref()),
Some("https://example.invalid/server2.jar")
);
// complianceLevel 只取子(非直觉点 2):父层 1 被丢弃。
assert_eq!(resolved.compliance_level, None);
// javaVersion 子覆盖父。
let java = resolved.java_version.as_ref().expect("javaVersion 应存在");
assert_eq!(java.major_version, Some(21));
// minimumLauncherVersion 取 max(21, 14)=21。
assert_eq!(resolved.minimum_launcher_version, Some(21));
// logging 子没有 → 继承父。
assert!(resolved.logging.is_some());
// 解析结果的不变量。
assert_eq!(resolved.jar.as_deref(), Some("1.20.1-forge-47.2.0"));
assert!(resolved.inherits_from.is_none());
assert!(resolved.patches.is_none());
assert_eq!(resolved.root, Some(true));
assert!(resolved.is_resolved());
}
#[test]
fn three_level_chain_orders_ancestor_first() {
const A: &str = r#"{ "id": "a", "mainClass": "a.Main", "libraries": [{ "name": "l:a:1" }] }"#;
const B: &str = r#"{ "id": "b", "inheritsFrom": "a", "libraries": [{ "name": "l:b:1" }] }"#;
const C: &str = r#"{ "id": "c", "inheritsFrom": "b", "mainClass": "c.Main",
"libraries": [{ "name": "l:c:1" }] }"#;
let sources = source(&[("a", A), ("b", B), ("c", C)]);
let resolved = resolve(&sources, &parse(C)).expect("三层链应可解析");
assert_eq!(resolved.main_class.as_deref(), Some("c.Main"));
let names: Vec<&str> = resolved
.libraries
.as_ref()
.expect("libraries 应存在")
.iter()
.map(|l| l.name.as_str())
.collect();
assert_eq!(names, vec!["l:a:1", "l:b:1", "l:c:1"]);
}
#[test]
fn patch_priority_sorts_ascending() {
let root = parse(
r#"{
"id": "patched",
"inheritsFrom": "1.20.1",
"patches": [
{ "id": "forge", "priority": 30000, "mainClass": "loader.Main",
"libraries": [{ "name": "loader:a:1" }] },
{ "id": "base", "mainClass": "base.Main",
"arguments": { "game": ["--base"] } }
]
}"#,
);
let resolved = resolve(&vanilla_and_forge(), &root).expect("带 patches 的清单应可解析");
// 无 priority 的补丁排最前(Integer.MIN_VALUE 语义),loader 后合并 → 覆盖 mainClass。
assert_eq!(resolved.main_class.as_deref(), Some("loader.Main"));
let libraries = resolved.libraries.as_ref().expect("libraries 应存在");
assert_eq!(
libraries.last().map(|l| l.name.as_str()),
Some("loader:a:1")
);
let arguments = resolved.arguments.as_ref().expect("arguments 应存在");
let flat: Vec<String> = arguments.game.iter().map(|a| format!("{a:?}")).collect();
assert!(flat.iter().any(|a| a.contains("--base")));
}
#[test]
fn equal_priority_patches_keep_insertion_order() {
let root = parse(
r#"{
"id": "patched",
"patches": [
{ "id": "first", "priority": 10, "mainClass": "first.Main" },
{ "id": "second", "priority": 10, "mainClass": "second.Main" }
]
}"#,
);
let resolved = resolve(&source(&[]), &root).expect("无继承的清单应可解析");
assert_eq!(
resolved.main_class.as_deref(),
Some("second.Main"),
"同优先级按插入顺序合并,后者覆盖前者"
);
}
#[test]
fn sorted_patches_puts_missing_priority_first() {
let patches = vec![
parse(r#"{ "id": "loader", "priority": 30000 }"#),
parse(r#"{ "id": "base" }"#),
parse(r#"{ "id": "mc", "priority": 0 }"#),
];
let order: Vec<&str> = sorted_patches(&patches)
.iter()
.filter_map(|p| p.id.as_deref())
.collect();
assert_eq!(order, vec!["base", "mc", "loader"]);
}
#[test]
fn apply_patches_stacks_layers() {
let base =
parse(r#"{ "id": "root", "mainClass": "base.Main", "libraries": [{ "name": "l:a:1" }] }"#);
let patched = apply_patches(
base,
&[parse(
r#"{ "id": "loader", "priority": 30000, "mainClass": "loader.Main",
"libraries": [{ "name": "l:loader:1" }] }"#,
)],
);
assert_eq!(patched.main_class.as_deref(), Some("loader.Main"));
let names: Vec<&str> = patched
.libraries
.as_ref()
.expect("libraries 应存在")
.iter()
.map(|l| l.name.as_str())
.collect();
assert_eq!(names, vec!["l:a:1", "l:loader:1"]);
}
#[test]
fn circular_inheritance_is_reported() {
let sources = source(&[
("a", r#"{ "id": "a", "inheritsFrom": "b" }"#),
("b", r#"{ "id": "b", "inheritsFrom": "a" }"#),
]);
let error = resolve(&sources, &parse(r#"{ "id": "a", "inheritsFrom": "b" }"#))
.expect_err("成环必须报错而不是静默降级");
match error {
GameError::CircularInheritance { chain } => {
assert_eq!(chain, vec!["a", "b", "a"], "链要能看出环的位置");
}
other => panic!("期望 CircularInheritance,得到 {other:?}"),
}
}
#[test]
fn missing_parent_is_reported() {
let error = resolve(&source(&[]), &parse(FORGE)).expect_err("缺失父版本必须报错");
match error {
GameError::MissingParent {
parent,
required_by,
} => {
assert_eq!(parent, "1.20.1");
assert_eq!(required_by, "1.20.1-forge-47.2.0");
}
other => panic!("期望 MissingParent,得到 {other:?}"),
}
}
#[test]
fn manifest_without_id_is_rejected() {
let error =
resolve(&source(&[]), &parse(r#"{ "mainClass": "a.B" }"#)).expect_err("缺 id 应报错");
assert!(matches!(error, GameError::MissingVersionId));
}
#[test]
fn merge_preserves_unknown_fields_with_child_winning() {
let parent = parse(r#"{ "id": "p", "customParentOnly": 1, "shared": "parent" }"#);
let child = parse(r#"{ "id": "c", "shared": "child" }"#);
let merged = merge(&child, &parent);
assert_eq!(
merged.extra.get("customParentOnly"),
Some(&serde_json::json!(1))
);
assert_eq!(
merged.extra.get("shared"),
Some(&serde_json::json!("child"))
);
assert_eq!(merged.id.as_deref(), Some("c"));
}
+194
View File
@@ -0,0 +1,194 @@
//! D20 实例隔离:覆盖链派生、双侧切换检测、稀疏落盘不变量。
//!
//! 对应 mission 的验收点:三档派生 / 覆盖链优先级 / 切换检测(双侧空、单侧、双侧)/
//! `inherit` 不落盘 / 恒共享层不可配。
use std::path::Path;
use unlucky_core::game::install::instance::{InstanceConfig, Isolation};
use unlucky_core::game::isolation::{
derive_game_dir, probe_both_sides, probe_side, IsolationMode, IsolationSource,
PROBED_CONTENT_DIRS, SHARED_LAYERS,
};
use unlucky_core::game::GameError;
use super::helpers::{layout, seed, TempDir};
// ---------------------------------------------------------------- 派生(三档)
#[test]
fn isolated_mode_derives_version_dir() {
let temp = TempDir::new("iso-derived");
let game = layout(temp.path());
let derived = derive_game_dir(&game, "1.20.1", IsolationMode::Isolated, None, None)
.expect("isolated 一定能派生");
assert_eq!(derived.isolation, Isolation::Isolated);
assert_eq!(derived.game_dir, game.version_dir("1.20.1"));
assert_eq!(derived.source, IsolationSource::Global);
}
#[test]
fn shared_mode_derives_game_root() {
let temp = TempDir::new("iso-shared");
let game = layout(temp.path());
let derived = derive_game_dir(&game, "1.20.1", IsolationMode::Shared, None, None)
.expect("shared 一定能派生");
assert_eq!(derived.isolation, Isolation::Shared);
assert_eq!(derived.game_dir, game.root());
}
#[test]
fn custom_mode_uses_instance_game_dir() {
let temp = TempDir::new("iso-custom");
let game = layout(temp.path());
let custom = temp.path().join("elsewhere").join("farm");
let mut config = InstanceConfig::new("id-1", "1.20.1");
config.isolation = Some(Isolation::Custom);
config.game_dir = Some(custom.clone());
let derived = derive_game_dir(
&game,
"1.20.1",
IsolationMode::Isolated,
Some(&config),
None,
)
.expect("custom 有目录就能派生");
assert_eq!(derived.isolation, Isolation::Custom);
assert_eq!(derived.game_dir, custom);
assert_eq!(derived.source, IsolationSource::Instance);
}
#[test]
fn custom_without_game_dir_is_an_error() {
let temp = TempDir::new("iso-custom-missing");
let game = layout(temp.path());
let mut config = InstanceConfig::new("id-missing", "1.20.1");
config.isolation = Some(Isolation::Custom);
let error = derive_game_dir(
&game,
"1.20.1",
IsolationMode::Isolated,
Some(&config),
None,
)
.expect_err("custom 缺目录必须报错,不许猜默认值");
assert!(matches!(error, GameError::CustomIsolationWithoutDir { .. }));
assert!(error.to_string().contains("id-missing"), "{error}");
}
// ------------------------------------------------------------ 覆盖链(优先级)
#[test]
fn instance_isolation_beats_global_mode() {
let temp = TempDir::new("iso-priority");
let game = layout(temp.path());
let mut config = InstanceConfig::new("id-2", "1.20.1");
config.isolation = Some(Isolation::Shared);
let derived = derive_game_dir(
&game,
"1.20.1",
IsolationMode::Isolated,
Some(&config),
None,
)
.expect("派生");
assert_eq!(derived.isolation, Isolation::Shared);
assert_eq!(derived.game_dir, game.root());
assert_eq!(derived.source, IsolationSource::Instance);
}
#[test]
fn inherit_falls_back_to_global_mode() {
let temp = TempDir::new("iso-inherit");
let game = layout(temp.path());
let config = InstanceConfig::new("id-3", "1.20.1");
assert!(config.inherits_isolation(), "新实例默认 inherit");
for (mode, expected) in [
(IsolationMode::Isolated, game.version_dir("1.20.1")),
(IsolationMode::Shared, game.root().to_path_buf()),
] {
let derived = derive_game_dir(&game, "1.20.1", mode, Some(&config), None).expect("派生");
assert_eq!(
derived.game_dir,
expected,
"全局 {} 应决定 inherit",
mode.as_str()
);
assert_eq!(derived.source, IsolationSource::Global);
}
}
#[test]
fn temporary_override_wins_over_everything() {
let temp = TempDir::new("iso-temporary");
let game = layout(temp.path());
let mut config = InstanceConfig::new("id-4", "1.20.1");
config.isolation = Some(Isolation::Isolated);
let forced = temp.path().join("forced");
let derived = derive_game_dir(
&game,
"1.20.1",
IsolationMode::Shared,
Some(&config),
Some(&forced),
)
.expect("派生");
assert_eq!(derived.game_dir, forced);
assert_eq!(derived.source, IsolationSource::Temporary);
// 档位本身仍是实例档位(临时参数只改目录,不改语义)。
assert_eq!(derived.isolation, Isolation::Isolated);
// 空路径 = 没给临时参数。
let derived = derive_game_dir(
&game,
"1.20.1",
IsolationMode::Shared,
Some(&config),
Some(Path::new("")),
)
.expect("派生");
assert_eq!(derived.source, IsolationSource::Instance);
}
#[test]
fn shared_layers_are_fixed_and_unconfigurable() {
assert_eq!(SHARED_LAYERS, ["versions", "assets", "libraries"]);
// natives 不在恒共享层里:它按实例解包(两家启动器一致)。
assert!(!SHARED_LAYERS.contains(&"natives"));
assert_eq!(PROBED_CONTENT_DIRS, ["mods", "saves", "config"]);
}
// -------------------------------------------------------------- 切换检测(探测)
#[test]
fn probe_counts_only_player_content() {
let temp = TempDir::new("iso-probe");
seed(temp.path(), "mods");
seed(temp.path(), "saves");
seed(temp.path(), "screenshots");
let side = probe_side(temp.path()).expect("探测");
assert_eq!(side.entries, 2, "screenshots 不算玩家内容");
assert!(side.occupied());
}
#[test]
fn probe_missing_dir_counts_as_zero() {
let temp = TempDir::new("iso-probe-empty");
let side = probe_side(&temp.path().join("nope")).expect("目录不存在不算错误");
assert_eq!(side.entries, 0);
assert!(!side.occupied());
}
#[test]
fn probe_both_sides_reads_isolated_and_shared() {
let temp = TempDir::new("iso-probe-both");
let game = layout(temp.path());
seed(&game.version_dir("1.20.1"), "saves");
seed(game.root(), "mods");
seed(game.root(), "config");
let (isolated, shared) = probe_both_sides(&game, "1.20.1").expect("探测");
assert_eq!(isolated.entries, 1);
assert_eq!(shared.entries, 2);
assert_eq!(isolated.path, game.version_dir("1.20.1"));
assert_eq!(shared.path, game.root());
}
+222
View File
@@ -0,0 +1,222 @@
//! D20 切换检测与稀疏落盘:observe/warn 语义、`inherit` 与 `lastModeChange` 不落盘、
//! 旧字面量读兼容(从 `isolation.rs` 拆出,保持单文件 ≤400 行)。
use std::path::PathBuf;
use unlucky_core::game::install::instance::{InstanceConfig, Isolation};
use unlucky_core::game::isolation::{
derive_game_dir, observe_mode, IsolationMode, IsolationSource,
};
use super::helpers::{layout, seed, TempDir};
// ------------------------------------------------- 切换记录(幂等 + 只提示一次)
#[test]
fn observe_mode_records_baseline_without_warning() {
let temp = TempDir::new("iso-observe-baseline");
let game = layout(temp.path());
seed(game.root(), "saves");
let mut config = InstanceConfig::new("id-5", "1.20.1");
let changed = observe_mode(
&mut config,
&game,
"2026-10-30T00:00:00Z",
Isolation::Isolated,
)
.expect("观察");
assert!(changed, "首次观察要建基线");
let change = config.last_mode_change.clone().expect("基线");
assert_eq!(change.from, change.to);
assert!(!change.is_real_change());
assert!(change.warned, "基线不该弹提示");
assert_eq!(change.probe.shared_side, 1);
assert!(
config.take_mode_change_warning().is_none(),
"基线即使两侧都有内容也不提示"
);
}
#[test]
fn observe_mode_is_idempotent() {
let temp = TempDir::new("iso-observe-idempotent");
let game = layout(temp.path());
let mut config = InstanceConfig::new("id-6", "1.20.1");
assert!(
observe_mode(&mut config, &game, "t1", Isolation::Isolated).expect("观察"),
"首次写入"
);
assert!(
!observe_mode(&mut config, &game, "t2", Isolation::Isolated).expect("观察"),
"同一档位不重复记"
);
assert_eq!(
config.last_mode_change.expect("记录仍在").at,
"t1",
"时间戳不该被刷新"
);
}
#[test]
fn mode_change_with_both_sides_warns_exactly_once() {
let temp = TempDir::new("iso-warn-once");
let game = layout(temp.path());
seed(&game.version_dir("1.20.1"), "saves");
seed(&game.version_dir("1.20.1"), "mods");
seed(game.root(), "saves");
let mut config = InstanceConfig::new("id-7", "1.20.1");
config.isolation = Some(Isolation::Shared);
observe_mode(&mut config, &game, "t0", Isolation::Shared).expect("基线");
config.isolation = Some(Isolation::Isolated);
assert!(
observe_mode(&mut config, &game, "t1", Isolation::Isolated).expect("记切换"),
"档位变了要写记录"
);
let change = config.last_mode_change.clone().expect("记录");
assert_eq!(change.from, Isolation::Shared);
assert_eq!(change.to, Isolation::Isolated);
assert!(change.is_real_change());
assert_eq!(change.probe.isolated_side, 2);
assert_eq!(change.probe.shared_side, 1);
let warning = config
.take_mode_change_warning()
.expect("两侧都有内容要提示");
assert!(warning.probe.both_occupied());
let message = warning.message();
assert!(message.contains("isolated"), "{message}");
assert!(
message.contains("不会自动搬"),
"文案必须说清不迁移:{message}"
);
assert!(
config.take_mode_change_warning().is_none(),
"同一次切换只提示一次"
);
assert!(config.last_mode_change.expect("记录").warned);
}
#[test]
fn mode_change_with_one_empty_side_is_quiet() {
let temp = TempDir::new("iso-warn-single-side");
let game = layout(temp.path());
// 只有共享侧有内容:隔离侧空 → 不提示(切到共享还能看见存档,反之亦然)。
seed(game.root(), "saves");
let mut config = InstanceConfig::new("id-8", "1.20.1");
observe_mode(&mut config, &game, "t0", Isolation::Shared).expect("基线");
observe_mode(&mut config, &game, "t1", Isolation::Isolated).expect("记切换");
assert!(config.take_mode_change_warning().is_none());
// 两侧都空同样安静。
let empty = TempDir::new("iso-warn-both-empty");
let game = layout(empty.path());
let mut config = InstanceConfig::new("id-9", "1.20.1");
observe_mode(&mut config, &game, "t0", Isolation::Shared).expect("基线");
observe_mode(&mut config, &game, "t1", Isolation::Isolated).expect("记切换");
assert!(config.take_mode_change_warning().is_none());
}
// -------------------------------------------------------------- 稀疏落盘不变量
#[test]
fn inherit_and_no_change_stay_out_of_the_file() {
let config = InstanceConfig::new("id-10", "1.20.1");
let json = config.to_json_string().expect("序列化");
assert!(!json.contains("isolation"), "inherit 不许落盘:{json}");
assert!(!json.contains("lastModeChange"), "没切过就不写:{json}");
assert!(!json.contains("gameDir"), "没目录就不写:{json}");
let back = InstanceConfig::from_json_str(&json).expect("回读");
assert_eq!(back, config);
}
#[test]
fn last_mode_change_round_trips_in_camel_case() {
let temp = TempDir::new("iso-roundtrip");
let game = layout(temp.path());
let mut config = InstanceConfig::new("id-11", "1.20.1");
observe_mode(&mut config, &game, "t0", Isolation::Shared).expect("基线");
let json = config.to_json_string().expect("序列化");
assert!(json.contains("\"lastModeChange\""), "{json}");
assert!(json.contains("\"isolatedSide\""), "{json}");
assert!(json.contains("\"sharedSide\""), "{json}");
let back = InstanceConfig::from_json_str(&json).expect("回读");
assert_eq!(back, config);
}
#[test]
fn legacy_per_version_value_reads_as_isolated() {
let raw = r#"{
"id": "id-12",
"versionId": "1.20.1",
"isolation": "per_version"
}"#;
let config = InstanceConfig::from_json_str(raw).expect("旧字面量读兼容");
assert_eq!(config.isolation, Some(Isolation::Isolated));
assert_eq!(Isolation::parse("per_version"), Some(Isolation::Isolated));
assert_eq!(Isolation::parse("isolated"), Some(Isolation::Isolated));
assert_eq!(Isolation::parse("nonsense"), None);
}
#[test]
fn effective_isolation_resolves_inherit() {
let config = InstanceConfig::new("id-13", "1.20.1");
assert_eq!(
config.effective_isolation(IsolationMode::Shared),
Isolation::Shared
);
let mut config = InstanceConfig::new("id-14", "1.20.1");
config.isolation = Some(Isolation::Custom);
assert_eq!(
config.effective_isolation(IsolationMode::Isolated),
Isolation::Custom
);
}
#[test]
fn custom_dir_from_relative_path_is_kept_verbatim() {
// custom 目录不做规范化(可能是相对路径或系统符号链接),派生层只透传。
let temp = TempDir::new("iso-custom-relative");
let game = layout(temp.path());
let mut config = InstanceConfig::new("id-15", "1.20.1");
config.isolation = Some(Isolation::Custom);
config.game_dir = Some(PathBuf::from("relative/mc"));
let derived = derive_game_dir(
&game,
"1.20.1",
IsolationMode::Isolated,
Some(&config),
None,
)
.expect("派生");
assert_eq!(derived.game_dir, PathBuf::from("relative/mc"));
}
#[test]
fn explicit_inherit_reads_back_as_absent() {
// 回归 P2-2(review r1):schema 允许写 `"isolation": "inherit"`,
// 读路径必须把它折叠成「键不存在」,否则设置层写回默认值后启动会报 unknown variant。
let raw = r#"{
"id": "id-16",
"versionId": "1.20.1",
"isolation": "inherit"
}"#;
let config = InstanceConfig::from_json_str(raw).expect("显式 inherit 必须能读");
assert!(config.inherits_isolation());
assert_eq!(config.isolation, None);
let json = config.to_json_string().expect("序列化");
assert!(
!json.contains("isolation"),
"写回时把 inherit 落成键缺失:{json}"
);
// 走到派生层就是「跟随全局」,不再是错误路径。
let temp = TempDir::new("iso-inherit-explicit");
let game = layout(temp.path());
let derived =
derive_game_dir(&game, "1.20.1", IsolationMode::Shared, Some(&config), None).expect("派生");
assert_eq!(derived.isolation, Isolation::Shared);
assert_eq!(derived.source, IsolationSource::Global);
// 其他非法字面量仍然报错(不静默取默认值)。
let bad = r#"{ "id": "id-17", "versionId": "1.20.1", "isolation": "maybe" }"#;
assert!(InstanceConfig::from_json_str(bad).is_err());
}
+129
View File
@@ -0,0 +1,129 @@
//! `core::game::java::discover`:标准安装位置扫描与来源优先级。
//!
//! 真机上扫到什么取决于本机装了什么,所以断言只钉**不变量**:
//! 扫描结果形状、来源优先级(env 胜过扫盘)、去重保留高优先级、缺目录不报错。
use unlucky_core::game::java::discover;
use unlucky_core::game::java::mapping::{JavaRequirement, RequirementSource};
use unlucky_core::game::java::{pick, JavaOrigin, JavaRuntime};
fn runtime(path: &str, major: u32, origin: JavaOrigin) -> JavaRuntime {
JavaRuntime {
binary: std::path::PathBuf::from(path),
origin,
version: Some(format!("openjdk version \"{major}.0.1\"")),
major: Some(major),
bits: 64,
vendor: None,
}
}
fn at_least(minimum: u32) -> JavaRequirement {
JavaRequirement::new(minimum, None, RequirementSource::Table)
}
#[test]
fn scanned_paths_always_point_at_a_java_binary() {
// 本机可能一个都没有(裸机/容器)——那也算通过,只是列表为空。
for binary in discover::scan_java_binaries() {
assert!(
binary.is_file(),
"扫描结果必须是真实存在的文件:{}",
binary.display()
);
assert_eq!(
binary
.file_name()
.map(|name| name.to_string_lossy().into_owned()),
Some(unlucky_core::game::java::java_binary_name().to_string()),
"扫描结果必须是 bin/java[.exe]:{}",
binary.display()
);
}
}
#[test]
fn standard_roots_are_absolute_and_unique() {
let roots = discover::standard_roots();
for root in &roots {
assert!(
root.is_absolute(),
"候选根必须是绝对路径:{}",
root.display()
);
}
let mut keys: Vec<String> = roots.iter().map(|root| discover::path_key(root)).collect();
let total = keys.len();
keys.sort();
keys.dedup();
assert_eq!(keys.len(), total, "候选根不该重复");
}
#[test]
fn environment_java_beats_scanned_java_when_both_satisfy() {
// D13 的「优先级链不变」:环境变量是用户意图,扫盘只是候选。
let candidates = [
runtime(
"C:/Program Files/Eclipse Adoptium/jdk-21/bin/java.exe",
21,
JavaOrigin::System,
),
runtime(
"C:/Program Files/Microsoft/jdk-21/bin/java.exe",
21,
JavaOrigin::JavaHome,
),
];
let picked = pick(&candidates, &at_least(17)).expect("应有候选");
assert_eq!(picked.origin, JavaOrigin::JavaHome);
}
#[test]
fn scanned_java_is_still_used_when_nothing_else_matches() {
// MSI 装了 Temurin 21、没设环境变量、PATH 上是 Java 8:1.20.5 只认扫盘那个。
let candidates = [
runtime("java", 8, JavaOrigin::Path),
runtime(
"C:/Program Files/Java/jdk-21/bin/java.exe",
21,
JavaOrigin::System,
),
];
let picked = pick(&candidates, &at_least(21)).expect("应有候选");
assert_eq!(picked.origin, JavaOrigin::System);
}
#[test]
fn dedupe_collapses_one_install_found_twice() {
// `JAVA_HOME` 指向 Program Files 里的 JDK → 同一个 java 不应列两次。
// 第二条的「另一种写法」随平台给:Windows 上大小写和分隔符都可能不一致(NTFS 不区分
// 大小写),POSIX 上大小写是有效区别,只能靠分隔符拼出同一路径的第二种写法。
let duplicate = if cfg!(windows) {
"c:/program files/java/jdk-21/bin/java.exe"
} else {
"C:\\Program Files\\Java\\jdk-21/bin/java.exe"
};
let candidates = discover::dedupe(vec![
runtime(
"C:/Program Files/Java/jdk-21/bin/java.exe",
21,
JavaOrigin::JavaHome,
),
runtime(duplicate, 21, JavaOrigin::System),
]);
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].origin, JavaOrigin::JavaHome);
}
#[test]
fn registry_absence_is_tolerated() {
// 非 Windows 必然为空;Windows 上没装老 Oracle JDK 也是空。
let homes = discover::registry_homes();
for home in &homes {
assert!(
home.is_absolute(),
"注册表里的 JavaHome 应是绝对路径:{}",
home.display()
);
}
}
+291
View File
@@ -0,0 +1,291 @@
//! `core::game::java` 的安装侧集成测试:归档解压(幂等 / 顶层剥离 / 越界拒绝)、
//! 托管目录命名、下载请求装配、平台归档形态。
//!
//! 镜像选源见 `java_mirrors.rs`,MC↔Java 映射见 `java_mapping.rs`。
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use flate2::write::GzEncoder;
use flate2::Compression;
use unlucky_core::download::ResourceKind;
use unlucky_core::game::java::{
self, extract, install, probe, AdoptiumTarget, JdkArchive, JdkRelease,
};
/// 64 位十六进制(解压戳记只当字符串比对,不校验内容)。
const SHA_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
/// 另一个「新版本」的摘要。
const SHA_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
fn temp_dir(tag: &str) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.as_nanos());
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"ull-java-{tag}-{}-{nanos}-{seq}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建临时目录");
dir
}
/// 造一个 Temurin 形状的 zip(顶层 `jdk-21.0.13+9/`)。
fn write_zip(path: &Path, entries: &[(&str, &[u8])]) {
let file = File::create(path).expect("建 zip");
let mut writer = zip::ZipWriter::new(file);
let options =
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, body) in entries {
writer.start_file(*name, options).expect("写 zip 条目");
writer.write_all(body).expect("写 zip 内容");
}
writer.finish().expect("收尾 zip");
}
/// 造一个 Temurin 形状的 tar.gz。
fn write_tar_gz(path: &Path, entries: &[(&str, &[u8])]) {
let file = File::create(path).expect("建 tar.gz");
let encoder = GzEncoder::new(file, Compression::fast());
let mut builder = tar::Builder::new(encoder);
for (name, body) in entries {
let mut header = tar::Header::new_gnu();
header.set_size(body.len() as u64);
header.set_mode(0o755);
header.set_entry_type(tar::EntryType::Regular);
builder
.append_data(&mut header, name, *body)
.expect("写 tar 条目");
}
let encoder = builder.into_inner().expect("收尾 tar");
encoder.finish().expect("收尾 gzip");
}
fn java_bin(root: &Path) -> PathBuf {
root.join("bin").join(java::java_binary_name())
}
#[test]
fn zip_archive_loses_the_temurin_top_level_directory() {
let dir = temp_dir("zip");
let archive = dir.join("jdk.zip");
let top = "jdk-21.0.13+9";
write_zip(
&archive,
&[
(
&format!("{top}/bin/{}", java::java_binary_name()),
b"#!/bin/true\n",
),
(&format!("{top}/release"), b"JAVA_VERSION=\"21.0.13\"\n"),
],
);
let dest = dir.join("jdk-21-temurin");
let report = extract::extract_archive(&archive, SHA_A, 21, &dest).expect("解压应成功");
assert!(!report.skipped);
assert_eq!(report.files, 2);
assert!(java_bin(&dest).is_file(), "bin/java 应在顶层");
assert!(!dest.join(top).exists(), "顶层目录必须被剥掉");
let stamp = extract::read_stamp(&dest).expect("应写戳记");
assert_eq!(stamp.major, 21);
assert_eq!(stamp.sha256, SHA_A);
assert_eq!(stamp.files, 2);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn second_extract_with_same_sha_touches_nothing() {
let dir = temp_dir("idem");
let archive = dir.join("jdk.zip");
write_zip(
&archive,
&[(
&format!("jdk-21.0.13+9/bin/{}", java::java_binary_name()),
b"bin\n",
)],
);
let dest = dir.join("jdk-21-temurin");
extract::extract_archive(&archive, SHA_A, 21, &dest).expect("首解");
// 用户手工动过的痕迹:幂等命中时不能被抹掉。
let marker = dest.join("安装后手工放的.txt");
std::fs::write(&marker, b"keep").expect("写标记");
let again = extract::extract_archive(&archive, SHA_A, 21, &dest).expect("重解");
assert!(again.skipped, "同一 sha256 应整体跳过");
assert_eq!(again.files, 0);
assert!(marker.is_file(), "幂等命中时不应重写目录");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn new_sha_replaces_the_managed_directory() {
let dir = temp_dir("upgrade");
let archive = dir.join("jdk.zip");
write_zip(
&archive,
&[(
&format!("jdk-21.0.13+9/bin/{}", java::java_binary_name()),
b"v1\n",
)],
);
let dest = dir.join("jdk-21-temurin");
extract::extract_archive(&archive, SHA_A, 21, &dest).expect("首解");
let marker = dest.join("旧版残留");
std::fs::write(&marker, b"old").expect("写标记");
let report = extract::extract_archive(&archive, SHA_B, 21, &dest).expect("新摘要重解");
assert!(!report.skipped);
assert!(!marker.exists(), "官方发新版后旧目录应被替换");
assert_eq!(
extract::read_stamp(&dest).map(|stamp| stamp.sha256),
Some(SHA_B.to_string())
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn tar_gz_archive_is_supported_for_linux_and_mac() {
let dir = temp_dir("targz");
let archive = dir.join("OpenJDK21U-jdk_x64_linux_hotspot_21.0.13_9.tar.gz");
let top = "jdk-21.0.13+9";
write_tar_gz(
&archive,
&[
(&format!("{top}/bin/{}", java::java_binary_name()), b"bin\n"),
(&format!("{top}/lib/modules"), b"modules\n"),
],
);
let dest = dir.join("jdk-21-temurin");
let report = extract::extract_archive(&archive, SHA_A, 21, &dest).expect("解压 tar.gz");
assert_eq!(report.files, 2);
assert!(dest.join("bin").join(java::java_binary_name()).is_file());
assert!(dest.join("lib").join("modules").is_file());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn archive_without_a_single_top_directory_is_promoted_as_is() {
// 有的魔改发行版直接把 bin/ 放在根上:不能硬剥一层。
let dir = temp_dir("flat");
let archive = dir.join("flat.zip");
write_zip(
&archive,
&[
(
&format!("bin/{}", java::java_binary_name()),
b"bin\n".as_slice(),
),
("release", b"x\n".as_slice()),
],
);
let dest = dir.join("jdk-17-temurin");
extract::extract_archive(&archive, SHA_A, 17, &dest).expect("解压");
assert!(dest.join("bin").join(java::java_binary_name()).is_file());
assert!(dest.join("release").is_file());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn foreign_directory_is_never_clobbered() {
let dir = temp_dir("foreign");
let archive = dir.join("jdk.zip");
write_zip(
&archive,
&[(
&format!("jdk-21.0.13+9/bin/{}", java::java_binary_name()),
b"bin\n",
)],
);
let dest = dir.join("jdk-21-temurin");
std::fs::create_dir_all(&dest).expect("建目标目录");
std::fs::write(dest.join("用户的文件.txt"), b"mine").expect("写用户文件");
let error = extract::extract_archive(&archive, SHA_A, 21, &dest).expect_err("应拒绝覆盖");
assert_eq!(error.kind(), java::JavaErrorKind::Io);
assert!(dest.join("用户的文件.txt").is_file(), "用户文件必须还在");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn zip_slip_entries_do_not_escape_the_target_directory() {
let dir = temp_dir("slip");
let archive = dir.join("evil.zip");
write_zip(
&archive,
&[
(
&format!("jdk-21.0.13+9/bin/{}", java::java_binary_name()),
b"bin\n",
),
("../../escaped.txt", b"nope"),
],
);
let dest = dir.join("jdk-21-temurin");
let report = extract::extract_archive(&archive, SHA_A, 21, &dest).expect("越界条目只跳过");
assert_eq!(report.files, 1, "越界条目不应被写盘");
assert!(!dir.join("escaped.txt").exists());
assert!(!dir.parent().unwrap_or(&dir).join("escaped.txt").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn managed_layout_names_carry_major_and_vendor() {
let root = Path::new("/tmp/java-managed");
let dest = install::managed_root_dir(root, 21, java::JDK_VENDOR);
assert_eq!(dest, root.join("jdk-21-temurin"));
assert_eq!(
probe::parse_managed_dir("jdk-21-temurin"),
Some((21, "temurin"))
);
assert_eq!(probe::managed_dir_name(8, "temurin"), "jdk-8-temurin");
}
#[test]
fn archive_request_carries_sha256_and_the_java_runtime_kind() {
let release = JdkRelease {
vendor: java::JdkVendor::Temurin,
major: 21,
version: "21.0.13+9".to_string(),
semver: Some("21.0.13+9.0.LTS".to_string()),
release_name: "jdk-21.0.13+9".to_string(),
archive: JdkArchive {
name: "OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip".to_string(),
url: "https://github.com/adoptium/temurin21-binaries/releases/download/x.zip"
.to_string(),
sha256: SHA_A.to_string(),
size: 1024,
},
};
let dest = Path::new("/tmp/x.zip");
let request = install::archive_request(&release, dest.to_path_buf());
assert_eq!(request.kind, ResourceKind::JavaRuntime);
assert_eq!(request.expected_size, Some(1024));
assert_eq!(
request
.integrity
.as_ref()
.map(|integrity| integrity.hex.as_str()),
Some(SHA_A)
);
assert!(request.validate().is_ok());
assert_eq!(
install::archive_path(Path::new("/tmp/java-managed"), &release),
Path::new("/tmp/java-managed/.cache/OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip")
);
}
#[test]
fn host_target_picks_the_platform_archive_shape() {
let target = AdoptiumTarget::host().expect("宿主应受支持");
assert_eq!(target.is_zip(), cfg!(windows));
assert_eq!(
target.archive_ext(),
if cfg!(windows) { "zip" } else { "tar.gz" }
);
}
+136
View File
@@ -0,0 +1,136 @@
//! `core::game::java::location`:落点三模式(Portable / Standard / Custom)+ 孤儿目录清理。
//!
//! 三模式的口径来自 D23/D6:Portable 保持 v1 布局、Standard 走 OS 标准目录的
//! `<发行方>/<版本>` 分层、Custom 由 `java.installRoot` 覆盖;清理只碰我们自己的形状。
use std::path::PathBuf;
use unlucky_core::game::java::location::{
cleanup_orphans, sanitize_version, standard_java_root, JavaInstallRoot, LocationMode,
};
use unlucky_core::game::java::JdkVendor;
use unlucky_core::platform::layout::LauncherLayout;
use unlucky_core::platform::paths::{MapEnv, Platform};
fn temp_dir(tag: &str) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.as_nanos());
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"ull-java-loc-{tag}-{}-{nanos}-{seq}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建临时目录");
dir
}
#[test]
fn standard_root_follows_each_platform_convention() {
let env = MapEnv::new()
.with("APPDATA", "C:/Users/u/AppData/Roaming")
.with("HOME", "/home/u")
.with("USERPROFILE", "C:/Users/u");
// 期望值一律用 `join` 拼(Windows 上的分隔符与 `Path` 比较细节交给标准库)。
let unix_home = PathBuf::from("/home/u");
assert_eq!(
standard_java_root(Platform::Windows, &env),
Some(PathBuf::from("C:/Users/u/AppData/Roaming").join("Java")),
"Windows 用户原话:%APPDATA%\\Java"
);
assert_eq!(
standard_java_root(Platform::MacOs, &env),
Some(unix_home.join("Library/Application Support").join("Java"))
);
assert_eq!(
standard_java_root(Platform::Linux, &env),
Some(unix_home.join(".local/share").join("java")),
"Linux 走 XDG 约定(无 XDG_DATA_HOME 时回落 ~/.local/share)"
);
// `Path::is_absolute()` 的口径是编译目标决定的:Windows 上 `C:/data/u` 才是绝对路径,
// 而 `/data/u` 只是「rooted」。夹具按目标平台给,才能同时验到「绝对 → 采用」。
let xdg_root = if cfg!(windows) {
"C:/data/u"
} else {
"/data/u"
};
let xdg = MapEnv::new()
.with("HOME", "/home/u")
.with("XDG_DATA_HOME", xdg_root);
assert_eq!(
standard_java_root(Platform::Linux, &xdg),
Some(PathBuf::from(xdg_root).join("java"))
);
// 相对路径的 XDG_DATA_HOME 按规范忽略 → 回落 HOME。
let relative = MapEnv::new()
.with("HOME", "/home/u")
.with("XDG_DATA_HOME", "data/u");
assert_eq!(
standard_java_root(Platform::Linux, &relative),
Some(unix_home.join(".local/share").join("java"))
);
assert_eq!(standard_java_root(Platform::Linux, &MapEnv::new()), None);
}
#[test]
fn portable_and_custom_modes_derive_the_documented_directories() {
let launcher = LauncherLayout::new("/data/.ULL");
let portable = JavaInstallRoot::portable(&launcher);
assert_eq!(portable.mode, LocationMode::Portable);
assert_eq!(
portable.jdk_dir(21, JdkVendor::Temurin, "21.0.13+9"),
PathBuf::from("/data/.ULL/data/java-managed/jdk-21-temurin"),
"便携模式必须保持 v1 布局(M33/M35 兼容)"
);
let custom = JavaInstallRoot::custom("/opt/java");
assert_eq!(custom.mode, LocationMode::Custom);
assert_eq!(
custom.jdk_dir(21, JdkVendor::Oracle, "21.0.2"),
PathBuf::from("/opt/java/oracle/21.0.2"),
"Custom 用 <vendor>/<version>(D23)"
);
assert_eq!(custom.cache_dir(), PathBuf::from("/opt/java/.cache"));
assert!(custom.describe().contains("custom"));
}
#[test]
fn version_strings_are_sanitized_for_directory_names() {
assert_eq!(sanitize_version("21.0.13+9"), "21.0.13+9");
assert_eq!(sanitize_version("21.0.13.0.2"), "21.0.13.0.2");
assert_eq!(sanitize_version("21.0.13+3"), "21.0.13+3");
assert_eq!(sanitize_version(" 21.0.2 "), "21.0.2");
assert_eq!(sanitize_version("../evil"), ".._evil");
assert_eq!(sanitize_version(""), "unknown");
}
#[test]
fn cleanup_removes_staging_and_empty_shells_but_never_foreign_dirs() {
let root = temp_dir("cleanup");
// 半成品:解压中断的暂存目录。
std::fs::create_dir_all(root.join(".staging-jdk-21-temurin")).expect("建暂存");
std::fs::write(root.join(".staging-jdk-21-temurin/bin"), b"x").expect("写暂存文件");
// 空壳 vendor 目录(Standard 布局的残留)。
std::fs::create_dir_all(root.join("oracle")).expect("建 vendor");
// 用户自己的目录(非空、无戳记):绝不能删。
std::fs::create_dir_all(root.join("my-own-jdk")).expect("建用户目录");
std::fs::write(root.join("my-own-jdk/notes.txt"), b"mine").expect("写用户文件");
// 归档缓存目录:保留。
std::fs::create_dir_all(root.join(".cache")).expect("建缓存");
let report = cleanup_orphans(&root);
assert!(
!root.join(".staging-jdk-21-temurin").exists(),
"暂存目录应删"
);
assert!(!root.join("oracle").exists(), "空 vendor 目录应删");
assert!(
root.join("my-own-jdk/notes.txt").is_file(),
"用户目录必须还在"
);
assert!(root.join(".cache").is_dir(), "缓存目录不能删");
assert_eq!(report.removed.len(), 2, "{:?}", report.removed);
let _ = std::fs::remove_dir_all(&root);
}
+106
View File
@@ -0,0 +1,106 @@
//! MC↔Java 映射:版本 JSON 的 `javaVersion` 优先(含继承链),上限取自静态表。
use std::path::PathBuf;
use unlucky_core::game::java::mapping;
fn temp_dir(tag: &str) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.as_nanos());
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"ull-java-{tag}-{}-{nanos}-{seq}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建临时目录");
dir
}
#[test]
fn version_json_java_version_beats_the_static_table() {
let root = temp_dir("mapping");
let layout = unlucky_core::game::GameLayout::new(&root);
let write = |id: &str, json: serde_json::Value| {
let dir = root.join("versions").join(id);
std::fs::create_dir_all(&dir).expect("建版本目录");
std::fs::write(
dir.join(format!("{id}.json")),
serde_json::to_vec(&json).expect("序列化"),
)
.expect("写版本 JSON");
};
// 1.17 的清单自己说是 16(静态表也是 16,但来源必须标 manifest)。
write(
"1.17.1",
serde_json::json!({"id": "1.17.1", "javaVersion": {"component": "java-runtime-alpha", "majorVersion": 16}}),
);
let manifest = mapping::requirement_for_version(&layout, "1.17.1");
assert_eq!(manifest.minimum, 16);
assert_eq!(manifest.source.as_str(), "manifest");
// 魔改实例覆盖父版本:21 应当生效。
write("1.20.1", serde_json::json!({"id": "1.20.1"}));
write(
"1.20.1-forge",
serde_json::json!({"id": "1.20.1-forge", "inheritsFrom": "1.20.1", "javaVersion": {"majorVersion": 21}}),
);
assert_eq!(
mapping::requirement_for_version(&layout, "1.20.1-forge").minimum,
21
);
// 清单没写 javaVersion:父版本也没有 → 静态表兜底。
let table = mapping::requirement_for_version(&layout, "1.20.1");
assert_eq!(table.minimum, 17);
assert_eq!(table.source.as_str(), "table");
// 1.12.2:清单只说「最低 8」,但上限(LaunchWrapper 只能 Java 8)由静态表补上。
write(
"1.12.2",
serde_json::json!({"id": "1.12.2", "javaVersion": {"component": "jre-legacy", "majorVersion": 8}}),
);
let legacy = mapping::requirement_for_version(&layout, "1.12.2");
assert_eq!((legacy.minimum, legacy.maximum), (8, Some(8)));
assert_eq!(legacy.source.as_str(), "manifest");
assert!(
mapping::check(21, &legacy).is_some(),
"1.12.2 + Java 21 必须报不符"
);
// 版本根本没装:按 id 走静态表,不报错。
let missing = mapping::requirement_for_version(&layout, "26.3");
assert_eq!(missing.minimum, 25);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn parent_java_version_is_inherited() {
let root = temp_dir("inherit");
let layout = unlucky_core::game::GameLayout::new(&root);
let write = |id: &str, json: serde_json::Value| {
let dir = root.join("versions").join(id);
std::fs::create_dir_all(&dir).expect("建版本目录");
std::fs::write(
dir.join(format!("{id}.json")),
serde_json::to_vec(&json).expect("序列化"),
)
.expect("写版本 JSON");
};
write(
"1.20.5",
serde_json::json!({"id": "1.20.5", "javaVersion": {"component": "java-runtime-delta", "majorVersion": 21}}),
);
write(
"1.20.5-fabric",
serde_json::json!({"id": "1.20.5-fabric", "inheritsFrom": "1.20.5"}),
);
let requirement = mapping::requirement_for_version(&layout, "1.20.5-fabric");
assert_eq!(requirement.minimum, 21);
assert_eq!(requirement.source.as_str(), "manifest");
let _ = std::fs::remove_dir_all(&root);
}
+96
View File
@@ -0,0 +1,96 @@
//! 镜像选源(D7 地区策略注入样本):国内镜像优先 / 国外官方优先,候选里都留兜底。
use unlucky_core::download::mirrors::MirrorTable;
use unlucky_core::download::region::RegionProfile;
use unlucky_core::download::ResourceKind;
#[test]
fn domestic_region_puts_college_mirrors_first() {
let table = MirrorTable::builtin();
let url = "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.13%2B9/OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip";
let domestic = table.expand(
url,
ResourceKind::JavaRuntime,
&RegionProfile::mirror_first(0),
);
assert_eq!(
domestic.first().map(|candidate| candidate.source.as_str()),
Some("tuna")
);
assert_eq!(
domestic.first().map(|candidate| candidate.url.as_str()),
Some("https://mirrors.tuna.tsinghua.edu.cn/Adoptium/21/jdk/x64/windows/OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip")
);
let sources: Vec<&str> = domestic
.iter()
.map(|candidate| candidate.source.as_str())
.collect();
// USTC/SJTU/BFSU 没有这套目录树(实测 404)、华为云是门户假阳性(M34 实测)
// → 清单里全停用,不该出现在候选里。
for dead in ["ustc", "sjtu", "bfsu", "huaweicloud"] {
assert!(!sources.contains(&dead), "{dead} 应已停用:{sources:?}");
}
assert_eq!(sources.last().copied(), Some("official"), "官方兜底在最后");
}
#[test]
fn foreign_region_keeps_official_first_but_still_has_mirrors() {
let table = MirrorTable::builtin();
let url = "https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.13%2B11/OpenJDK17U-jdk_aarch64_linux_hotspot_17.0.13_11.tar.gz";
let abroad = table.expand(url, ResourceKind::JavaRuntime, &RegionProfile::direct(0));
assert_eq!(
abroad.first().map(|candidate| candidate.source.as_str()),
Some("official")
);
assert_eq!(
abroad.first().map(|candidate| candidate.url.as_str()),
Some(url)
);
// 镜像仍然排在后面(国外也可能是镜像更快;健康度会让它上位)。
assert!(abroad.len() > 1);
assert!(abroad
.iter()
.any(|candidate| candidate.url.contains("/Adoptium/17/jdk/aarch64/linux/")));
}
#[test]
fn vendor_cdn_and_mirror_urls_are_labelled_by_their_real_host() {
let table = MirrorTable::builtin();
let direct = RegionProfile::direct(0);
// Oracle 走国内镜像时,直连 URL 就是华为云——不能标成 official(否则用户以为在啃官方 5.3KB/s)。
let oracle =
"https://mirrors.huaweicloud.com/openjdk/21.0.2/openjdk-21.0.2_windows-x64_bin.zip";
assert_eq!(
table
.expand(oracle, ResourceKind::JavaRuntime, &direct)
.first()
.map(|candidate| candidate.source.as_str()),
Some("huawei")
);
// MS 的 aka.ms 与 Zulu 的 CDN 同理。
let microsoft = "https://aka.ms/download-jdk/microsoft-jdk-21.0.13.0.2-windows-x64.zip";
assert_eq!(
table
.expand(microsoft, ResourceKind::JavaRuntime, &direct)
.first()
.map(|candidate| candidate.source.as_str()),
Some("microsoft")
);
let zulu = "https://cdn.azul.com/zulu/bin/zulu21.54.109-ca-jdk21.0.13.0.3-win_x64.zip";
assert_eq!(
table
.expand(zulu, ResourceKind::JavaRuntime, &direct)
.first()
.map(|candidate| candidate.source.as_str()),
Some("azul")
);
// Mojang 系资源仍是 official(别把官方源误标成厂商)。
let mojang = "https://piston-data.mojang.com/v1/objects/abc/client.jar";
assert_eq!(
table
.expand(mojang, ResourceKind::ClientJar, &direct)
.first()
.map(|candidate| candidate.source.as_str()),
Some("official")
);
}
+186
View File
@@ -0,0 +1,186 @@
//! D23 发行版回退链:地区默认序、用户优先级覆盖、能力过滤、mirrors v3 三张新表。
//!
//! 全部离线:只跑纯函数与清单解析,不发一个请求。
//! 真机验收(TUNA 装 17 / 华为云装 Oracle 21 / `%APPDATA%` 落点)见 mission 报告。
use unlucky_core::download::MirrorTable;
use unlucky_core::game::java::install::chain_order;
use unlucky_core::game::java::release::{parse_sha256_text, JdkArchive, JdkRelease};
use unlucky_core::game::java::vendor::{ids, order, parse_priority, JdkVendor, VENDOR_IDS};
use unlucky_core::game::java::InstallRequest;
fn vendors(items: &[&str]) -> Vec<JdkVendor> {
parse_priority(
&items
.iter()
.map(|item| (*item).to_string())
.collect::<Vec<_>>(),
)
}
#[test]
fn domestic_default_is_temurin_oracle_microsoft_zulu() {
assert_eq!(
ids(&order(&[], true)),
vec!["temurin", "oracle", "microsoft", "zulu"],
"国内:Oracle 国内镜像 11.5MB/s vs MS 410KB/s(D23 实测修正)"
);
assert_eq!(
ids(&order(&[], false)),
vec!["temurin", "microsoft", "oracle", "zulu"],
"国外:维持用户原话 Temurin→MS→Oracle"
);
}
#[test]
fn setting_priority_overrides_the_region_default_but_never_drops_a_vendor() {
let priority = vendors(&["microsoft"]);
assert_eq!(
ids(&order(&priority, true)),
vec!["microsoft", "temurin", "oracle", "zulu"]
);
// 写全四家也等于顺序覆盖。
let full = vendors(&["zulu", "microsoft", "oracle", "temurin"]);
assert_eq!(
ids(&order(&full, false)),
vec!["zulu", "microsoft", "oracle", "temurin"]
);
// 未知值被忽略(老配置里可能残留 graalvm 之类)。
let messy = vendors(&["graalvm", "oracle"]);
assert_eq!(
ids(&order(&messy, true)),
vec!["oracle", "temurin", "microsoft", "zulu"]
);
}
#[test]
fn capability_filter_drops_microsoft_and_oracle_for_java_8() {
// Java 8:MS 已停发、Oracle archive 从 9 起 → 链里只剩 Temurin 与 Zulu。
assert_eq!(
ids(&chain_order(&InstallRequest::new(8), true)),
vec!["temurin", "zulu"]
);
// Java 21:四家都发,顺序完整。
assert_eq!(
ids(&chain_order(&InstallRequest::new(21), true)),
vec!["temurin", "oracle", "microsoft", "zulu"]
);
// 钉死一家:链里只放它,即使它不支持这个大版本(由 install 报明确原因,不偷偷换家)。
let pinned = InstallRequest::with_vendor(8, JdkVendor::Microsoft);
assert_eq!(
ids(&chain_order(&pinned, true)),
vec!["microsoft"],
"--vendor 不做能力过滤:用户点名的那家要显式失败"
);
}
#[test]
fn pinned_vendor_wins_over_the_user_priority() {
let request =
InstallRequest::with_vendor(17, JdkVendor::Oracle).with_priority(vendors(&["zulu"]));
assert_eq!(ids(&request.order(true)), vec!["oracle"]);
let chained = InstallRequest::new(17).with_priority(vendors(&["zulu"]));
assert_eq!(
ids(&chained.order(true)),
vec!["zulu", "temurin", "oracle", "microsoft"]
);
}
#[test]
fn mirror_table_v3_registers_all_four_source_families() {
let table = MirrorTable::builtin();
assert_eq!(table.version, 3);
// adoptium 收敛:只有 TUNA 启用,其余六家登记为 disabled。
assert_eq!(
table
.adoptium
.iter()
.filter(|mirror| mirror.enabled)
.map(|mirror| mirror.id.as_str())
.collect::<Vec<_>>(),
vec!["tuna"]
);
assert_eq!(table.adoptium.len(), 7, "TUNA + USTC/SJTU/BFSU/NJU/ZJU/163");
// 华为云的假阳性不再登记成 adoptium 源(它没有 Adoptium 镜像)。
assert!(table
.adoptium
.iter()
.all(|mirror| mirror.id != "huaweicloud"));
// Oracle:华为云在前、南大在后(顺序即默认尝试顺序)。
assert_eq!(
table
.oracle_mirrors()
.map(|mirror| mirror.id.as_str())
.collect::<Vec<_>>(),
vec!["huawei", "nju"]
);
// MS:模板 + 无国内镜像 + 四个大版本。
let microsoft = table.microsoft_openjdk().expect("MS 表");
assert!(microsoft.domestic_mirrors.is_empty());
assert_eq!(microsoft.majors, vec![25, 21, 17, 11]);
// Zulu:size 不可信,只信 sha256。
assert!(!table.zulu().expect("Zulu 表").size_field_reliable);
}
#[test]
fn every_vendor_release_shape_carries_its_own_version_semantics() {
// 四家的版本串并列:目录名、戳记、--json 都直接用 vendor 原生串。
let samples = [
(JdkVendor::Temurin, "21.0.13+9"),
(JdkVendor::Oracle, "21.0.2"),
(JdkVendor::Microsoft, "21.0.13.0.2"),
(JdkVendor::Zulu, "21.0.13+3"),
];
for (vendor, version) in samples {
let release = JdkRelease {
vendor,
major: 21,
version: version.to_string(),
semver: None,
release_name: version.to_string(),
archive: JdkArchive {
name: format!("{}-{version}.zip", vendor.id()),
url: format!("https://example.invalid/{}-{version}.zip", vendor.id()),
sha256: "a".repeat(64),
size: 0,
},
};
assert!(release.matches(vendor, 21));
assert!(release.summary().contains(version), "{}", release.summary());
assert_eq!(release.archive_json()["vendor"], vendor.id());
assert_eq!(
unlucky_core::game::java::sanitize_version(&release.version),
version,
"版本串要能直接当目录名"
);
}
}
#[test]
fn sha256_texts_are_parsed_the_same_way_for_every_upstream() {
let hash = "b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664";
// 华为云 .sha256:纯哈希。
assert_eq!(parse_sha256_text(hash).as_deref(), Some(hash));
// MS .sha256sum.txt:`<hash> <filename>`(大写 + CRLF)。
assert_eq!(
parse_sha256_text(&format!(
"{} microsoft-jdk-21.0.13-windows-x64.zip\r\n",
hash.to_uppercase()
))
.as_deref(),
Some(hash)
);
assert_eq!(parse_sha256_text("404 not found"), None);
}
#[test]
fn vendor_ids_are_the_setting_whitelist() {
assert_eq!(
VENDOR_IDS,
&["temurin", "oracle", "microsoft", "zulu", "termux"]
);
for id in VENDOR_IDS {
assert_eq!(JdkVendor::parse(id).map(|vendor| vendor.id()), Some(*id));
}
assert!(JdkVendor::parse("graalvm").is_none());
}
+143
View File
@@ -0,0 +1,143 @@
//! `.minecraft` 目录布局:路径规则、Maven 坐标、已安装版本枚举。
use std::path::PathBuf;
use unlucky_core::game::error::GameError;
use unlucky_core::game::layout::GameLayout;
use unlucky_core::game::library::MavenCoordinate;
use crate::helpers::TempDir;
fn layout() -> (TempDir, GameLayout) {
let temp = TempDir::new("layout");
let layout = GameLayout::new(temp.path().to_path_buf());
(temp, layout)
}
#[test]
fn version_paths_follow_mojang_layout() {
let (_temp, layout) = layout();
let root = layout.root();
assert_eq!(layout.versions_dir(), root.join("versions"));
assert_eq!(layout.version_dir("1.20.1"), root.join("versions/1.20.1"));
assert_eq!(
layout.version_json("1.20.1"),
root.join("versions/1.20.1/1.20.1.json")
);
assert_eq!(
layout.version_jar("1.20.1"),
root.join("versions/1.20.1/1.20.1.jar")
);
// ULL 扩展:实例私有状态与 natives 解压目录。
assert_eq!(
layout.version_state_dir("1.20.1"),
root.join("versions/1.20.1/.ull")
);
assert_eq!(
layout.natives_dir("1.20.1"),
root.join("versions/1.20.1/natives")
);
assert_eq!(
layout.version_libraries_dir("1.20.1"),
root.join("versions/1.20.1/libraries")
);
assert_eq!(layout.ull_root(), root.join(".ull"));
}
#[test]
fn asset_paths_use_two_level_sharding() {
let (_temp, layout) = layout();
let root = layout.root();
assert_eq!(layout.asset_index("5"), root.join("assets/indexes/5.json"));
let hash = "a3b3b1b0e0d8f2a8f0fbe1a4b0a8f6a1c6c6a7a2";
assert_eq!(
layout.asset_object(hash).expect("哈希合法"),
root.join("assets/objects/a3").join(hash)
);
assert_eq!(
layout.log_config("client-1.12.xml"),
root.join("assets/log_configs/client-1.12.xml")
);
let error = layout.asset_object("a").expect_err("短哈希应报错");
assert!(matches!(error, GameError::InvalidAssetHash { .. }));
}
#[test]
fn maven_coordinates_parse_and_lay_out() {
let coordinate = MavenCoordinate::parse("org.lwjgl:lwjgl:3.3.2").expect("坐标应可解析");
assert_eq!(coordinate.group, "org.lwjgl");
assert_eq!(coordinate.group_path(), "org/lwjgl");
assert_eq!(coordinate.file_name(), "lwjgl-3.3.2.jar");
assert_eq!(
coordinate.relative_path(),
PathBuf::from("org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2.jar")
);
let natives =
MavenCoordinate::parse("org.lwjgl:lwjgl:3.3.2:natives-windows-64").expect("带分类器");
assert_eq!(
natives.relative_path(),
PathBuf::from("org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-windows-64.jar")
);
let non_jar = MavenCoordinate::parse("net.java:jinput:1.0.0@zip").expect("带扩展名");
assert_eq!(non_jar.file_name(), "jinput-1.0.0.zip");
}
#[test]
fn invalid_coordinates_are_rejected() {
for raw in ["a:b", "a::1", "a:b:c:d:e"] {
let error = MavenCoordinate::parse(raw).expect_err("不合法坐标应报错");
assert!(
matches!(error, GameError::InvalidCoordinate { .. }),
"{raw}"
);
}
}
#[test]
fn library_paths_land_under_libraries() {
let (_temp, layout) = layout();
let root = layout.root();
let coordinate = MavenCoordinate::parse("com.mojang:logging:1.1.1").expect("坐标应可解析");
assert_eq!(
layout.library(&coordinate),
root.join("libraries/com/mojang/logging/1.1.1/logging-1.1.1.jar")
);
assert_eq!(
layout.version_library("1.20.1", &coordinate),
root.join("versions/1.20.1/libraries/com/mojang/logging/1.1.1/logging-1.1.1.jar")
);
}
#[test]
fn installed_versions_requires_manifest_file() {
let (_temp, layout) = layout();
assert!(
layout
.installed_versions()
.expect("缺目录视为空")
.is_empty(),
"目录不存在时不能报错"
);
let version_dir = layout.version_dir("1.20.1");
std::fs::create_dir_all(&version_dir).expect("建目录");
std::fs::write(layout.version_json("1.20.1"), "{}").expect("写清单");
// 只有 jar 没有 json 的目录不算已安装。
let broken = layout.version_dir("1.19.4");
std::fs::create_dir_all(&broken).expect("建目录");
std::fs::write(layout.version_jar("1.19.4"), b"jar").expect("写 jar");
assert!(layout.is_version_installed("1.20.1"));
assert!(!layout.is_version_installed("1.19.4"));
assert_eq!(
layout.installed_versions().expect("应可枚举"),
vec!["1.20.1".to_owned()]
);
}
+30
View File
@@ -0,0 +1,30 @@
//! `core::game` 的集成测试入口(阶段 A:先写好,mod.rs 接线在阶段 B 完成)。
//!
//! 每个子模块对应 mission 清单里的一项:
//! - `manifest_parse`:版本 JSON 解析与保真
//! - `inherit_chain`:inheritsFrom 解析 / 环检测 / 缺失父 / merge 语义 / patch 优先级
//! - `arguments_rules`:规则求值、参数展开、natives 选择
//! - `version_list`:清单 v2 解析、分类、排序、unlisted 合并
//! - `layout`:`.minecraft` 目录布局
//! - `snapshot`:不可变快照 + 单写者草稿
//! - `profiles`:实例清单与稳定 UUID
//! - `vulkan_capability`:版本库表 → `has_vulkan_backend` 能力位
mod arguments_rules;
mod helpers;
mod inherit_chain;
mod isolation;
mod isolation_switch;
mod java_discover;
mod java_install;
mod java_location;
mod java_mapping;
mod java_mirrors;
mod java_vendor_chain;
mod layout;
mod manifest_parse;
mod profiles;
mod snapshot;
mod unlisted;
mod version_list;
mod vulkan_capability;
+186
View File
@@ -0,0 +1,186 @@
//! 版本 JSON 解析:宽松解析、未知字段保真、旧版字段并存。
use unlucky_core::game::arguments::{Argument, ArgumentValue, RuleAction};
use unlucky_core::game::manifest::VersionJson;
use unlucky_core::game::version::ReleaseType;
use crate::helpers::parse;
const VANILLA_1_20_1: &str = r#"{
"id": "1.20.1",
"type": "release",
"time": "2023-06-12T13:25:51+00:00",
"releaseTime": "2023-06-12T13:25:51+00:00",
"complianceLevel": 1,
"minimumLauncherVersion": 21,
"mainClass": "net.minecraft.client.main.Main",
"assets": "5",
"assetIndex": {
"id": "5",
"sha1": "a3b3b1b0e0d8f2a8f0fbe1a4b0a8f6a1c6c6a7a2",
"size": 404297,
"totalSize": 598385820,
"url": "https://piston-meta.mojang.com/v1/packages/sha/5.json"
},
"javaVersion": { "component": "java-runtime-gamma", "majorVersion": 17 },
"downloads": {
"client": { "sha1": "abc", "size": 100, "url": "https://example.invalid/client.jar" },
"client_mappings": { "sha1": "def", "size": 50, "url": "https://example.invalid/mappings.txt" }
},
"arguments": {
"game": [
"--username",
"${auth_player_name}",
{ "rules": [{ "action": "allow", "features": { "has_custom_resolution": true } }],
"value": ["--width", "${resolution_width}"] }
],
"jvm": [
{ "rules": [{ "action": "allow", "os": { "name": "windows", "version": "^10\\." } }],
"value": "-Dos.name=Windows 10" }
]
},
"libraries": [
{ "name": "com.mojang:logging:1.1.1",
"downloads": { "artifact": { "path": "com/mojang/logging/1.1.1/logging-1.1.1.jar",
"sha1": "aaa", "size": 10,
"url": "https://example.invalid/logging.jar" } } }
],
"logging": {
"client": { "argument": "-Dlog4j.configurationFile=${path}",
"file": { "id": "client-1.12.xml", "sha1": "bbb", "size": 20,
"url": "https://example.invalid/log4j.xml" },
"type": "log4j2-xml" }
},
"tlauncherVersion": 1,
"MMC-hint": "local"
}"#;
#[test]
fn parses_full_version_json() {
let version = parse(VANILLA_1_20_1);
assert_eq!(version.id.as_deref(), Some("1.20.1"));
assert_eq!(version.version_type, Some(ReleaseType::Release));
assert_eq!(
version.main_class.as_deref(),
Some("net.minecraft.client.main.Main")
);
assert_eq!(version.compliance_level, Some(1));
assert_eq!(version.minimum_launcher_version, Some(21));
assert_eq!(version.assets_id(), "5");
let asset_index = version.asset_index.as_ref().expect("assetIndex 应解析");
assert_eq!(asset_index.id, "5");
assert_eq!(asset_index.total_size, Some(598_385_820));
let java = version.java_version.as_ref().expect("javaVersion 应解析");
assert_eq!(java.component.as_deref(), Some("java-runtime-gamma"));
assert_eq!(java.major_version, Some(17));
let downloads = version.downloads.as_ref().expect("downloads 应解析");
assert_eq!(downloads.len(), 2, "未知键也要保留在映射里");
assert_eq!(
downloads.client().and_then(|c| c.url.as_deref()),
Some("https://example.invalid/client.jar")
);
assert!(downloads.get("client_mappings").is_some());
}
#[test]
fn keeps_unknown_fields_verbatim() {
let version = parse(VANILLA_1_20_1);
// 第三方启动器私有字段(rawJson 保真,analyse/03 §4.2)。
assert_eq!(
version.extra.get("tlauncherVersion"),
Some(&serde_json::json!(1))
);
assert_eq!(
version.extra.get("MMC-hint"),
Some(&serde_json::json!("local"))
);
let roundtrip = version.to_json_string().expect("应可序列化");
let reparsed = VersionJson::from_json_str(&roundtrip).expect("应可再解析");
assert_eq!(reparsed.extra, version.extra);
}
#[test]
fn parses_arguments_with_rules() {
let version = parse(VANILLA_1_20_1);
let arguments = version.arguments.as_ref().expect("arguments 应解析");
assert_eq!(arguments.game.len(), 3);
assert_eq!(arguments.jvm.len(), 1);
assert!(matches!(&arguments.game[0], Argument::Plain(value) if value == "--username"));
let conditional = match &arguments.game[2] {
Argument::Conditional(cond) => cond,
Argument::Plain(_) => panic!("第三项应是条件参数"),
};
assert_eq!(conditional.rules.len(), 1);
assert_eq!(conditional.rules[0].action, RuleAction::Allow);
assert_eq!(
conditional.rules[0]
.features
.as_ref()
.and_then(|f| f.get("has_custom_resolution")),
Some(&true)
);
assert_eq!(
conditional.value,
ArgumentValue::Many(vec!["--width".into(), "${resolution_width}".into()])
);
// jvm 里的 os 限制带正则版本模式。
let jvm = match &arguments.jvm[0] {
Argument::Conditional(cond) => cond,
Argument::Plain(_) => panic!("jvm 第一项应是条件参数"),
};
let os = jvm.rules[0].os.as_ref().expect("应有 os 限制");
assert_eq!(os.name.as_deref(), Some("windows"));
assert_eq!(os.version.as_deref(), Some("^10\\."));
}
#[test]
fn parses_libraries_and_logging() {
let version = parse(VANILLA_1_20_1);
let libraries = version.libraries.as_ref().expect("libraries 应解析");
assert_eq!(libraries.len(), 1);
let artifact = libraries[0].artifact().expect("应有 artifact");
assert_eq!(
artifact.path.as_deref(),
Some("com/mojang/logging/1.1.1/logging-1.1.1.jar")
);
let logging = version.logging.as_ref().expect("logging 应解析");
let client = logging.client().expect("应有 client 日志配置");
assert_eq!(client.kind.as_deref(), Some("log4j2-xml"));
assert_eq!(client.file.id.as_deref(), Some("client-1.12.xml"));
}
#[test]
fn tolerant_defaults_for_minimal_json() {
let version = parse(r#"{ "id": "b1.7.3" }"#);
assert_eq!(version.id.as_deref(), Some("b1.7.3"));
assert_eq!(version.version_type, None, "未知 type 不报错");
assert_eq!(version.client_jar(), None, "老 JSON 没有 downloads.client");
assert_eq!(version.assets_id(), "legacy", "assets 缺失按 legacy");
assert!(!version.is_resolved());
}
#[test]
fn unknown_type_value_falls_back_to_unknown() {
let version = parse(r#"{ "id": "x", "type": "some_new_kind" }"#);
assert_eq!(version.version_type, Some(ReleaseType::Unknown));
}
#[test]
fn missing_id_is_reported() {
let version = parse(r#"{ "mainClass": "a.B" }"#);
let error = version.require_id().expect_err("缺 id 应报错");
assert_eq!(
error.to_string(),
"版本 JSON 缺少 id",
"错误文案要给用户看得懂"
);
}
+109
View File
@@ -0,0 +1,109 @@
//! 实例清单:稳定 UUID、增删查、落盘格式兼容。
use unlucky_core::game::profiles::{
InstanceProfile, ProfileId, ProfileList, PROFILE_SCHEMA_VERSION,
};
#[test]
fn generated_ids_are_unique_and_uuid_shaped() {
let ids: Vec<ProfileId> = (0..256).map(|_| ProfileId::generate()).collect();
for id in &ids {
assert!(id.is_uuid_shaped(), "id 形状不对: {id}");
}
let mut unique: Vec<&str> = ids.iter().map(ProfileId::as_str).collect();
let total = unique.len();
unique.sort_unstable();
unique.dedup();
assert_eq!(unique.len(), total, "同一批生成的 id 不能重复");
}
#[test]
fn id_shape_validation_rejects_junk() {
assert!(!ProfileId::new("not-a-uuid").is_uuid_shaped());
assert!(!ProfileId::new("").is_uuid_shaped());
assert!(ProfileId::new("7c9e6679-7425-40de-944b-e07fc1f90ae7").is_uuid_shaped());
}
#[test]
fn upsert_and_remove_keep_one_entry_per_id() {
let mut list = ProfileList::default();
assert_eq!(list.schema_version, PROFILE_SCHEMA_VERSION);
assert!(list.is_empty());
let mut profile = InstanceProfile::new("主世界", "1.20.1");
assert_eq!(list.upsert(profile.clone()), None, "首次插入没有旧值");
assert_eq!(list.len(), 1);
profile.name = "主世界(Fabric)".to_owned();
let replaced = list.upsert(profile.clone()).expect("同 id 应替换");
assert_eq!(replaced.name, "主世界");
assert_eq!(list.len(), 1);
assert_eq!(
list.get(&profile.id).map(|p| p.name.as_str()),
Some("主世界(Fabric)")
);
let removed = list.remove(&profile.id).expect("应可删除");
assert_eq!(removed.id, profile.id);
assert!(list.is_empty());
assert!(list.remove(&profile.id).is_none());
}
#[test]
fn profiles_group_by_version() {
let mut list = ProfileList::default();
list.upsert(InstanceProfile::new("a", "1.20.1"));
list.upsert(InstanceProfile::new("b", "1.20.1"));
list.upsert(InstanceProfile::new("c", "1.19.4"));
assert_eq!(list.for_version("1.20.1").len(), 2);
assert_eq!(list.for_version("1.19.4").len(), 1);
assert!(list.for_version("1.18.2").is_empty());
}
#[test]
fn roundtrips_profiles_json() {
let mut list = ProfileList::default();
let mut profile = InstanceProfile::new("整合包", "1.20.1-forge-47.2.0");
profile.running_dir = Some(std::path::PathBuf::from("D:/mc/runs/pack"));
profile
.extra
.insert("thirdPartyFlag".to_owned(), serde_json::json!(true));
list.upsert(profile.clone());
let json = list.to_json_string().expect("应可序列化");
let reparsed = ProfileList::from_json_str(&json).expect("应可再解析");
assert_eq!(reparsed, list);
assert_eq!(reparsed.schema_version, PROFILE_SCHEMA_VERSION);
}
#[test]
fn reads_minimal_profile_file() {
// 用户手改/旧版本写下的最小档案:只有三个必填语义字段,其它缺省。
let list = ProfileList::from_json_str(
r#"{ "profiles": [ { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"name": "旧档案", "version_id": "1.12.2" } ] }"#,
)
.expect("最小档案应可解析");
assert_eq!(
list.schema_version, PROFILE_SCHEMA_VERSION,
"schemaVersion 缺省补齐"
);
assert_eq!(list.len(), 1);
let profile = &list.profiles[0];
assert_eq!(profile.name, "旧档案");
assert!(profile.running_dir.is_none());
assert!(profile.id.is_uuid_shaped());
}
#[test]
fn invalid_profiles_json_reports_context() {
let error = ProfileList::from_json_str("{").expect_err("非法 JSON 应报错");
match error {
unlucky_core::game::error::GameError::Json { context, .. } => {
assert_eq!(context, "profiles")
}
other => panic!("期望 Json 错误,得到 {other:?}"),
}
}
+75
View File
@@ -0,0 +1,75 @@
//! 快照仓库:不可变读、单写者草稿、发布即指针交换。
use std::sync::Arc;
use unlucky_core::game::error::GameError;
use unlucky_core::game::snapshot::{Snapshot, Store};
#[test]
fn snapshot_survives_draft_publish() {
let store = Store::new(vec!["1.20.1".to_owned()]);
let before = store.snapshot();
let mut draft = store.open_draft().expect("首个草稿应可打开");
draft.edit().push("1.20.2".to_owned());
let published = draft.commit();
// 旧快照不变(读端遍历时不会看到半新半旧的状态)。
assert_eq!(before.get().len(), 1);
assert_eq!(published.get().len(), 2);
assert_eq!(store.snapshot().get().len(), 2);
}
#[test]
fn only_one_draft_at_a_time() {
let store = Store::new(0u32);
let draft = store.open_draft().expect("首个草稿应可打开");
match store.open_draft() {
Err(GameError::DraftBusy) => {}
other => panic!("第二个草稿必须被拒绝,得到 {other:?}"),
}
drop(draft);
let reopened = store.open_draft();
assert!(reopened.is_ok(), "放弃草稿后写权必须释放");
}
#[test]
fn dropping_draft_discards_changes() {
let store = Store::new(vec![1u32]);
{
let mut draft = store.open_draft().expect("草稿应可打开");
draft.edit().push(2);
}
assert_eq!(store.snapshot().get(), &vec![1u32]);
}
#[test]
fn replace_publishes_whole_table() {
let store = Store::new(vec!["old".to_owned()]);
let snapshot = store.replace(vec!["new".to_owned()]).expect("替换应成功");
assert_eq!(snapshot.get(), &vec!["new".to_owned()]);
assert_eq!(store.snapshot().get(), &vec!["new".to_owned()]);
}
#[test]
fn clones_share_one_allocation() {
let store = Store::new(vec![1u32]);
let first = store.snapshot();
let second = first.clone();
assert!(Arc::ptr_eq(&first.shared(), &second.shared()));
assert_eq!(first, second);
let published = store.replace(vec![2u32]).expect("替换应成功");
assert!(!Arc::ptr_eq(&first.shared(), &published.shared()));
assert_eq!(first.get(), &vec![1u32], "旧快照保持旧内容");
}
#[test]
fn snapshot_new_wraps_value() {
let snapshot = Snapshot::new("payload".to_owned());
assert_eq!(snapshot.get(), "payload");
assert_eq!(snapshot.clone().get(), "payload");
}
+90
View File
@@ -0,0 +1,90 @@
//! 内嵌补充清单 `unlisted-versions.json`:条目数、分类分布、合并规则。
//!
//! 这些断言同时是**数据完整性守门**:内嵌数据换版本时如果条目数/分类崩了,
//! 这里先红,而不是等用户在旧版列表里少看到几十个版本。
use unlucky_core::game::unlisted::{self, EMBEDDED_VERSION_COUNT};
use unlucky_core::game::version::{ReleaseType, RemoteVersion, VersionCategory, VersionList};
#[test]
fn embedded_list_has_expected_shape() {
let embedded = unlisted::versions();
assert_eq!(embedded.len(), EMBEDDED_VERSION_COUNT);
assert_eq!(
embedded.versions.len(),
unlisted::entries().len(),
"借用视图与清单一致"
);
assert!(embedded.latest.is_none(), "补充清单没有 latest 对象");
for entry in unlisted::entries() {
assert!(!entry.id.is_empty());
assert!(!entry.url.is_empty(), "每条都要有下载地址: {}", entry.id);
assert_ne!(
entry.version_type,
ReleaseType::Unknown,
"内嵌数据的 type 必须能归类: {}",
entry.id
);
}
}
#[test]
fn embedded_categories_match_analysis() {
// analyse/03 §2.3 实测口径:old_alpha 88 / old_beta 16 / snapshot 70 /
// pending 20 / unobfuscated 11。
let counts = |category: VersionCategory| {
unlisted::entries()
.iter()
.filter(|v| v.version_type.category() == category)
.count()
};
assert_eq!(counts(VersionCategory::Old), 88 + 16);
assert_eq!(counts(VersionCategory::Snapshot), 70);
assert_eq!(counts(VersionCategory::Pending), 20);
assert_eq!(counts(VersionCategory::Unobfuscated), 11);
assert_eq!(counts(VersionCategory::Release), 0);
}
#[test]
fn embedded_ids_are_unique() {
let mut ids: Vec<&str> = unlisted::entries().iter().map(|v| v.id.as_str()).collect();
let total = ids.len();
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), total, "同 id 重复会让合并结果不确定");
}
#[test]
fn merge_adds_missing_entries_only() {
let mut official = VersionList {
latest: None,
versions: vec![RemoteVersion {
id: "a1.1.1".to_owned(),
version_type: ReleaseType::OldAlpha,
url: "https://example.invalid/official-a1.1.1.json".to_owned(),
time: None,
release_time: None,
sha1: Some("official".to_owned()),
size: Some(1),
}],
};
let added = unlisted::merge_into(&mut official);
assert_eq!(added, EMBEDDED_VERSION_COUNT - 1, "只补官方缺的条目");
assert_eq!(official.len(), EMBEDDED_VERSION_COUNT);
// 同 id 冲突时官方条目胜出(我们与 HMCL 的差异,见 unlisted 模块注释)。
assert_eq!(
official.find("a1.1.1").map(|v| v.url.as_str()),
Some("https://example.invalid/official-a1.1.1.json")
);
}
#[test]
fn merge_into_empty_list_is_identity() {
let mut empty = VersionList::default();
assert!(empty.is_empty());
assert_eq!(unlisted::merge_into(&mut empty), EMBEDDED_VERSION_COUNT);
assert_eq!(empty, *unlisted::versions());
}
+137
View File
@@ -0,0 +1,137 @@
//! 版本清单(`version_manifest_v2.json`):解析、分类、排序、与补充清单合并。
use unlucky_core::game::error::GameError;
use unlucky_core::game::version::{ReleaseType, RemoteVersion, VersionCategory, VersionList};
const MANIFEST_V2: &str = r#"{
"latest": { "release": "1.20.1", "snapshot": "23w31a" },
"versions": [
{ "id": "1.20.1", "type": "release", "url": "https://example.invalid/1.20.1.json",
"time": "2023-06-12T13:25:51+00:00", "releaseTime": "2023-06-12T13:25:51+00:00",
"sha1": "aaa", "size": 1000 },
{ "id": "23w31a", "type": "snapshot", "url": "https://example.invalid/23w31a.json",
"time": "2023-08-02T12:00:00+00:00", "releaseTime": "2023-08-02T12:00:00+00:00",
"sha1": "bbb", "size": 900 },
{ "id": "b1.7.3", "type": "old_beta", "url": "https://example.invalid/b1.7.3.json",
"time": "2011-07-08T00:00:00+00:00", "releaseTime": "2011-07-08T00:00:00+00:00" }
]
}"#;
fn manifest() -> VersionList {
VersionList::from_json_str(MANIFEST_V2).expect("清单应可解析")
}
#[test]
fn parses_manifest_v2_with_checksums() {
let list = manifest();
assert_eq!(list.len(), 3);
let latest = list.latest.as_ref().expect("latest 应解析");
assert_eq!(latest.release.as_deref(), Some("1.20.1"));
assert_eq!(latest.snapshot.as_deref(), Some("23w31a"));
let release = list.find("1.20.1").expect("1.20.1 应存在");
assert!(release.is_verifiable());
assert_eq!(release.size, Some(1000));
// 内嵌旧清单没有 sha1/size(v1 形态)。
assert!(!list.find("b1.7.3").expect("b1.7.3 应存在").is_verifiable());
}
#[test]
fn classifies_release_types() {
assert_eq!(
ReleaseType::from_json_str("release").category(),
VersionCategory::Release
);
assert_eq!(
ReleaseType::from_json_str("old_beta").category(),
VersionCategory::Old
);
assert_eq!(
ReleaseType::from_json_str("old_alpha").category(),
VersionCategory::Old
);
assert_eq!(
ReleaseType::from_json_str("pending").category(),
VersionCategory::Pending
);
assert_eq!(
ReleaseType::from_json_str("unobfuscated").category(),
VersionCategory::Unobfuscated
);
assert_eq!(
ReleaseType::from_json_str("未见过").category(),
VersionCategory::Uncategorized
);
// 序列化回原始字符串,保证能原样写回清单。
assert_eq!(ReleaseType::OldBeta.as_json_str(), "old_beta");
assert_eq!(ReleaseType::Unknown.as_json_str(), "unknown");
}
#[test]
fn sorts_newest_first() {
let mut list = manifest();
list.sort_newest_first();
let ids: Vec<&str> = list.versions.iter().map(|v| v.id.as_str()).collect();
assert_eq!(ids, vec!["23w31a", "1.20.1", "b1.7.3"]);
}
#[test]
fn filters_by_ui_category() {
let list = manifest();
assert_eq!(list.filter_category(VersionCategory::Release).len(), 1);
assert_eq!(list.filter_category(VersionCategory::Snapshot).len(), 1);
assert_eq!(list.filter_category(VersionCategory::Old).len(), 1);
assert!(list.filter_category(VersionCategory::Pending).is_empty());
}
#[test]
fn merging_keeps_existing_entries() {
let mut list = manifest();
let added = list.merge_missing(vec![
// 同 id 冲突:已有条目(官方)胜出,URL 不被覆盖。
RemoteVersion {
id: "1.20.1".to_owned(),
version_type: ReleaseType::Release,
url: "https://mirror.invalid/1.20.1.json".to_owned(),
time: None,
release_time: None,
sha1: None,
size: None,
},
RemoteVersion {
id: "a1.1.1".to_owned(),
version_type: ReleaseType::OldAlpha,
url: "https://mirror.invalid/a1.1.1.json".to_owned(),
time: None,
release_time: None,
sha1: None,
size: None,
},
]);
assert_eq!(added, 1, "只应新增缺失条目");
assert_eq!(list.len(), 4);
assert_eq!(
list.find("1.20.1").map(|v| v.url.as_str()),
Some("https://example.invalid/1.20.1.json")
);
assert!(list.find("a1.1.1").is_some());
}
#[test]
fn roundtrips_manifest_json() {
let list = manifest();
let json = list.to_json_string().expect("应可序列化");
let reparsed = VersionList::from_json_str(&json).expect("应可再解析");
assert_eq!(reparsed, list);
}
#[test]
fn invalid_json_reports_context() {
let error = VersionList::from_json_str("not json").expect_err("非法 JSON 应报错");
match error {
GameError::Json { context, .. } => assert_eq!(context, "version_manifest"),
other => panic!("期望 Json 错误,得到 {other:?}"),
}
}
+125
View File
@@ -0,0 +1,125 @@
//! 版本能力位:从版本 JSON 的库表推 `has_vulkan_backend`。
//!
//! 事实基线(`research/05-mc-vulkan.md` §2.2,piston-meta 921 个版本逐版扫过):
//! `org.lwjgl:lwjgl-vulkan` 从 **26.2-snapshot-1** 起出现,26.1 全线(含 `26w14a`)没有。
//! 所以「库在不在」是判断版本带不带 Vulkan 后端的可靠依据。
use unlucky_core::game::capability::{
is_vulkan_library, library_names_have_vulkan, VersionCapabilities,
};
use unlucky_core::game::manifest::VersionJson;
use crate::helpers::parse;
/// 26.2-snapshot-1 的库表形态(Vulkan 三件套 + 常规库,实测结构)。
const VULKAN_ERA_JSON: &str = r#"{
"id": "26.2-snapshot-1",
"type": "snapshot",
"mainClass": "net.minecraft.client.main.Main",
"libraries": [
{ "name": "org.lwjgl:lwjgl:3.4.1", "downloads": { "artifact": { "path": "org/lwjgl/lwjgl/3.4.1/lwjgl-3.4.1.jar" } } },
{ "name": "org.lwjgl:lwjgl-vulkan:3.4.1", "downloads": { "artifact": { "path": "org/lwjgl/lwjgl-vulkan/3.4.1/lwjgl-vulkan-3.4.1.jar" } } },
{ "name": "org.lwjgl:lwjgl-vulkan:3.4.1:natives-macos" },
{ "name": "org.lwjgl:lwjgl-shaderc:3.4.1" },
{ "name": "com.mojang:brigadier:1.3.10" }
]
}"#;
/// 26.1(含 `26w14a` 周快照那一段)的库表:只有 opengl,没有 vulkan。
const PRE_VULKAN_JSON: &str = r#"{
"id": "26.1",
"type": "release",
"mainClass": "net.minecraft.client.main.Main",
"libraries": [
{ "name": "org.lwjgl:lwjgl:3.4.1" },
{ "name": "org.lwjgl:lwjgl-glfw:3.4.1" },
{ "name": "org.lwjgl:lwjgl-opengl:3.4.1" }
]
}"#;
#[test]
fn version_json_with_lwjgl_vulkan_carries_the_capability() {
let version = parse(VULKAN_ERA_JSON);
assert!(version.has_vulkan_backend());
let capabilities = VersionCapabilities::from_json(&version);
assert!(capabilities.has_vulkan_backend());
}
#[test]
fn version_json_without_lwjgl_vulkan_lacks_the_capability() {
let version = parse(PRE_VULKAN_JSON);
assert!(!version.has_vulkan_backend());
assert_eq!(
VersionCapabilities::from_json(&version),
VersionCapabilities::default()
);
}
#[test]
fn missing_libraries_field_is_not_a_capability() {
let version = parse(r#"{ "id": "custom", "mainClass": "a.B" }"#);
assert!(!version.has_vulkan_backend());
}
#[test]
fn coordinate_forms_are_all_recognised() {
// 主构件、带 classifier 的 natives、带 @ext 的写法都算命中(只比对 group:artifact)。
assert!(is_vulkan_library("org.lwjgl:lwjgl-vulkan:3.4.3"));
assert!(is_vulkan_library(
"org.lwjgl:lwjgl-vulkan:3.4.3:natives-macos"
));
assert!(is_vulkan_library("org.lwjgl:lwjgl-vulkan:3.4.3@jar"));
// 相邻但不该命中的(前缀相同 / group 不同)。
assert!(!is_vulkan_library("org.lwjgl:lwjgl-opengl:3.4.3"));
assert!(!is_vulkan_library("org.lwjgl:lwjgl:3.4.3"));
assert!(!is_vulkan_library("com.mojang:lwjgl-vulkan:1.0"));
assert!(!is_vulkan_library("lwjgl-vulkan"));
assert!(!is_vulkan_library(""));
}
#[test]
fn library_name_slice_helper_matches_the_json_path() {
let names = ["org.lwjgl:lwjgl:3.4.1", "org.lwjgl:lwjgl-vulkan:3.4.1"];
assert!(library_names_have_vulkan(names));
assert!(!library_names_have_vulkan(["org.lwjgl:lwjgl:3.4.1"]));
assert!(!library_names_have_vulkan(std::iter::empty()));
// 与 game 侧的库表判定同源:同一份 JSON 两条路径给同一结论。
let version = parse(VULKAN_ERA_JSON);
assert_eq!(
library_names_have_vulkan(
version
.libraries
.as_deref()
.unwrap_or_default()
.iter()
.map(|l| l.name.as_str())
),
version.has_vulkan_backend()
);
}
#[test]
fn capability_bit_is_not_the_parameter_gate() {
// 26.2-snapshot-1 已经有库,却还没有 `--graphicsBackend`(实测差两天)。
// 能力位是「必要条件」,下发门限另有精确版本门(见 launch 侧
// `graphics_backend_allowed`),两者不能被压成一个判断。
let snapshot_one = parse(VULKAN_ERA_JSON);
assert!(
snapshot_one.has_vulkan_backend(),
"26.2-snapshot-1 有 Vulkan 库"
);
assert_eq!(snapshot_one.id.as_deref(), Some("26.2-snapshot-1"));
}
#[test]
fn version_json_accessor_delegates_to_capability_module() {
for json in [VULKAN_ERA_JSON, PRE_VULKAN_JSON] {
let version: VersionJson = parse(json);
assert_eq!(
version.has_vulkan_backend(),
VersionCapabilities::from_json(&version).has_vulkan_backend()
);
}
}
+166
View File
@@ -0,0 +1,166 @@
//! game 快照 → `LaunchRequest` 适配层测试(缺失库显式记录、features 派生)。
use crate::prelude::*;
fn write_file(path: &std::path::Path, content: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("建目录");
}
std::fs::write(path, content).expect("写文件");
}
fn jvm_tokens() -> Vec<String> {
vec![
"-Djava.library.path=${natives_directory}".to_string(),
"-cp".to_string(),
"${classpath}".to_string(),
]
}
fn game_tokens() -> Vec<String> {
vec![
"--username".to_string(),
"${auth_player_name}".to_string(),
"--version".to_string(),
"${version_name}".to_string(),
]
}
#[test]
fn adapter_records_missing_libraries_and_fails_before_start() {
let dir = temp_dir("adapter-strict");
let lib_ok = dir.join("libraries/a.jar");
let lib_missing = dir.join("libraries/b.jar");
let primary = dir.join("versions/1.20.1/1.20.1.jar");
write_file(&lib_ok, "x");
write_file(&primary, "x");
let libraries = vec![
LibraryInput::new(lib_ok.clone(), "org.example:a:1"),
LibraryInput::new(lib_missing.clone(), "org.example:b:2"),
];
let jvm = jvm_tokens();
let game = game_tokens();
let snapshot = SnapshotInput {
version_name: Some("1.20.1 Fabric"),
version_type: Some("release"),
game_version: Some("1.20.1"),
main_class: Some("net.fabricmc.loader.impl.launch.knot.KnotClient"),
jvm_arguments: &jvm,
game_arguments: &game,
libraries: &libraries,
primary_jar: primary.clone(),
game_dir: dir.clone(),
assets_index: Some("17"),
natives_dir: dir.join("versions/1.20.1/natives"),
library_dir: dir.join("libraries"),
assets_root: dir.join("assets"),
game_assets: dir.join("assets/virtual/legacy"),
..SnapshotInput::minimal("1.20.1", "")
};
let mut options = AdapterOptions::strict();
options.java = JavaSelection::new("C:/java/bin/java.exe", 21, 64);
options.memory = MemoryOptions {
min_mb: Some(512),
max_mb: Some(2048),
};
options.resolution = Some(Resolution {
width: 1280,
height: 720,
});
let output = build_request(&snapshot, options);
assert_eq!(output.classpath_entries, 2, "a.jar + 主 jar");
assert_eq!(output.missing.len(), 1);
assert_eq!(output.missing[0].name, "org.example:b:2");
assert!(output.warnings.iter().any(|warning| matches!(
warning,
AdapterWarning::MissingLibrary { name, .. } if name == "org.example:b:2"
)));
// features 派生:给了分辨率就打开 has_custom_resolution;1.20.1 支持 QuickPlay。
assert!(output.request.features.has_custom_resolution);
assert!(output.request.features.has_quick_plays_support);
assert_eq!(output.request.instance.name, "1.20.1 Fabric");
assert_eq!(
output.request.manifest.main_class.as_deref(),
Some("net.fabricmc.loader.impl.launch.knot.KnotClient")
);
// 严格模式下缺失库 → 组装前显式失败(不再交给运行时 NoClassDefFoundError)。
let ctx = PlatformCtx::new(OsFamily::Windows, "x86_64");
match assemble(&output.request, &ctx) {
Err(LaunchError::Assembly(AssemblyError::MissingComponents(items))) => {
assert_eq!(items.len(), 1);
}
other => panic!("应因缺组件失败,实际:{other:?}"),
}
}
#[test]
fn adapter_tolerant_mode_skips_missing_libraries() {
let dir = temp_dir("adapter-tolerant");
let lib_missing = dir.join("libraries/b.jar");
let libraries = vec![LibraryInput::new(lib_missing.clone(), "org.example:b:2")];
let jvm = jvm_tokens();
let snapshot = SnapshotInput {
main_class: Some("net.minecraft.client.main.Main"),
jvm_arguments: &jvm,
libraries: &libraries,
primary_jar: dir.join("client.jar"),
game_dir: dir.clone(),
assets_index: Some("5"),
..SnapshotInput::minimal("1.12.2", "")
};
let mut options = AdapterOptions::strict();
options.check_classpath = false;
options.java = JavaSelection::new("java", 8, 64);
let output = build_request(&snapshot, options);
assert!(
!output.request.classpath.is_empty(),
"宽松模式仍保留库(交给启动期报错)"
);
assert!(output.request.missing_components.is_empty());
// 宽松模式不报缺失库(其它告警——如缺游戏参数——与本用例无关)。
assert!(!output
.warnings
.iter()
.any(|warning| matches!(warning, AdapterWarning::MissingLibrary { .. })));
}
#[test]
fn adapter_warns_on_incomplete_manifest() {
let dir = temp_dir("adapter-incomplete");
let snapshot = SnapshotInput {
main_class: None,
..SnapshotInput::minimal("1.7.10", "")
};
let output = build_request(&snapshot, AdapterOptions::strict());
let warnings = &output.warnings;
assert!(warnings.contains(&AdapterWarning::MissingMainClass));
assert!(warnings.contains(&AdapterWarning::MissingAssetsIndex));
assert!(warnings.contains(&AdapterWarning::MissingGameArguments));
// 1.7.10 < 1.20:不支持 QuickPlay。
assert!(!output.request.features.has_quick_plays_support);
assert_eq!(
output.request.dirs.assets_index, "legacy",
"缺索引时兜底 legacy"
);
let _ = dir;
}
#[test]
fn adapter_legacy_arguments_are_passed_through() {
let dir = temp_dir("adapter-legacy");
let snapshot = SnapshotInput {
main_class: Some("net.minecraft.launchwrapper.Launch"),
legacy_arguments: Some("--username ${auth_player_name} --gameDir ${game_directory}"),
game_dir: dir.clone(),
..SnapshotInput::minimal("1.12.2", "")
};
let output = build_request(&snapshot, AdapterOptions::strict());
assert!(output.request.manifest.is_legacy());
assert!(output.request.manifest.game.is_empty());
assert!(!output
.warnings
.contains(&AdapterWarning::MissingGameArguments));
}
@@ -0,0 +1,338 @@
//! 命令行组装快照测试(`analyse/02` §4.1 的 33 项顺序表 + §4.2 默认值抑制)。
//!
//! 期望值是手工核对过的**完整 argv**:一旦组装顺序变了,这里必须一起改——
//! 顺序本身有语义(后加的默认值会被先加的显式值抑制)。
use crate::prelude::*;
#[test]
fn legacy_forge_full_argv() {
let (mut request, ctx) = legacy_forge_request();
request.mod_loader = ModLoaderKind::Forge;
request.manifest.legacy_arguments = Some(
"--username ${auth_player_name} --version ${version_name} --gameDir ${game_directory} \
--assetsDir ${assets_root} --assetIndex ${assets_index_name} --uuid ${auth_uuid} \
--accessToken ${auth_session} --userType ${user_type} \
--tweakClass net.minecraftforge.fml.common.launcher.FMLTweaker"
.to_string(),
);
// 用户指定 GBK → 日志泵也必须用 GBK 解码(`analyse/02` §4.4)。
request.user.jvm = vec!["-Dfile.encoding=GBK".to_string()];
let command = assemble(&request, &ctx).expect("组装应成功");
assert_eq!(command.encoding, "GBK");
assert_eq!(command.main_class, "net.minecraft.launchwrapper.Launch");
assert_eq!(command.classpath.len(), 3, "主 jar 必须追加在末位");
assert_argv(
&command,
&[
"C:/java/bin/java.exe",
"-Xmx4096m",
"-Xms1024m",
"-XX:MetaspaceSize=64m",
"-Dfile.encoding=GBK",
"-Dsun.stdout.encoding=GBK",
"-Dsun.stderr.encoding=GBK",
"-Djava.rmi.server.useCodebaseOnly=true",
"-Dcom.sun.jndi.rmi.object.trustURLCodebase=false",
"-Dcom.sun.jndi.cosnaming.object.trustURLCodebase=false",
"-Dlog4j2.formatMsgNoLookups=true",
"-Dlog4j.configurationFile=C:/mc/log4j2.xml",
"-Dminecraft.client.jar=C:/mc/versions/1.12.2/1.12.2.jar",
"-Dfml.ignoreInvalidMinecraftCertificates=true",
"-Dfml.ignorePatchDiscrepancies=true",
"-Djava.library.path=C:/mc/versions/1.12.2/natives-windows-x86_64",
"-cp",
"C:/mc/libraries/a.jar;C:/mc/libraries/b.jar;C:/mc/versions/1.12.2/1.12.2.jar",
"net.minecraft.launchwrapper.Launch",
"--username",
"Steve",
"--version",
"1.12.2-Forge",
"--gameDir",
"C:/mc",
"--assetsDir",
"C:/mc/assets",
"--assetIndex",
"1.12",
"--uuid",
"0123456789abcdef0123456789abcdef",
"--accessToken",
"SECRET-TOKEN-123456",
"--userType",
"msa",
"--tweakClass",
"net.minecraftforge.fml.common.launcher.FMLTweaker",
"--width",
"854",
"--height",
"480",
],
);
}
#[test]
fn modern_fabric_full_argv_with_rules() {
let (mut request, ctx) = legacy_forge_request();
request.instance.name = "1.20.1-Fabric".to_string();
request.instance.version = GameVersion::parse("1.20.1");
request.manifest.main_class =
Some("net.fabricmc.loader.impl.launch.knot.KnotClient".to_string());
request.manifest.legacy_arguments = None;
request.manifest.jvm = vec![
ArgumentEntry::plain(["-Djava.library.path=${natives_directory}"]),
ArgumentEntry::plain(["-cp", "${classpath}"]),
];
request.manifest.game = vec![
ArgumentEntry::plain(["--username", "${auth_player_name}"]),
ArgumentEntry::plain(["--version", "${version_name}"]),
ArgumentEntry::ruled(
vec![Rule::allow().with_os_name("windows")],
["-Dos.windows=true"],
),
ArgumentEntry::ruled(vec![Rule::allow().with_os_name("osx")], ["-Dos.macos=true"]),
ArgumentEntry::ruled(
vec![Rule::allow().with_feature("has_custom_resolution", true)],
[
"--width",
"${resolution_width}",
"--height",
"${resolution_height}",
],
),
// 先 allow 后 disallow:按“最后一个命中的 action”生效(`analyse/02` §4.7)。
ArgumentEntry::ruled(
vec![
Rule::allow().with_os_name("windows"),
Rule::disallow().with_feature("is_demo_user", true),
],
["--windows-only"],
),
];
request.resolution = Some(Resolution {
width: 1280,
height: 720,
});
request.user.game = vec!["--fullscreen".to_string()];
let command = assemble(&request, &ctx).expect("组装应成功");
// 主 jar 由组装器追加到最后,manifest 里的 `-cp` 只覆盖库。
assert_eq!(command.classpath.len(), 3);
assert_argv(
&command,
&[
"C:/java/bin/java.exe",
"-Xmx4096m",
"-Xms1024m",
"-XX:MetaspaceSize=64m",
"-Dfile.encoding=UTF-8",
"-Dsun.stdout.encoding=UTF-8",
"-Dsun.stderr.encoding=UTF-8",
"-Djava.rmi.server.useCodebaseOnly=true",
"-Dcom.sun.jndi.rmi.object.trustURLCodebase=false",
"-Dcom.sun.jndi.cosnaming.object.trustURLCodebase=false",
"-Dlog4j2.formatMsgNoLookups=true",
"-Dlog4j.configurationFile=C:/mc/log4j2.xml",
"-Dminecraft.client.jar=C:/mc/versions/1.12.2/1.12.2.jar",
"-Djava.library.path=C:/mc/versions/1.12.2/natives-windows-x86_64",
"-cp",
"C:/mc/libraries/a.jar;C:/mc/libraries/b.jar;C:/mc/versions/1.12.2/1.12.2.jar",
"net.fabricmc.loader.impl.launch.knot.KnotClient",
"--username",
"Steve",
"--version",
"1.20.1-Fabric",
"-Dos.windows=true",
"--width",
"1280",
"--height",
"720",
"--windows-only",
"--fullscreen",
],
);
assert!(!command.args.iter().any(|arg| arg == "-Dos.macos=true"));
}
#[test]
fn user_override_beats_defaults() {
let (mut request, ctx) = legacy_forge_request();
request.java = JavaSelection::new("C:/java8/bin/java.exe", 8, 32);
request.user.override_jvm = vec!["-Xmx8G".to_string(), "-XX:+UseSerialGC".to_string()];
request.user.jvm = vec!["-Xmx2G".to_string()];
request.optimization = OptimizationOptions {
enabled: true,
physical_memory_mb: Some(8192),
};
request.manifest.legacy_arguments = None;
let command = assemble(&request, &ctx).expect("组装应成功");
let args = command.args;
// override 的 -Xmx8G 生效,默认的 -Xmx4096m 被抑制。
assert!(args.iter().any(|arg| arg == "-Xmx8G"));
assert!(!args.iter().any(|arg| arg == "-Xmx4096m"));
// 用户 JVM 参数里的 -Xmx2G 属于“默认值”,被 override 抑制。
assert!(!args.iter().any(|arg| arg == "-Xmx2G"));
// 用户显式挑了 GC → 不注入 G1 整组,但诊断关键项照旧。
assert!(args.iter().any(|arg| arg == "-XX:+UseSerialGC"));
assert!(!args.iter().any(|arg| arg == "-XX:+UseG1GC"));
assert!(args
.iter()
.any(|arg| arg == "-XX:-OmitStackTraceInFastThrow"));
// 32 位老 Java → PermSize,且不因内存充裕注入 CodeCache 组。
assert!(args.iter().any(|arg| arg == "-XX:MetaspaceSize=64m"));
assert!(!args
.iter()
.any(|arg| arg.starts_with("-XX:ReservedCodeCacheSize")));
}
#[test]
fn java8_uses_legacy_encoding_keys_and_keeps_xincgc() {
let (mut request, ctx) = legacy_forge_request();
request.java = JavaSelection::new("C:/java8/bin/java.exe", 8, 32);
request.user.jvm = vec!["-Xincgc".to_string()];
request.manifest.legacy_arguments = None;
let command = assemble(&request, &ctx).expect("组装应成功");
assert!(command
.args
.iter()
.any(|arg| arg == "-Dsun.stdout.encoding=UTF-8"));
assert!(!command
.args
.iter()
.any(|arg| arg == "-Dstdout.encoding=UTF-8"));
// Java 8 上 -Xincgc 仍合法(只在 ≥9 上被禁,`analyse/02` §4.1 #33)。
assert!(command.args.iter().any(|arg| arg == "-Xincgc"));
let mut modern = request.clone();
modern.java = JavaSelection::new("C:/java21/bin/java.exe", 21, 64);
let command = assemble(&modern, &ctx).expect("组装应成功");
assert!(command
.args
.iter()
.any(|arg| arg == "-Dstdout.encoding=UTF-8"));
assert!(
!command.args.iter().any(|arg| arg == "-Xincgc"),
"Java ≥9 必须删掉 -Xincgc"
);
}
#[test]
fn quickplay_and_graphics_backend_are_version_gated() {
let (mut request, ctx) = legacy_forge_request();
request.manifest.legacy_arguments = None;
request.quick_play = Some(QuickPlay::Multiplayer {
address: "mc.example.com:25565".to_string(),
});
request.graphics_backend = Some("vulkan".to_string());
// 1.12.2:QuickPlay 退化成 --server/--port,--graphicsBackend 丢弃。
let legacy = assemble(&request, &ctx).expect("组装应成功");
let server = legacy
.args
.windows(2)
.any(|pair| pair[0] == "--server" && pair[1] == "mc.example.com");
assert!(server, "老版本应退化为 --server");
assert!(legacy
.args
.windows(2)
.any(|pair| pair[0] == "--port" && pair[1] == "25565"));
assert!(!legacy.args.iter().any(|arg| arg == "--graphicsBackend"));
assert!(legacy
.warnings
.contains(&AssemblyWarning::QuickPlayDegraded {
address: "mc.example.com:25565".to_string()
}));
assert!(legacy
.warnings
.iter()
.any(|warning| matches!(warning, AssemblyWarning::GraphicsBackendSkipped { .. })));
// 26.3:QuickPlay 直用,--graphicsBackend 允许。
let mut modern = request.clone();
modern.instance.version = GameVersion::parse("26.3");
let modern = assemble(&modern, &ctx).expect("组装应成功");
assert!(modern
.args
.windows(2)
.any(|pair| pair[0] == "--quickPlayMultiplayer" && pair[1] == "mc.example.com:25565"));
assert!(modern
.args
.windows(2)
.any(|pair| pair[0] == "--graphicsBackend" && pair[1] == "vulkan"));
}
#[test]
fn assembly_fails_fast_on_missing_components() {
let (mut request, ctx) = legacy_forge_request();
request.missing_components = vec![
MissingComponent::named("org.lwjgl:lwjgl:3.3.1"),
MissingComponent::at_path("net.minecraft:client", "C:/mc/versions/1.12.2/1.12.2.jar"),
];
let error = assemble(&request, &ctx).expect_err("缺组件必须启动前失败");
match error {
LaunchError::Assembly(AssemblyError::MissingComponents(items)) => {
assert_eq!(items.len(), 2);
assert_eq!(items[0].name, "org.lwjgl:lwjgl:3.3.1");
}
other => panic!("错误类型不对:{other}"),
}
let (mut request, ctx) = legacy_forge_request();
request.manifest.main_class = None;
assert!(matches!(
assemble(&request, &ctx),
Err(LaunchError::Assembly(AssemblyError::MissingMainClass))
));
let (mut request, ctx) = legacy_forge_request();
request.classpath.clear();
request.dirs.primary_jar = std::path::PathBuf::new();
assert!(matches!(
assemble(&request, &ctx),
Err(LaunchError::Assembly(AssemblyError::EmptyClasspath))
));
}
#[test]
fn posix_gets_nice_and_colon_classpath() {
let mut ctx = PlatformCtx::new(OsFamily::Linux, "x86_64");
ctx.home_dir = Some(std::path::PathBuf::from("/home/aska"));
let (mut request, _) = legacy_forge_request();
request.nice = Some(5);
request.wrapper = vec!["prime-run".to_string()];
request.manifest.legacy_arguments = None;
let command = assemble(&request, &ctx).expect("组装应成功");
assert_eq!(command.argv()[0], "nice");
assert_eq!(command.argv()[1], "-n");
assert_eq!(command.argv()[2], "5");
assert_eq!(command.argv()[3], "prime-run");
assert_eq!(command.argv()[4], "C:/java/bin/java.exe");
assert!(command
.args
.iter()
.any(|arg| arg
== "C:/mc/libraries/a.jar:C:/mc/libraries/b.jar:C:/mc/versions/1.12.2/1.12.2.jar"));
// 非 Windows 才注入 -Duser.home。
assert!(command
.args
.iter()
.any(|arg| arg.starts_with("-Duser.home=")));
}
#[test]
fn placeholder_table_is_complete() {
let (request, ctx) = legacy_forge_request();
let map = unlucky_core::launch::placeholders::PlaceholderMap::from_request(&request, &ctx);
for key in unlucky_core::launch::placeholders::PLACEHOLDER_KEYS {
assert!(map.get(key).is_some(), "占位符 ${key} 未登记");
}
assert_eq!(map.get("primary_jar_name"), Some("1.12.2.jar"));
assert_eq!(map.get("version_name"), Some("1.12.2-Forge"));
// 未定义的占位符原样保留(便于发现清单漏项)。
assert_eq!(map.render("${nope}"), "${nope}");
assert_eq!(map.render("a${version_name}b"), "a1.12.2-Forgeb");
}
@@ -0,0 +1,148 @@
//! `CommandBuilder` 默认值抑制语义测试(`analyse/02` §4.2)。
use crate::prelude::*;
fn strings(items: &[&str]) -> Vec<String> {
items.iter().map(|item| (*item).to_string()).collect()
}
#[test]
fn add_default_suppresses_when_prefix_exists() {
let mut builder = CommandBuilder::new();
builder.add("-Xmx8G");
let existing = builder.add_default("-Xmx", "4096m");
assert_eq!(existing.as_deref(), Some("-Xmx8G"));
assert_eq!(builder.args(), &strings(&["-Xmx8G"]));
let added = builder.add_default("-Xms", "1024m");
assert_eq!(added, None);
assert_eq!(builder.args(), &strings(&["-Xmx8G", "-Xms1024m"]));
}
#[test]
fn unstable_flags_do_not_conflict() {
let mut builder = CommandBuilder::new();
builder.add("-XX:-UseG1GC");
// 用户已经用 `-XX:-UseG1GC` 关掉了 G1,就不再补 `-XX:+UseG1GC`。
assert!(builder.add_unstable_flag("UseG1GC", true).is_some());
assert!(!builder.contains_exact("-XX:+UseG1GC"));
let mut builder = CommandBuilder::new();
builder.add("-XX:MaxInlineLevel=20");
assert!(builder
.add_unstable_default("MaxInlineLevel", "15")
.is_some());
assert_eq!(builder.args(), &strings(&["-XX:MaxInlineLevel=20"]));
}
#[test]
fn add_all_default_handles_each_shape() {
let mut builder = CommandBuilder::new();
builder.add("-Dfile.encoding=GBK");
builder.add("-XX:+UseSerialGC");
builder.add("-Xmx8G");
builder.add("-cp");
builder.add("user-cp");
builder.add_all_default(&strings(&[
"-Dfile.encoding=UTF-8",
"-Dsun.stdout.encoding=UTF-8",
"-XX:+UseG1GC",
"-XX:G1MixedGCCountTarget=5",
"-Xmx4096m",
"-Xss1m",
"-cp",
"default-cp",
"exact-arg",
]));
assert_eq!(
builder.args(),
&strings(&[
"-Dfile.encoding=GBK",
"-XX:+UseSerialGC",
"-Xmx8G",
"-cp",
"user-cp",
"-Dsun.stdout.encoding=UTF-8",
"-XX:+UseG1GC",
"-XX:G1MixedGCCountTarget=5",
"-Xss1m",
"exact-arg",
])
);
}
#[test]
fn argfile_expansion_suppresses_later_defaults() {
let mut builder = CommandBuilder::new();
builder.add_all_without_parsing_and_read_external(
&strings(&["@options.txt"]),
&|path: &str| {
if path == "options.txt" {
Some("-Xmx12G\n-Dfile.encoding=COMPAT\n".to_string())
} else {
None
}
},
);
// @argfile 里的 -Xmx12G 已登记为外部参数,默认值必须被抑制。
assert!(builder.add_default("-Xmx", "4096m").is_some());
assert!(builder.add_default("-Dfile.encoding=", "UTF-8").is_some());
// 外部参数只参与判重,不进 argv。
assert!(!builder.contains_exact("-Xmx12G"));
assert_eq!(builder.args(), &strings(&["@options.txt"]));
}
#[test]
fn tokenize_handles_quotes_and_escapes() {
assert_eq!(
command_builder::tokenize(r#"--username "a b" --path 'C:\x y' plain"#),
strings(&["--username", "a b", "--path", "C:\\x y", "plain"])
);
assert_eq!(
command_builder::tokenize(r#"-Dmsg="say \"hi\"""#),
strings(&[r#"-Dmsg=say "hi""#])
);
assert!(command_builder::tokenize(" ").is_empty());
}
#[test]
fn parse_argfile_ignores_comments_and_blank_lines() {
let contents = "# 注释\n-Xmx2G\n\n-Xms512m -Dfoo=bar\n";
assert_eq!(
command_builder::parse_argfile(contents),
strings(&["-Xmx2G", "-Xms512m", "-Dfoo=bar"])
);
}
#[test]
fn forbidden_args_are_removed_at_the_end() {
let mut builder = CommandBuilder::new();
builder.add_all(&strings(&["-Xincgc", "-Xmx1G", "-Xincgc"]));
let removed = builder.remove_forbidden(&["-Xincgc"]);
assert_eq!(removed.len(), 2);
assert_eq!(builder.args(), &strings(&["-Xmx1G"]));
}
#[test]
fn placeholders_are_rendered_on_add_but_not_on_raw_add() {
let mut map = unlucky_core::launch::placeholders::PlaceholderMap::new();
map.insert("version_name", "1.20.1");
let mut builder = CommandBuilder::with_placeholders(map);
builder.add("--version");
builder.add("${version_name}");
builder.add_without_parsing("--raw ${version_name}");
assert_eq!(
builder.args(),
&strings(&["--version", "1.20.1", "--raw ${version_name}"])
);
// 人读形式给含空格的参数加引号(日志与快照测试用)。
assert_eq!(
builder.display_args(),
"--version 1.20.1 \"--raw ${version_name}\""
);
let mut quoted = CommandBuilder::new();
quoted.add("-Dmsg=hello world");
assert_eq!(quoted.display_args(), "\"-Dmsg=hello world\"");
}
+204
View File
@@ -0,0 +1,204 @@
//! 退出分析测试:退出码语义 + `exit 0` 静默失败(`analyse/02` §6.3 §7.2 §7.10-10)。
use crate::prelude::*;
fn line(stream: Stream, text: &str) -> LogLine {
LogLine::new(stream, text)
}
fn analyze(
code: i32,
uptime_ms: u64,
lines: &[LogLine],
posix: bool,
) -> unlucky_core::launch::exit_analysis::ExitAnalysis {
let mut signals = exit_analysis::ExitSignals::new(code, uptime_ms, lines);
signals.is_posix = posix;
exit_analysis::analyze(&signals)
}
#[test]
fn clean_long_run_is_normal() {
let lines = vec![line(
Stream::Stdout,
"[12:00:00] [Render thread/INFO]: started",
)];
let analysis = analyze(0, 120_000, &lines, true);
assert_eq!(analysis.exit_type, ExitType::Normal);
assert!(analysis.silent_failure.is_none());
assert!(!analysis.needs_diagnosis);
}
#[test]
fn exit_zero_short_lived_is_silent_failure() {
let lines = vec![line(Stream::Stdout, "Stopping!")];
let analysis = analyze(0, 8_000, &lines, true);
assert_eq!(analysis.exit_type, ExitType::Normal);
let silent = analysis.silent_failure.expect("应标记静默失败");
assert!(silent.short_lived);
assert!(!silent.error_output);
assert!(analysis.needs_diagnosis, "静默失败必须进诊断流水线");
}
#[test]
fn exit_zero_with_error_output_is_silent_failure() {
let lines = vec![
line(Stream::Stdout, "[12:00:00] [main/INFO]: loading"),
line(
Stream::Stderr,
"java.lang.RuntimeException: mixin apply failed",
),
];
let analysis = analyze(0, 90_000, &lines, true);
assert_eq!(analysis.exit_type, ExitType::Normal);
let silent = analysis.silent_failure.expect("应标记静默失败");
assert!(silent.error_output);
assert!(!silent.short_lived);
assert!(silent.describe().contains("错误特征"));
assert_eq!(analysis.error_lines, 1);
}
#[test]
fn jvm_keywords_take_precedence() {
let lines = vec![line(
Stream::Stderr,
"Error occurred during initialization of VM",
)];
let analysis = analyze(1, 500, &lines, true);
assert_eq!(analysis.exit_type, ExitType::JvmError);
assert_eq!(analysis.jvm_keywords.len(), 1);
assert!(analysis.needs_diagnosis);
}
#[test]
fn nonzero_without_keywords_is_application_error() {
let lines = vec![line(Stream::Stdout, "[12:00:00] [main/INFO]: hi")];
let analysis = analyze(1, 40_000, &lines, true);
assert_eq!(analysis.exit_type, ExitType::ApplicationError);
assert!(analysis.needs_diagnosis);
}
#[test]
fn crash_keywords_promote_exit_zero() {
let lines = vec![line(
Stream::Stdout,
"[12:00:00] [Render thread/ERROR]: Crash report saved to ./crash-reports/crash-1.txt",
)];
let analysis = analyze(0, 60_000, &lines, true);
assert_eq!(analysis.exit_type, ExitType::ApplicationError);
assert_eq!(analysis.crash_keywords.len(), 1);
assert!(
analysis.silent_failure.is_none(),
"已判异常就不再是静默失败"
);
}
#[test]
fn posix_signals_are_recognized() {
assert_eq!(analyze(137, 60_000, &[], true).exit_type, ExitType::SigKill);
assert_eq!(analyze(143, 60_000, &[], true).exit_type, ExitType::SigTerm);
// Windows 上 137 没有信号语义,按应用错误处理。
assert_eq!(
analyze(137, 60_000, &[], false).exit_type,
ExitType::ApplicationError
);
}
#[test]
fn windows_terminated_code_with_no_output_is_not_an_application_error() {
// D10:1.12.2 挂死后被任务管理器 / `taskkill /F` 结束 —— 退出码 1,零错误输出。
let analysis = analyze(1, 60_000, &[], false);
assert_eq!(analysis.exit_type, ExitType::Killed);
assert!(analysis.terminated_externally);
assert!(
analysis.summary().contains("疑似被外部结束"),
"{}",
analysis.summary()
);
// 同样的退出码带错误输出 → 仍是应用错误(不能把所有 1 都洗成「被结束」)。
let lines = vec![line(
Stream::Stderr,
"Exception in thread \"main\" java.lang.NullPointerException",
)];
let analysis = analyze(1, 60_000, &lines, false);
assert_eq!(analysis.exit_type, ExitType::ApplicationError);
assert!(!analysis.terminated_externally);
// POSIX 上 1 不是信号痕迹:不套这条判据(137/143 才是)。
let analysis = analyze(1, 60_000, &[], true);
assert_eq!(analysis.exit_type, ExitType::ApplicationError);
assert!(!analysis.terminated_externally);
}
#[test]
fn a_deliberate_kill_is_reported_as_killed_not_suspected() {
// 「我们杀的」有登记作据,不该被说成「疑似」。
let mut signals = exit_analysis::ExitSignals::new(1, 60_000, &[]);
signals.killed = true;
let analysis = exit_analysis::analyze(&signals);
assert_eq!(analysis.exit_type, ExitType::Killed);
assert!(!analysis.terminated_externally);
}
#[test]
fn windows_ntstatus_is_explained() {
let analysis = analyze(-1_073_741_819, 60_000, &[], false);
assert_eq!(analysis.exit_type, ExitType::ApplicationError);
assert_eq!(
analysis.windows_status.as_deref(),
Some("0xC0000005 ACCESS_VIOLATION")
);
let dll = analyze(-1_073_741_515, 60_000, &[], false);
assert_eq!(
dll.windows_status.as_deref(),
Some("0xC0000135 DLL_NOT_FOUND")
);
// 0xC000013A = STATUS_CONTROL_C_EXIT(控制台 Ctrl+C / 关窗口),十进制 -1073741510。
let control_c = analyze(-1_073_741_510, 60_000, &[], false);
assert_eq!(
control_c.windows_status.as_deref(),
Some("0xC000013A CONTROL_C_EXIT")
);
// 回归:-1073741743 其实是 0xC0000051,不得被当成 CONTROL_C_EXIT。
assert!(analyze(-1_073_741_743, 60_000, &[], false)
.windows_status
.is_none());
}
#[test]
fn killed_and_interrupted_override_exit_code() {
let mut signals = exit_analysis::ExitSignals::new(1, 5_000, &[]);
signals.killed = true;
assert_eq!(exit_analysis::analyze(&signals).exit_type, ExitType::Killed);
let mut signals = exit_analysis::ExitSignals::new(1, 5_000, &[]);
signals.interrupted = true;
assert_eq!(
exit_analysis::analyze(&signals).exit_type,
ExitType::Interrupted
);
}
#[test]
fn exit_type_strings_match_contract() {
// 与 M15 刻意保持一致的字符串集合(`launch-接口约定.md` §3)。
assert_eq!(ExitType::Normal.as_str(), "normal");
assert_eq!(ExitType::ApplicationError.as_str(), "application_error");
assert_eq!(ExitType::JvmError.as_str(), "jvm_error");
assert_eq!(ExitType::SigKill.as_str(), "sigkill");
assert_eq!(ExitType::SigTerm.as_str(), "sigterm");
assert_eq!(ExitType::Killed.as_str(), "killed");
assert_eq!(ExitType::Interrupted.as_str(), "interrupted");
assert!(!ExitType::Normal.is_abnormal());
assert!(ExitType::JvmError.is_abnormal());
}
#[test]
fn summary_is_human_readable() {
let analysis = analyze(-1_073_741_819, 61_500, &[], false);
let summary = analysis.summary();
assert!(summary.contains("-1073741819"));
assert!(summary.contains("61.5s"));
assert!(summary.contains("ACCESS_VIOLATION"));
}
@@ -0,0 +1,147 @@
//! `--graphicsBackend` 的两套判据(能力位 vs 精确版本门)与周快照放行。
//!
//! 事实基线(`research/05-mc-vulkan.md` §3.2/§4.2,实测):
//! - `--graphicsBackend` 从 **26.2-snapshot-2**(2026-04-09)才有;
//! - `org.lwjgl:lwjgl-vulkan` 从 **26.2-snapshot-1**(2026-04-07)就有 —— 差两天;
//! - 周快照(`26w14a`)无法与 `26.2-snapshot-N` 排序,只能靠库表判。
//!
//! 结论:能力位**不能**替代精确版本门,两者的分工是本文件的断言对象。
use unlucky_core::launch::game_args::{graphics_backend_allowed, GRAPHICS_BACKEND_MIN_VERSION};
use crate::prelude::*;
#[test]
fn known_versions_use_the_exact_threshold_and_ignore_the_capability_bit() {
// 26.2-snapshot-1:有 Vulkan 库却没有参数,绝不能因为能力位放行
// (jopt-simple 未开 allowsUnrecognizedOptions,未声明选项会让游戏直接起不来)。
assert!(!graphics_backend_allowed(
&GameVersion::parse("26.2-snapshot-1"),
true
));
assert!(!graphics_backend_allowed(
&GameVersion::parse("26.2-snapshot-1"),
false
));
// 门限上的那一版起放行(能力位无关)。
assert!(graphics_backend_allowed(
&GameVersion::parse(GRAPHICS_BACKEND_MIN_VERSION),
false
));
assert!(graphics_backend_allowed(&GameVersion::parse("26.2"), false));
assert!(graphics_backend_allowed(&GameVersion::parse("26.3"), false));
// 可解析的老版本:能力位再真也不放行。
assert!(!graphics_backend_allowed(
&GameVersion::parse("1.20.1"),
true
));
}
#[test]
fn weekly_snapshots_fall_back_to_the_capability_bit() {
let weekly = GameVersion::parse("26w14a");
assert!(!weekly.is_known(), "周快照不做数字排序(不猜)");
assert!(weekly.is_weekly_snapshot());
// 26.1 线(含周快照)没有 lwjgl-vulkan → 丢弃,和以前一样。
assert!(!graphics_backend_allowed(&weekly, false));
// 带 Vulkan 库的周快照(26.2 之后的周快照)→ 不再一律丢弃(这就是被修的误伤)。
assert!(graphics_backend_allowed(&weekly, true));
assert!(graphics_backend_allowed(
&GameVersion::parse("26w30a"),
true
));
}
#[test]
fn non_weekly_unknown_versions_are_still_dropped() {
// 第三方自定义版本串没有可依据的语义,能力位为真也不猜。
let custom = GameVersion::parse("custom-build");
assert!(!custom.is_known());
assert!(!custom.is_weekly_snapshot());
assert!(!graphics_backend_allowed(&custom, true));
assert!(!graphics_backend_allowed(&GameVersion::default(), true));
}
#[test]
fn weekly_snapshot_shape_is_parsed_narrowly() {
for shape in ["20w14a", "26w14a", "26W14A", "12w01a"] {
assert!(
GameVersion::parse(shape).is_weekly_snapshot(),
"{shape} 应识别为周快照"
);
}
for other in ["26.2-snapshot-1", "26w14", "26ww14a", "w14a", "26w14ab", ""] {
assert!(
!GameVersion::parse(other).is_weekly_snapshot(),
"{other} 不该被当成周快照"
);
}
}
#[test]
fn assemble_lets_a_vulkan_weekly_snapshot_through() {
let (request, ctx) = weekly_request("26w30a", true);
let assembled = assemble(&request, &ctx).expect("组装应成功");
assert!(
assembled
.args
.windows(2)
.any(|pair| pair[0] == "--graphicsBackend" && pair[1] == "vulkan"),
"带 Vulkan 库的周快照应下发 --graphicsBackend:{:?}",
assembled.args
);
assert!(!assembled
.warnings
.iter()
.any(|warning| matches!(warning, AssemblyWarning::GraphicsBackendSkipped { .. })));
}
#[test]
fn assemble_still_drops_it_for_a_pre_vulkan_weekly_snapshot() {
let (request, ctx) = weekly_request("26w14a", false);
let assembled = assemble(&request, &ctx).expect("组装应成功");
assert!(!assembled.args.iter().any(|arg| arg == "--graphicsBackend"));
assert!(assembled
.warnings
.iter()
.any(|warning| matches!(warning, AssemblyWarning::GraphicsBackendSkipped { .. })));
}
#[test]
fn adapter_derives_the_capability_bit_from_the_library_names() {
let dir = temp_dir("adapter-vulkan-capability");
let with_vulkan = vec![
LibraryInput::new(dir.join("libraries/lwjgl.jar"), "org.lwjgl:lwjgl:3.4.3"),
LibraryInput::new(
dir.join("libraries/lwjgl-vulkan.jar"),
"org.lwjgl:lwjgl-vulkan:3.4.3",
),
];
let without_vulkan = vec![LibraryInput::new(
dir.join("libraries/lwjgl.jar"),
"org.lwjgl:lwjgl:3.4.3",
)];
for (libraries, expected) in [(&with_vulkan, true), (&without_vulkan, false)] {
let snapshot = SnapshotInput {
main_class: Some("net.minecraft.client.main.Main"),
libraries,
..SnapshotInput::minimal("26.3", "")
};
let output = build_request(&snapshot, AdapterOptions::strict());
assert_eq!(
output.request.manifest.has_vulkan_backend, expected,
"库表 {libraries:?} 的能力位判定不对"
);
}
}
/// 一个只关心 `--graphicsBackend` 的请求(周快照版本 + 指定能力位)。
fn weekly_request(version: &str, has_vulkan_backend: bool) -> (LaunchRequest, PlatformCtx) {
let (mut request, ctx) = legacy_forge_request();
request.instance.version = GameVersion::parse(version);
request.manifest.has_vulkan_backend = has_vulkan_backend;
request.graphics_backend = Some("vulkan".to_string());
(request, ctx)
}
+18
View File
@@ -0,0 +1,18 @@
//! launch 模块集成测试入口(`cargo test --test launch`)。
mod adapter_cases;
mod cmdline_snapshot;
mod command_builder_cases;
mod exit_cases;
mod graphics_backend_cases;
mod natives_args_cases;
mod natives_cases;
mod natives_dry_run_cases;
mod pipeline_cases;
mod prelude;
mod process_smoke;
mod pump_buffer;
mod session_events;
mod sink_cases;
mod tree_kill_cases;
mod watchdog_cases;
@@ -0,0 +1,136 @@
//! natives 时代分界与参数接线:清单口径收集、`-Djava.library.path` 归属、1.19+ 四个目录属性。
//!
//! 抽取行为本身在 `natives_cases.rs`;这里只验「参数从哪来、收集口径对不对」。
use unlucky_core::launch::natives;
use crate::prelude::*;
/// 参数接线:legacy 清单没有 `arguments.jvm`,`-Djava.library.path` 由启动器补且只补一次。
#[test]
fn legacy_manifest_gets_single_java_library_path() {
let (request, ctx) = legacy_forge_request();
let command = assemble(&request, &ctx).expect("组装应成功");
let natives = request.dirs.natives_dir.display().to_string();
let library_path: Vec<&String> = command
.args
.iter()
.filter(|arg| arg.starts_with("-Djava.library.path="))
.collect();
assert_eq!(library_path.len(), 1, "legacy 只该有一条:{library_path:?}");
assert_eq!(library_path[0], &format!("-Djava.library.path={natives}"));
}
/// 参数接线:1.13+ 清单自带 `-Djava.library.path=${natives_directory}`,启动器不得重复注入。
#[test]
fn modern_manifest_keeps_its_own_library_path_once() {
let (mut request, ctx) = legacy_forge_request();
let natives = request.dirs.natives_dir.display().to_string();
request.manifest.jvm = vec![
ArgumentEntry::plain(["-Djava.library.path=${natives_directory}".to_string()]),
ArgumentEntry::plain(["-cp".to_string(), "${classpath}".to_string()]),
];
request.manifest.legacy_arguments = None;
let command = assemble(&request, &ctx).expect("组装应成功");
let library_path: Vec<&String> = command
.args
.iter()
.filter(|arg| arg.starts_with("-Djava.library.path="))
.collect();
assert_eq!(library_path.len(), 1, "清单 + 默认值不该叠加");
assert_eq!(library_path[0], &format!("-Djava.library.path={natives}"));
}
/// 参数接线:1.19+ 的四个目录属性都要渲染到同一目录(LWJGL 3.3 运行期往那里解压)。
#[test]
fn classpath_native_era_renders_all_four_directory_properties() {
let (mut request, ctx) = legacy_forge_request();
let natives = request.dirs.natives_dir.display().to_string();
request.manifest.jvm = vec![ArgumentEntry::plain([
"-Djava.library.path=${natives_directory}".to_string(),
"-Djna.tmpdir=${natives_directory}".to_string(),
"-Dorg.lwjgl.system.SharedLibraryExtractPath=${natives_directory}".to_string(),
"-Dio.netty.native.workdir=${natives_directory}".to_string(),
])];
request.manifest.legacy_arguments = None;
let command = assemble(&request, &ctx).expect("组装应成功");
for key in [
"-Djava.library.path=",
"-Djna.tmpdir=",
"-Dorg.lwjgl.system.SharedLibraryExtractPath=",
"-Dio.netty.native.workdir=",
] {
assert!(
command
.args
.iter()
.any(|arg| arg == &format!("{key}{natives}")),
"缺少 {key}{natives}:{:?}",
command.args
);
}
}
/// 收集口径:`natives` 表按 OS 命中 → 分类器 jar;1.19+ 无表 → 空;
/// `hint=local` 走实例私有目录(HMCL `analyse/03` §6.8 口径)。
///
/// 注意:安装层当前的 natives 分类器分支**不认** `hint=local`(走共享 `libraries/`),
/// 所以这一条断言测的是 `collect` 的正确行为,不是「与安装层一致」——见
/// `collect` 文档里的已知缺口说明。
#[test]
fn collect_picks_classifier_jars_per_environment() {
use unlucky_core::game::{Environment, GameLayout, Library};
let libraries: Vec<Library> = serde_json::from_str(
r#"[
{"name":"org.lwjgl:lwjgl:3.2.2",
"natives":{"linux":"natives-linux","windows":"natives-windows"},
"extract":{"exclude":["META-INF/"]},
"downloads":{"artifact":{"path":"org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2.jar","url":"https://libraries.minecraft.net/a.jar"},
"classifiers":{"natives-windows":{"path":"org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2-natives-windows.jar","url":"https://libraries.minecraft.net/b.jar"}}}},
{"name":"org.lwjgl:lwjgl-tinyfd:3.2.2","natives":{"windows":"natives-windows"},
"hint":"local",
"downloads":{"classifiers":{"natives-windows":{"path":"org/lwjgl/lwjgl-tinyfd/3.2.2/lwjgl-tinyfd-3.2.2-natives-windows.jar","url":"https://libraries.minecraft.net/c.jar"}}}},
{"name":"org.lwjgl:lwjgl:3.3.3:natives-linux",
"rules":[{"action":"allow","os":{"name":"linux"}}],
"downloads":{"artifact":{"path":"org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-linux.jar","url":"https://libraries.minecraft.net/d.jar"}}}
]"#,
)
.expect("库 JSON 应可解析");
let layout = GameLayout::new("C:/mc");
let environment = Environment {
os: "windows".to_string(),
os_version: "10.0.19045".to_string(),
arch: "x86_64".to_string(),
..Environment::default()
};
let natives = natives::collect(&layout, "1.16.5", &libraries, &environment);
assert_eq!(natives.len(), 2, "只该收 windows 分类器:{natives:?}");
assert!(
natives[0]
.path
.ends_with("libraries/org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2-natives-windows.jar"),
"{:?}",
natives[0].path
);
assert_eq!(natives[0].name, "org.lwjgl:lwjgl:3.2.2:natives-windows");
assert_eq!(natives[0].exclude, vec!["META-INF/".to_string()]);
// `hint=local`:按 HMCL 口径落实例私有目录(安装层尚未跟进,见函数文档)。
assert!(
natives[1]
.path
.ends_with("versions/1.16.5/libraries/org/lwjgl/lwjgl-tinyfd/3.2.2/lwjgl-tinyfd-3.2.2-natives-windows.jar"),
"{:?}",
natives[1].path
);
// 1.19+ 形态(无 natives 表)→ 空表(natives jar 在 classpath 上)。
let modern: Vec<Library> = serde_json::from_str(
r#"[{"name":"org.lwjgl:lwjgl:3.3.3:natives-windows",
"rules":[{"action":"allow","os":{"name":"windows"}}],
"downloads":{"artifact":{"path":"org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-windows.jar","url":"https://libraries.minecraft.net/e.jar"}}}]"#,
)
.expect("库 JSON 应可解析");
assert!(natives::collect(&layout, "1.20.1", &modern, &environment).is_empty());
}
+221
View File
@@ -0,0 +1,221 @@
//! natives 解压(`launch::natives`)的用例:解析正确性 / 排除项 / 幂等 / 时代参数 / 事件流。
//!
//! 假 jar 用 `zip` 的 Stored 模式现造(不依赖真下载),真机验收另有人工步骤。
use std::path::Path;
use unlucky_core::launch::natives::{self, NativeJar, NativesEra, NativesPlan, STAMP_FILE};
use crate::prelude::*;
fn plan(jars: Vec<NativeJar>) -> NativesPlan {
NativesPlan::new(jars)
}
#[test]
fn extracts_native_libraries_and_drops_noise() {
let root = temp_dir("natives-extract");
let jar = root.join("libraries/lwjgl-natives-windows.jar");
make_jar(&jar, &lwjgl_jar_entries());
let target = root.join("versions/1.16.5/natives");
let report =
natives::extract(&target, &plan(vec![NativeJar::new(&jar, "lwjgl")])).expect("解压应成功");
assert_eq!(report.files, 2, "只该解出两个原生库:{report:?}");
assert!(!report.up_to_date);
assert_eq!(report.jars, 1);
// META-INF/ 两条 + .sha1 + README.txt。
assert_eq!(report.ignored, 4, "噪音条目数:{report:?}");
assert_eq!(
std::fs::read(target.join("lwjgl.dll")).expect("读 dll"),
b"DLL-BYTES"
);
assert!(target.join("OpenAL.dll").is_file());
assert!(!target.join("META-INF").exists(), "META-INF/ 不该落地");
assert!(!target.join("lwjgl.dll.sha1").exists(), "签名文件不该落地");
assert!(!target.join("README.txt").exists(), "非原生库不该落地");
assert!(target.join(STAMP_FILE).is_file(), "应写戳记");
}
#[test]
fn second_run_is_idempotent_and_source_change_reextracts() {
let root = temp_dir("natives-idempotent");
let jar = root.join("natives-windows.jar");
make_jar(&jar, &[("lwjgl.dll", b"V1".as_slice())]);
let target = root.join("natives");
let extract_plan = plan(vec![NativeJar::new(&jar, "lwjgl")]);
let first = natives::extract(&target, &extract_plan).expect("首次解压");
assert_eq!(first.files, 1);
assert!(!first.up_to_date);
// 第二次:戳记命中,零写盘(文件 mtime 不变)。
let before = std::fs::metadata(target.join("lwjgl.dll"))
.expect("stat")
.modified()
.expect("mtime");
let second = natives::extract(&target, &extract_plan).expect("二次解压");
assert!(second.up_to_date, "应判定目录已就绪:{second:?}");
assert_eq!(second.files, 0);
assert_eq!(second.skipped, 1);
let after = std::fs::metadata(target.join("lwjgl.dll"))
.expect("stat")
.modified()
.expect("mtime");
assert_eq!(before, after, "幂等路径不该重写文件");
// 源 jar 变了(内容 + 长度都变):必须重解,且旧的原生库被清掉。
make_jar(&jar, &[("lwjgl.dll", b"V2-LONGER".as_slice())]);
let third = natives::extract(&target, &extract_plan).expect("三次解压");
assert!(!third.up_to_date, "源变了必须重解:{third:?}");
assert_eq!(
std::fs::read(target.join("lwjgl.dll")).expect("读 dll"),
b"V2-LONGER"
);
}
#[test]
fn stale_native_libraries_are_cleaned_before_reextract() {
let root = temp_dir("natives-clean");
let jar = root.join("natives-windows.jar");
make_jar(&jar, &[("lwjgl.dll", b"NEW".as_slice())]);
let target = root.join("natives");
std::fs::create_dir_all(&target).expect("建目录");
// 上一代遗留(旧版本 / 旧 loader 解出来的),必须清掉,否则两代 dll 混在 java.library.path 里。
std::fs::write(target.join("old-lwjgl.dll"), b"stale").expect("写旧 dll");
std::fs::write(target.join("keep-me.txt"), b"user file").expect("写用户文件");
natives::extract(&target, &plan(vec![NativeJar::new(&jar, "lwjgl")])).expect("解压");
assert!(!target.join("old-lwjgl.dll").exists(), "旧原生库应被清理");
assert!(target.join("keep-me.txt").is_file(), "非原生库文件不该被删");
assert!(target.join("lwjgl.dll").is_file());
}
#[test]
fn empty_plan_still_creates_directory_for_1_19() {
// 1.19+ 没有 natives 分类器:目录仍要存在(SharedLibraryExtractPath 指向它)。
let root = temp_dir("natives-empty");
let target = root.join("versions/1.21.4/natives");
let report = natives::extract(&target, &NativesPlan::default()).expect("建目录");
assert!(target.is_dir());
assert!(report.up_to_date);
assert_eq!(report.files, 0);
assert_eq!(report.jars, 0);
assert_eq!(
report.era,
NativesEra::ClasspathNative,
"默认时代(无 jar)"
);
}
#[test]
fn missing_directory_is_an_error_not_a_silent_pass() {
let plan = NativesPlan::new(vec![NativeJar::new("x.jar", "x")]);
let err = natives::extract(Path::new(""), &plan).expect_err("空目录必须报错");
assert!(err.message().contains("natives 目录"), "{err}");
}
#[test]
fn broken_jar_reports_zip_error() {
let root = temp_dir("natives-broken");
let jar = root.join("natives-broken.jar");
std::fs::write(&jar, b"not a zip at all").expect("写坏 jar");
let target = root.join("natives");
let err = natives::extract(&target, &plan(vec![NativeJar::new(&jar, "broken")]))
.expect_err("坏 jar 必须报错");
assert!(err.message().contains("zip"), "错误应点明 zip:{err}");
}
#[test]
fn era_detect_covers_three_generations() {
assert_eq!(NativesEra::detect(0, 3), NativesEra::Legacy);
assert_eq!(NativesEra::detect(9, 3), NativesEra::Lwjgl3);
assert_eq!(NativesEra::detect(9, 0), NativesEra::ClasspathNative);
assert_eq!(NativesEra::Legacy.as_str(), "legacy");
assert_eq!(NativesEra::Lwjgl3.as_str(), "lwjgl3");
assert_eq!(NativesEra::ClasspathNative.as_str(), "classpath_native");
}
/// 事件流:`natives_extracted` 落盘字段与接口约定一致(dir/files/skipped/custom)。
#[test]
fn natives_extracted_event_carries_report_fields() {
let root = temp_dir("natives-event");
let jar = root.join("natives-windows.jar");
make_jar(&jar, &[("lwjgl.dll", b"DLL".as_slice())]);
let target = root.join("natives");
let report =
natives::extract(&target, &plan(vec![NativeJar::new(&jar, "lwjgl")])).expect("解压");
let sessions = temp_sessions_root("natives-event-sessions");
let mut writer = SessionWriter::create(
&sessions,
SessionMeta::new("inst", "1.16.5", "1.16.5"),
Redactor::new(),
)
.expect("建会话");
writer.natives_extracted(&report).expect("写事件");
let path = writer.path().to_path_buf();
writer.finish(EndReason::Exit).expect("收尾");
let events = read_events(&path);
let event = events
.iter()
.find(|event| event["kind"] == "natives_extracted")
.expect("应有 natives_extracted 事件");
let payload = &event["payload"];
assert_eq!(payload["files"], 1);
assert_eq!(payload["skipped"], 0);
assert_eq!(payload["custom"], false);
assert_eq!(payload["jars"], 1);
assert_eq!(payload["up_to_date"], false);
assert_eq!(payload["era"], "lwjgl3");
assert!(payload["dir"]
.as_str()
.expect("dir 是字符串")
.ends_with("natives"));
}
/// 缺 natives jar:适配层当成组件缺失(组装前显式失败),而不是等到 UnsatisfiedLinkError。
#[test]
fn missing_native_jar_becomes_missing_component() {
let (mut request, ctx) = legacy_forge_request();
request.natives = NativesPlan::new(vec![NativeJar::new(
"C:/mc/libraries/missing-natives-windows.jar",
"org.lwjgl:lwjgl:3.2.2:natives-windows",
)]);
// 直接验证适配层:给一个不存在的 natives jar,严格模式应产出 MissingNativeJar 告警。
let libraries: Vec<LibraryInput> = request
.classpath
.iter()
.map(|path| LibraryInput::new(path.clone(), "x"))
.collect();
let natives = request.natives.jars.clone();
let snapshot = SnapshotInput {
version_id: "1.16.5",
main_class: Some("net.minecraft.client.main.Main"),
libraries: &libraries,
natives: &natives,
..SnapshotInput::minimal("1.16.5", "net.minecraft.client.main.Main")
};
let options = AdapterOptions {
java: JavaSelection::new("C:/java/bin/java.exe", 17, 64),
..AdapterOptions::strict()
};
let output = build_request(&snapshot, options);
assert!(
output
.warnings
.iter()
.any(|warning| matches!(warning, AdapterWarning::MissingNativeJar { .. })),
"应有 MissingNativeJar 告警:{:?}",
output.warnings
);
assert_eq!(output.request.natives.era, NativesEra::Legacy);
// 组装按既有语义直接失败(missing_components 非空)。
assert!(matches!(
assemble(&output.request, &ctx).err(),
Some(LaunchError::Assembly(AssemblyError::MissingComponents(_)))
));
}
@@ -0,0 +1,245 @@
//! `--dry-run` 的零落盘契约(M231 / DRYRUNPURE):预览只读版本树,不建目录、不改 mtime。
//!
//! 判据是「版本树的节点类型 + mtime 快照逐字相等」而不是「natives 目录不存在」:
//! 后者抓不住 `create_dir_all` 之后立刻删掉的情形(父目录 mtime 已经变了),
//! 也抓不住「目录本来就在、但被重写/清空」的情形。
use std::path::{Path, PathBuf};
use unlucky_core::launch::natives::{self, NativeJar, NativesPlan};
use crate::prelude::*;
/// 版本树的「节点类型 + mtime(纳秒)+ 相对路径」快照,用来逐字比较 dry-run 前后。
///
/// 抓得住:新增/删除任何节点(含 `natives/` 目录本身)、任何已有节点被重写或 mtime 被
/// 更新;根节点(= 父目录 `versions/<id>/`)也在快照里,所以「建目录」会同时体现在
/// 节点集合与父目录 mtime 上。
/// 抓不住:①内容变了但 mtime 被回填成原值(本仓没有这种写法);②**建了立刻删**这类
/// 瞬时写入只留父目录 mtime 一条线索,而 NTFS 会把该更新合并掉 —— 本仓实测:全量并行跑
/// 测试时这条偶发看不见(单跑又看得见),所以瞬时建删只剩「`natives` 目录不存在」兜底。
///
/// 时间戳口径:NTFS / ext4 的 mtime 精度是 100ns 级,快照取纳秒整数比较,同一秒内的
/// 两次操作(写文件 → 再写一次)分辨得出来,不需要 `sleep` 把操作推到不同秒。
/// 判据自检见 `tree_snapshot_sees_new_nodes_and_mtime_changes`(防假绿)。
fn tree_snapshot(root: &Path) -> Vec<String> {
fn line(meta: &std::fs::Metadata, relative: &str) -> String {
let nanos = meta
.modified()
.expect("mtime")
.duration_since(std::time::UNIX_EPOCH)
.expect("epoch 之后")
.as_nanos();
let kind = if meta.is_dir() { "d" } else { "f" };
format!("{kind} {nanos} {relative}")
}
fn walk(dir: &Path, prefix: &str, out: &mut Vec<String>) {
let mut entries: Vec<PathBuf> = std::fs::read_dir(dir)
.expect("读目录")
.flatten()
.map(|entry| entry.path())
.collect();
entries.sort();
for path in entries {
let meta = std::fs::symlink_metadata(&path).expect("元数据");
let name = path
.file_name()
.expect("文件名")
.to_string_lossy()
.to_string();
let relative = format!("{prefix}{name}");
out.push(line(&meta, &relative));
if meta.is_dir() {
walk(&path, &format!("{relative}/"), out);
}
}
}
let mut out = vec![line(
&std::fs::symlink_metadata(root).expect("根元数据"),
"<root>",
)];
walk(root, "", &mut out);
out
}
/// `--dry-run` 零落盘(有 jar):只报「将解压几个」,版本树里一个节点都不新增、mtime 不动。
#[test]
fn dry_run_previews_natives_without_touching_disk() {
let root = temp_dir("natives-dry-run-pure");
let jar = root.join("libraries/lwjgl-natives-windows.jar");
make_jar(&jar, &lwjgl_jar_entries());
let version_dir = root.join("versions/1.16.5");
std::fs::create_dir_all(&version_dir).expect("建版本目录");
std::fs::write(version_dir.join("1.16.5.json"), b"{}").expect("放清单占位");
let natives_dir = version_dir.join("natives");
let before = tree_snapshot(&version_dir);
let (mut request, _) = legacy_forge_request();
request.dirs.natives_dir = natives_dir.clone();
request.natives = NativesPlan::new(vec![NativeJar::new(&jar, "lwjgl")]);
request.manifest.legacy_arguments = Some("--username ${auth_player_name}".to_string());
let sessions = temp_sessions_root("natives-dry-run-pure-sessions");
let launcher = Launcher::new(LauncherConfig::new(&sessions));
let dry = launcher.dry_run(&request).expect("dry-run 应成功");
assert_eq!(
before,
tree_snapshot(&version_dir),
"dry-run 不许在版本树里加节点 / 改 mtime"
);
let report = dry.natives.expect("应有 natives 预览");
assert!(report.planned, "预览报告要自报家门:{report:?}");
assert!(!report.dir_exists, "natives 目录原本不在盘上:{report:?}");
assert_eq!(report.files, 2, "要算出「将写下」的数量:{report:?}");
assert_eq!(report.ignored, 4, "忽略口径要与真解压一致:{report:?}");
assert!(!report.up_to_date, "{report:?}");
assert!(
report.summary().contains("未落盘"),
"人读行得说清没写盘:{}",
report.summary()
);
assert!(!natives_dir.exists(), "natives 目录不该被建出来");
}
/// `--dry-run` 零落盘(无 jar,1.19+ 常态):不建 natives 目录,人读行说清「尚不存在」。
#[test]
fn dry_run_with_no_natives_jar_creates_no_directory() {
let root = temp_dir("natives-dry-run-nojar");
let version_dir = root.join("versions/1.20.1");
std::fs::create_dir_all(&version_dir).expect("建版本目录");
let natives_dir = version_dir.join("natives");
let before = tree_snapshot(&version_dir);
let (mut request, _) = legacy_forge_request();
request.dirs.natives_dir = natives_dir.clone();
request.natives = NativesPlan::default();
request.manifest.legacy_arguments = Some("--username ${auth_player_name}".to_string());
let sessions = temp_sessions_root("natives-dry-run-nojar-sessions");
let launcher = Launcher::new(LauncherConfig::new(&sessions));
let dry = launcher.dry_run(&request).expect("dry-run 应成功");
assert_eq!(before, tree_snapshot(&version_dir), "无 jar 也不许建目录");
let report = dry.natives.expect("应有 natives 预览");
assert_eq!(report.jars, 0, "{report:?}");
assert_eq!(report.files, 0, "{report:?}");
assert!(!report.dir_exists, "盘上没有这个目录:{report:?}");
assert!(report.up_to_date, "无 jar = 无需解压:{report:?}");
assert!(
report.summary().contains("尚不存在"),
"{}",
report.summary()
);
assert!(!natives_dir.exists(), "natives 目录不该被建出来");
}
/// `--dry-run` 零落盘(空 jar:只有 `META-INF/` 与噪音):数得出 0 个原生库,仍不建目录。
#[test]
fn dry_run_with_empty_native_jar_creates_no_directory() {
let root = temp_dir("natives-dry-run-emptyjar");
let jar = root.join("natives-windows.jar");
make_jar(
&jar,
&[
("META-INF/MANIFEST.MF", b"manifest".as_slice()),
("README.txt", b"noise".as_slice()),
],
);
let version_dir = root.join("versions/1.16.5");
std::fs::create_dir_all(&version_dir).expect("建版本目录");
let natives_dir = version_dir.join("natives");
let before = tree_snapshot(&version_dir);
let (mut request, _) = legacy_forge_request();
request.dirs.natives_dir = natives_dir.clone();
request.natives = NativesPlan::new(vec![NativeJar::new(&jar, "lwjgl")]);
request.manifest.legacy_arguments = Some("--username ${auth_player_name}".to_string());
let sessions = temp_sessions_root("natives-dry-run-emptyjar-sessions");
let launcher = Launcher::new(LauncherConfig::new(&sessions));
let dry = launcher.dry_run(&request).expect("dry-run 应成功");
assert_eq!(before, tree_snapshot(&version_dir), "空 jar 也不许建目录");
let report = dry.natives.expect("应有 natives 预览");
assert_eq!(report.files, 0, "空 jar 解不出原生库:{report:?}");
assert_eq!(report.ignored, 2, "两条噪音都要数进忽略:{report:?}");
assert!(!report.up_to_date, "{report:?}");
assert!(!report.dir_exists, "{report:?}");
assert!(!natives_dir.exists(), "natives 目录不该被建出来");
}
/// 目录已经真解压过:预览读戳记报「已就绪」,同样零写盘(幂等路径也不许碰盘)。
#[test]
fn dry_run_after_real_extract_reports_ready_without_writing() {
let root = temp_dir("natives-dry-run-ready");
let jar = root.join("natives-windows.jar");
make_jar(&jar, &[("lwjgl.dll", b"DLL".as_slice())]);
let version_dir = root.join("versions/1.16.5");
let natives_dir = version_dir.join("natives");
std::fs::create_dir_all(&version_dir).expect("建版本目录");
natives::extract(
&natives_dir,
&NativesPlan::new(vec![NativeJar::new(&jar, "lwjgl")]),
)
.expect("先真解压一次");
let before = tree_snapshot(&version_dir);
let (mut request, _) = legacy_forge_request();
request.dirs.natives_dir = natives_dir.clone();
request.natives = NativesPlan::new(vec![NativeJar::new(&jar, "lwjgl")]);
request.manifest.legacy_arguments = Some("--username ${auth_player_name}".to_string());
let sessions = temp_sessions_root("natives-dry-run-ready-sessions");
let launcher = Launcher::new(LauncherConfig::new(&sessions));
let dry = launcher.dry_run(&request).expect("dry-run 应成功");
assert_eq!(
before,
tree_snapshot(&version_dir),
"预览不该重写已有的原生库或戳记"
);
let report = dry.natives.expect("应有 natives 预览");
assert!(report.planned, "{report:?}");
assert!(report.dir_exists, "{report:?}");
assert!(report.up_to_date, "戳记命中应报已就绪:{report:?}");
assert_eq!(report.files, 0, "预览不该再数一遍:{report:?}");
assert_eq!(report.skipped, 1, "{report:?}");
}
/// 判据自检(防假绿):快照必须看得见「新增节点」与「已有节点的 mtime 变化」,
/// 否则上面几条「快照逐字相等」等于没断言。
///
/// 这里**不**用「建了立刻删」来探父目录 mtime:NTFS 会把瞬时建删对父目录 mtime 的影响
/// 合并掉(本仓实测:全量并行跑时偶发看不见、单跑看得见),拿它当门禁就是引入 flaky。
/// 改成两种确定性变化:新节点(结构维度)+ 同内容重写(时间维度,只有文件 mtime 变,
/// 不依赖父目录时间戳的合并行为)。
#[test]
fn tree_snapshot_sees_new_nodes_and_mtime_changes() {
let root = temp_dir("natives-snapshot-probe");
let version_dir = root.join("versions/1.16.5");
std::fs::create_dir_all(&version_dir).expect("建版本目录");
std::fs::write(version_dir.join("1.16.5.json"), b"{}").expect("放清单占位");
let baseline = tree_snapshot(&version_dir);
// 1) 结构维度:多一个目录节点(正是 `create_dir_all(natives)` 留下的痕迹)。
std::fs::create_dir(version_dir.join("natives")).expect("建目录");
let with_dir = tree_snapshot(&version_dir);
assert!(
with_dir.len() > baseline.len(),
"快照必须看得见新目录:{baseline:?} vs {with_dir:?}"
);
assert_ne!(baseline, with_dir);
// 2) 时间维度:同一份内容重写一遍,节点集合不变、文件 mtime 必须变。
std::fs::write(version_dir.join("1.16.5.json"), b"{}").expect("重写清单占位");
let rewritten = tree_snapshot(&version_dir);
assert_eq!(
with_dir.len(),
rewritten.len(),
"节点集合没变,差异只该在 mtime 上"
);
assert_ne!(
with_dir, rewritten,
"快照必须看得见已有文件的 mtime 变化,否则零落盘断言是假绿"
);
}
+376
View File
@@ -0,0 +1,376 @@
//! 顶层启动管线测试:dry-run(D18)、完整会话、停止、组装失败、崩溃现场。
//!
//! 真进程来自 shell(`cmd /C` 或 `sh -c`),所以这些是端到端用例:
//! 命令行组装 → 事件流落盘 → 退出判定 → 诊断挂钩 → 索引。
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use serde_json::json;
use crate::prelude::*;
/// 记录调用并返回固定结论的诊断挂钩。
#[derive(Default)]
struct RecordingHook {
calls: Mutex<Vec<String>>,
}
impl DiagnosisHook for RecordingHook {
fn analyze(&self, request: &DiagnosisRequest) -> Option<serde_json::Value> {
self.calls.lock().unwrap().push(request.summary());
Some(json!({
"source": "rules",
"needs_more_info": false,
"candidates": [{ "id": "stub_candidate", "severity": "high", "confidence": 0.9 }],
}))
}
}
fn launcher(sessions_root: &std::path::Path, hook: Option<Arc<RecordingHook>>) -> Launcher {
let mut config = LauncherConfig::new(sessions_root);
// 固定平台上下文,让命令行断言与运行平台无关。
config.platform = PlatformCtx::new(OsFamily::Windows, "x86_64");
config.process = ProcessConfig {
graceful_stop: Duration::from_millis(200),
..ProcessConfig::default()
};
let launcher = Launcher::new(config);
match hook {
Some(hook) => launcher.with_diagnosis_hook(hook),
None => launcher,
}
}
/// 用 shell 冒充 java:只为验证管线,不验证 JVM 语义。
fn shell_request(script: &str, run_dir: &std::path::Path) -> (LaunchRequest, LaunchRequest) {
let (mut request, ctx) = legacy_forge_request();
let _ = ctx;
#[cfg(windows)]
{
request.wrapper = vec!["cmd".to_string(), "/C".to_string(), script.to_string()];
}
#[cfg(not(windows))]
{
request.wrapper = vec!["/bin/sh".to_string(), "-c".to_string(), script.to_string()];
}
request.java = JavaSelection::new("fake-java", 17, 64);
request.manifest.legacy_arguments = None;
request.dirs.game_dir = run_dir.to_path_buf();
// natives 解压是管线第 6 段,会真写盘——指到临时目录,绝不碰 `C:/mc`。
request.dirs.natives_dir = run_dir.join("natives");
request.user.jvm = Vec::new();
request.user.game = Vec::new();
(request.clone(), request)
}
fn echo_script() -> String {
// 命令里带上 access_token 明文,用来验证“落盘前脱敏”。
#[cfg(windows)]
{
"echo LWJGL Version: 3.3.1 SECRET-TOKEN-123456".to_string()
}
#[cfg(not(windows))]
{
"echo 'LWJGL Version: 3.3.1 SECRET-TOKEN-123456'".to_string()
}
}
fn long_running_script(dir: &std::path::Path) -> String {
#[cfg(windows)]
let (file, body) = (
dir.join("linger.bat"),
"@echo off\r\nping -n 30 127.0.0.1 >nul\r\n",
);
#[cfg(not(windows))]
let (file, body) = (dir.join("linger.sh"), "#!/bin/sh\nsleep 30\n");
std::fs::write(&file, body).expect("写长跑脚本");
file.display().to_string()
}
#[test]
fn dry_run_returns_command_without_spawning_or_writing() {
let sessions = temp_sessions_root("dry");
let run_dir = temp_dir("dry-run");
let launcher = launcher(&sessions, None);
let (request, _) = shell_request(&echo_script(), &run_dir);
let dry: DryRun = launcher.dry_run(&request).expect("dry-run 应成功");
assert!(!dry.argv.is_empty());
assert!(
dry.display.contains("fake-java")
|| dry.display.contains("cmd")
|| dry.display.contains("/bin/sh")
);
assert!(
dry.display.contains("<redacted:token>"),
"命令行必须脱敏:{}",
dry.display
);
assert!(!dry.display.contains("SECRET-TOKEN-123456"));
assert_eq!(dry.classpath_entries, 3);
assert_eq!(dry.line(), dry.display.as_str());
// 不 spawn、不写会话文件。
assert!(!sessions.exists(), "dry-run 不能创建会话目录");
}
#[test]
fn pipeline_records_session_events_diagnosis_and_index() {
let sessions = temp_sessions_root("pipeline");
let run_dir = temp_dir("pipeline-run");
let hook = Arc::new(RecordingHook::default());
let launcher = launcher(&sessions, Some(hook.clone()));
let (request, _) = shell_request(&echo_script(), &run_dir);
let handle: SessionHandle = launcher.launch(&request).expect("启动应成功");
assert_eq!(handle.exit.exit_type, ExitType::Normal);
assert_eq!(handle.exit.code, 0);
// 存活 <30s → 静默失败 + 需要诊断。
assert!(handle.exit.silent_failure.is_some());
assert!(handle.needs_diagnosis());
assert_eq!(hook.calls.lock().unwrap().len(), 1, "诊断挂钩应被调用一次");
assert_eq!(
handle
.diagnostics
.as_ref()
.and_then(|value| value["candidates"][0]["id"].as_str()),
Some("stub_candidate")
);
assert!(
handle.event_write_errors.is_empty(),
"{:?}",
handle.event_write_errors
);
// 对外展示的 argv 已是脱敏版本(脚本里带着 token 明文)。
assert!(handle
.redacted_argv()
.iter()
.any(|arg| arg.contains("<redacted:token>")));
assert!(!handle
.redacted_argv()
.iter()
.any(|arg| arg.contains("SECRET-TOKEN-123456")));
assert!(handle.redacted_display().contains("<redacted:token>"));
assert!(handle.index.is_some());
assert!(handle.uptime_ms < 30_000);
let events = read_events(&handle.log_path);
let kinds: Vec<&str> = events
.iter()
.filter_map(|event| event["kind"].as_str())
.collect();
for expected in [
"session_start",
"cmd_built",
"proc_start",
"stdout",
"window_detected",
"exit",
"diagnosis",
"session_end",
] {
assert!(kinds.contains(&expected), "缺少事件 {expected}:{kinds:?}");
}
// 日志行里的 access_token 必须已脱敏。
let raw = std::fs::read_to_string(&handle.log_path).expect("读会话文件");
assert!(!raw.contains("SECRET-TOKEN-123456"), "事件流不得含凭据明文");
// 索引落盘且字段正确。
let index = session_index::read_all(&session_index::index_path(&sessions)).expect("读索引");
assert_eq!(index.len(), 1);
assert_eq!(index[0].session_id, handle.session_id);
assert!(index[0].silent_failure);
assert!(index[0].window_detected);
assert_eq!(index[0].exit_code, Some(0));
assert_eq!(index[0].diagnosis_top.as_deref(), Some("stub_candidate"));
}
#[test]
fn pipeline_stop_records_killed_exit() {
let sessions = temp_sessions_root("killed");
let run_dir = temp_dir("killed-run");
let launcher = launcher(&sessions, None);
let (request, _) = shell_request(&long_running_script(&run_dir), &run_dir);
let mut session = launcher.spawn(&request).expect("spawn 应成功");
assert!(session.pid() > 0);
session.stop().expect("停止应成功");
let handle = session.finish().expect("收尾应成功");
assert_eq!(handle.exit.exit_type, ExitType::Killed);
assert!(!handle.succeeded());
let events = read_events(&handle.log_path);
let kinds: Vec<&str> = events
.iter()
.filter_map(|event| event["kind"].as_str())
.collect();
assert!(kinds.contains(&"killed"), "应记录 killed 事件:{kinds:?}");
let index = session_index::read_all(&session_index::index_path(&sessions)).expect("读索引");
assert_eq!(index.len(), 1);
assert_eq!(index[0].exit_type.as_deref(), Some("killed"));
}
#[test]
fn a_kill_from_another_thread_is_recorded_as_killed() {
// D10:GUI 的 `host::launch::stop(pid)` 是从别的线程直接 `kill_tree`,不经过
// `LaunchSession::stop()`。这条路以前会记成 `application_error`(Windows 退出码 1)。
let sessions = temp_sessions_root("external-kill");
let run_dir = temp_dir("external-kill-run");
let launcher = launcher(&sessions, None);
let (request, _) = shell_request(&long_running_script(&run_dir), &run_dir);
let session = launcher.spawn(&request).expect("spawn 应成功");
let pid = session.pid();
// `kill_tree` 的返回值不代表「进程还在」:温和阶段可能已经关掉了它(taskkill 报 128)。
let _ = unlucky_core::launch::tree_kill::kill_tree(pid, Duration::from_millis(200));
let handle = session.finish().expect("收尾应成功");
assert_eq!(
handle.exit.exit_type,
ExitType::Killed,
"我们杀的要判成 killed:{}",
handle.exit.summary()
);
assert!(
!handle.exit.terminated_externally,
"有登记作据就不该只是「疑似」:{}",
handle.exit.summary()
);
}
#[test]
fn pipeline_assembly_failure_still_writes_evidence() {
let sessions = temp_sessions_root("assembly-fail");
let run_dir = temp_dir("assembly-fail-run");
let launcher = launcher(&sessions, None);
let (mut request, _) = shell_request(&echo_script(), &run_dir);
request.missing_components = vec![MissingComponent::named("org.lwjgl:lwjgl:3.3.1")];
let error = launcher.spawn(&request).expect_err("缺组件必须启动前失败");
assert!(matches!(
error,
LaunchError::Assembly(AssemblyError::MissingComponents(_))
));
let game_dir = sessions.join("game");
let files: Vec<PathBuf> = std::fs::read_dir(&game_dir)
.expect("会话目录应存在")
.flatten()
.map(|entry| entry.path())
.filter(|path| path.extension().map(|ext| ext == "jsonl").unwrap_or(false))
.filter(|path| {
path.file_stem()
.map(|stem| stem != "index")
.unwrap_or(false)
})
.collect();
assert_eq!(files.len(), 1, "应留下一个会话文件");
let events = read_events(&files[0]);
let kinds: Vec<&str> = events
.iter()
.filter_map(|event| event["kind"].as_str())
.collect();
assert_eq!(
kinds,
vec![
"session_start",
"natives_extracted",
"assembly_failed",
"session_end"
]
);
let missing = &events[2]["payload"]["missing_components"][0]["name"];
assert_eq!(missing.as_str(), Some("org.lwjgl:lwjgl:3.3.1"));
let index = session_index::read_all(&session_index::index_path(&sessions)).expect("读索引");
assert_eq!(index.len(), 1);
assert!(index[0].exit_type.is_none(), "没有进程就没有退出判定");
}
#[test]
fn pipeline_records_crash_report_artifact() {
let sessions = temp_sessions_root("crash");
let run_dir = temp_dir("crash-run");
let crash_dir = run_dir.join("crash-reports");
std::fs::create_dir_all(&crash_dir).expect("建 crash-reports");
std::fs::write(
crash_dir.join("crash-2026-10-29.txt"),
"---- Minecraft Crash Report ----\nDescription: Rendering screen\n",
)
.expect("写崩溃报告");
let launcher = launcher(&sessions, None);
let (request, _) = shell_request(&echo_script(), &run_dir);
let handle = launcher.launch(&request).expect("启动应成功");
assert!(
handle
.artifacts
.iter()
.any(|artifact| artifact.source == CrashSource::CrashReport),
"应发现崩溃报告:{:?}",
handle.artifacts
);
let events = read_events(&handle.log_path);
let crash = events
.iter()
.find(|event| event["kind"] == "crash_found")
.expect("应有 crash_found 事件");
let path = crash["payload"]["path"].as_str().unwrap_or_default();
assert!(path.ends_with("crash-2026-10-29.txt"));
assert!(crash["payload"]["excerpt"]
.as_str()
.unwrap_or_default()
.contains("Minecraft Crash Report"));
}
#[test]
fn pipeline_spawn_failure_writes_session_end() {
let sessions = temp_sessions_root("spawn-fail");
let run_dir = temp_dir("spawn-fail-run");
let launcher = launcher(&sessions, None);
let (mut request, _) = shell_request(&echo_script(), &run_dir);
// 包装器就是 program:给一个不存在的可执行文件,走派生失败分支。
request.wrapper = vec!["definitely-not-a-real-binary-ull".to_string()];
let error = launcher.spawn(&request).expect_err("程序不存在必须失败");
assert!(
matches!(error, LaunchError::Spawn { .. }),
"错误类型:{error:?}"
);
let game_dir = sessions.join("game");
let files: Vec<PathBuf> = std::fs::read_dir(&game_dir)
.expect("会话目录应存在")
.flatten()
.map(|entry| entry.path())
.filter(|path| path.extension().map(|ext| ext == "jsonl").unwrap_or(false))
.filter(|path| {
path.file_stem()
.map(|stem| stem != "index")
.unwrap_or(false)
})
.collect();
assert_eq!(files.len(), 1, "应留下一个会话文件");
let events = read_events(&files[0]);
let kinds: Vec<&str> = events
.iter()
.filter_map(|event| event["kind"].as_str())
.collect();
assert_eq!(
kinds,
vec![
"session_start",
"natives_extracted",
"cmd_built",
"session_end"
]
);
assert_eq!(events[3]["payload"]["reason"], "spawn_failed");
let index = session_index::read_all(&session_index::index_path(&sessions)).expect("读索引");
assert_eq!(index.len(), 1, "派生失败也要进索引");
assert!(index[0].exit_type.is_none());
}
+183
View File
@@ -0,0 +1,183 @@
//! 测试共用设施。
//!
//! **crate 名只在本文件出现**(tower 定案 `unlucky_core`;若 M11 改包名,只改这里)。
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use zip::write::SimpleFileOptions;
use zip::{CompressionMethod, ZipWriter};
pub use unlucky_core::launch::args_table::{assemble, AssembledCommand, AssemblyWarning};
pub use unlucky_core::launch::argument::{ArgumentEntry, Rule};
pub use unlucky_core::launch::command_builder::{self, CommandBuilder};
pub use unlucky_core::launch::error::{AssemblyError, LaunchError, MissingComponent};
pub use unlucky_core::launch::exit_analysis::{self, ExitType};
pub use unlucky_core::launch::game_version::GameVersion;
pub use unlucky_core::launch::log_level::{self, Level};
pub use unlucky_core::launch::platform::{OsFamily, PlatformCtx};
pub use unlucky_core::launch::pump::{LineSink, LogLine, RingBuffer, RingBufferConfig, Stream};
pub use unlucky_core::launch::redact::{Redactor, TOKEN_PLACEHOLDER};
pub use unlucky_core::launch::request::*;
pub use unlucky_core::launch::session::{EndReason, SessionMeta, SessionSink, SessionWriter};
pub use unlucky_core::launch::session_index;
pub use unlucky_core::launch::window_detect::WindowDetector;
pub use unlucky_core::launch::adapter::{
build_request, AdapterOptions, AdapterWarning, LibraryInput, SnapshotInput,
};
pub use unlucky_core::launch::crash_scan::CrashSource;
pub use unlucky_core::launch::diagnosis_hook::{DiagnosisHook, DiagnosisRequest};
pub use unlucky_core::launch::launch_session::SessionHandle;
pub use unlucky_core::launch::launcher::{DryRun, Launcher, LauncherConfig};
pub use unlucky_core::launch::process::ProcessConfig;
pub use unlucky_core::launch::sink::{CompositeSink, CountingSink, FnSink};
/// 断言 argv 逐项相等,失败时打印全部实际值。
pub fn assert_argv(command: &AssembledCommand, expected: &[&str]) {
let actual = command.argv();
if actual.len() != expected.len() {
panic!(
"argv 长度 {} != 期望 {}\n实际:\n{:#?}",
actual.len(),
expected.len(),
actual
);
}
for (index, (actual, expected)) in actual.iter().zip(expected.iter()).enumerate() {
assert_eq!(actual, expected, "第 {index} 个参数不同");
}
}
/// 一个“1.12.2 + Forge”的基准请求(Windows/x86_64)。
pub fn legacy_forge_request() -> (LaunchRequest, PlatformCtx) {
let ctx = PlatformCtx::new(OsFamily::Windows, "x86_64");
let request = LaunchRequest {
instance: InstanceInfo {
id: "inst-1".to_string(),
name: "1.12.2-Forge".to_string(),
version: GameVersion::parse("1.12.2"),
version_type: "release".to_string(),
},
launcher: LauncherInfo {
name: "UnluckyLauncher".to_string(),
version: "0.1.0".to_string(),
},
java: JavaSelection::new("C:/java/bin/java.exe", 17, 64),
memory: MemoryOptions {
min_mb: Some(1024),
max_mb: Some(4096),
},
manifest: ManifestArgs {
main_class: Some("net.minecraft.launchwrapper.Launch".to_string()),
..ManifestArgs::default()
},
dirs: LaunchDirs {
game_dir: PathBuf::from("C:/mc"),
assets_root: PathBuf::from("C:/mc/assets"),
game_assets: PathBuf::from("C:/mc/assets/virtual/legacy"),
assets_index: "1.12".to_string(),
natives_dir: PathBuf::from("C:/mc/versions/1.12.2/natives-windows-x86_64"),
library_dir: PathBuf::from("C:/mc/libraries"),
primary_jar: PathBuf::from("C:/mc/versions/1.12.2/1.12.2.jar"),
},
classpath: vec![
PathBuf::from("C:/mc/libraries/a.jar"),
PathBuf::from("C:/mc/libraries/b.jar"),
],
auth: AuthPlaceholders {
player_name: "Steve".to_string(),
uuid: "0123456789abcdef0123456789abcdef".to_string(),
access_token: "SECRET-TOKEN-123456".to_string(),
user_type: "msa".to_string(),
user_properties: "{}".to_string(),
},
log4j: Log4jOptions {
enabled: true,
config_file: Some(PathBuf::from("C:/mc/log4j2.xml")),
debug: false,
},
// 大部分断言只关心“命令骨架”,优化参数会淹没差异;需要时单独打开。
optimization: OptimizationOptions {
enabled: false,
physical_memory_mb: None,
},
language: "zh-CN".to_string(),
features: FeatureSet {
has_custom_resolution: true,
..FeatureSet::default()
},
resolution: Some(Resolution {
width: 854,
height: 480,
}),
..LaunchRequest::default()
};
(request, ctx)
}
/// 一个唯一的临时目录(不自动清理,便于失败时排查;OS 会回收)。
pub fn temp_dir(name: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|value| value.as_nanos())
.unwrap_or_default();
let dir =
std::env::temp_dir().join(format!("ull-launch-{name}-{}-{nanos}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("创建临时目录");
dir
}
/// 一个唯一的临时会话根目录(测试结束不自动清理,便于失败时排查;OS 会回收)。
pub fn temp_sessions_root(name: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|value| value.as_nanos())
.unwrap_or_default();
let root = std::env::temp_dir().join(format!(
"ull-launch-test-{name}-{}-{nanos}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
root
}
/// 读回 jsonl 的每一行并解析成 JSON。
pub fn read_events(path: &std::path::Path) -> Vec<serde_json::Value> {
let text = std::fs::read_to_string(path).expect("读取会话文件");
text.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).expect("解析事件行"))
.collect()
}
/// 造一个假 jar(`zip` Stored 模式,不依赖真下载):`entries` 是(条目名, 内容)。
///
/// natives 用例共用(真解压与 `--dry-run` 预览两边都要现造 jar)。
pub fn make_jar(path: &Path, entries: &[(&str, &[u8])]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("建 jar 目录");
}
let file = File::create(path).expect("建 jar");
let mut writer = ZipWriter::new(file);
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
for (name, bytes) in entries {
writer.start_file(*name, options).expect("写条目");
writer.write_all(bytes).expect("写条目内容");
}
writer.finish().expect("收尾 jar");
}
/// 一份 1.16.5 风格的 natives jar 的条目集(2 个原生库 + 4 条该被排除的噪音)。
pub fn lwjgl_jar_entries() -> Vec<(&'static str, &'static [u8])> {
vec![
("lwjgl.dll", b"DLL-BYTES".as_slice()),
("OpenAL.dll", b"OPENAL-BYTES".as_slice()),
("META-INF/MANIFEST.MF", b"manifest".as_slice()),
("META-INF/LWJGL.SF", b"signature".as_slice()),
("lwjgl.dll.sha1", b"deadbeef".as_slice()),
("README.txt", b"noise".as_slice()),
]
}
+167
View File
@@ -0,0 +1,167 @@
//! 进程冒烟测试:真实 spawn 一个 shell,验证泵、退出码、进程树 kill。
//!
//! 这些用例跑的是真进程(不是 mock),是“事件流 + 退出分析 + 树 kill”端到端的最小证据。
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use crate::prelude::*;
use unlucky_core::launch::process::{self, ProcessConfig, RunningProcess};
use unlucky_core::launch::pump::{LineSink, NullSink};
fn shell_command(script: &str) -> AssembledCommand {
#[cfg(windows)]
let (program, args) = (
"cmd".to_string(),
vec!["/C".to_string(), script.to_string()],
);
#[cfg(not(windows))]
let (program, args) = (
"/bin/sh".to_string(),
vec!["-c".to_string(), script.to_string()],
);
AssembledCommand {
program: PathBuf::from(program),
args,
classpath: Vec::new(),
encoding: "UTF-8".to_string(),
memory: MemoryOptions::default(),
env: Vec::new(),
warnings: Vec::new(),
main_class: String::new(),
}
}
fn config() -> ProcessConfig {
ProcessConfig {
graceful_stop: Duration::from_millis(200),
..ProcessConfig::default()
}
}
#[test]
fn spawn_collects_both_streams_and_exit_code() {
let root = temp_sessions_root("smoke");
let meta = SessionMeta::new("inst-1", "smoke", "1.20.1");
let writer =
SessionWriter::create_with_id(&root, "smoke-1", meta, Redactor::new()).expect("创建会话");
let path = writer.path().to_path_buf();
let sink = Arc::new(SessionSink::new(writer));
let mut child = RunningProcess::spawn(
&shell_command("echo hello-from-stdout & echo hello-from-stderr 1>&2"),
None,
sink.clone(),
config(),
)
.expect("spawn 应成功");
let status = child.wait().expect("wait 应成功");
assert_eq!(process::exit_code(&status), 0);
assert!(child.pid() > 0);
let verdict = child.supervise();
assert!(verdict.is_complete(), "两条流都应在预算内结束");
let texts: Vec<String> = child.lines().into_iter().map(|line| line.text).collect();
assert!(
texts.iter().any(|text| text.contains("hello-from-stdout")),
"stdout 未收集到:{texts:?}"
);
assert!(
texts.iter().any(|text| text.contains("hello-from-stderr")),
"stderr 未收集到:{texts:?}"
);
// 事件流里应有 stdout/stderr 行事件。
let events = read_events(&path);
let stdout_lines = events
.iter()
.filter(|event| event["kind"] == "stdout")
.count();
let stderr_lines = events
.iter()
.filter(|event| event["kind"] == "stderr")
.count();
assert!(stdout_lines >= 1, "应有 stdout 事件");
assert!(stderr_lines >= 1, "应有 stderr 事件");
}
#[test]
fn stop_kills_process_tree() {
#[cfg(windows)]
let script = "ping -n 30 127.0.0.1 >nul";
#[cfg(not(windows))]
let script = "sleep 30";
let mut child =
RunningProcess::spawn(&shell_command(script), None, Arc::new(NullSink), config())
.expect("spawn 应成功");
assert!(child.is_running());
child.stop().expect("停止应成功");
let status = child.wait().expect("wait 应成功");
assert_ne!(
process::exit_code(&status),
0,
"被结束的进程不应返回退出码 0"
);
assert!(!child.is_running());
}
#[cfg(target_os = "linux")]
#[test]
fn spawn_puts_child_in_its_own_process_group() {
/// `/proc/<pid>/stat` 里的 `pgrp` 字段。
fn pgid(pid: u32) -> u32 {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).expect("读 /proc stat");
let tail = &stat[stat.rfind(')').expect("stat 应有 comm 括号") + 1..];
tail.split_whitespace()
.nth(2)
.expect("stat 应有 pgrp 字段")
.parse()
.expect("pgrp 应是数字")
}
let mut child = RunningProcess::spawn(
&shell_command("sleep 30"),
None,
Arc::new(NullSink),
config(),
)
.expect("spawn 应成功");
let pid = child.pid();
let own_pgid = pgid(std::process::id());
assert_eq!(
pgid(pid),
pid,
"子进程应是自己的进程组组长(process_group(0))"
);
assert_ne!(pgid(pid), own_pgid, "子进程不能挂在测试进程的进程组上");
assert_eq!(
unlucky_core::launch::tree_kill::group_kill_target(pid),
Some(pid),
"独立组的组长才是合法的组杀目标"
);
child.stop().expect("停止应成功");
let _ = child.wait();
}
#[test]
fn spawn_reports_missing_program() {
let mut command = shell_command("echo hi");
command.program = PathBuf::from("definitely-not-a-real-binary-ull");
let error = RunningProcess::spawn(&command, None, Arc::new(NullSink), config())
.expect_err("程序不存在必须报错");
assert!(matches!(error, LaunchError::Spawn { .. }));
}
#[test]
fn line_sink_trait_object_is_usable() {
let sink: Arc<dyn LineSink> = Arc::new(NullSink);
sink.on_line(&LogLine::new(Stream::Stdout, "ignored"));
}
+111
View File
@@ -0,0 +1,111 @@
//! 环形缓冲 / 级别猜测 / 窗口检测。
use crate::prelude::*;
fn line(stream: Stream, text: &str) -> LogLine {
LogLine::new(stream, text)
}
#[test]
fn ring_buffer_respects_line_limit() {
let mut buffer = RingBuffer::new(RingBufferConfig {
max_lines: 3,
max_bytes: 1024 * 1024,
});
for index in 0..5 {
buffer.push(line(Stream::Stdout, &format!("line-{index}")));
}
assert_eq!(buffer.len(), 3);
let texts: Vec<String> = buffer.lines().map(|item| item.text.clone()).collect();
assert_eq!(texts, vec!["line-2", "line-3", "line-4"]);
assert_eq!(buffer.total_pushed(), 5);
}
#[test]
fn ring_buffer_keeeps_key_lines_after_eviction() {
let mut buffer = RingBuffer::new(RingBufferConfig {
max_lines: 2,
max_bytes: 1024 * 1024,
});
buffer.push(line(
Stream::Stderr,
"java.lang.ExceptionInInitializerError",
));
buffer.push(line(Stream::Stdout, "noise-1"));
buffer.push(line(Stream::Stdout, "noise-2"));
buffer.push(line(Stream::Stdout, "noise-3"));
let texts: Vec<String> = buffer.lines().map(|item| item.text.clone()).collect();
assert!(texts
.iter()
.any(|text| text.contains("ExceptionInInitializer")));
assert!(texts.contains(&"noise-3".to_string()));
assert!(!texts.contains(&"noise-1".to_string()));
assert_eq!(buffer.error_lines().len(), 1);
}
#[test]
fn ring_buffer_respects_byte_limit() {
let mut buffer = RingBuffer::new(RingBufferConfig {
max_lines: 1000,
max_bytes: 32,
});
buffer.push(line(Stream::Stdout, "0123456789"));
buffer.push(line(Stream::Stdout, "abcdefghij"));
buffer.push(line(Stream::Stdout, "ABCDEFGHIJ"));
assert!(buffer.len() < 4, "字节上限应触发淘汰");
assert!(buffer.text().contains("ABCDEFGHIJ"));
}
#[test]
fn level_guessing_matches_hmcl_rules() {
assert_eq!(
log_level::guess(false, "[12:34:56] [Render thread/ERROR]: boom"),
Level::Error
);
assert_eq!(
log_level::guess(false, "[12:34:56] [main/INFO]: hi"),
Level::Info
);
assert_eq!(
log_level::guess(false, "[12:34:56] [Worker-Main-1/WARN] [mixin/]: careful"),
Level::Warn
);
assert_eq!(log_level::guess(false, "[SEVERE] java error"), Level::Error);
assert_eq!(log_level::guess(false, "plain text"), Level::Unknown);
// stderr 一律当错误,authlib-injector 例外。
assert_eq!(log_level::guess(true, "anything"), Level::Error);
assert_eq!(
log_level::guess(true, "[authlib-injector] loaded"),
Level::Info
);
}
#[test]
fn line_level_uses_stream_and_text() {
assert_eq!(line(Stream::Stderr, "boom").level(), Level::Error);
assert_eq!(
line(Stream::Stdout, "[12:34:56] [main/DEBUG]: detail").level(),
Level::Debug
);
}
#[test]
fn window_detector_reports_heuristic_once() {
let mut detector = WindowDetector::new();
assert!(detector
.observe("[12:00:00] [main/INFO]: Loading LWJGL")
.is_none());
let detection = detector
.observe("[12:00:01] [Render thread/INFO]: LWJGL Version: 3.3.1")
.expect("应命中 lwjgl version");
assert_eq!(detection.heuristic, "lwjgl_version");
assert!(detection.evidence.contains("LWJGL Version"));
assert!(detector.is_detected());
// 只报一次。
assert!(detector.observe("LWJGL Version: 3.3.1").is_none());
assert_eq!(
detector.detection().map(|d| d.heuristic.as_str()),
Some("lwjgl_version")
);
}
+257
View File
@@ -0,0 +1,257 @@
//! 会话事件流(JSONL)与索引测试。
//!
//! 校验 `DevDocs/SubAgentsTalk/launch-接口约定.md` 的硬约定:
//! `seq` 单调、写前脱敏、索引同 id 取最后一行。
use std::sync::Arc;
use serde_json::json;
use unlucky_core::launch::pump::LineSink;
use crate::prelude::*;
fn command_with_token() -> AssembledCommand {
let (mut request, ctx) = legacy_forge_request();
request.manifest.legacy_arguments =
Some("--accessToken ${auth_session} --username ${auth_player_name}".to_string());
assemble(&request, &ctx).expect("组装应成功")
}
#[test]
fn session_stream_records_all_event_kinds() {
let root = temp_sessions_root("events");
let ctx = PlatformCtx::new(OsFamily::Windows, "x86_64");
let meta = SessionMeta::new("inst-1", "1.12.2-Forge", "1.12.2")
.with_launcher("UnluckyLauncher", "0.1.0")
.with_platform(&ctx);
let redactor = Redactor::new().with_secret("SECRET-TOKEN-123456");
let mut writer = SessionWriter::create_with_id(&root, "20261029T120000-abcdef", meta, redactor)
.expect("创建会话");
writer.session_start().expect("session_start");
writer.cmd_built(&command_with_token()).expect("cmd_built");
writer.proc_start(4242).expect("proc_start");
writer
.line(&LogLine::new(Stream::Stdout, "LWJGL Version: 3.3.1"))
.expect("stdout");
writer
.line(&LogLine::new(
Stream::Stderr,
"java.lang.IllegalStateException: boom",
))
.expect("stderr");
writer
.window_detected(&unlucky_core::launch::window_detect::Detection {
heuristic: "lwjgl_version".to_string(),
took_ms: 820,
evidence: "LWJGL Version: 3.3.1".to_string(),
})
.expect("window_detected");
let lines = vec![LogLine::new(Stream::Stderr, "java.lang.Exception: boom")];
let analysis = exit_analysis::analyze(&exit_analysis::ExitSignals::new(1, 40_000, &lines));
writer.exit(&analysis).expect("exit");
writer
.diagnosis(json!({
"source": "rules",
"needs_more_info": false,
"candidates": [{ "id": "mixin_apply_failed", "confidence": 0.9 }],
}))
.expect("diagnosis");
let entry = writer.finish(EndReason::Exit).expect("session_end");
assert!(writer.is_finished());
assert_eq!(entry.session_id, "20261029T120000-abcdef");
assert_eq!(entry.exit_code, Some(1));
assert_eq!(entry.exit_type.as_deref(), Some("application_error"));
assert!(entry.window_detected);
assert_eq!(entry.diagnosis_top.as_deref(), Some("mixin_apply_failed"));
assert_eq!(entry.log_path, "game/20261029T120000-abcdef.jsonl");
let events = read_events(writer.path());
let kinds: Vec<&str> = events
.iter()
.filter_map(|event| event.get("kind").and_then(|value| value.as_str()))
.collect();
assert_eq!(
kinds,
vec![
"session_start",
"cmd_built",
"proc_start",
"stdout",
"stderr",
"window_detected",
"exit",
"diagnosis",
"session_end",
]
);
// seq 从 0 单调递增,v/ts/t_ms/session_id 齐全。
for (index, event) in events.iter().enumerate() {
assert_eq!(event["v"], 1);
assert_eq!(event["seq"], index as u64);
assert_eq!(event["session_id"], "20261029T120000-abcdef");
assert!(event["ts"].as_str().unwrap_or_default().ends_with('Z'));
assert!(event["t_ms"].is_u64());
}
assert_eq!(entry.event_count, events.len() as u64);
// 索引落盘且可读回。
let index = session_index::index_path(&root);
let listed = session_index::read_all(&index).expect("读索引");
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].session_id, entry.session_id);
}
#[test]
fn cmd_built_argv_is_redacted_before_writing() {
let root = temp_sessions_root("redact");
let meta = SessionMeta::new("inst-1", "1.12.2-Forge", "1.12.2");
let redactor = Redactor::new().with_secret("SECRET-TOKEN-123456");
let mut writer =
SessionWriter::create_with_id(&root, "redact-1", meta, redactor).expect("创建会话");
writer.cmd_built(&command_with_token()).expect("cmd_built");
let raw = std::fs::read_to_string(writer.path()).expect("读文件");
assert!(!raw.contains("SECRET-TOKEN-123456"), "命令行必须脱敏后落盘");
assert!(raw.contains(TOKEN_PLACEHOLDER));
let events = read_events(writer.path());
let argv = events[0]["payload"]["argv"]
.as_array()
.cloned()
.unwrap_or_default();
assert!(argv
.iter()
.any(|arg| arg.as_str() == Some(TOKEN_PLACEHOLDER)));
}
#[test]
fn diagnosis_payload_is_redacted_before_writing() {
let root = temp_sessions_root("diagnosis-redact");
let meta = SessionMeta::new("inst-1", "1.20.1", "1.20.1");
let redactor = Redactor::new()
.with_secret("SECRET-TOKEN-123456")
.with_home_str("C:/Users/aska");
let mut writer =
SessionWriter::create_with_id(&root, "diag-redact-1", meta, redactor).expect("创建会话");
writer
.diagnosis(json!({
"source": "rules",
"needs_more_info": false,
"candidates": [{
"id": "mixin_failed",
"confidence": 0.9,
"message_key": "launch.crash.reason.mixin_failed",
"evidence": [
"hs_err:C:/Users/aska/.minecraft/hs_err_pid123.log: java.lang.Exception",
"token=SECRET-TOKEN-123456",
],
}],
}))
.expect("写 diagnosis");
let raw = std::fs::read_to_string(writer.path()).expect("读文件");
assert!(!raw.contains("C:/Users/aska"), "诊断证据里的主目录必须脱敏");
assert!(
!raw.contains("SECRET-TOKEN-123456"),
"诊断证据里的凭据必须脱敏"
);
assert!(raw.contains("<home>"));
assert!(raw.contains("<redacted:token>"), "同一个值内也允许混合脱敏");
// 字段名/数值/布尔原样保留(launch 只脱敏、不改写字段)。
let events = read_events(writer.path());
let payload = &events[0]["payload"];
assert_eq!(payload["needs_more_info"], json!(false));
assert_eq!(payload["candidates"][0]["id"], json!("mixin_failed"));
assert_eq!(payload["candidates"][0]["confidence"], json!(0.9));
assert_eq!(
payload["candidates"][0]["message_key"],
json!("launch.crash.reason.mixin_failed")
);
assert_eq!(payload["source"], json!("rules"));
// diagnosis_top 仍能从脱敏后的 candidates 取出。
assert_eq!(
writer
.finish(EndReason::Exit)
.expect("结束会话")
.diagnosis_top
.as_deref(),
Some("mixin_failed")
);
}
#[test]
fn index_keeps_last_line_per_session_id() {
let root = temp_sessions_root("index");
let mut entry = session_index::SessionIndexEntry {
v: 1,
session_id: "same-id".to_string(),
instance_id: "inst-1".to_string(),
instance_name: "one".to_string(),
game_version: "1.20.1".to_string(),
started_at: "2026-10-29T12:00:00.000Z".to_string(),
ended_at: "2026-10-29T12:01:00.000Z".to_string(),
duration_ms: 60_000,
exit_code: Some(0),
exit_type: Some("normal".to_string()),
..session_index::SessionIndexEntry::default()
};
let index = session_index::index_path(&root);
session_index::append(&index, &entry).expect("第一次写索引");
// 崩溃重启后重写同一会话的索引行。
entry.exit_code = Some(1);
entry.exit_type = Some("application_error".to_string());
entry.duration_ms = 30_000;
session_index::append(&index, &entry).expect("第二次写索引");
let listed = session_index::read_all(&index).expect("读索引");
assert_eq!(listed.len(), 1, "同 id 只保留一条");
assert_eq!(listed[0].exit_code, Some(1));
assert_eq!(listed[0].duration_ms, 30_000);
// 追加另一实例的会话,按实例过滤生效。
let mut other = listed[0].clone();
other.session_id = "other-id".to_string();
other.instance_id = "inst-2".to_string();
other.started_at = "2026-10-30T12:00:00.000Z".to_string();
session_index::append(&index, &other).expect("第三次写索引");
let only = session_index::list(&index, 10, Some("inst-2")).expect("过滤");
assert_eq!(only.len(), 1);
assert_eq!(only[0].session_id, "other-id");
// 倒序:最新的在前。
let all = session_index::list(&index, 10, None).expect("全部");
assert_eq!(
all.first().map(|item| item.session_id.as_str()),
Some("other-id")
);
}
#[test]
fn session_sink_writes_lines_and_detects_window() {
let root = temp_sessions_root("sink");
let meta = SessionMeta::new("inst-1", "1.20.1", "1.20.1");
let writer =
SessionWriter::create_with_id(&root, "sink-1", meta, Redactor::new()).expect("创建会话");
let path = writer.path().to_path_buf();
let sink = Arc::new(SessionSink::new(writer));
sink.on_line(&LogLine::new(Stream::Stdout, "preparing"));
sink.on_line(&LogLine::new(Stream::Stdout, "LWJGL Version: 3.3.1"));
sink.on_line(&LogLine::new(Stream::Stderr, "warn: something"));
let kinds: Vec<String> = read_events(&path)
.iter()
.filter_map(|event| event["kind"].as_str().map(str::to_string))
.collect();
assert!(
kinds.contains(&"window_detected".to_string()),
"sink 应自动检测窗口"
);
assert_eq!(kinds.iter().filter(|kind| *kind == "stdout").count(), 2);
assert_eq!(kinds.iter().filter(|kind| *kind == "stderr").count(), 1);
}
+73
View File
@@ -0,0 +1,73 @@
//! sink 组合测试(复合 sink 是接 M15 采集器 / UI 日志页的接缝)。
use std::sync::{Arc, Mutex};
use crate::prelude::*;
fn line(stream: Stream, text: &str) -> LogLine {
LogLine::new(stream, text)
}
#[test]
fn composite_sink_broadcasts_in_registration_order() {
let order = Arc::new(Mutex::new(Vec::<String>::new()));
let first = {
let order = order.clone();
FnSink::new(move |line: &LogLine| {
order.lock().unwrap().push(format!("first:{}", line.text));
})
};
let second = {
let order = order.clone();
FnSink::new(move |line: &LogLine| {
order.lock().unwrap().push(format!("second:{}", line.text));
})
};
let composite = CompositeSink::new()
.with(first.shared())
.with(second.shared());
assert_eq!(composite.len(), 2);
composite.on_line(&line(Stream::Stdout, "hello"));
let recorded = order.lock().unwrap().clone();
assert_eq!(recorded, vec!["first:hello", "second:hello"]);
}
#[test]
fn composite_sink_passes_stream_and_text() {
let seen = Arc::new(Mutex::new(Vec::<(Stream, String)>::new()));
let recorder = {
let seen = seen.clone();
FnSink::new(move |line: &LogLine| {
seen.lock().unwrap().push((line.stream, line.text.clone()));
})
};
// 模拟 diagnose 侧:只记文本 + 流标记(不解析 jsonl、不 import launch 类型)。
let mut composite = CompositeSink::new();
composite.push(recorder.shared());
composite.on_line(&line(Stream::Stdout, "out"));
composite.on_line(&line(Stream::Stderr, "err"));
let seen = seen.lock().unwrap().clone();
assert_eq!(seen.len(), 2);
assert_eq!(seen[0], (Stream::Stdout, "out".to_string()));
assert_eq!(seen[1], (Stream::Stderr, "err".to_string()));
}
#[test]
fn counting_sink_counts_per_stream() {
let counter = Arc::new(CountingSink::new());
counter.on_line(&line(Stream::Stdout, "a"));
counter.on_line(&line(Stream::Stdout, "b"));
counter.on_line(&line(Stream::Stderr, "c"));
assert_eq!(counter.stdout_lines(), 2);
assert_eq!(counter.stderr_lines(), 1);
assert_eq!(counter.total_lines(), 3);
}
#[test]
fn empty_composite_is_harmless() {
let composite = CompositeSink::new();
assert!(composite.is_empty());
composite.on_line(&line(Stream::Stdout, "ignored"));
}
+201
View File
@@ -0,0 +1,201 @@
//! 进程树回收的安全钉子:组杀只打“目标自己的独立进程组”,绝不命中我们所在的组。
//!
//! 这些用例钉的是 M48 的两条根因:
//! - 子进程必须自成一个进程组(`process_group(0)`),否则 `kill -- -<pgid>` 没有可用的组;
//! - 组杀前必须验组([`tree_kill::group_kill_target`]),否则 CI 容器里“全体进程 pgid=1”
//! 的那种环境会被一发组杀连测试进程一起带走。
//!
//! `/proc` 只有 Linux 有,进程组相关的用例都用 `#[cfg(target_os = "linux")]` 门住;
//! `pid == 0` 的守卫与平台无关,各平台都跑。
use std::time::Duration;
use unlucky_core::launch::tree_kill;
/// `pid` 0 在 POSIX `kill` 里表示“当前进程组”——绝不能顺手发出去。
#[test]
fn pid_zero_is_rejected() {
assert!(tree_kill::terminate(0).is_err(), "terminate(0) 必须拒绝");
assert!(tree_kill::force_kill(0).is_err(), "force_kill(0) 必须拒绝");
assert!(
tree_kill::kill_tree(0, Duration::ZERO).is_err(),
"kill_tree(0) 必须拒绝"
);
}
#[cfg(target_os = "linux")]
mod linux {
use super::*;
use std::process::{Child, Command, Stdio};
use unlucky_core::launch::tree_kill::group_kill_target;
/// `/proc/<pid>/stat` 里 `)` 之后的字段(state ppid pgrp ...)。
fn stat_tail(pid: u32) -> Option<Vec<String>> {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let tail = stat.get(stat.rfind(')')? + 1..)?;
Some(tail.split_whitespace().map(str::to_string).collect())
}
fn pgid(pid: u32) -> Option<u32> {
stat_tail(pid)?.get(2)?.parse().ok()
}
/// 还活着(僵尸不算:已经死了只是没人 wait)。
fn running(pid: u32) -> bool {
matches!(stat_tail(pid).as_deref().and_then(|fields| fields.first().map(String::as_str)), Some(state) if state != "Z")
}
fn wait_gone(pid: u32, budget: Duration) -> bool {
let deadline = std::time::Instant::now() + budget;
while std::time::Instant::now() < deadline {
if !running(pid) {
return true;
}
std::thread::sleep(Duration::from_millis(20));
}
!running(pid)
}
/// 组长的子进程(`process_group(0)`):自带一个组。
fn spawn_grouped(script: &str) -> Child {
use std::os::unix::process::CommandExt;
Command::new("/bin/sh")
.arg("-c")
.arg(script)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0)
.spawn()
.expect("spawn 应成功")
}
/// 和测试进程同组的子进程(不调 `process_group`):组长不是它。
fn spawn_in_our_group(script: &str) -> Child {
Command::new("/bin/sh")
.arg("-c")
.arg(script)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn 应成功")
}
/// 测试失败时也别把 `sleep 30` 留在机器上。
struct KillOnDrop(u32);
impl Drop for KillOnDrop {
fn drop(&mut self) {
let _ = tree_kill::force_kill(self.0);
}
}
/// 自己的进程组号:组杀安全的基准。
fn self_pgid() -> u32 {
pgid(std::process::id()).expect("/proc/self/stat 应可读")
}
#[test]
fn group_kill_target_accepts_only_a_verified_own_group() {
let own_pgid = self_pgid();
// 我们自己所在的组永远不是合法目标(哪怕我们正好是组长)。
assert_eq!(
group_kill_target(std::process::id()),
None,
"自己的进程号绝不能成为组杀目标"
);
assert_eq!(
group_kill_target(own_pgid),
None,
"自己的进程组绝不能成为组杀目标"
);
assert_eq!(group_kill_target(0), None, "pid 0 不是合法目标");
// 同组的子进程:它不是组长,`-<pid>` 指向的不是“它的”组。
let mut child = spawn_in_our_group("sleep 30");
let pid = child.id();
let _guard = KillOnDrop(pid);
assert_eq!(pgid(pid), Some(own_pgid), "同组子进程应继承我们的 pgid");
assert_eq!(
group_kill_target(pid),
None,
"非组长子进程不能走组杀(`-<pid>` 会打到别人的组)"
);
// 独立组的子进程:合法的组杀目标。
let mut grouped = spawn_grouped("sleep 30");
let grouped_pid = grouped.id();
let _grouped_guard = KillOnDrop(grouped_pid);
assert_eq!(pgid(grouped_pid), Some(grouped_pid), "子进程应是组长");
assert_eq!(
group_kill_target(grouped_pid),
Some(grouped_pid),
"独立组的组长可以组杀"
);
let _ = child.kill();
let _ = child.wait();
let _ = grouped.kill();
let _ = grouped.wait();
}
#[test]
fn group_kill_reaps_grandchildren_without_touching_our_group() {
let mut child = spawn_grouped("sleep 30 & sleep 30");
let pid = child.id();
let _guard = KillOnDrop(pid);
std::thread::sleep(Duration::from_millis(300));
let children_of = || -> Vec<u32> {
std::fs::read_to_string(format!("/proc/{pid}/task/{pid}/children"))
.unwrap_or_default()
.split_whitespace()
.filter_map(|value| value.parse().ok())
.collect()
};
let grandchildren = children_of();
assert!(
!grandchildren.is_empty(),
"shell 应当先 fork 出一个 sleep 子进程:{grandchildren:?}"
);
for grandchild in &grandchildren {
assert_eq!(pgid(*grandchild), Some(pid), "后代必须落在子进程自己的组里");
}
tree_kill::terminate(pid).expect("组杀应送达");
assert!(wait_gone(pid, Duration::from_secs(3)), "组长应被组杀带走");
for grandchild in &grandchildren {
assert!(
wait_gone(*grandchild, Duration::from_secs(3)),
"组杀应覆盖后代 {grandchild}"
);
}
// 组杀没有溢出:我们自己还在原来的组里,而且没有被信号带走。
assert_eq!(self_pgid(), pgid(std::process::id()).expect("仍然可读"));
assert_ne!(pid, self_pgid(), "目标组和我们的组必须不同");
let _ = child.wait();
}
#[test]
fn terminate_still_kills_a_child_that_shares_our_group() {
let own_pgid = self_pgid();
let mut child = spawn_in_our_group("sleep 30");
let pid = child.id();
let _guard = KillOnDrop(pid);
assert_eq!(pgid(pid), Some(own_pgid));
tree_kill::terminate(pid).expect("单 pid 兜底应送达");
assert!(
wait_gone(pid, Duration::from_secs(3)),
"同组子进程也必须能被单 pid TERM 干掉"
);
// 走到这里本身就说明“组杀没有命中自身进程组”——否则测试进程已被 TERM。
assert_eq!(self_pgid(), pgid(std::process::id()).expect("仍然可读"));
let _ = child.wait();
}
}
+328
View File
@@ -0,0 +1,328 @@
//! D6 看门狗:有界等待(真进程,不是 mock)。
//!
//! 四条用例各自钉一个判据:
//!
//! 1. 没窗口也没退出 → 立刻停止等待并报错,**进程还活着**(不杀);
//! 2. 窗口出现过 → 慢慢加载不会被判死,正常退出照常给结果;
//! 3. 出现关闭标记后仍不退出 → 判「卡在关闭」(实机 D6 的形态);
//! 4. 短命的正常退出 → 不会被超时误判成错误。
//!
//! 子进程一律是**脚本文件**(`.bat` / `.sh`),不把 `&` / 重定向塞进 `cmd /C` 的单个参数:
//! 那样 cmd 的引号剥离规则会让脚本变成另一条命令(第一版就是这么翻车的)。
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use crate::prelude::*;
use unlucky_core::launch::tree_kill;
use unlucky_core::launch::watchdog::WatchdogConfig;
fn launcher(sessions_root: &Path, startup: Duration, grace: Duration) -> Launcher {
let mut config = LauncherConfig::new(sessions_root);
config.platform = PlatformCtx::new(OsFamily::Windows, "x86_64");
config.process = ProcessConfig {
graceful_stop: Duration::from_millis(200),
// 泵的预算压到 200ms:放弃等待之后不该再为「等日志落盘」多等默认的 10 秒。
watchdog: WatchdogConfig {
pump_timeout: Duration::from_millis(200),
},
startup_timeout: startup,
shutdown_grace: grace,
..ProcessConfig::default()
};
Launcher::new(config)
}
/// 写一个伪装成 java 的脚本,返回它的路径。
fn script(dir: &Path, name: &str, lines: &[&str]) -> PathBuf {
#[cfg(windows)]
let (file, shebang) = (dir.join(format!("{name}.bat")), "@echo off");
#[cfg(not(windows))]
let (file, shebang) = (dir.join(format!("{name}.sh")), "#!/bin/sh");
let body = format!("{shebang}\n{}\n", lines.join("\n"));
std::fs::write(&file, body).expect("写测试脚本");
file
}
/// 用脚本文件当 java(命令行组装、事件流、退出判定都是真的,只有 JVM 是假的)。
fn request(script_path: &Path, run_dir: &Path) -> LaunchRequest {
let (mut request, _) = legacy_forge_request();
#[cfg(windows)]
{
request.wrapper = vec![
"cmd".to_string(),
"/C".to_string(),
script_path.display().to_string(),
];
}
#[cfg(not(windows))]
{
request.wrapper = vec!["/bin/sh".to_string(), script_path.display().to_string()];
}
request.java = JavaSelection::new("fake-java", 17, 64);
request.manifest.legacy_arguments = None;
request.dirs.game_dir = run_dir.to_path_buf();
request.dirs.natives_dir = run_dir.join("natives");
request.user.jvm = Vec::new();
request.user.game = Vec::new();
request
}
/// 进程是否还活着(看门狗「不杀进程」的判据)。
fn is_alive(pid: u32) -> bool {
#[cfg(windows)]
{
let output = std::process::Command::new("tasklist")
.args(["/FI", &format!("PID eq {pid}"), "/NH"])
.output()
.expect("tasklist 应可执行");
String::from_utf8_lossy(&output.stdout).contains(&pid.to_string())
}
#[cfg(not(windows))]
{
std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.status()
.map(|status| status.success())
.unwrap_or(false)
}
}
/// 一条「活着但什么也不干」的脚本(约 3 秒)。
fn linger_lines() -> Vec<&'static str> {
#[cfg(windows)]
{
vec!["ping -n 4 127.0.0.1 >nul"]
}
#[cfg(not(windows))]
{
vec!["sleep 3"]
}
}
/// 先打一行窗口标记,再活 1 秒左右。
fn window_then_exit_lines() -> Vec<&'static str> {
#[cfg(windows)]
{
vec!["echo LWJGL Version: 3.3.1", "ping -n 2 127.0.0.1 >nul"]
}
#[cfg(not(windows))]
{
vec!["echo 'LWJGL Version: 3.3.1'", "sleep 1"]
}
}
/// 每秒打一行、总共约 4 秒的脚本(**有**日志、**没有**窗口标记)。
#[cfg(windows)]
fn chatty_lines() -> Vec<&'static str> {
// `ping` 每秒打一行;不重定向就是为了让它真的输出。
vec!["ping -n 5 127.0.0.1"]
}
/// 同上(POSIX)。
#[cfg(not(windows))]
fn chatty_lines() -> Vec<&'static str> {
vec!["i=0; while [ $i -lt 5 ]; do echo \"tick $i\"; sleep 1; i=$((i+1)); done"]
}
#[test]
fn a_chatty_slow_load_is_not_mistaken_for_a_hang() {
// 一直没有窗口,但每秒都有一行日志:静默预算 1.5s,总时长 4s+ ⇒ 不能被判死。
let sessions = temp_sessions_root("watchdog-chatty");
let run_dir = temp_dir("watchdog-chatty-run");
let path = script(&run_dir, "chatty", &chatty_lines());
let launcher = launcher(
&sessions,
Duration::from_millis(1500),
Duration::from_secs(60),
);
let session = launcher
.spawn(&request(&path, &run_dir))
.expect("spawn 应成功");
let started = Instant::now();
let handle = session.finish().expect("还在打日志就不算卡死");
assert!(
started.elapsed() > Duration::from_millis(1500),
"这条用例要真的越过静默预算,否则什么都没验到:{:?}",
started.elapsed()
);
assert_eq!(handle.exit.code, 0);
assert_eq!(handle.exit.exit_type, ExitType::Normal);
assert!(
!handle
.index
.as_ref()
.is_some_and(|entry| entry.window_detected),
"这条脚本不该命中窗口启发式"
);
}
/// 先打关闭标记,再挂住不走(约 9 秒)。
fn shutdown_then_hang_lines() -> Vec<&'static str> {
#[cfg(windows)]
{
vec![
"echo [Client thread/INFO]: Stopping!",
"ping -n 10 127.0.0.1 >nul",
]
}
#[cfg(not(windows))]
{
vec!["echo '[Client thread/INFO]: Stopping!'", "sleep 9"]
}
}
#[test]
fn startup_timeout_stops_waiting_and_never_kills_the_process() {
let sessions = temp_sessions_root("watchdog-startup");
let run_dir = temp_dir("watchdog-startup-run");
let path = script(&run_dir, "linger", &linger_lines());
let launcher = launcher(
&sessions,
Duration::from_millis(300),
Duration::from_secs(60),
);
let session = launcher
.spawn(&request(&path, &run_dir))
.expect("spawn 应成功");
let pid = session.pid();
let log_path = session.log_path().to_path_buf();
let started = Instant::now();
let error = session
.finish()
.expect_err("没窗口也没退出:必须停止等待并报错,不能无限等");
let spent = started.elapsed();
assert!(
spent < Duration::from_secs(5),
"超时后应立即返回:{spent:?}"
);
match &error {
LaunchError::Watchdog {
pid: reported,
reason,
limit_ms,
..
} => {
assert_eq!(*reported, pid);
assert_eq!(*reason, "startup_timeout");
assert_eq!(*limit_ms, 300, "上限要如实回报(可配 + 可见)");
}
other => panic!("应报看门狗超时,实际 {other:?}"),
}
let text = error.to_string();
assert!(text.contains("没有被结束"), "{text}");
// M220 nit1:这句话直接打到终端,残留的 `**` 标记在终端里只会原样显示成星号。
assert!(
!text.contains("**"),
"人读错误串里不该有 Markdown 标记:{text}"
);
assert!(text.contains("ULL_LAUNCH_STARTUP_TIMEOUT_SECS"), "{text}");
// 会话照样收尾(历史里看得见这一局),原因是 still_running 而不是 exit。
let events = read_events(&log_path);
let last = events.last().expect("至少写了 session_start");
assert_eq!(last["kind"], "session_end");
assert_eq!(last["payload"]["reason"], "still_running");
// 关键断言:进程还活着 —— 看门狗只停止等待,绝不结束进程。
assert!(is_alive(pid), "看门狗不该结束进程(pid {pid} 已经不在了)");
let _ = tree_kill::force_kill(pid);
}
#[test]
fn a_seen_window_keeps_the_wait_alive_for_slow_loads() {
// 启动预算是 300ms,但窗口标记在几十毫秒内就到了:慢加载不是卡死。
let sessions = temp_sessions_root("watchdog-window");
let run_dir = temp_dir("watchdog-window-run");
let path = script(&run_dir, "windowed", &window_then_exit_lines());
let launcher = launcher(
&sessions,
Duration::from_millis(300),
Duration::from_secs(60),
);
let session = launcher
.spawn(&request(&path, &run_dir))
.expect("spawn 应成功");
let started = Instant::now();
let handle = session
.finish()
.expect("窗口出现过之后就该一直等到它自己退出");
assert!(
started.elapsed() > Duration::from_millis(300),
"这条用例要真的越过启动预算,否则什么都没验到"
);
assert_eq!(
handle.exit.exit_type,
ExitType::Normal,
"{}",
handle.summary()
);
assert_eq!(handle.exit.code, 0);
assert!(
handle
.index
.as_ref()
.is_some_and(|entry| entry.window_detected),
"窗口检测要落进索引(GUI/诊断都看这一位)"
);
}
#[test]
fn a_shutdown_marker_followed_by_hang_is_reported_as_stuck_on_close() {
// 实机 D6:关窗后 java.exe 仍存活。游戏自己打了「Stopping!」,之后就再没动静。
let sessions = temp_sessions_root("watchdog-shutdown");
let run_dir = temp_dir("watchdog-shutdown-run");
let path = script(&run_dir, "hanging", &shutdown_then_hang_lines());
let launcher = launcher(
&sessions,
Duration::from_secs(60),
Duration::from_millis(400),
);
let session = launcher
.spawn(&request(&path, &run_dir))
.expect("spawn 应成功");
let pid = session.pid();
let started = Instant::now();
let error = session
.finish()
.expect_err("关闭标记之后挂住:必须停止等待并报错");
assert!(
started.elapsed() < Duration::from_secs(6),
"宽限一到就要返回:{:?}",
started.elapsed()
);
match &error {
LaunchError::Watchdog { reason, .. } => assert_eq!(*reason, "shutdown_timeout"),
other => panic!("应报卡在关闭,实际 {other:?}"),
}
assert!(error.to_string().contains("已开始关闭"), "{error}");
let _ = tree_kill::force_kill(pid);
}
#[test]
fn a_quick_clean_exit_is_never_punished_by_the_watchdog() {
let sessions = temp_sessions_root("watchdog-quick");
let run_dir = temp_dir("watchdog-quick-run");
let path = script(&run_dir, "quick", &["echo hello"]);
let launcher = launcher(&sessions, Duration::from_secs(3), Duration::from_secs(3));
let session = launcher
.spawn(&request(&path, &run_dir))
.expect("spawn 应成功");
let handle = session.finish().expect("正常退出不该报错");
assert_eq!(handle.exit.code, 0);
assert_eq!(handle.exit.exit_type, ExitType::Normal);
assert!(!handle.exit.terminated_externally);
}
/// M220 nit1:`launcher.log` 与会话输出里的这句「看门狗生效值」是给人看的,
/// 残留的 `**` 标记在终端里只会原样显示成星号(旧文案就是「停止等待**不结束进程**」)。
#[test]
fn the_watchdog_summary_is_plain_text() {
let summary = LauncherConfig::new(std::env::temp_dir()).watchdog_summary();
assert!(summary.contains("不结束进程"), "{summary}");
assert!(
!summary.contains("**"),
"人读串里不该有 Markdown 标记:{summary}"
);
}
@@ -0,0 +1,98 @@
{
"meta": {
"description": "LiteLoader is a lightweight mod bootstrap designed to provide basic loader functionality for mods which don't need to modify game mechanics.",
"authors": "Mumfrey",
"url": "http://dl.liteloader.com",
"updated": "2017-11-29T16:31:41+00:00",
"updatedTime": 1511973101
},
"versions": {
"1.7.10": {
"repo": {
"stream": "RELEASE",
"type": "ivy",
"url": "http://dl.liteloader.com/versions/",
"classifier": "mcpnames"
},
"artefacts": {
"com.mumfrey:liteloader": {
"63ada46e033d0cb6782bada09ad5ca4e": {
"tweakClass": "com.mumfrey.liteloader.launch.LiteLoaderTweaker",
"libraries": [
{ "name": "net.minecraft:launchwrapper:1.11" },
{ "name": "org.ow2.asm:asm-all:5.0.3" }
],
"srcJar": "liteloader-1.7.10_04-mcpnames-sources.jar",
"mcpJar": "liteloader-1.7.10_04-mcpnames.jar",
"stream": "RELEASE",
"file": "liteloader-1.7.10_04.jar",
"version": "1.7.10_04",
"md5": "63ada46e033d0cb6782bada09ad5ca4e",
"timestamp": "1414368553"
},
"db7235aefd407ac1fde09a7baba50839": {
"tweakClass": "com.mumfrey.liteloader.launch.LiteLoaderTweaker",
"libraries": [
{ "name": "net.minecraft:launchwrapper:1.9" },
{
"name": "org.ow2.asm:asm-all:5.0.3",
"url": "http://repo.maven.apache.org/maven2/"
}
],
"stream": "RELEASE",
"file": "liteloader-1.7.10_00.jar",
"version": "1.7.10_00",
"md5": "db7235aefd407ac1fde09a7baba50839",
"timestamp": "1404330030"
}
}
}
},
"1.12.2": {
"repo": {
"stream": "SNAPSHOT",
"type": "m2",
"url": "http://repo.mumfrey.com/content/repositories/snapshots/",
"classifier": ""
},
"snapshots": {
"libraries": [
{ "name": "net.minecraft:launchwrapper:1.12" },
{
"name": "org.spongepowered:mixin:0.7.4-SNAPSHOT",
"url": "https://repo.spongepowered.org/maven/"
}
],
"com.mumfrey:liteloader": {
"1420785ecbfed5aff4a586c5c9dd97eb": {
"tweakClass": "com.mumfrey.liteloader.launch.LiteLoaderTweaker",
"libraries": [
{ "name": "net.minecraft:launchwrapper:1.12" },
{ "name": "org.ow2.asm:asm-all:5.2" }
],
"stream": "SNAPSHOT",
"file": "liteloader-1.12.2-SNAPSHOT.jar",
"version": "1.12.2-SNAPSHOT",
"build": "1.12.2-SNAPSHOT-r4CC2BB0-b4-4",
"md5": "1420785ecbfed5aff4a586c5c9dd97eb",
"timestamp": "1511880271",
"lastSuccessfulBuild": 4
},
"latest": {
"tweakClass": "com.mumfrey.liteloader.launch.LiteLoaderTweaker",
"libraries": [
{ "name": "net.minecraft:launchwrapper:1.12" },
{ "name": "org.ow2.asm:asm-all:5.2" }
],
"stream": "SNAPSHOT",
"file": "liteloader-1.12.2-SNAPSHOT.jar",
"version": "1.12.2-SNAPSHOT",
"md5": "1420785ecbfed5aff4a586c5c9dd97eb",
"timestamp": "1511880271",
"lastSuccessfulBuild": 4
}
}
}
}
}
}
+200
View File
@@ -0,0 +1,200 @@
//! 边界与失败面:父版本缺失、组合不支持、数据源形状坏、取消。
//!
//! 网络层的失败映射(`DownloadError` → `loader_network`)由 `core::download` 的引擎测试
//! 与既有加载器测试覆盖,这里**不联网**:外部源的真实可用性在 mission 报告里单独列
//! (含探测结果),不混进单元测试冒充。
use std::sync::atomic::AtomicBool;
use unlucky_core::addon::loader::liteloader::parse_versions;
use unlucky_core::addon::loader::{
LoaderErrorKind, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderSource,
};
use unlucky_core::download::blocking::block_on;
use unlucky_core::game::arguments::Environment;
use unlucky_core::game::install::{execute_with_cancel, InstallErrorKind};
use unlucky_core::game::layout::GameLayout;
use crate::support::{engine, liteloader_release, prefill, put_version, TempDir, VANILLA_1122};
/// 父版本不是原版、又没装在盘上 → **具名报错**,绝不默默改成叠原版。
#[test]
fn missing_loader_parent_is_a_named_error() {
let temp = TempDir::new("missing-parent");
let layout = GameLayout::new(temp.path());
put_version(&layout, "1.12.2", VANILLA_1122);
let release = liteloader_release("1.12.2-forge-14.23.5.2860");
let error = LoaderInstallPlan::build(LoaderPlanInput {
layout: &layout,
release: &release,
environment: &Environment::host(),
parent_installed: false,
})
.expect_err("父版本缺失必须报错");
assert_eq!(error.kind(), LoaderErrorKind::NotFound);
assert!(
error.message().contains("1.12.2-forge-14.23.5.2860"),
"错误里要点名缺的是哪个父版本:{}",
error.message()
);
assert!(error.hint().is_some(), "要给出下一步怎么办");
}
/// 父版本链:读盘定位原版版本 id(合成 loader id / 定位 jar 都用它)。
#[test]
fn parent_chain_walks_to_the_vanilla_root() {
let temp = TempDir::new("parent-chain");
let layout = GameLayout::new(temp.path());
put_version(&layout, "1.12.2", VANILLA_1122);
put_version(
&layout,
"1.12.2-forge-14.23.5.2860",
crate::support::FORGE_1122,
);
assert_eq!(
unlucky_core::addon::loader::parent::root_version_id(&layout, "1.12.2-forge-14.23.5.2860")
.expect("两层链应走得到根"),
"1.12.2"
);
assert_eq!(
unlucky_core::addon::loader::parent::root_version_id(&layout, "1.12.2").expect("根是自己"),
"1.12.2"
);
// 没装的父版本 → not_found(而不是猜一个原版版本号)。
let error = unlucky_core::addon::loader::parent::root_version_id(&layout, "9.9.9-forge-1.0.0")
.expect_err("缺父版本必须报错");
assert_eq!(error.kind(), LoaderErrorKind::NotFound);
}
/// 只有 LiteLoader 接受「父版本不是原版」;别家**具名拒绝**,不去发请求。
#[test]
fn only_liteloader_accepts_a_non_vanilla_parent() {
let temp = TempDir::new("combo-guard");
let engine = engine(temp.path());
let source = LoaderSource::new(&engine, temp.path());
let error = block_on(async {
source
.resolve_on(
LoaderKind::Forge,
"1.12.2",
Some("14.23.5.2860"),
Some("1.12.2-liteloader-1.12.2-SNAPSHOT"),
)
.await
})
.expect_err("Forge 叠在 LiteLoader 上必须被拒");
assert_eq!(error.kind(), LoaderErrorKind::Unsupported);
assert!(
error
.message()
.contains("1.12.2-liteloader-1.12.2-SNAPSHOT"),
"要说清是哪个组合:{}",
error.message()
);
}
/// 版本表形状坏 / 空表:各自给具名大类,不当成「没有版本」糊过去。
#[test]
fn broken_version_table_is_reported_distinctly() {
let shape = parse_versions(r#"{"versions": []}"#).expect_err("形状不符");
assert_eq!(shape.kind(), LoaderErrorKind::Metadata);
let empty = parse_versions(r#"{"meta": {}, "versions": {}}"#).expect_err("空表");
assert_eq!(empty.kind(), LoaderErrorKind::NotFound);
// 版本段缺失(记录没有 version):只剩 latest 别名 → 仍当空表处理。
let only_latest = r#"{"versions": {"1.7.10": {"artefacts": {"com.mumfrey:liteloader":
{"latest": {"tweakClass": "x", "version": "", "file": "liteloader.jar"}}}}}}"#;
let error = parse_versions(only_latest).expect_err("只有 http 别名时没有可用版本");
assert_eq!(error.kind(), LoaderErrorKind::NotFound);
}
/// 取消:执行段看标志就返回 `Cancelled`,**版本 JSON 不落盘**(不留半成品)。
#[test]
fn cancel_leaves_no_manifest() {
let temp = TempDir::new("cancel");
let layout = GameLayout::new(temp.path());
put_version(&layout, "1.12.2", VANILLA_1122);
let release = liteloader_release("1.12.2");
let plan = LoaderInstallPlan::build(LoaderPlanInput {
layout: &layout,
release: &release,
environment: &Environment::host(),
parent_installed: true,
})
.expect("计划应可组");
let engine = engine(temp.path());
prefill(&plan.install);
let cancel = AtomicBool::new(true);
let error = block_on(execute_with_cancel(
&engine,
&plan.install,
4,
None,
Some(&cancel),
))
.expect_err("取消必须返回错误");
assert_eq!(error.kind(), InstallErrorKind::Cancelled);
assert!(
!layout.version_json(&release.version_id).exists(),
"取消之后不许留下版本清单"
);
}
/// 同一个 loader 版本 id 被两条父链请求:**具名报错**,且盘上那份清单一个字节都不动。
///
/// 为什么必须有这道门:`<原版>-liteloader-<ver>` 对「叠原版」与「叠 Forge」是同一个目录名,
/// 直接覆盖会让已经登记、指向这个版本 id 的旧实例**静默换父链**(启动行为跟着变)。
#[test]
fn parent_collision_is_refused_instead_of_overwriting() {
let temp = TempDir::new("parent-collision");
let layout = GameLayout::new(temp.path());
put_version(&layout, "1.12.2", VANILLA_1122);
put_version(
&layout,
"1.12.2-forge-14.23.5.2860",
crate::support::FORGE_1122,
);
let vanilla_release = liteloader_release("1.12.2");
let existing = serde_json::json!({
"id": vanilla_release.version_id,
"inheritsFrom": "1.12.2",
"mainClass": "net.minecraft.launchwrapper.Launch",
"arguments": {"game": ["--tweakClass", "com.mumfrey.liteloader.launch.LiteLoaderTweaker"]},
"libraries": []
});
put_version(
&layout,
&vanilla_release.version_id,
&serde_json::to_string_pretty(&existing).expect("序列化"),
);
let before = std::fs::read_to_string(layout.version_json(&vanilla_release.version_id))
.expect("读旧清单");
let forge_release = liteloader_release("1.12.2-forge-14.23.5.2860");
assert_eq!(
forge_release.version_id, vanilla_release.version_id,
"两条父链撞同一个 id"
);
let error = LoaderInstallPlan::build(LoaderPlanInput {
layout: &layout,
release: &forge_release,
environment: &Environment::host(),
parent_installed: true,
})
.expect_err("撞父链必须报错");
assert_eq!(error.kind(), LoaderErrorKind::Unsupported);
assert!(
error.message().contains("1.12.2-forge-14.23.5.2860") && error.message().contains("1.12.2"),
"要说清两条父链各是什么:{}",
error.message()
);
let after = std::fs::read_to_string(layout.version_json(&vanilla_release.version_id))
.expect("读旧清单");
assert_eq!(before, after, "报错之后盘上那份清单不许被改动");
}
+297
View File
@@ -0,0 +1,297 @@
//! 解析 → 计划 → 落盘 → 继承解析 → 启动参数顺序(两条父链:原版 / Forge)。
use unlucky_core::addon::loader::{LoaderInstallPlan, LoaderPlanInput};
use unlucky_core::download::blocking::block_on;
use unlucky_core::game::arguments::Environment;
use unlucky_core::game::inherit::resolve;
use unlucky_core::game::install::execute;
use unlucky_core::game::layout::GameLayout;
use unlucky_core::launch::command_builder::tokenize;
use crate::support::{
engine, liteloader_release, prefill, put_version, read_version, TempDir, FORGE_1122,
VANILLA_1122,
};
/// 原版父链:装完能落盘,清单指向原版,`arguments.game` 带 tweakClass。
#[test]
fn installs_over_vanilla_and_writes_the_manifest() {
let temp = TempDir::new("vanilla");
let layout = GameLayout::new(temp.path());
put_version(&layout, "1.12.2", VANILLA_1122);
let release = liteloader_release("1.12.2");
assert_eq!(release.version_id, "1.12.2-liteloader-1.12.2-SNAPSHOT");
let plan = LoaderInstallPlan::build(LoaderPlanInput {
layout: &layout,
release: &release,
environment: &Environment::host(),
parent_installed: true,
})
.expect("父版本已装,计划应可组");
assert!(!plan.vanilla_install_needed);
assert!(plan.install.client_jar().is_none(), "jar 归原版层");
// 三个库:launchwrapper(Mojang 库仓)+ asm-all(Central)+ 本体 jar(官方直链)。
let names: Vec<&str> = plan.files().iter().map(|file| file.name.as_str()).collect();
assert_eq!(names.len(), 3, "{names:?}");
let loader_jar = plan
.files()
.iter()
.find(|file| file.name == "com.mumfrey:liteloader:1.12.2-SNAPSHOT")
.expect("缺本体 jar");
assert!(
loader_jar
.url
.ends_with("/1.12.2-SNAPSHOT/liteloader-1.12.2-20171128.144431-4-release.jar"),
"本体直链:{}",
loader_jar.url
);
let asm = plan
.files()
.iter()
.find(|file| file.name.contains("asm-all"))
.expect("缺 asm-all");
assert!(
asm.url.starts_with("https://repo1.maven.org/maven2/"),
"asm-all 只能从 Central 取:{}",
asm.url
);
// 全部落点先铺好占位文件(库无 sha1/size → 存在即信),执行段只写版本 JSON。
let engine = engine(temp.path());
let prefilled = prefill(&plan.install);
assert_eq!(prefilled, 3);
let report = block_on(execute(&engine, &plan.install, 4, None)).expect("执行应成功");
assert!(report.is_complete(), "{:?}", report.failures(3));
let written = read_version(&layout, &release.version_id);
assert_eq!(written.inherits_from.as_deref(), Some("1.12.2"));
assert_eq!(
written.main_class.as_deref(),
Some("net.minecraft.launchwrapper.Launch")
);
assert!(
written.minecraft_arguments.is_none(),
"loader 层不许写 minecraftArguments:那会顶掉父链"
);
}
/// Forge 父链:**父链的 FMLTweaker 必须还在**,LiteLoader 的 tweakClass 追加在它后面。
///
/// 这条是 M218 SURVEY-p1 的「准入条件」:靠 `--tweakClass` 生效,就要求继承时
/// `arguments.game` 是追加、`minecraftArguments` 不被子层覆盖。启动参数顺序照
/// `core::launch::game_args` 的第 24 项(legacy 子串)→ 第 25 项(`arguments.game`)复现,
/// 真实 argv 由 `tests/launch/cmdline_snapshot.rs` 钉住。
#[test]
fn stacks_on_forge_and_keeps_the_forge_tweak_class() {
let temp = TempDir::new("forge");
let layout = GameLayout::new(temp.path());
put_version(&layout, "1.12.2", VANILLA_1122);
put_version(&layout, "1.12.2-forge-14.23.5.2860", FORGE_1122);
let release = liteloader_release("1.12.2-forge-14.23.5.2860");
assert!(!release.parent_is_vanilla());
let plan = LoaderInstallPlan::build(LoaderPlanInput {
layout: &layout,
release: &release,
environment: &Environment::host(),
parent_installed: true,
})
.expect("父版本已装,计划应可组");
assert_eq!(plan.install.jar_id, "1.12.2", "jar 仍取原版那层");
let engine = engine(temp.path());
prefill(&plan.install);
let report = block_on(execute(&engine, &plan.install, 4, None)).expect("执行应成功");
assert!(report.is_complete(), "{:?}", report.failures(3));
let installed = read_version(&layout, &release.version_id);
let mut source = std::collections::BTreeMap::new();
source.insert("1.12.2".to_string(), read_version(&layout, "1.12.2"));
source.insert(
"1.12.2-forge-14.23.5.2860".to_string(),
read_version(&layout, "1.12.2-forge-14.23.5.2860"),
);
source.insert(release.version_id.clone(), installed);
let resolved = resolve(&source, &source[&release.version_id]).expect("三层继承应可解析");
// ① 父链的 legacy 参数整串还在(子层没写 minecraftArguments)。
let legacy = resolved
.minecraft_arguments
.as_deref()
.expect("Forge 的 minecraftArguments 不许被子层顶掉");
assert!(
legacy.contains("net.minecraftforge.fml.common.launcher.FMLTweaker"),
"Forge 的 tweakClass 丢了:{legacy}"
);
// ② 启动顺序:legacy(第 24 项)在前,arguments.game(第 25 项)在后。
let mut game: Vec<String> = tokenize(legacy);
game.extend(
resolved
.arguments
.as_ref()
.expect("arguments 应保留")
.expand(&Environment::host())
.game,
);
let forge_at = game
.iter()
.position(|token| token.contains("FMLTweaker"))
.expect("Forge tweakClass 不在游戏参数里");
let liteloader_at = game
.iter()
.position(|token| token == "com.mumfrey.liteloader.launch.LiteLoaderTweaker")
.expect("LiteLoader tweakClass 不在游戏参数里");
assert!(
forge_at < liteloader_at,
"launchwrapper 按出现顺序应用 tweak:Forge 必须在前,实际 {game:?}"
);
assert_eq!(game[liteloader_at - 1], "--tweakClass");
// ③ 主类与库顺序:Forge 的库在前(classpath 先到先得),LiteLoader 的在后。
assert_eq!(
resolved.main_class.as_deref(),
Some("net.minecraft.launchwrapper.Launch")
);
let libraries: Vec<&str> = resolved
.libraries
.as_deref()
.expect("libraries")
.iter()
.map(|library| library.name.as_str())
.collect();
let forge_lib = libraries
.iter()
.position(|name| name.starts_with("net.minecraftforge:forge:"))
.expect("缺 Forge 本体库");
let liteloader_lib = libraries
.iter()
.position(|name| name.starts_with("com.mumfrey:liteloader:"))
.expect("缺 LiteLoader 本体库");
assert!(forge_lib < liteloader_lib, "{libraries:?}");
}
/// **新式 args 的父链**:父版本用 `arguments.game`(没有 `minecraftArguments`)。
///
/// 现实里 LiteLoader 只到 1.12.2,碰不到这种父版本;但「追加语义」是这套路线的准入条件,
/// 两种父版本形状都得钉住——否则哪天上游把老版本清单改成新式,我们会静默丢参数。
/// 这里用一份**手工夹具**(`--launchTarget forgeclient` 是 Forge 1.13+ 的真实形态)说明:
/// 父层参数在前、LiteLoader 的 tweakClass 在后,中间不互相覆盖。
#[test]
fn new_style_parent_args_are_appended_not_replaced() {
const MODERN_PARENT: &str = r#"{
"id": "1.12.2-modern-1.0",
"inheritsFrom": "1.12.2",
"mainClass": "net.minecraft.launchwrapper.Launch",
"arguments": {"game": ["--launchTarget", "forgeclient"], "jvm": ["-Dmodern=1"]},
"libraries": [{"name": "example:modern:1.0", "url": "https://example.invalid/"}]
}"#;
let temp = TempDir::new("modern-args");
let layout = GameLayout::new(temp.path());
put_version(&layout, "1.12.2", VANILLA_1122);
put_version(&layout, "1.12.2-modern-1.0", MODERN_PARENT);
let release = liteloader_release("1.12.2-modern-1.0");
let plan = LoaderInstallPlan::build(LoaderPlanInput {
layout: &layout,
release: &release,
environment: &Environment::host(),
parent_installed: true,
})
.expect("计划应可组");
let engine = engine(temp.path());
prefill(&plan.install);
let report = block_on(execute(&engine, &plan.install, 4, None)).expect("执行应成功");
assert!(report.is_complete(), "{:?}", report.failures(3));
let mut source = std::collections::BTreeMap::new();
source.insert("1.12.2".to_string(), read_version(&layout, "1.12.2"));
source.insert(
"1.12.2-modern-1.0".to_string(),
read_version(&layout, "1.12.2-modern-1.0"),
);
source.insert(
release.version_id.clone(),
read_version(&layout, &release.version_id),
);
let resolved = resolve(&source, &source[&release.version_id]).expect("三层继承应可解析");
// 子层没写 legacy 串:继承结果里的 `minecraftArguments` 只能是祖父层(原版)那一份,
// 里面绝不能出现 LiteLoader 的 tweakClass(写了就说明我们用了覆盖语义)。
let legacy = resolved
.minecraft_arguments
.as_deref()
.expect("原版那份 legacy 串应沿链继承下来");
assert!(
!legacy.contains("LiteLoaderTweaker"),
"loader 层不许往 minecraftArguments 里写:{legacy}"
);
let game = resolved
.arguments
.as_ref()
.expect("arguments")
.expand(&Environment::host())
.game;
let assert_order = |needle: &str| {
game.iter()
.position(|token| token == needle)
.unwrap_or_else(|| panic!("缺 `{needle}`:{game:?}"))
};
assert!(
assert_order("forgeclient")
< assert_order("com.mumfrey.liteloader.launch.LiteLoaderTweaker"),
"父层参数必须在前:{game:?}"
);
}
/// LiteLoader 生效所依赖的**两条继承合并律**(M222 第一步核实,直接钉在合并函数上)。
///
/// 1. `arguments.game`:父在前、子在后**追加**(所以 tweakClass 能叠在父链参数之后);
/// 2. `minecraftArguments`:**子覆盖父**(所以 loader 层绝不能写它,否则父链的 tweakClass
/// 整串消失)。
///
/// 抓得住:有人把 `minecraftArguments` 改成追加(HMCL/原版语义不是这样),或把
/// `arguments.game` 改成整体替换(LiteLoader/其它叠层加载器当场失效)。
/// 抓不住:真实启动 argv 的顺序(那是 `launch::game_args` 的第 24/25 项,
/// 由 `tests/launch/cmdline_snapshot.rs` 钉住;本 mission 没有改那一层)。
#[test]
fn merge_laws_liteloader_depends_on() {
use unlucky_core::game::inherit::merge;
use unlucky_core::game::manifest::VersionJson;
let parse = |json: &str| VersionJson::from_json_str(json).expect("夹具应可解析");
let parent = parse(
r#"{"id":"p","minecraftArguments":"--tweakClass FMLTweaker","arguments":{"game":["--parent"]}}"#,
);
let child = parse(r#"{"id":"c","arguments":{"game":["--child"]}}"#);
let merged = merge(&child, &parent);
assert_eq!(
merged.minecraft_arguments.as_deref(),
Some("--tweakClass FMLTweaker"),
"子没写就继承父(这条是「叠在 Forge 上」的前提)"
);
let game: Vec<String> = merged
.arguments
.as_ref()
.expect("arguments")
.expand(&Environment::host())
.game;
assert_eq!(
game,
["--parent", "--child"],
"arguments.game 是父在前、子在后追加"
);
// 子写了 minecraftArguments:整串替换(不是追加)。
let loud_child = parse(r#"{"id":"c2","minecraftArguments":"--tweakClass LiteLoaderTweaker"}"#);
let merged = merge(&loud_child, &parent);
assert_eq!(
merged.minecraft_arguments.as_deref(),
Some("--tweakClass LiteLoaderTweaker"),
"子覆盖父:这正是 loader 层不许写 minecraftArguments 的原因"
);
}
+20
View File
@@ -0,0 +1,20 @@
//! LiteLoader 安装链路的集成测试(`tests/liteloader*` 这个 test target 的入口)。
//!
//! 判据链:**真实元数据夹具 → 解析 → 合成清单 → 计划 → 落盘 → 继承解析 → 启动参数顺序**。
//! 全程不联网:LiteLoader 的库没有 sha1/size,`execute` 的跳过判据是「存在即信」,
//! 所以先把每个待下文件按计划里的落点写一份占位字节,执行期就只做「跳过 + 写版本 JSON」。
//!
//! 夹具来源(可追溯):
//! - `fixtures/liteloader-versions.json` = 2026-11 从 `https://dl.liteloader.com/versions/versions.json`
//! 真取的响应(200 / 49129 字节 / 16 个原版版本)裁剪到两个原版版本,**字段名与嵌套结构原样**;
//! - `metadata.xml` = 同一次探测里 `repo.mumfrey.com/…/liteloader/1.12.2-SNAPSHOT/maven-metadata.xml`
//! 的原样响应(200 / 358 字节);
//! - 原版 / Forge 1.12.2 的版本 JSON 是按官方清单形状写的最小夹具(Forge 那侧的
//! `minecraftArguments` 与 `crates/core/src/addon/loader/tests/forge.rs` 的实测夹具一致)。
//!
//! 未覆盖(如实留档):真实下载(走 `core::download` 引擎的镜像/退避,已有 M13 的单测覆盖)、
//! 真实 MC 启动、`dl.liteloader.com` / `repo.mumfrey.com` 的持续可用性。
mod guards;
mod install;
mod support;
+157
View File
@@ -0,0 +1,157 @@
//! 集成测试公用件:临时游戏目录、离线下载引擎、元数据夹具、父版本清单夹具。
#![allow(dead_code)]
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use unlucky_core::addon::loader::liteloader::{self, LiteLoaderEntry};
use unlucky_core::addon::loader::LoaderRelease;
use unlucky_core::download::backoff::BackoffPolicy;
use unlucky_core::download::cache::ContentCache;
use unlucky_core::download::chunked::ChunkedConfig;
use unlucky_core::download::config::EngineConfig;
use unlucky_core::download::engine::DownloadEngine;
use unlucky_core::download::health::HealthRegistry;
use unlucky_core::download::mirrors::MirrorTable;
use unlucky_core::download::region::RegionProfile;
use unlucky_core::game::layout::GameLayout;
use unlucky_core::game::manifest::VersionJson;
/// 真实响应的裁剪版(见 `tests/liteloader.rs` 文件头的来源说明)。
pub const VERSION_TABLE: &str = include_str!("fixtures/liteloader-versions.json");
/// 1.12.2-SNAPSHOT 的 maven-metadata(真实响应原样)。
pub const METADATA_XML: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<metadata>
<groupId>com.mumfrey</groupId>
<artifactId>liteloader</artifactId>
<version>1.12.2-SNAPSHOT</version>
<versioning>
<snapshot>
<timestamp>20171128.144431</timestamp>
<buildNumber>4</buildNumber>
</snapshot>
<lastUpdated>20171128144431</lastUpdated>
</versioning>
</metadata>"#;
/// 原版 1.12.2(老式 `minecraftArguments`,与官方清单同形)。
pub const VANILLA_1122: &str = r#"{
"id": "1.12.2",
"type": "release",
"mainClass": "net.minecraft.client.main.Main",
"assets": "1.12",
"assetIndex": {"id": "1.12"},
"minecraftArguments": "--username ${auth_player_name} --version ${version_name}",
"libraries": [
{"name": "com.mojang:patchy:1.1", "url": "https://libraries.minecraft.net/"}
]
}"#;
/// Forge 1.12.2-14.23.5.2860(spec-0 的 version.json:主类 launchwrapper + FMLTweaker)。
pub const FORGE_1122: &str = r#"{
"id": "1.12.2-forge-14.23.5.2860",
"inheritsFrom": "1.12.2",
"mainClass": "net.minecraft.launchwrapper.Launch",
"minecraftArguments": "--tweakClass net.minecraftforge.fml.common.launcher.FMLTweaker",
"libraries": [
{"name": "net.minecraftforge:forge:1.12.2-14.23.5.2860", "url": "https://maven.minecraftforge.net/"}
]
}"#;
/// 唯一临时目录守卫(`drop` 时递归删除)。
pub struct TempDir {
path: PathBuf,
}
impl TempDir {
pub fn new(tag: &str) -> Self {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_nanos());
let path = std::env::temp_dir().join(format!(
"ull-liteloader-{tag}-{}-{stamp}-{unique}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("临时目录应可创建");
Self { path }
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
/// 离线引擎:单次尝试、短超时、分块关闭(测试里只用来跑「全部跳过」的执行段)。
pub fn engine(cache_root: &Path) -> DownloadEngine {
let config = EngineConfig {
max_attempts_per_source: 1,
backoff: BackoffPolicy {
base_ms: 1,
max_ms: 2,
jitter_ratio: 0.0,
max_attempts: 1,
},
connect_timeout: Duration::from_secs(3),
read_timeout: Duration::from_secs(3),
chunked: ChunkedConfig::disabled(),
..EngineConfig::default()
};
DownloadEngine::new(
config,
Arc::new(MirrorTable::builtin()),
Arc::new(RegionProfile::direct(0)),
Arc::new(HealthRegistry::in_memory()),
Arc::new(ContentCache::open(cache_root.join("cache"))),
)
}
/// 解析夹具表里某个原版版本的条目。
pub fn entries(mc_version: &str) -> Vec<LiteLoaderEntry> {
let table = liteloader::parse_versions(VERSION_TABLE).expect("夹具版本表应可解析");
table.get(mc_version).cloned().unwrap_or_default()
}
/// 取 1.12.2 的 SNAPSHOT 条目并合成 release(父版本由调用方给)。
pub fn liteloader_release(parent_version_id: &str) -> LoaderRelease {
let entries = entries("1.12.2");
let entry = liteloader::default_entry(&entries).expect("1.12.2 应有一条版本");
let url = liteloader::snapshot_artifact_url(entry, METADATA_XML).expect("快照直链");
liteloader::build_release("1.12.2", parent_version_id, entry, &url).expect("合成清单")
}
/// 把一份版本 JSON 写进 `versions/<id>/<id>.json`(模拟「父版本已装好」)。
pub fn put_version(layout: &GameLayout, id: &str, json: &str) {
let path = layout.version_json(id);
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("建版本目录");
std::fs::write(&path, json).expect("写版本 JSON");
}
/// 读盘上的版本 JSON。
pub fn read_version(layout: &GameLayout, id: &str) -> VersionJson {
let path = layout.version_json(id);
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|error| panic!("读不到 {}:{error}", path.display()));
VersionJson::from_json_str(&text).expect("版本 JSON 应可解析")
}
/// 按计划把每个待下文件写成占位字节(`execute` 的跳过判据 = 无 sha1 且存在即信)。
pub fn prefill(plan: &unlucky_core::game::install::InstallPlan) -> usize {
for file in &plan.files {
std::fs::create_dir_all(file.dest.parent().expect("文件父目录")).expect("建库目录");
std::fs::write(&file.dest, b"placeholder").expect("写占位文件");
}
plan.files.len()
}
@@ -0,0 +1,136 @@
//! Linux 探测:`/etc/os-release`、内核 release、会话类型、glibc 版本。
use std::collections::HashMap;
use crate::prelude::*;
const UBUNTU_OS_RELEASE: &str = "\
PRETTY_NAME=\"Ubuntu 24.04.1 LTS\"
NAME=\"Ubuntu\"
VERSION_ID=\"24.04\"
ID=ubuntu
";
#[test]
fn os_release_prefers_pretty_name() {
assert_eq!(
linux_caps::parse_os_release(UBUNTU_OS_RELEASE),
Some("Ubuntu 24.04.1 LTS".to_string())
);
// 没有 PRETTY_NAME 的裁剪发行版:NAME + VERSION_ID 拼一个(不够精确但够日志用)。
let minimal = "NAME=\"Alpine Linux\"\nVERSION_ID=3.19\n";
assert_eq!(
linux_caps::parse_os_release(minimal),
Some("Alpine Linux 3.19".to_string())
);
assert_eq!(linux_caps::parse_os_release(""), None);
assert_eq!(linux_caps::parse_os_release("ID=arch\n"), None);
}
#[test]
fn kernel_release_yields_major_minor() {
assert_eq!(
linux_caps::parse_kernel_version("6.8.0-45-generic"),
Some((6, 8))
);
assert_eq!(linux_caps::parse_kernel_version("5.15.0"), Some((5, 15)));
assert_eq!(linux_caps::parse_kernel_version("3.2"), Some((3, 2)));
assert_eq!(linux_caps::parse_kernel_version("6.8"), Some((6, 8)));
assert_eq!(linux_caps::parse_kernel_version(""), None);
assert_eq!(linux_caps::parse_kernel_version("not-a-version"), None);
}
#[test]
fn session_detection_order_is_wayland_then_x11_then_headless() {
fn detect(pairs: &[(&str, &str)]) -> SessionKind {
let map: HashMap<String, String> = pairs
.iter()
.map(|(key, value)| ((*key).to_string(), (*value).to_string()))
.collect();
linux_caps::session_from_env(|key| map.get(key).cloned())
}
assert_eq!(
detect(&[("WAYLAND_DISPLAY", "wayland-0")]),
SessionKind::Wayland
);
assert_eq!(
detect(&[("XDG_SESSION_TYPE", "wayland"), ("DISPLAY", ":0")]),
SessionKind::Wayland
);
assert_eq!(
detect(&[("XDG_SESSION_TYPE", "x11"), ("DISPLAY", ":0")]),
SessionKind::X11
);
assert_eq!(detect(&[("DISPLAY", ":0")]), SessionKind::X11);
// 空值不算有会话(SSH 转发里 `WAYLAND_DISPLAY=` 是常见的空串形态)。
assert_eq!(
detect(&[("WAYLAND_DISPLAY", " "), ("DISPLAY", ":0")]),
SessionKind::X11
);
assert_eq!(detect(&[]), SessionKind::Headless);
}
#[test]
fn session_kind_helpers() {
assert!(SessionKind::Wayland.has_display());
assert!(SessionKind::X11.has_display());
assert!(!SessionKind::Headless.has_display());
assert!(!SessionKind::Unknown.has_display());
assert_eq!(SessionKind::Wayland.as_str(), "wayland");
assert_eq!(SessionKind::Headless.as_str(), "headless");
assert_eq!(GlProbe::Unknown.as_str(), "unknown");
assert_eq!(GlProbe::Missing.as_str(), "missing");
}
#[test]
fn glibc_version_comes_from_the_first_ldd_line() {
assert_eq!(
linux_caps::parse_glibc_version(
"ldd (Ubuntu GLIBC 2.39-0ubuntu8.3) 2.39\nCopyright (C) 2024\n"
),
Some((2, 39))
);
assert_eq!(
linux_caps::parse_glibc_version("ldd (GNU libc) 2.31"),
Some((2, 31))
);
assert_eq!(
linux_caps::parse_glibc_version("ldd (Debian GLIBC 2.36-9+deb12u4) 2.36"),
Some((2, 36))
);
// musl / 命令不存在:不能瞎猜,交给上层「不阻塞」。
assert_eq!(linux_caps::parse_glibc_version("ldd: not found"), None);
assert_eq!(linux_caps::parse_glibc_version(""), None);
}
#[test]
fn format_version_is_dotted() {
assert_eq!(linux_caps::format_version((2, 31)), "2.31");
assert_eq!(linux_caps::format_version((6, 8)), "6.8");
}
#[test]
fn gl_probe_is_a_hook_returning_unknown_for_now() {
// P1 只留钩子:真探测要 dlopen(FFI),core 禁 unsafe,故恒为 Unknown 且不阻塞。
assert_eq!(linux_caps::probe_gl(), GlProbe::Unknown);
}
#[test]
fn caps_probe_matches_the_host() {
let caps: LinuxCaps = linux_caps::probe();
if cfg!(target_os = "linux") {
// 容器里可能全空,但会话类型至少是「探过了」。
assert_ne!(caps.session, SessionKind::Unknown);
if let Some(kernel) = &caps.kernel {
assert!(
caps.kernel_version.is_some(),
"内核串 {kernel} 应能取 major.minor"
);
}
} else {
assert_eq!(caps.session, SessionKind::Unknown);
assert!(caps.kernel.is_none());
assert!(caps.distro.is_none());
}
}
+58
View File
@@ -0,0 +1,58 @@
//! macOS 探测:`sw_vers -productVersion` 与 `sysctl hw.optional.arm64`。
use crate::prelude::*;
#[test]
fn product_version_parses_sw_vers_output() {
assert_eq!(
macos_version::parse_product_version("12.3.1\n"),
Some((12, 3, 1))
);
assert_eq!(
macos_version::parse_product_version("12.3"),
Some((12, 3, 0))
);
assert_eq!(macos_version::parse_product_version("12"), Some((12, 0, 0)));
assert_eq!(
macos_version::parse_product_version(" 14.2.1 "),
Some((14, 2, 1))
);
assert_eq!(macos_version::parse_product_version(""), None);
assert_eq!(macos_version::parse_product_version("ProductVersion"), None);
}
#[test]
fn sysctl_arm64_flag() {
assert!(macos_version::parse_sysctl_bool("1\n"));
assert!(!macos_version::parse_sysctl_bool("0\n"));
// Intel 机器上 sysctl 以非零码退出 → stdout 为空 → 不是 arm64。
assert!(!macos_version::parse_sysctl_bool(""));
}
#[test]
fn version_strings_and_runtime_arch() {
let apple_silicon = MacOsVersion::new(12, 3, 1, true);
assert_eq!(apple_silicon.version_string(), "12.3.1");
assert_eq!(apple_silicon.runtime_arch(), "arm64");
assert_eq!(apple_silicon.display_name(), "macOS 12.3.1 (arm64)");
// Rosetta 2 下编译目标是 x86_64,但真实架构仍是 arm64:
// 探到 arm64 时 runtime_arch 必须是 arm64(MC 原生库与渲染路径都按它选)。
let rosetta = MacOsVersion::new(12, 3, 1, true);
assert_eq!(rosetta.runtime_arch(), "arm64");
let intel = MacOsVersion::new(15, 0, 0, false);
assert_eq!(intel.runtime_arch(), "x86_64");
assert_eq!(intel.display_name(), "macOS 15.0.0 (x86_64)");
}
#[test]
fn probe_matches_the_host() {
let probed = macos_version::probe();
if cfg!(target_os = "macos") {
let version = probed.expect("macOS 宿主必须能探到产品版本");
assert!(version.major >= 10, "探到 {version:?}");
} else {
assert!(probed.is_none(), "非 macOS 平台不应探到 macOS 版本");
}
}
+17
View File
@@ -0,0 +1,17 @@
//! platform 模块集成测试入口(`cargo test -p unlucky-core --test platform`)。
//!
//! 覆盖 D19(`PlanDoc/11-os-detection.md`)的运行期探测:
//! - 等级映射 / 文案:注入式假数据,不改机器真实版本;
//! - 各平台探测的**解析**(Windows 注册表与 `ver`、Linux `/etc/os-release`+内核+glibc、
//! macOS `sw_vers`+`sysctl`):解析函数跨平台编译,所以能在任意宿主上单测;
//! - `rules.os.version` 用真实探测值求值的接线;
//! - 系统主题探测(`theme_probe`:注册表 / `defaults` / `gsettings` 解析 + 三态契约)。
mod linux_caps_cases;
mod macos_cases;
mod os_info_cases;
mod prelude;
mod rules_wiring;
mod theme_cases;
mod vk_cases;
mod windows_version_cases;
+299
View File
@@ -0,0 +1,299 @@
//! 支持等级映射与双语文案:全部用注入式假数据,不改机器真实版本。
//!
//! 基线(用户拍板):Windows 10+ / macOS 12+ / Linux 达标 → 一等,其余一律不支持;
//! Win8.1/8/7 也不承诺 CLI,所以没有中间档。
use crate::prelude::*;
fn windows_info(major_minor: Option<(u32, u32)>) -> OsInfo {
let product = match major_minor {
Some(version) if version >= os_info::MIN_WINDOWS => "Windows 10",
Some(_) => "Windows 7",
None => "Windows",
};
OsInfo {
family: OsFamily::Windows,
arch: "x86_64".to_string(),
major_minor,
build: Some(19_045),
product: product.to_string(),
version_string: "10.0.19045".to_string(),
session: SessionKind::Unknown,
gl: GlProbe::Unknown,
vk: VkProbe::Unknown,
kernel: None,
glibc: None,
support_level: os_info::support_level_for(OsFamily::Windows, major_minor, None, None),
}
}
fn linux_info(kernel: Option<(u32, u32)>, glibc: Option<(u32, u32)>) -> OsInfo {
OsInfo {
family: OsFamily::Linux,
arch: "x86_64".to_string(),
major_minor: kernel,
build: None,
product: "Ubuntu 24.04.1 LTS".to_string(),
version_string: "6.8.0-45-generic".to_string(),
session: SessionKind::Wayland,
gl: GlProbe::Unknown,
vk: VkProbe::Unknown,
kernel,
glibc,
support_level: os_info::support_level_for(OsFamily::Linux, kernel, kernel, glibc),
}
}
#[test]
fn windows_levels_require_windows_10() {
// Win10 / Win11(都报 10.0,靠 build 区分产品名)→ 一等。
assert_eq!(os_info::windows_level(Some((10, 0))), SupportLevel::One);
// Win8.1 / Win8 / Win7:低于基线,GUI 与 CLI 都不承诺。
assert_eq!(
os_info::windows_level(Some((6, 3))),
SupportLevel::Unsupported
);
assert_eq!(
os_info::windows_level(Some((6, 2))),
SupportLevel::Unsupported
);
assert_eq!(
os_info::windows_level(Some((6, 1))),
SupportLevel::Unsupported
);
// Vista / XP。
assert_eq!(
os_info::windows_level(Some((6, 0))),
SupportLevel::Unsupported
);
assert_eq!(
os_info::windows_level(Some((5, 1))),
SupportLevel::Unsupported
);
// 探测失败 → 保守(无法确认 Win10+ 就不放行)。
assert_eq!(os_info::windows_level(None), SupportLevel::Unsupported);
}
#[test]
fn macos_levels_use_twelve_as_the_floor() {
assert_eq!(os_info::macos_level(Some(12)), SupportLevel::One);
assert_eq!(os_info::macos_level(Some(15)), SupportLevel::One);
assert_eq!(os_info::macos_level(Some(11)), SupportLevel::Unsupported);
assert_eq!(os_info::macos_level(None), SupportLevel::Unsupported);
}
#[test]
fn linux_levels_gate_on_kernel_and_glibc() {
assert_eq!(
os_info::linux_level(Some((6, 8)), Some((2, 39))),
SupportLevel::One
);
assert_eq!(
os_info::linux_level(Some((2, 6)), Some((2, 39))),
SupportLevel::Unsupported
);
assert_eq!(
os_info::linux_level(Some((6, 8)), Some((2, 16))),
SupportLevel::Unsupported
);
// musl / Termux / 容器里取不到 glibc 与内核 → 不阻塞。
assert_eq!(os_info::linux_level(None, None), SupportLevel::One);
assert_eq!(os_info::linux_level(Some((3, 2)), None), SupportLevel::One);
// 恰好等于下限也算支持。
assert_eq!(
os_info::linux_level(Some((6, 8)), Some(os_info::MIN_GLIBC)),
SupportLevel::One
);
}
#[test]
fn support_level_for_dispatches_by_family() {
assert_eq!(
os_info::support_level_for(OsFamily::Windows, Some((10, 0)), None, None),
SupportLevel::One
);
assert_eq!(
os_info::support_level_for(OsFamily::Windows, Some((6, 1)), None, None),
SupportLevel::Unsupported
);
assert_eq!(
os_info::support_level_for(OsFamily::MacOs, Some((12, 3)), None, None),
SupportLevel::One
);
assert_eq!(
os_info::support_level_for(OsFamily::MacOs, Some((11, 7)), None, None),
SupportLevel::Unsupported
);
// Linux 的 major_minor 不参与判定,看 kernel/glibc。
assert_eq!(
os_info::support_level_for(OsFamily::Linux, Some((10, 0)), Some((2, 6)), None),
SupportLevel::Unsupported
);
assert_eq!(
os_info::support_level_for(OsFamily::Linux, None, Some((6, 8)), Some((2, 39))),
SupportLevel::One
);
}
#[test]
fn windows_7_is_reported_as_unsupported_with_the_new_baseline() {
let info = windows_info(Some((6, 1)));
assert_eq!(info.support_level, SupportLevel::Unsupported);
let text = info.describe();
assert!(text.zh.contains("不支持"), "zh: {}", text.zh);
assert!(text.zh.contains("Windows 10"), "zh: {}", text.zh);
// 不再有「受限模式 / HeadlessOnly」文案。
assert!(!text.zh.contains("受限模式"), "zh: {}", text.zh);
assert!(text.en.contains("Unsupported"), "en: {}", text.en);
assert!(text.en.contains("Windows 10 or newer"), "en: {}", text.en);
// combined() 是 CLI 打印形态:中文在前、英文在后。
let combined = text.combined();
let (zh, en) = combined.split_once('\n').expect("两行");
assert!(zh.contains("不支持"));
assert!(en.contains("Unsupported"));
}
#[test]
fn unsupported_macos_and_linux_name_their_own_minimums() {
let mac = OsInfo {
family: OsFamily::MacOs,
arch: "arm64".to_string(),
major_minor: Some((11, 7)),
build: None,
product: "macOS 11.7.0 (arm64)".to_string(),
version_string: "11.7.0".to_string(),
session: SessionKind::Unknown,
gl: GlProbe::Unknown,
vk: VkProbe::Unknown,
kernel: None,
glibc: None,
support_level: SupportLevel::Unsupported,
};
let text = mac.describe();
assert!(text.zh.contains("macOS 12"), "zh: {}", text.zh);
assert!(text.en.contains("macOS 12"), "en: {}", text.en);
assert!(!text.zh.contains("Windows"), "zh: {}", text.zh);
let linux = linux_info(Some((2, 6)), None);
assert_eq!(linux.support_level, SupportLevel::Unsupported);
let text = linux.describe();
assert!(text.zh.contains("内核"), "zh: {}", text.zh);
assert!(text.en.contains("kernel"), "en: {}", text.en);
}
#[test]
fn unknown_windows_version_is_unsupported_and_says_so() {
let info = windows_info(None);
assert_eq!(info.support_level, SupportLevel::Unsupported);
let text = info.describe();
assert!(text.zh.contains("未能探测到系统版本"), "zh: {}", text.zh);
assert!(text.zh.contains("Windows 10"), "zh: {}", text.zh);
}
#[test]
fn supported_linux_without_display_adds_a_hint() {
let mut info = linux_info(Some((6, 8)), Some((2, 39)));
info.session = SessionKind::Headless;
let text = info.describe();
assert!(text.zh.contains("图形会话"), "zh: {}", text.zh);
assert!(text.en.contains("graphical session"), "en: {}", text.en);
// 有显示服务器时不加提示。
let text = linux_info(Some((6, 8)), Some((2, 39))).describe();
assert!(!text.zh.contains("图形会话"), "zh: {}", text.zh);
}
#[test]
fn summary_line_carries_the_diagnostic_fields() {
let line = linux_info(Some((6, 8)), Some((2, 39))).summary_line();
assert!(line.contains("os=linux"), "{line}");
assert!(line.contains("version=6.8.0-45-generic"), "{line}");
assert!(line.contains("arch=x86_64"), "{line}");
assert!(line.contains("level=one"), "{line}");
assert!(line.contains("session=wayland"), "{line}");
assert!(line.contains("gl=unknown"), "{line}");
assert!(line.contains("vk=unknown"), "{line}");
}
#[test]
fn summary_line_reports_the_vulkan_probe() {
let mut info = linux_info(Some((6, 8)), Some((2, 39)));
info.vk = VkProbe::LoaderOnly;
assert!(info.summary_line().contains("vk=loader_only"));
info.vk = VkProbe::Available;
assert!(info.summary_line().contains("vk=available"));
info.vk = VkProbe::Missing;
assert!(info.summary_line().contains("vk=missing"));
}
#[test]
fn summary_line_marks_missing_version_as_unknown() {
let mut info = windows_info(None);
info.version_string = String::new();
let line = info.summary_line();
assert!(line.contains("version=unknown"), "{line}");
assert!(line.contains("level=unsupported"), "{line}");
}
#[test]
fn support_level_helpers() {
assert_eq!(SupportLevel::One.as_str(), "one");
assert_eq!(SupportLevel::Unsupported.as_str(), "unsupported");
assert!(SupportLevel::One.is_usable());
assert!(!SupportLevel::Unsupported.is_usable());
}
#[test]
fn os_family_facts_and_launch_reexport_are_the_same_type() {
// launch::platform 只是把 OsFamily 从 platform::os_info 再导出(避免两套枚举)。
let family: unlucky_core::platform::os_info::OsFamily = OsFamily::Windows;
assert_eq!(family, OsFamily::Windows);
assert_eq!(OsFamily::Windows.manifest_name(), "windows");
assert_eq!(OsFamily::MacOs.manifest_name(), "osx");
assert_eq!(OsFamily::Linux.manifest_name(), "linux");
assert!(!OsFamily::Windows.is_posix());
assert!(OsFamily::Linux.is_posix());
assert!(!OsFamily::current().manifest_arch().is_empty());
assert_eq!(
OsFamily::current().manifest_arch(),
OsFamily::Linux.manifest_arch()
);
}
#[test]
fn detect_reports_the_host_it_runs_on() {
let info = os_info::detect();
// 家族与当前编译目标一致。
assert_eq!(info.family, OsFamily::current());
assert!(!info.arch.is_empty());
assert!(!info.product.is_empty());
// 缓存入口返回同一份事实。
let cached = os_info::current();
assert_eq!(cached, &info);
assert_eq!(cached.family, info.family);
}
#[test]
fn windows_host_probes_a_real_version() {
if !cfg!(target_os = "windows") {
return;
}
let info = os_info::detect();
let (major, _minor) = info.major_minor.expect("Windows 上必须探到 major.minor");
assert!(major >= 6, "探到的版本不合理: {:?}", info.major_minor);
assert!(!info.os_version().is_empty(), "os_version 不能是空串");
assert!(info.build.is_some(), "Windows 上应有 build 号");
// 非 Windows 分支的事实不应泄漏进来。
assert!(info.kernel.is_none());
assert!(info.glibc.is_none());
assert_eq!(info.session, SessionKind::Unknown);
}
#[test]
fn check_returns_level_message_and_os_info() {
let check: SupportCheck = os_info::check();
assert_eq!(check.level, os_info::current().support_level);
assert_eq!(check.is_usable(), check.level.is_usable());
assert!(check.message.contains('\n'), "文案应为中英双语多行");
assert_eq!(check.message, check.os.describe().combined());
}
+53
View File
@@ -0,0 +1,53 @@
//! 测试共用设施(crate 名只出现在这里)。
use std::path::PathBuf;
pub use unlucky_core::launch::args_table::assemble;
pub use unlucky_core::launch::argument::{ArgumentEntry, OsRule, Rule, RuleContext};
pub use unlucky_core::launch::game_version::GameVersion;
pub use unlucky_core::launch::platform::{OsFamily, PlatformCtx};
pub use unlucky_core::launch::request::*;
pub use unlucky_core::platform::linux_caps::{self, GlProbe, LinuxCaps, SessionKind};
pub use unlucky_core::platform::macos_version::{self, MacOsVersion};
pub use unlucky_core::platform::os_info::{self, OsInfo, SupportCheck, SupportLevel};
pub use unlucky_core::platform::vk_probe::{
classify, count_icd_manifests, env_icd_path, ldconfig_has_libvulkan, loader_candidates,
probe_vk, registry_has_driver_entry, VkEvidence, VkProbe, ICD_DIRS, ICD_ENV_VARS,
VULKAN_DRIVERS_KEY,
};
pub use unlucky_core::platform::windows_version::{self, WindowsVersion};
/// 一个最小可组装请求:现代清单,游戏参数由调用方给。
pub fn request_with_game(game: Vec<ArgumentEntry>) -> LaunchRequest {
LaunchRequest {
instance: InstanceInfo {
id: "inst-1".to_string(),
name: "1.20.1".to_string(),
version: GameVersion::parse("1.20.1"),
version_type: "release".to_string(),
},
launcher: LauncherInfo {
name: "UnluckyLauncher".to_string(),
version: "0.1.0".to_string(),
},
java: JavaSelection::new("C:/java/bin/java.exe", 17, 64),
manifest: ManifestArgs {
main_class: Some("net.minecraft.client.main.Main".to_string()),
game,
..ManifestArgs::default()
},
classpath: vec![PathBuf::from("C:/mc/libraries/a.jar")],
..LaunchRequest::default()
}
}
/// 一条带 `rules.os.version` 的游戏参数项。
pub fn version_ruled_entry(pattern: &str, value: &str) -> ArgumentEntry {
let mut rule = Rule::allow();
rule.os = Some(OsRule {
name: None,
arch: None,
version: Some(pattern.to_string()),
});
ArgumentEntry::ruled(vec![rule], [value])
}
@@ -0,0 +1,96 @@
//! 接线:`rules.os.version` 用真实探测值求值(D19 §2 —— 之前 `os_version` 是空串,
//! 带版本规则的参数项永远命中不了)。
use crate::prelude::*;
fn argv_of(entries: Vec<ArgumentEntry>, ctx: &PlatformCtx) -> Vec<String> {
let request = request_with_game(entries);
assemble(&request, ctx).expect("组装失败").argv().to_vec()
}
#[test]
fn windows_10_rule_hits_with_detected_version() {
let ctx = PlatformCtx::new(OsFamily::Windows, "x86_64").with_os_version("10.0.19045");
let entries = vec![version_ruled_entry(r"^10\.", "--demo-windows10")];
let argv = argv_of(entries, &ctx);
assert!(
argv.iter().any(|arg| arg == "--demo-windows10"),
"argv 应包含带版本规则的参数: {argv:?}"
);
}
#[test]
fn same_rule_misses_when_version_is_empty() {
// 空版本串是修复前的常态:规则求值走保守分支(不放参数)。
let ctx = PlatformCtx::new(OsFamily::Windows, "x86_64");
assert!(ctx.os_version.is_empty());
let entries = vec![version_ruled_entry(r"^10\.", "--demo-windows10")];
let argv = argv_of(entries, &ctx);
assert!(
!argv.iter().any(|arg| arg == "--demo-windows10"),
"空版本串不该命中: {argv:?}"
);
}
#[test]
fn escaped_dot_patterns_match_real_versions() {
// `^10\.15` 是 macOS 上常见的写法:反转义后按 `10.15` 前缀比较。
let ctx = PlatformCtx::new(OsFamily::MacOs, "x86_64").with_os_version("10.15.7");
let entries = vec![version_ruled_entry(r"^10\.15", "--demo-catalina")];
let argv = argv_of(entries, &ctx);
assert!(argv.iter().any(|arg| arg == "--demo-catalina"), "{argv:?}");
}
#[test]
fn regex_shaped_patterns_stay_conservative() {
let ctx = PlatformCtx::new(OsFamily::Windows, "x86_64").with_os_version("10.0.19045");
// 需要真正则的模式:宁可少加参数,也不猜。
let entries = vec![version_ruled_entry(r"^10\.[0-9]+", "--demo-regex")];
let argv = argv_of(entries, &ctx);
assert!(!argv.iter().any(|arg| arg == "--demo-regex"), "{argv:?}");
}
#[test]
fn rule_context_uses_the_injected_version() {
let features = FeatureSet::default();
let entry = version_ruled_entry(r"^6\.1\.", "--demo-win7");
assert!(entry.applies(&RuleContext::new(
"windows", "x86_64", "6.1.7601", &features
)));
assert!(!entry.applies(&RuleContext::new(
"windows",
"x86_64",
"10.0.19045",
&features
)));
// 空版本串:任何版本规则都不命中。
assert!(!entry.applies(&RuleContext::new("windows", "x86_64", "", &features)));
}
#[test]
fn platform_ctx_current_carries_the_probed_version() {
let ctx = PlatformCtx::current();
let info = os_info::current();
assert_eq!(ctx.os, info.family);
assert_eq!(ctx.arch, info.arch);
assert_eq!(ctx.os_version, info.os_version());
if cfg!(target_os = "windows") {
assert!(!ctx.os_version.is_empty(), "Windows 上版本串不能为空");
// 版本串应能被规则前缀匹配到(形如 10.0.19045)。
assert!(
ctx.os_version.starts_with(|c: char| c.is_ascii_digit()),
"版本串形态异常: {}",
ctx.os_version
);
}
}
#[test]
fn default_matches_current() {
let ctx = PlatformCtx::default();
assert_eq!(ctx.os_version, PlatformCtx::current().os_version);
assert_eq!(
ctx.path_separator(),
PlatformCtx::current().path_separator()
);
}
+102
View File
@@ -0,0 +1,102 @@
//! 系统主题探测的解析与判定(`core::platform::theme_probe`)。
//!
//! 解析函数跨平台编译,所以本用例在任意宿主上都能跑;真实探测只是把同一份解析套到
//! `reg query` / `defaults` / `gsettings` 的输出上(`probe()` 的非确定性不进断言)。
use unlucky_core::platform::theme_probe::{
self, parse_apps_use_light_theme, parse_gsettings_color_scheme, parse_gtk_theme,
parse_macos_interface_style, SystemTheme, WINDOWS_PERSONALIZE_KEY,
};
#[test]
fn windows_registry_apps_use_light_theme_maps_dword() {
// 真实 `reg query` 输出(REG_DWORD 以 0x 十六进制打印)。
let light = "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize\n \
AppsUseLightTheme REG_DWORD 0x1\n SystemUsesLightTheme REG_DWORD 0x0\n";
assert_eq!(parse_apps_use_light_theme(light), Some(SystemTheme::Light));
let dark = " AppsUseLightTheme REG_DWORD 0x0\n";
assert_eq!(parse_apps_use_light_theme(dark), Some(SystemTheme::Dark));
// 十进制形式的输出(某些区域设置/工具会把 DWORD 打成十进制)。
assert_eq!(
parse_apps_use_light_theme(" AppsUseLightTheme REG_DWORD 1"),
Some(SystemTheme::Light)
);
// 键不存在(老系统 / 被策略删掉)与别的值都不猜。
assert_eq!(parse_apps_use_light_theme(""), None);
assert_eq!(
parse_apps_use_light_theme(" SystemUsesLightTheme REG_DWORD 0x0"),
None
);
assert_eq!(
parse_apps_use_light_theme(" AppsUseLightTheme REG_SZ yes"),
None
);
}
#[test]
fn windows_key_points_at_personalize_hive() {
assert!(WINDOWS_PERSONALIZE_KEY.contains("Themes\\Personalize"));
}
#[test]
fn macos_interface_style_maps_dark_and_empty() {
assert_eq!(
parse_macos_interface_style("Dark\n"),
Some(SystemTheme::Dark)
);
assert_eq!(
parse_macos_interface_style("Light\n"),
Some(SystemTheme::Light)
);
// 浅色时 `AppleInterfaceStyle` 键不存在,defaults 无输出。
assert_eq!(parse_macos_interface_style(""), None);
}
#[test]
fn gsettings_color_scheme_prefers_explicit_preference() {
assert_eq!(
parse_gsettings_color_scheme("'prefer-dark'\n"),
Some(SystemTheme::Dark)
);
assert_eq!(
parse_gsettings_color_scheme("'prefer-light'\n"),
Some(SystemTheme::Light)
);
// `default` = 未表态,交给 gtk-theme 名字兜底。
assert_eq!(parse_gsettings_color_scheme("'default'\n"), None);
}
#[test]
fn gtk_theme_name_falls_back_to_dark_suffix() {
assert_eq!(parse_gtk_theme("'Yaru-dark'\n"), Some(SystemTheme::Dark));
assert_eq!(parse_gtk_theme("'Adwaita-dark'\n"), Some(SystemTheme::Dark));
assert_eq!(parse_gtk_theme("'Yaru'\n"), Some(SystemTheme::Light));
assert_eq!(parse_gtk_theme("''\n"), None);
}
#[test]
fn system_theme_tristate_contract() {
assert_eq!(SystemTheme::Light.is_dark(), Some(false));
assert_eq!(SystemTheme::Dark.is_dark(), Some(true));
assert_eq!(
SystemTheme::Unknown.is_dark(),
None,
"Unknown 必须让调用方自己决定回落(不假装浅色)"
);
assert_eq!(SystemTheme::Light.as_str(), "light");
assert_eq!(SystemTheme::Dark.as_str(), "dark");
assert_eq!(SystemTheme::Unknown.as_str(), "unknown");
}
#[test]
fn probe_never_panics_and_reports_one_of_three_states() {
// 真实探测(Windows 读注册表,其余平台问 DE);不断言具体值,只要求落在三态内。
let probed = theme_probe::probe();
assert!(matches!(
probed,
SystemTheme::Light | SystemTheme::Dark | SystemTheme::Unknown
));
}
+240
View File
@@ -0,0 +1,240 @@
//! Vulkan 能力探测(`platform::vk_probe`)的三平台注入样本。
//!
//! 全部用 [`VkEvidence`] 注入,不读真机、不起子进程:
//! 三平台解析函数是纯函数且跨平台编译,任意宿主都能跑这几个用例。
//!
//! 关键语义(`research/05-mc-vulkan.md` §4.3 的真机证据):
//! **Windows 注册表 `HKLM\SOFTWARE\Khronos\Vulkan\Drivers` 缺失不等于没有 Vulkan**
//! (实测机 Vulkan 1.3.217 可用但该键不存在),所以只能降级到 `LoaderOnly`,不能判 `Missing`。
use std::path::PathBuf;
use crate::prelude::*;
#[test]
fn windows_loader_without_registry_key_is_only_loader_only() {
// 真机证据的复刻:vulkan-1.dll 在,注册表键缺失 → 只能说「loader 在、驱动未确认」。
let probe = classify(VkEvidence::new(OsFamily::Windows, true, false));
assert_eq!(probe, VkProbe::LoaderOnly);
assert!(probe.has_loader());
assert!(!probe.is_missing(), "注册表缺失被误判成没有 Vulkan");
assert!(!probe.is_available(), "没有驱动信号不该承诺可用");
}
#[test]
fn windows_loader_plus_registry_entry_is_available() {
assert_eq!(
classify(VkEvidence::new(OsFamily::Windows, true, true)),
VkProbe::Available
);
}
#[test]
fn windows_without_loader_is_missing() {
assert_eq!(
classify(VkEvidence::new(OsFamily::Windows, false, true)),
VkProbe::Missing
);
}
#[test]
fn linux_loader_with_icd_manifests_is_available() {
assert_eq!(
classify(VkEvidence::new(OsFamily::Linux, true, true)),
VkProbe::Available
);
// 只有 loader、没有 ICD 目录/环境变量:同样不否定(驱动可能由 loader 自寻)。
assert_eq!(
classify(VkEvidence::new(OsFamily::Linux, true, false)),
VkProbe::LoaderOnly
);
assert_eq!(
classify(VkEvidence::new(OsFamily::Linux, false, false)),
VkProbe::Missing
);
}
#[test]
fn macos_molten_vk_needs_no_separate_icd_signal() {
// macOS 没有系统 Vulkan:MoltenVK 既是 loader 又是 ICD,没有 LoaderOnly 这一档。
assert_eq!(
classify(VkEvidence::new(OsFamily::MacOs, true, false)),
VkProbe::Available
);
assert_eq!(
classify(VkEvidence::new(OsFamily::MacOs, false, true)),
VkProbe::Missing
);
}
#[test]
fn probe_names_are_stable_and_lower_snake() {
assert_eq!(VkProbe::Unknown.as_str(), "unknown");
assert_eq!(VkProbe::LoaderOnly.as_str(), "loader_only");
assert_eq!(VkProbe::Available.as_str(), "available");
assert_eq!(VkProbe::Missing.as_str(), "missing");
assert_eq!(VkProbe::default(), VkProbe::Unknown);
assert!(!VkProbe::Unknown.has_loader());
}
#[test]
fn loader_candidates_cover_each_platform_signature() {
let windows = loader_candidates(OsFamily::Windows);
assert!(
windows
.iter()
.any(|path| path.ends_with("System32/vulkan-1.dll")
|| path.ends_with(r"System32\vulkan-1.dll")),
"Windows 判据应是 System32\\vulkan-1.dll:{windows:?}"
);
let linux = loader_candidates(OsFamily::Linux);
assert!(
linux
.iter()
.any(|path| path.to_string_lossy().contains("libvulkan.so.1")),
"Linux 判据应含 libvulkan.so.1:{linux:?}"
);
let macos = loader_candidates(OsFamily::MacOs);
let macos_names = macos
.iter()
.map(|path| {
path.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned()
})
.collect::<Vec<_>>();
assert!(
macos_names.iter().any(|name| name == "libvulkan.1.dylib"),
"macOS 判据应含 libvulkan.1.dylib:{macos_names:?}"
);
assert!(
macos_names.iter().any(|name| name == "libMoltenVK.dylib"),
"macOS 判据应含 MoltenVK:{macos_names:?}"
);
}
#[test]
fn registry_query_output_is_parsed_for_driver_entries() {
// 键不存在:只有本地化错误行(中英各一例,真机输出)。
let missing_en =
"ERROR: The system was unable to find the specified registry key or value.\r\n";
let missing_zh = "\r\n错误: 系统找不到指定的注册表项或值。\r\n";
assert!(!registry_has_driver_entry(missing_en));
assert!(!registry_has_driver_entry(missing_zh));
assert!(!registry_has_driver_entry(""));
}
#[test]
fn registry_query_real_format_is_recognised() {
// 真实 `reg query KEY`(不带 /s)格式,本机实测:
// 空行 → 键头行(顶格、完整 hive 名、无尾反斜杠)→ 缩进的值行
// ` <值名> REG_DWORD 0x0`
// Vulkan 的 ICD 注册是「值名 = manifest 绝对路径」,名字里可能有空格,所以列宽不定。
let present = "\r\n\
HKEY_LOCAL_MACHINE\\SOFTWARE\\Khronos\\Vulkan\\Drivers\r\n \
C:\\Windows\\System32\\DriverStore\\FileRepository\\amdvlk64.inf_amd64_1b2c\\amdvlk64.json REG_DWORD 0x0\r\n \
C:\\vendor a\\vk_vendor_a.json REG_DWORD 0x0\r\n";
assert!(registry_has_driver_entry(present));
// 值可能是 REG_SZ(第三方 loader/层注册的写法)。
let reg_sz = "\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\Khronos\\Vulkan\\Drivers\r\n \
C:\\vendor\\vk.json REG_SZ \r\n";
assert!(registry_has_driver_entry(reg_sz));
}
#[test]
fn registry_key_without_values_is_not_a_driver_signal() {
// 键存在但没有任何值项:只有键头行。
let header_only = "\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\Khronos\\Vulkan\\Drivers\r\n";
assert!(!registry_has_driver_entry(header_only));
// 键存在但只有子键(无 /s 时子键也缩进打印,且不带 REG_ 类型标记)。
let subkeys_only = "\r\n\
HKEY_LOCAL_MACHINE\\SOFTWARE\\Khronos\\Vulkan\\Drivers\r\n \
LayerOne\r\n LayerTwo\r\n";
assert!(!registry_has_driver_entry(subkeys_only));
// 键头是**别的**键时,其值行不能算数(防止把上层/邻居键的值当成 ICD 注册)。
let other_key = "\r\n\
HKEY_LOCAL_MACHINE\\SOFTWARE\\Khronos\\Vulkan\r\n \
ImplicitLayers REG_DWORD 0x0\r\n";
assert!(!registry_has_driver_entry(other_key));
}
#[test]
fn ldconfig_output_is_parsed_for_the_loader() {
let with_loader =
"\tlibvulkan.so.1 (libc6,x86-64) => /usr/lib/x86_64-linux-gnu/libvulkan.so.1\n\
\tlibvulkan.so (libc6,x86-64) => /usr/lib/x86_64-linux-gnu/libvulkan.so\n";
assert!(ldconfig_has_libvulkan(with_loader));
assert!(!ldconfig_has_libvulkan(
"\tlibGL.so.1 (libc6,x86-64) => /usr/lib/libGL.so.1\n"
));
}
#[test]
fn icd_env_prefers_the_legacy_variable_and_skips_blanks() {
let vars = |pairs: &'static [(&'static str, &'static str)]| {
env_icd_path(move |key| {
pairs
.iter()
.find(|(name, _)| *name == key)
.map(|(_, value)| (*value).to_string())
})
};
assert_eq!(
vars(&[("VK_ICD_FILENAMES", "/a/nvidia_icd.json")]).as_deref(),
Some("/a/nvidia_icd.json")
);
assert_eq!(
vars(&[("VK_DRIVER_FILES", "/b/intel_icd.json")]).as_deref(),
Some("/b/intel_icd.json")
);
// 旧名优先(两者都设时 Khronos loader 也是旧名优先)。
assert_eq!(
vars(&[
("VK_ICD_FILENAMES", "/a/one.json"),
("VK_DRIVER_FILES", "/b/two.json"),
])
.as_deref(),
Some("/a/one.json")
);
// 空白值当作没设。
assert_eq!(vars(&[("VK_ICD_FILENAMES", " ")]), None);
assert_eq!(vars(&[]), None);
assert_eq!(ICD_ENV_VARS.len(), 2);
assert_eq!(ICD_DIRS.len(), 3);
assert!(VULKAN_DRIVERS_KEY.contains("Khronos"));
}
#[test]
fn icd_manifest_count_ignores_non_json_and_missing_dirs() {
let dir = temp_dir("vk-icd");
assert_eq!(count_icd_manifests(&dir), 0);
std::fs::write(dir.join("nvidia_icd.json"), "{}").expect("写 json");
std::fs::write(dir.join("intel_icd.json"), "{}").expect("写 json");
std::fs::write(dir.join("README"), "x").expect("写非 json");
assert_eq!(count_icd_manifests(&dir), 2);
assert_eq!(count_icd_manifests(&dir.join("nope")), 0);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn probe_vk_on_this_host_is_not_unknown() {
// 真探一次:只断言「给了确定结论」(本机有没有 Vulkan 都合法,不许恒 Unknown)。
assert_ne!(probe_vk(), VkProbe::Unknown);
}
/// 唯一临时目录(不引 tempfile 依赖)。
fn temp_dir(tag: &str) -> PathBuf {
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
let index = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("ull-vk-{tag}-{}-{index}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("创建临时目录");
dir
}
@@ -0,0 +1,179 @@
//! Windows 版本探测:注册表 / `ver` 输出的解析 + 本机真实探测。
use crate::prelude::*;
/// Win11(`CurrentMajorVersionNumber` 是 REG_DWORD,`reg query` 输出 0x..)。
const WIN11_REG: &str = "\
HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion
CurrentBuildNumber REG_SZ 22631
CurrentMajorVersionNumber REG_DWORD 0xa
CurrentMinorVersionNumber REG_DWORD 0x0
CurrentVersion REG_SZ 6.3
ProductName REG_SZ Windows 10 Pro
";
/// Win10:数字键存在,`CurrentVersion` 是过期的 6.3,不能拿它当版本。
const WIN10_REG: &str = "\
HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion
CurrentBuildNumber REG_SZ 19045
CurrentMajorVersionNumber REG_DWORD 0xa
CurrentMinorVersionNumber REG_DWORD 0x0
CurrentVersion REG_SZ 6.3
";
/// Win7:没有数字键,只有 `CurrentVersion` 字符串(`CurrentBuildNumber` Win7 也有)。
const WIN7_REG: &str = "\
HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion
CurrentBuildNumber REG_SZ 7601
CurrentVersion REG_SZ 6.1
ProductName REG_SZ Windows 7 Ultimate
";
#[test]
fn reg_query_parses_windows_11() {
let version = windows_version::parse_reg_query(WIN11_REG).expect("解析失败");
assert_eq!(version, WindowsVersion::new(10, 0, 22631));
assert_eq!(version.version_string(), "10.0.22631");
assert_eq!(version.display_name(), "Windows 11");
}
#[test]
fn reg_query_prefers_numeric_keys_over_stale_current_version() {
let version = windows_version::parse_reg_query(WIN10_REG).expect("解析失败");
assert_eq!(version, WindowsVersion::new(10, 0, 19045));
assert_eq!(version.display_name(), "Windows 10");
}
#[test]
fn reg_query_falls_back_to_current_version_on_windows_7() {
let version = windows_version::parse_reg_query(WIN7_REG).expect("解析失败");
assert_eq!(version, WindowsVersion::new(6, 1, 7601));
assert_eq!(version.major_minor(), (6, 1));
assert_eq!(version.display_name(), "Windows 7");
}
#[test]
fn reg_query_without_build_number_is_unparsable() {
let text = " CurrentVersion REG_SZ 6.1\n";
assert_eq!(windows_version::parse_reg_query(text), None);
// 空输出(reg 不可用 / 被策略禁用)也不能崩。
assert_eq!(windows_version::parse_reg_query(""), None);
assert_eq!(
windows_version::parse_reg_query("ERROR: 找不到指定的注册表项"),
None
);
}
#[test]
fn ver_output_parses_english_and_chinese() {
let english =
windows_version::parse_ver_output("Microsoft Windows [Version 10.0.19045.4046]\r\n")
.expect("解析失败");
assert_eq!(english, WindowsVersion::new(10, 0, 19045));
let chinese = windows_version::parse_ver_output(
"Microsoft Windows [\u{7248}\u{672c} 10.0.19045.4046]\r\n",
)
.expect("解析失败");
assert_eq!(chinese, english);
let win7 = windows_version::parse_ver_output("Microsoft Windows [Version 6.1.7601]")
.expect("解析失败");
assert_eq!(win7, WindowsVersion::new(6, 1, 7601));
assert_eq!(win7.display_name(), "Windows 7");
}
#[test]
fn ver_output_rejects_garbage() {
assert_eq!(windows_version::parse_ver_output(""), None);
assert_eq!(windows_version::parse_ver_output("no brackets here"), None);
assert_eq!(
windows_version::parse_ver_output("Microsoft Windows [Version ]"),
None
);
}
#[test]
fn build_number_maps_to_version_families() {
// 快路径:单值查询只给 build,major.minor 由 build 族反推。
assert_eq!(
windows_version::from_build(7601),
Some(WindowsVersion::new(6, 1, 7601))
);
assert_eq!(
windows_version::from_build(9200).map(|v| v.major_minor()),
Some((6, 2))
);
assert_eq!(
windows_version::from_build(9600).map(|v| v.major_minor()),
Some((6, 3))
);
assert_eq!(
windows_version::from_build(19045).map(|v| v.major_minor()),
Some((10, 0))
);
assert_eq!(
windows_version::from_build(22631).map(|v| v.display_name()),
Some("Windows 11")
);
assert_eq!(
windows_version::from_build(26100).map(|v| v.major_minor()),
Some((10, 0))
);
// Vista 之前不猜:交给整键/`ver` 解析兜底。
assert_eq!(windows_version::from_build(3790), None);
assert_eq!(windows_version::from_build(0), None);
}
#[test]
fn single_value_query_yields_build_number() {
let text = "\
HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion
CurrentBuildNumber REG_SZ 26100
";
assert_eq!(windows_version::parse_build_number(text), Some(26100));
assert_eq!(
windows_version::parse_build_number("ERROR: \u{627e}\u{4e0d}\u{5230}\u{6307}\u{5b9a}\u{7684}\u{6ce8}\u{518c}\u{8868}\u{9879}"),
None
);
assert_eq!(windows_version::parse_build_number(""), None);
}
#[test]
fn display_name_covers_the_matrix() {
assert_eq!(
WindowsVersion::new(10, 0, 22_000).display_name(),
"Windows 11"
);
assert_eq!(
WindowsVersion::new(10, 0, 21_999).display_name(),
"Windows 10"
);
assert_eq!(
WindowsVersion::new(6, 3, 9600).display_name(),
"Windows 8.1"
);
assert_eq!(WindowsVersion::new(6, 2, 9200).display_name(), "Windows 8");
assert_eq!(WindowsVersion::new(6, 1, 7601).display_name(), "Windows 7");
assert_eq!(
WindowsVersion::new(6, 0, 6002).display_name(),
"Windows Vista"
);
assert_eq!(WindowsVersion::new(5, 1, 2600).display_name(), "Windows XP");
}
#[test]
fn probe_matches_the_host() {
let probed = windows_version::probe();
if cfg!(target_os = "windows") {
let version = probed.expect("Windows 宿主必须能探到真实版本");
// 真实的 Windows 至少是 6.x(XP 是 5.x,不会被这个仓库支持)。
assert!(version.major >= 6, "探到 {version:?}");
assert!(!version.display_name().is_empty());
// 重新探一次结果稳定(reg 与 ver 两条路径应给同一个 major.minor)。
let again = windows_version::probe().expect("二次探测失败");
assert_eq!(again.major_minor(), version.major_minor());
} else {
assert!(probed.is_none(), "非 Windows 平台不应探到 Windows 版本");
}
}
@@ -0,0 +1,204 @@
//! 「外观(高级)」键簇(`setting::appearance` + `keys::launcher_schema` 的 appearance.* 段)。
//!
//! 守三条设计约束(09-ui-layout §111):
//! 1. 一切高级特性**默认关闭**(出厂 Metro 纯色)——默认值即出厂态;
//! 2. 全部走 D6 schema 且 **AI 可写**(白名单内,可被 agent 调节);
//! 3. 值域有硬边界(滑条夹取),坏值/手改配置不崩(回落默认)。
use serde_json::json;
use unlucky_core::setting::appearance::{
hex_to_rgb, normalize_hex, relative_luminance, Appearance, BgFill, DEFAULT_ACCENT,
PANEL_ALPHA_MIN,
};
use unlucky_core::setting::document::SettingDocument;
use unlucky_core::setting::keys::{launcher_schema, launcher_version};
use unlucky_core::setting::spec::ValueKind;
fn doc(raw: serde_json::Value) -> SettingDocument {
SettingDocument::load(&launcher_schema(), &raw).0
}
#[test]
fn factory_defaults_are_all_advanced_features_off() {
let appearance = Appearance::from_store(&unlucky_core::setting::store::SettingStore::load(
std::env::temp_dir().join(format!("ull-appearance-none-{}.json", std::process::id())),
launcher_schema(),
));
assert_eq!(appearance.theme_mode, "auto", "D17:出厂跟随系统");
assert!(!appearance.bg_image_enabled, "背景图默认关");
assert!(!appearance.bg_image_active());
assert_eq!(appearance.bg_fill, BgFill::Cover);
assert_eq!(appearance.panel_alpha, 1.0, "出厂完全不透明(Metro 纯色)");
assert!(!appearance.glass, "玻璃拟态默认关");
assert_eq!(appearance.accent_color, "", "空串 = 未自定义");
assert_eq!(appearance.accent_or_default(), DEFAULT_ACCENT);
}
#[test]
fn reading_a_configured_document_reflects_each_key() {
let document = doc(json!({
"appearance": {
"theme_mode": "dark",
"bg_image_enabled": true,
"bg_image": "D:/wall/paper.png",
"bg_fill": "tile",
"bg_dim": 0.6,
"panel_alpha": 0.8,
"glass": true,
"accent_color": "#ff5722",
}
}));
let appearance = Appearance::read(&document);
assert_eq!(appearance.theme_mode, "dark");
assert!(appearance.bg_image_active());
assert_eq!(appearance.bg_image, "D:/wall/paper.png");
assert_eq!(appearance.bg_fill, BgFill::Tile);
assert!((appearance.bg_dim - 0.6).abs() < 1e-9);
assert!((appearance.panel_alpha - 0.8).abs() < 1e-9);
assert!(appearance.glass);
assert_eq!(appearance.accent_color, "#FF5722", "读入即规范化大写");
assert_eq!(appearance.accent_or_default(), "#FF5722");
}
#[test]
fn bad_values_fall_back_instead_of_panicking() {
// 手改坏的配置:枚举拼错 / 开关类型错 / accent 不是 #RRGGBB。
let document = doc(json!({
"appearance": {
"bg_fill": "stretch",
"bg_image_enabled": "yes",
"accent_color": "rebeccapurple",
}
}));
let appearance = Appearance::read(&document);
assert_eq!(appearance.bg_fill, BgFill::Cover);
assert!(!appearance.bg_image_enabled);
assert_eq!(appearance.accent_color, "");
assert_eq!(appearance.accent_or_default(), DEFAULT_ACCENT);
}
#[test]
fn panel_alpha_is_clamped_to_the_contrast_floor() {
// schema 下限 0.4;读侧再夹一次(挡手改文件绕过 schema 的情况)。
let document = doc(json!({ "appearance": { "panel_alpha": 0.05 } }));
assert!((Appearance::read(&document).panel_alpha - PANEL_ALPHA_MIN).abs() < 1e-9);
}
#[test]
fn appearance_keys_are_all_ai_writable_and_registered() {
let schema = launcher_schema();
// 主题三态是 D17 已定案的最轻量键(silent/Low),其余高级键留审计痕迹(audited/Medium)。
let audited_keys = [
"appearance.bg_image_enabled",
"appearance.bg_image",
"appearance.bg_fill",
"appearance.bg_dim",
"appearance.panel_alpha",
"appearance.glass",
"appearance.accent_color",
// M91(D36/D37/D38)的外观下层三键,同样 audited。
"appearance.window_opacity",
"appearance.titlebar_mode",
"appearance.palette_source",
];
for key in audited_keys {
let spec = schema.spec(key).unwrap_or_else(|| panic!("{key} 必须登记"));
assert!(spec.ai.writable, "{key} 必须在 AI 可写白名单内");
// 纯视觉、可一键复原 → 不要求弹窗确认(High 级留给凭据/数据根/代理)。
assert!(
!spec.ai.sensitivity.requires_confirmation(),
"{key} 不该要确认"
);
assert_eq!(
spec.ai.sensitivity.as_str(),
"medium",
"{key} 要留审计痕迹(AI 改过什么可回看)"
);
}
// 主题三态仍是 silent(D17 已定案的最轻量键)。
assert_eq!(
schema
.spec("appearance.theme_mode")
.unwrap()
.ai
.sensitivity
.as_str(),
"low"
);
}
#[test]
fn schema_registration_shape_is_right() {
assert_eq!(
launcher_version().minor,
8,
"1.8.0 登记 M197 的 download.gh_proxy(1.7.0 是 M168 的 ui.font_cdn)"
);
let schema = launcher_schema();
assert_eq!(
schema.spec("appearance.bg_fill").unwrap().kind,
ValueKind::Enum(&["cover", "contain", "tile"])
);
assert!(matches!(
schema.spec("appearance.panel_alpha").unwrap().kind,
ValueKind::Float { min, max } if (min - PANEL_ALPHA_MIN).abs() < 1e-9 && (max - 1.0).abs() < 1e-9
));
assert_eq!(
schema.spec("appearance.accent_color").unwrap().kind,
ValueKind::Text { max_len: 9 },
"#RRGGBB 最多 7 字符,留 9 容错带井号与空白"
);
// 默认值 = 出厂态
assert_eq!(
schema.spec("appearance.panel_alpha").unwrap().default,
serde_json::Value::from(1.0)
);
assert_eq!(
schema.spec("appearance.glass").unwrap().default,
serde_json::Value::from(false)
);
}
#[test]
fn write_round_trips_through_the_store() {
let temp =
std::env::temp_dir().join(format!("ull-appearance-{}-{}", std::process::id(), line!()));
std::fs::create_dir_all(temp.join("config")).expect("临时目录");
let path = temp.join("config/launcher.json");
let _ = std::fs::remove_file(&path);
let mut store = unlucky_core::setting::store::SettingStore::load(&path, launcher_schema());
let mut appearance = Appearance::from_store(&store);
appearance.theme_mode = "dark".to_owned();
appearance.panel_alpha = 0.8;
appearance.bg_image_enabled = true;
appearance.bg_image = "D:/wall/p.png".to_owned();
appearance.accent_color = "#0f6cbd".to_owned();
appearance.write(&mut store).expect("写外观键");
store.save().expect("落盘");
let reloaded = Appearance::from_store(&unlucky_core::setting::store::SettingStore::load(
&path,
launcher_schema(),
));
assert_eq!(reloaded.theme_mode, "dark");
assert!((reloaded.panel_alpha - 0.8).abs() < 1e-9);
assert!(reloaded.bg_image_active());
assert_eq!(reloaded.accent_color, "#0F6CBD");
std::fs::remove_dir_all(&temp).ok();
}
#[test]
fn hex_helpers_validate_and_convert() {
assert_eq!(normalize_hex(" 0f6cbd "), Some("#0F6CBD".to_owned()));
assert_eq!(normalize_hex("#0F6CBD"), Some("#0F6CBD".to_owned()));
assert_eq!(normalize_hex("#0F6CB"), None);
assert_eq!(normalize_hex("#GGGGGG"), None);
assert_eq!(hex_to_rgb("#0078D4"), Some((0x00, 0x78, 0xD4)));
// WCAG 相对亮度:纯黑 0、纯白 1、Metro 蓝居中偏暗。
assert!(relative_luminance("#000000").unwrap().abs() < 1e-9);
assert!((relative_luminance("#FFFFFF").unwrap() - 1.0).abs() < 1e-9);
let metro = relative_luminance(DEFAULT_ACCENT).unwrap();
assert!(metro > 0.05 && metro < 0.3, "Metro 蓝亮度 {metro}");
}
+49
View File
@@ -0,0 +1,49 @@
//! 原子写:覆盖语义、父目录创建、不留临时文件、可选读。
use unlucky_core::platform::atomic::{read_optional, tmp_path, write_atomic};
use crate::common::{dir_entries, TempDir};
#[test]
fn writes_and_replaces_in_place() {
let temp = TempDir::new("atomic");
let path = temp.child("config/launcher.json");
write_atomic(&path, b"{\"a\":1}").expect("首次写");
assert_eq!(temp.read("config/launcher.json"), "{\"a\":1}");
write_atomic(&path, b"{\"a\":2}").expect("覆盖写");
assert_eq!(temp.read("config/launcher.json"), "{\"a\":2}");
// 目录里只该有目标文件,临时文件必须被改名掉。
assert_eq!(dir_entries(&temp.child("config")), vec!["launcher.json"]);
}
#[test]
fn read_optional_distinguishes_missing_from_failure() {
let temp = TempDir::new("atomic-read");
let path = temp.child("nope.json");
assert!(read_optional(&path).expect("读缺失文件").is_none());
temp.write("here.json", "内容");
let bytes = read_optional(&temp.child("here.json"))
.expect("读文件")
.expect("有内容");
assert_eq!(bytes, "内容".as_bytes());
}
#[test]
fn tmp_name_stays_in_same_directory() {
let temp = TempDir::new("atomic-tmp");
let path = temp.child("config/profiles.json");
let tmp = tmp_path(&path);
assert_eq!(
tmp.parent(),
path.parent(),
"临时文件必须同目录(rename 才原子)"
);
assert_ne!(tmp, path);
let first = tmp_path(&path);
let second = tmp_path(&path);
assert_ne!(first, second, "并发写要能拿到不同的临时名");
}
+81
View File
@@ -0,0 +1,81 @@
//! 测试公用小工具(临时目录 + 文件读写)。
//!
//! 不引 `tempfile`:M11 的依赖面只有 serde/serde_json,测试自己管临时目录。
#![allow(dead_code)]
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
/// 进程内唯一的临时目录,`Drop` 时递归删除。
pub struct TempDir {
path: PathBuf,
}
impl TempDir {
/// 建一个临时目录。
pub fn new(label: &str) -> Self {
let index = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"ull-setting-{label}-{}-{index}",
std::process::id()
));
std::fs::remove_dir_all(&path).ok();
std::fs::create_dir_all(&path).expect("创建临时目录");
Self { path }
}
/// 根路径。
pub fn path(&self) -> &Path {
&self.path
}
/// 拼接子路径(不创建)。
pub fn child(&self, name: &str) -> PathBuf {
self.path.join(name)
}
/// 写一个文件(自动建父目录)。
pub fn write(&self, name: &str, contents: &str) -> PathBuf {
let path = self.child(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("创建父目录");
}
std::fs::write(&path, contents).expect("写文件");
path
}
/// 读一个文件。
pub fn read(&self, name: &str) -> String {
std::fs::read_to_string(self.child(name)).expect("读文件")
}
/// 子路径是否存在。
pub fn exists(&self, name: &str) -> bool {
self.child(name).exists()
}
}
impl Drop for TempDir {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.path).ok();
}
}
/// 目录里的文件名(排序后返回,便于断言没留下临时文件)。
pub fn dir_entries(dir: &Path) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(dir)
.expect("读目录")
.map(|entry| {
entry
.expect("目录项")
.file_name()
.to_string_lossy()
.into_owned()
})
.collect();
names.sort();
names
}
+128
View File
@@ -0,0 +1,128 @@
//! 保存通道:200ms 去抖、同路径覆盖、攒批落盘、后台线程 flush/关停。
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use unlucky_core::setting::error::SettingResult;
use unlucky_core::setting::save::{CoalescingWriter, DebounceQueue, JobSink, DEFAULT_DEBOUNCE};
use unlucky_core::setting::save_worker::SaveWorker;
use crate::common::TempDir;
/// 记录下来的 (路径, 内容) 列表。
type Written = Vec<(PathBuf, Vec<u8>)>;
#[derive(Clone, Default)]
struct RecordingSink {
written: Arc<Mutex<Written>>,
}
impl RecordingSink {
fn entries(&self) -> Vec<(PathBuf, Vec<u8>)> {
self.written.lock().expect("锁").clone()
}
}
impl JobSink for RecordingSink {
fn write(&self, path: &Path, payload: &[u8]) -> SettingResult<()> {
self.written
.lock()
.expect("锁")
.push((path.to_path_buf(), payload.to_vec()));
Ok(())
}
}
#[test]
fn default_debounce_is_200ms() {
assert_eq!(DEFAULT_DEBOUNCE, Duration::from_millis(200));
}
#[test]
fn waits_for_the_window_before_writing() {
let start = Instant::now();
let mut queue = DebounceQueue::<String>::new(DEFAULT_DEBOUNCE);
assert!(queue.is_empty());
queue.enqueue("a".to_owned(), b"1".to_vec(), start);
queue.enqueue("b".to_owned(), b"2".to_vec(), start);
assert_eq!(queue.len(), 2);
assert!(queue.contains(&"a".to_owned()));
assert_eq!(queue.next_due(), Some(start + DEFAULT_DEBOUNCE));
assert!(queue
.drain_due(start + Duration::from_millis(100))
.is_empty());
let due = queue.drain_due(start + DEFAULT_DEBOUNCE);
assert_eq!(due.len(), 2);
assert_eq!(due[0].0, "a", "同一批按键排序,写盘顺序稳定");
assert!(queue.is_empty());
}
#[test]
fn same_key_collapses_and_keeps_first_deadline() {
let start = Instant::now();
let mut queue = DebounceQueue::<String>::new(DEFAULT_DEBOUNCE);
queue.enqueue("a".to_owned(), b"1".to_vec(), start);
queue.enqueue(
"a".to_owned(),
b"2".to_vec(),
start + Duration::from_millis(150),
);
assert_eq!(queue.len(), 1);
assert_eq!(
queue.next_due(),
Some(start + DEFAULT_DEBOUNCE),
"窗口从首次改动开始算,避免连续改动无限推迟写盘"
);
assert_eq!(
queue.drain_due(start + DEFAULT_DEBOUNCE),
vec![("a".to_owned(), b"2".to_vec())]
);
}
#[test]
fn coalescing_writer_batches_into_one_flush() {
let sink = RecordingSink::default();
let mut writer = CoalescingWriter::new(sink.clone(), DEFAULT_DEBOUNCE);
let start = Instant::now();
writer.queue("/tmp/a.json", b"1".to_vec(), start);
writer.queue(
"/tmp/a.json",
b"2".to_vec(),
start + Duration::from_millis(50),
);
writer.queue("/tmp/b.json", b"3".to_vec(), start);
assert_eq!(writer.pending_len(), 2);
assert!(!writer.is_empty());
assert_eq!(writer.flush_due(start).expect("未到窗口"), 0);
assert_eq!(
writer.flush_due(start + DEFAULT_DEBOUNCE).expect("到窗口"),
2
);
assert!(writer.is_empty());
assert_eq!(writer.flush_all().expect("空队列兜底"), 0);
let written = sink.entries();
assert_eq!(written.len(), 2);
assert_eq!(written[0].0, PathBuf::from("/tmp/a.json"));
assert_eq!(written[0].1, b"2".to_vec(), "只写最新内容");
}
#[test]
fn save_worker_flushes_and_shuts_down() {
let temp = TempDir::new("save-worker");
let target = temp.child("config/launcher.json");
let mut worker = SaveWorker::spawn(Duration::from_millis(30)).expect("起线程");
worker.queue(&target, b"{\"a\":1}").expect("入队");
worker.queue(&target, b"{\"a\":2}").expect("入队");
worker.flush().expect("同步落盘");
assert_eq!(temp.read("config/launcher.json"), "{\"a\":2}");
worker.shutdown().expect("关停");
}
@@ -0,0 +1,142 @@
//! 键废弃机制:`ui.theme` → `appearance.theme_mode`(tower 键冲突裁决)。
//!
//! 规则:**读兼容**(旧值按 `THEME_ALIASES` 映射,新键已在则新键优先)+ **写永远走新键**
//! (`set` 旧键直接报错,落盘不含旧键,AI 白名单与工具 JSON Schema 也不含旧键)。
use serde_json::{json, Value};
use unlucky_core::setting::document::{LoadProblem, SettingDocument};
use unlucky_core::setting::keys::{launcher_schema, LEGACY_THEMES, THEME_ALIASES, THEME_MODES};
use unlucky_core::setting::schema::{SchemaId, SchemaRef, SchemaVersion, SCHEMA_KEY};
use unlucky_core::setting::store::SettingStore;
use crate::common::TempDir;
fn legacy_file(raw_ui: Value) -> Value {
json!({
SCHEMA_KEY: SchemaRef::new(SchemaId::LAUNCHER, SchemaVersion::new(1, 0, 0)).url(),
"ui": raw_ui
})
}
fn deprecated_problem(problems: &[LoadProblem]) -> Option<&'static str> {
problems.iter().find_map(|problem| match problem {
LoadProblem::DeprecatedKey {
key, replaced_by, ..
} if key == "ui.theme" => Some(*replaced_by),
_ => None,
})
}
#[test]
fn legacy_theme_values_are_translated_on_read() {
assert_eq!(THEME_MODES.len(), 3);
assert!(THEME_MODES.contains(&"auto") && THEME_MODES.contains(&"light"));
assert!(THEME_MODES.contains(&"dark"));
assert_eq!(
LEGACY_THEMES.len(),
THEME_ALIASES.len(),
"三个旧值都要有映射"
);
for (legacy, expected) in [
("system", "auto"),
("metro_light", "light"),
("metro_dark", "dark"),
] {
let (doc, problems) =
SettingDocument::load(&launcher_schema(), &legacy_file(json!({ "theme": legacy })));
assert_eq!(
doc.effective_str("appearance.theme_mode"),
Some(expected),
"旧值 {legacy} 应映射成 {expected}"
);
assert_eq!(
doc.legacy_value("ui.theme").and_then(Value::as_str),
Some(legacy),
"旧键原文要留着给 UI 提示/回滚"
);
assert_eq!(deprecated_problem(&problems), Some("appearance.theme_mode"));
assert!(!doc.is_dirty(), "读兼容不算用户改动");
}
}
#[test]
fn new_key_wins_when_both_present() {
let raw = json!({
SCHEMA_KEY: SchemaRef::new(SchemaId::LAUNCHER, SchemaVersion::new(1, 0, 0)).url(),
"ui": { "theme": "metro_dark" },
"appearance": { "theme_mode": "light" }
});
let (doc, _) = SettingDocument::load(&launcher_schema(), &raw);
assert_eq!(doc.effective_str("appearance.theme_mode"), Some("light"));
}
#[test]
fn unmapped_legacy_value_is_reported() {
let (doc, problems) =
SettingDocument::load(&launcher_schema(), &legacy_file(json!({ "theme": "neon" })));
assert_eq!(doc.effective_str("appearance.theme_mode"), Some("neon"));
assert!(
problems.iter().any(|problem| matches!(
problem,
LoadProblem::InvalidValue { key, .. } if key == "appearance.theme_mode"
)),
"映射不过去的旧值要报出来: {problems:?}"
);
}
#[test]
fn writing_the_deprecated_key_is_refused() {
let (mut doc, _) = SettingDocument::load(&launcher_schema(), &json!({}));
let err = doc
.set("ui.theme", json!("metro_dark"))
.expect_err("旧键禁止写");
assert!(err.to_string().contains("appearance.theme_mode"), "{err}");
assert!(!doc.is_dirty());
}
#[test]
fn save_drops_the_old_key_and_writes_the_new_one() {
let temp = TempDir::new("deprecated-save");
temp.write(
"config/launcher.json",
&legacy_file(json!({ "theme": "metro_dark", "language": "en_us" })).to_string(),
);
let mut store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema());
assert_eq!(
store.effective("appearance.theme_mode"),
Some(&json!("dark"))
);
// 随便改一个别的键触发一次保存:旧键消失、新键(映射值)落盘。
store.set("ui.scale", json!(1.25)).expect("改缩放");
store.save().expect("保存");
let saved: Value = serde_json::from_str(&temp.read("config/launcher.json")).expect("JSON 合法");
assert_eq!(saved["appearance"]["theme_mode"], json!("dark"));
assert!(
saved["ui"].get("theme").is_none(),
"废弃键不再落盘: {saved}"
);
assert_eq!(saved["ui"]["language"], json!("en_us"));
}
#[test]
fn whitelist_and_tool_schema_exclude_the_old_key() {
let schema = launcher_schema();
assert_eq!(schema.deprecated_keys(), vec!["ui.theme"]);
assert_eq!(
schema.replacement_of("ui.theme"),
Some("appearance.theme_mode")
);
assert_eq!(schema.replacement_of("appearance.theme_mode"), None);
assert!(!schema.accepts_ai_write("ui.theme"), "废弃键不许 AI 写");
assert!(!schema.ai_writable_keys().contains(&"ui.theme"));
let projected = schema.to_json_schema();
let properties = projected["properties"].as_object().expect("properties");
assert!(properties.contains_key("appearance.theme_mode"));
assert!(!properties.contains_key("ui.theme"));
}
+185
View File
@@ -0,0 +1,185 @@
//! 数据目录迁移:计划校验、逐分区搬动、收据 + 指针、失败逆序回滚。
use std::path::{Path, PathBuf};
use unlucky_core::platform::data_dir::{DataMode, ResolvedData};
use unlucky_core::platform::dir_migration::{self, DirMigrationOptions};
use unlucky_core::platform::error::PlatformError;
use unlucky_core::platform::layout::{LauncherLayout, Partition};
use unlucky_core::platform::receipt::{self, ReceiptKind};
use unlucky_core::platform::redirect;
use crate::common::TempDir;
fn resolved(home: &Path) -> ResolvedData {
ResolvedData {
mode: DataMode::Custom,
launcher_home: home.to_path_buf(),
game_home: home.join(".minecraft"),
redirected_from: None,
}
}
fn seed(home: &Path) {
let layout = LauncherLayout::new(home.to_path_buf());
layout.ensure().expect("建四分区");
std::fs::create_dir_all(layout.auth_data()).expect("auth 目录");
std::fs::create_dir_all(layout.sha1_cache("ab")).expect("缓存分片");
std::fs::write(layout.launcher_config(), b"{\"x\":1}").expect("写配置");
std::fs::write(layout.auth_data().join("token.json"), b"{}").expect("写凭据");
std::fs::write(layout.sha1_cache("ab").join("hash"), b"payload").expect("写缓存");
std::fs::write(layout.logs().join("launcher.log"), b"log").expect("写日志");
}
#[test]
fn plan_orders_partitions_and_skips_missing_ones() {
let temp = TempDir::new("dir-migration-plan");
seed(&temp.child("old/.ULL"));
let from = resolved(&temp.child("old/.ULL"));
let to = resolved(&temp.child("new/.ULL"));
let plan = dir_migration::plan(&from, &to, &DirMigrationOptions::default()).expect("计划");
let partitions: Vec<Partition> = plan.steps.iter().map(|step| step.partition).collect();
assert_eq!(
partitions,
vec![
Partition::Config,
Partition::Data,
Partition::Logs,
Partition::Cache
],
"配置最先、缓存最后"
);
let cache_off = DirMigrationOptions {
migrate_cache: false,
};
let plan = dir_migration::plan(&from, &to, &cache_off).expect("计划");
assert_eq!(plan.steps.len(), 3);
assert!(plan
.steps
.iter()
.all(|step| step.partition != Partition::Cache));
}
#[test]
fn execute_moves_everything_and_leaves_a_pointer() {
let temp = TempDir::new("dir-migration-run");
let old_home = temp.child("old/.ULL");
let new_home = temp.child("new/.ULL");
seed(&old_home);
let from = resolved(&old_home);
let to = resolved(&new_home);
let plan = dir_migration::plan(&from, &to, &DirMigrationOptions::default()).expect("计划");
let outcome = dir_migration::execute(&plan, 1_700_000_000_000).expect("执行");
assert_eq!(
outcome.moved,
vec![
Partition::Config,
Partition::Data,
Partition::Logs,
Partition::Cache
]
);
let moved = LauncherLayout::new(new_home.clone());
assert!(moved.launcher_config().is_file(), "配置要搬过去");
assert!(moved.auth_data().join("token.json").is_file());
assert!(moved.logs().join("launcher.log").is_file());
assert!(moved.sha1_cache("ab").join("hash").is_file());
assert!(!LauncherLayout::new(old_home.clone()).config().exists());
let stored = receipt::read(&outcome.receipt_path)
.expect("读收据")
.expect("有收据");
assert_eq!(stored.kind, ReceiptKind::DataDirMove);
assert_eq!(stored.source, old_home);
assert_eq!(stored.target, new_home);
assert_eq!(stored.applied_at_ms, 1_700_000_000_000);
let pointer = redirect::read(&old_home)
.expect("读指针")
.expect("旧目录要留指针");
assert_eq!(pointer.launcher_home, new_home);
assert_eq!(pointer.mode, DataMode::Custom);
assert_eq!(outcome.pointer_path, old_home.join("redirect.json"));
}
#[test]
fn refuses_same_source_and_target() {
let temp = TempDir::new("dir-migration-same");
seed(&temp.child("only/.ULL"));
let home = resolved(&temp.child("only/.ULL"));
let err = dir_migration::plan(&home, &home, &DirMigrationOptions::default())
.expect_err("源目标相同要拒绝");
assert!(
matches!(err, PlatformError::MigrationRefused { .. }),
"{err}"
);
}
#[test]
fn refuses_missing_source_and_non_empty_target() {
let temp = TempDir::new("dir-migration-refuse");
let from = resolved(&temp.child("gone/.ULL"));
let to = resolved(&temp.child("new/.ULL"));
assert!(matches!(
dir_migration::plan(&from, &to, &DirMigrationOptions::default()).expect_err("源不存在"),
PlatformError::MigrationRefused { .. }
));
seed(&temp.child("old/.ULL"));
let from = resolved(&temp.child("old/.ULL"));
std::fs::create_dir_all(to.launcher_home.clone()).expect("建目标");
std::fs::write(to.launcher_home.join("something"), b"x").expect("占位");
let err = dir_migration::plan(&from, &to, &DirMigrationOptions::default())
.expect_err("目标非空要拒绝");
assert!(err.to_string().contains("已有数据"), "{err}");
}
#[test]
fn rolls_back_moved_partitions_when_a_later_step_fails() {
let temp = TempDir::new("dir-migration-rollback");
let old_home = temp.child("old/.ULL");
let new_home = temp.child("new/.ULL");
seed(&old_home);
let from = resolved(&old_home);
let to = resolved(&new_home);
let plan = dir_migration::plan(&from, &to, &DirMigrationOptions::default()).expect("计划");
// 计划之后在目标侧造障碍:data 位置先占成普通文件 → 搬 data 必失败。
std::fs::create_dir_all(&new_home).expect("建目标根");
std::fs::write(new_home.join("data"), b"obstruction").expect("造障碍");
let err = dir_migration::execute(&plan, 42).expect_err("应当失败");
assert!(
matches!(err, PlatformError::MigrationRefused { .. }),
"{err}"
);
let old = LauncherLayout::new(old_home);
assert!(old.launcher_config().is_file(), "config 必须被回滚搬回来");
assert!(old.auth_data().join("token.json").is_file());
assert!(!old.redirect_path().exists(), "失败不该留指针");
assert!(!new_home.join("config").exists(), "目标侧不该残留已搬分区");
assert!(!new_home.join("data").is_dir(), "障碍文件保持原样");
}
#[test]
fn receipt_anti_replay_uses_source_target_signature() {
let temp = TempDir::new("dir-migration-receipt");
let receipt_path: PathBuf = temp.child("config/migration-receipt.json");
let receipt = receipt::MigrationReceipt::data_dir_move(
PathBuf::from("/old/.ULL"),
PathBuf::from("/new/.ULL"),
1_700_000_000_000,
);
receipt::write(&receipt_path, &receipt).expect("写收据");
let loaded = receipt::read(&receipt_path).expect("读").expect("有");
assert_eq!(
receipt::decide(Some(&loaded), &receipt.source_fingerprint),
receipt::ReplayDecision::SkipSameSource
);
}
+205
View File
@@ -0,0 +1,205 @@
//! 三模式数据目录解析:Portable / Standard / Custom + 平台落点 + 高级覆盖 + redirect 跟随。
use std::path::PathBuf;
use unlucky_core::platform::data_dir::{DataDirRequest, DataMode, ResolvedData, Resolver};
use unlucky_core::platform::error::PlatformError;
use unlucky_core::platform::paths::{standard_game_home, standard_launcher_home, MapEnv, Platform};
use unlucky_core::platform::redirect::{self, RedirectPointer, REDIRECT_SCHEMA_VERSION};
use crate::common::TempDir;
#[test]
fn portable_puts_dot_ull_and_minecraft_beside_exe() {
let env = MapEnv::new().with("APPDATA", "/roaming");
let resolver = Resolver::with_env(Platform::Windows, Some(PathBuf::from("/games/ULL")), &env);
assert_eq!(resolver.default_mode(), DataMode::Portable);
let resolved = resolver.resolve(&DataDirRequest::default()).expect("解析");
assert_eq!(resolved.mode, DataMode::Portable);
assert_eq!(resolved.launcher_home, PathBuf::from("/games/ULL/.ULL"));
assert_eq!(resolved.game_home, PathBuf::from("/games/ULL/.minecraft"));
assert!(resolved.redirected_from.is_none());
}
#[test]
fn portable_without_exe_dir_errors() {
let env = MapEnv::new();
let resolver = Resolver::with_env(Platform::Linux, None, &env);
let err = resolver
.resolve(&DataDirRequest::default())
.expect_err("应当报错");
assert!(matches!(err, PlatformError::NoExecutableDir));
}
#[test]
fn standard_uses_per_os_locations() {
let windows = MapEnv::new().with("APPDATA", "/roaming");
assert_eq!(
standard_launcher_home(Platform::Windows, &windows),
Some(PathBuf::from("/roaming/ULL"))
);
assert_eq!(
standard_game_home(Platform::Windows, &windows),
Some(PathBuf::from("/roaming/.minecraft"))
);
let mac = MapEnv::new().with("HOME", "/Users/u");
assert_eq!(
standard_launcher_home(Platform::MacOs, &mac),
Some(PathBuf::from("/Users/u/Library/Application Support/ULL"))
);
assert_eq!(
standard_game_home(Platform::MacOs, &mac),
Some(PathBuf::from(
"/Users/u/Library/Application Support/minecraft"
))
);
// XDG 规范要求绝对路径;测试用 temp_dir(两种平台都算绝对路径)。
let xdg_root = std::env::temp_dir().join("ull-xdg");
let xdg_text = xdg_root.to_string_lossy().into_owned();
let linux = MapEnv::new()
.with("HOME", "/home/u")
.with("XDG_DATA_HOME", &xdg_text);
assert_eq!(
standard_launcher_home(Platform::Linux, &linux),
Some(xdg_root.join("ull"))
);
assert_eq!(
standard_game_home(Platform::Linux, &linux),
Some(PathBuf::from("/home/u/.minecraft"))
);
// 相对 XDG_DATA_HOME 不合法 → 回落 ~/.local/share/ull。
let relative_xdg = MapEnv::new()
.with("HOME", "/home/u")
.with("XDG_DATA_HOME", "relative/xdg");
assert_eq!(
standard_launcher_home(Platform::Linux, &relative_xdg),
Some(PathBuf::from("/home/u/.local/share/ull"))
);
}
#[test]
fn standard_falls_back_when_env_missing() {
let env = MapEnv::new()
.with("HOME", "/home/u")
.with("USERPROFILE", "/home/u");
assert_eq!(
standard_launcher_home(Platform::Linux, &env),
Some(PathBuf::from("/home/u/.local/share/ull"))
);
assert_eq!(
standard_launcher_home(Platform::Windows, &env),
Some(PathBuf::from("/home/u/AppData/Roaming/ULL"))
);
let empty = MapEnv::new();
assert_eq!(standard_launcher_home(Platform::Windows, &empty), None);
}
#[test]
fn standard_mode_resolution_and_macos_default() {
let env = MapEnv::new().with("HOME", "/Users/u");
let resolver = Resolver::with_env(Platform::MacOs, None, &env);
assert_eq!(resolver.default_mode(), DataMode::Standard);
let request = DataDirRequest {
mode: DataMode::Standard,
..DataDirRequest::default()
};
let resolved = resolver.resolve(&request).expect("解析");
assert_eq!(
resolved.launcher_home,
PathBuf::from("/Users/u/Library/Application Support/ULL")
);
assert_eq!(
resolved.game_home,
PathBuf::from("/Users/u/Library/Application Support/minecraft")
);
}
#[test]
fn custom_requires_absolute_existing_root_choice() {
let env = MapEnv::new();
let resolver = Resolver::with_env(Platform::Windows, Some(PathBuf::from("/games")), &env);
let root = std::env::temp_dir().join("ull-custom-root");
let ok = DataDirRequest {
mode: DataMode::Custom,
custom_root: Some(root.clone()),
..DataDirRequest::default()
};
let resolved = resolver.resolve(&ok).expect("解析");
assert_eq!(resolved.launcher_home, root.join(".ULL"));
assert_eq!(resolved.game_home, root.join(".minecraft"));
let relative = DataDirRequest {
mode: DataMode::Custom,
custom_root: Some(PathBuf::from("relative/dir")),
..DataDirRequest::default()
};
assert!(matches!(
resolver.resolve(&relative).expect_err("相对路径应报错"),
PlatformError::InvalidCustomRoot { .. }
));
let missing = DataDirRequest {
mode: DataMode::Custom,
custom_root: None,
..DataDirRequest::default()
};
assert!(resolver.resolve(&missing).is_err());
}
#[test]
fn advanced_overrides_split_launcher_and_game_home() {
let env = MapEnv::new();
let resolver = Resolver::with_env(Platform::Windows, Some(PathBuf::from("/games")), &env);
let request = DataDirRequest {
launcher_home: Some(PathBuf::from("/config/ULL")),
game_home: Some(PathBuf::from("/bigdisk/.minecraft")),
..DataDirRequest::default()
};
let resolved = resolver.resolve(&request).expect("解析");
assert_eq!(resolved.launcher_home, PathBuf::from("/config/ULL"));
assert_eq!(resolved.game_home, PathBuf::from("/bigdisk/.minecraft"));
assert!(!resolved.same_target(&ResolvedData {
mode: DataMode::Portable,
launcher_home: PathBuf::from("/other"),
game_home: PathBuf::from("/bigdisk/.minecraft"),
redirected_from: None,
}));
}
#[test]
fn effective_resolution_follows_redirect_pointer() {
let temp = TempDir::new("data-dir-redirect");
let exe_dir = temp.child("portable");
let moved_to = temp.child("after-move");
std::fs::create_dir_all(&exe_dir).expect("建目录");
let pointer = RedirectPointer {
schema_version: REDIRECT_SCHEMA_VERSION,
mode: DataMode::Custom,
launcher_home: moved_to.clone(),
game_home: moved_to.join(".minecraft"),
migrated_at_ms: 1_700_000_000_000,
};
redirect::write(&exe_dir.join(".ULL"), &pointer).expect("写指针");
let env = MapEnv::new();
let resolver = Resolver::with_env(Platform::Windows, Some(exe_dir.clone()), &env);
let resolved = resolver
.resolve_default(&DataDirRequest::default())
.expect("解析");
assert_eq!(resolved.launcher_home, moved_to);
assert_eq!(resolved.mode, DataMode::Portable);
assert_eq!(
resolved.redirected_from,
Some(exe_dir.join(".ULL")),
"旧目录要留在记录里,便于 UI 提示「数据已迁走」"
);
}
+221
View File
@@ -0,0 +1,221 @@
//! 文档模型:脏字段、未知字段保留、默认值不落盘、嵌套点路径、坏值容错、`$schema` 保留。
use serde_json::{json, Value};
use unlucky_core::setting::document::{LoadProblem, RenderMode, SettingDocument};
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::schema::{SchemaRef, SchemaVersion, SCHEMA_KEY, SCHEMA_VERSION_KEY};
fn schema_url(version: SchemaVersion) -> String {
SchemaRef::new(unlucky_core::setting::schema::SchemaId::LAUNCHER, version).url()
}
fn loaded(raw: Value) -> (SettingDocument, Vec<LoadProblem>) {
SettingDocument::load(&launcher_schema(), &raw)
}
#[test]
fn reads_nested_keys_and_keeps_unknown_subtrees() {
let raw = json!({
SCHEMA_KEY: schema_url(launcher_version()),
SCHEMA_VERSION_KEY: 1,
"download": { "concurrency": 16 },
"ui": { "language": "en_us", "unknown_ui": true },
"future_section": { "nested": { "flag": true } }
});
let (doc, problems) = loaded(raw);
assert_eq!(doc.effective_i64("download.concurrency"), Some(16));
assert_eq!(doc.effective_str("ui.language"), Some("en_us"));
let unknown: Vec<&str> = doc.unknown_keys().collect();
assert!(unknown.contains(&"ui.unknown_ui"), "{unknown:?}");
assert!(
unknown.contains(&"future_section.nested.flag"),
"未知键清单是叶子粒度: {unknown:?}"
);
let reported: Vec<&str> = problems
.iter()
.filter_map(|problem| match problem {
LoadProblem::UnknownKey { key } => Some(key.as_str()),
_ => None,
})
.collect();
assert!(reported.contains(&"ui.unknown_ui"), "{problems:?}");
assert!(
reported.contains(&"future_section"),
"陌生子树按最上层节点报一次,不做逐叶展开: {problems:?}"
);
let rendered = doc.to_value();
assert_eq!(rendered[SCHEMA_VERSION_KEY], json!(1));
assert_eq!(
rendered["future_section"]["nested"]["flag"],
json!(true),
"未知字段必须原样回写"
);
assert_eq!(rendered["ui"]["unknown_ui"], json!(true));
}
#[test]
fn defaults_never_reach_the_file() {
let (doc, _) = loaded(json!({ SCHEMA_KEY: schema_url(launcher_version()) }));
// 生效值有默认,但落盘视图里没有这些键。
assert_eq!(doc.effective_i64("download.concurrency"), Some(8));
assert_eq!(doc.effective_bool("ai.enabled"), Some(true));
assert!(doc.get("download.concurrency").is_none());
let rendered = doc.to_value();
assert!(rendered.get("download").is_none(), "{rendered}");
// 无默认值的路径键 → None。
assert!(doc.effective_str("java.selected_path").is_none());
}
#[test]
fn tracks_dirty_keys_and_renders_patches() {
let raw = json!({
SCHEMA_KEY: schema_url(launcher_version()),
"download": { "concurrency": 8, "retry_limit": 2 }
});
let (mut doc, _) = loaded(raw);
assert!(!doc.is_dirty());
doc.set("download.concurrency", json!(32)).expect("写");
doc.set("appearance.theme_mode", json!("dark")).expect("写");
let mut dirty: Vec<&str> = doc.dirty_keys().collect();
dirty.sort_unstable();
assert_eq!(dirty, vec!["appearance.theme_mode", "download.concurrency"]);
let patch = doc.dirty_patch();
assert_eq!(patch["download"]["concurrency"], json!(32));
assert_eq!(patch["appearance"]["theme_mode"], json!("dark"));
assert!(
patch["download"].get("retry_limit").is_none(),
"非脏字段不进补丁"
);
let dirty_view = doc.render(RenderMode::DirtyOnly);
assert_eq!(dirty_view["download"]["concurrency"], json!(32));
assert!(dirty_view["download"].get("retry_limit").is_none());
doc.mark_clean();
assert!(!doc.is_dirty());
assert_eq!(doc.dirty_patch(), json!({}));
}
#[test]
fn invalid_values_are_preserved_with_a_problem() {
let raw = json!({
SCHEMA_KEY: schema_url(launcher_version()),
"download": { "concurrency": 9999, "retry_limit": "many" }
});
let (doc, problems) = loaded(raw);
assert_eq!(
doc.get("download.concurrency").and_then(Value::as_i64),
Some(9999),
"坏值原样保留(用户能看见自己写坏了什么)"
);
let invalid: Vec<&str> = problems
.iter()
.filter_map(|problem| match problem {
LoadProblem::InvalidValue { key, .. } => Some(key.as_str()),
_ => None,
})
.collect();
assert!(invalid.contains(&"download.concurrency"), "{problems:?}");
assert!(invalid.contains(&"download.retry_limit"), "{problems:?}");
}
#[test]
fn schema_version_mirror_mismatch_is_reported_but_schema_wins() {
let raw = json!({
SCHEMA_KEY: schema_url(launcher_version()),
SCHEMA_VERSION_KEY: 7
});
let (doc, problems) = loaded(raw);
assert_eq!(doc.version(), launcher_version());
assert!(problems.iter().any(|problem| matches!(
problem,
LoadProblem::SchemaVersionMismatch {
declared: 7,
expected: 1
}
)));
}
#[test]
fn newer_patch_preserves_original_schema_string() {
// 同 minor、更高 patch(**相对构造**:版本号涨了这条也不会假绿)。
let current = launcher_version();
let newer = SchemaVersion::new(current.major, current.minor, current.patch + 1);
let url = schema_url(newer);
let (doc, _) = loaded(json!({ SCHEMA_KEY: url.clone() }));
assert_eq!(
doc.preserve_schema(),
Some(url.as_str()),
"同 minor 更高 patch 要保留原 $schema"
);
assert_eq!(doc.to_value()[SCHEMA_KEY], json!(url));
assert_eq!(doc.version(), newer);
}
#[test]
fn wrong_shape_for_known_prefix_is_reported() {
let raw = json!({
SCHEMA_KEY: schema_url(launcher_version()),
"download": "oops"
});
let (doc, problems) = loaded(raw);
assert!(
problems.iter().any(|problem| matches!(
problem,
LoadProblem::InvalidShape { key, .. } if key == "download"
)),
"{problems:?}"
);
assert!(doc.get("download").is_none());
// 原始坏结构不进 values,也不会被当成未知键回写。
assert!(doc.to_value().get("download").is_none());
}
#[test]
fn non_object_json_yields_problem_and_empty_doc() {
let (doc, problems) = loaded(json!([1, 2, 3]));
assert!(!problems.is_empty());
assert_eq!(doc.effective_i64("download.concurrency"), Some(8));
}
#[test]
fn set_rejects_unknown_keys_and_out_of_range_values() {
let (mut doc, _) = loaded(json!({}));
assert!(doc.set("nope.key", json!(1)).is_err());
assert!(doc.set("download.concurrency", json!(0)).is_err());
assert!(doc
.set("appearance.theme_mode", json!("solarized"))
.is_err());
assert!(!doc.is_dirty(), "校验失败不能标脏");
}
#[test]
fn unknown_schema_prefix_reports_problem_and_keeps_values() {
let raw = json!({
SCHEMA_KEY: "https://unlucky-launcher.local/schema/launcher/1.2.0.json",
"core": { "data_mode": "standard" },
});
let (doc, problems) = loaded(raw);
// 值照读(旧实现按「没有声明」整份装默认值)。
assert_eq!(doc.effective_str("core.data_mode"), Some("standard"));
let found = problems.iter().find_map(|problem| match problem {
LoadProblem::UnknownSchema { url } => Some(url.as_str()),
_ => None,
});
assert_eq!(
found,
Some("https://unlucky-launcher.local/schema/launcher/1.2.0.json"),
"{problems:?}"
);
}
fn launcher_version() -> SchemaVersion {
unlucky_core::setting::keys::launcher_version()
}
+252
View File
@@ -0,0 +1,252 @@
//! D20 隔离键的 schema 契约:全局 `instances.mode`、实例 `isolation`、`lastModeChange`、
//! 恒共享层不可配、旧配置缺键即默认值(不需要迁移步骤)。
use serde_json::json;
use unlucky_core::game::install::instance::{InstanceConfig, Isolation, OverrideKey};
use unlucky_core::game::isolation::{observe_mode, IsolationMode};
use unlucky_core::game::GameLayout;
use unlucky_core::setting::document::{LoadProblem, RenderMode, SettingDocument};
use unlucky_core::setting::instance_keys::{
instance_schema, instance_version, ISOLATION_OVERRIDES, ISOLATION_RESOLVED, OVERRIDE_KEYS,
};
use unlucky_core::setting::keys::{launcher_schema, launcher_version, INSTANCE_MODES};
use unlucky_core::setting::schema::{SchemaId, SchemaVersion};
use unlucky_core::setting::spec::{Sensitivity, ValueKind};
use unlucky_core::setting::store::SettingStore;
use super::common::TempDir;
#[test]
fn global_mode_key_defaults_to_isolated() {
let schema = launcher_schema();
let spec = schema.spec("instances.mode").expect("instances.mode 键");
assert_eq!(spec.default, json!("isolated"), "D20 默认隔离");
assert_eq!(spec.kind, ValueKind::Enum(INSTANCE_MODES));
assert_eq!(
spec.ai.sensitivity,
Sensitivity::High,
"切档会改变存档可见性,AI 改必须弹窗确认"
);
assert!(schema.validate("instances.mode", &json!("shared")).is_ok());
assert!(schema
.validate("instances.mode", &json!("isolated"))
.is_ok());
assert!(schema
.validate("instances.mode", &json!("per_version"))
.is_err());
assert!(schema.validate("instances.mode", &json!("custom")).is_err());
}
#[test]
fn shared_layers_are_not_configurable() {
let schema = launcher_schema();
for key in ["shared.versions", "shared.assets", "shared.libraries"] {
assert!(
!schema.is_known(key),
"{key} 是恒共享层,不该出现在可配键表里"
);
}
}
#[test]
fn instance_isolation_defaults_to_inherit() {
let schema = instance_schema();
let spec = schema.spec("isolation").expect("isolation 键");
assert_eq!(spec.default, json!("inherit"));
assert_eq!(spec.kind, ValueKind::Enum(ISOLATION_OVERRIDES));
for value in ["inherit", "isolated", "shared", "custom"] {
assert!(
schema.validate("isolation", &json!(value)).is_ok(),
"{value}"
);
}
assert!(schema.validate("isolation", &json!("per_version")).is_err());
}
#[test]
fn inherit_never_reaches_the_file() {
// 默认值不落盘:空文档渲染出来只应该有 $schema / schemaVersion。
let schema = instance_schema();
let (document, problems) = SettingDocument::load(&schema, &json!({}));
assert!(problems.is_empty(), "{problems:?}");
let rendered = document.render(RenderMode::Full);
assert!(rendered.get("isolation").is_none(), "{rendered}");
assert!(rendered.get("lastModeChange").is_none(), "{rendered}");
let dirty = document.render(RenderMode::DirtyOnly);
assert!(dirty.get("isolation").is_none(), "{dirty}");
}
#[test]
fn last_mode_change_keys_are_bookkeeping_only() {
let schema = instance_schema();
for key in [
"lastModeChange.at",
"lastModeChange.from",
"lastModeChange.to",
"lastModeChange.probe.isolatedSide",
"lastModeChange.probe.sharedSide",
"lastModeChange.warned",
] {
assert!(schema.is_known(key), "{key} 必须登记");
assert!(!schema.accepts_ai_write(key), "{key} 不许 AI 写");
}
assert!(schema
.validate("lastModeChange.probe.isolatedSide", &json!(12))
.is_ok());
assert!(schema
.validate("lastModeChange.probe.isolatedSide", &json!(-1))
.is_err());
assert!(schema
.validate("lastModeChange.warned", &json!(true))
.is_ok());
assert!(schema.validate("lastModeChange.warned", &json!(1)).is_err());
assert!(schema
.validate("lastModeChange.from", &json!("shared"))
.is_ok());
assert!(schema
.validate("lastModeChange.from", &json!("inherit"))
.is_err());
assert_eq!(ISOLATION_RESOLVED, ["isolated", "shared", "custom"]);
}
#[test]
fn overrides_key_is_an_enum_whitelist() {
let schema = instance_schema();
assert!(schema.validate("overrides", &json!([])).is_ok());
assert!(schema
.validate("overrides", &json!(["maxMemory", "runningDirectory"]))
.is_ok());
assert!(
schema
.validate("overrides", &json!(["max_memory"]))
.is_err(),
"旧 snake_case 拼写不许过(拼错会静默失效)"
);
assert!(schema.validate("overrides", &json!([1])).is_err());
assert_eq!(OVERRIDE_KEYS, ["maxMemory", "javaType", "runningDirectory"]);
}
#[test]
fn schema_versions_register_the_new_keys_without_migration() {
// 1.8.0 登记 M197 的 `download.gh_proxy`(1.7.0 = M168 的 `ui.font_cdn`,1.6.0 登记 M161 的
// 自更新两键,1.5.0 = M116 窗口圆角,1.4.0 = M91 外观下层三键,1.3.0 = D17「外观(高级)」,
// 1.2.0 = D23 的 JDK 落点/优先级键)。
assert_eq!(launcher_version(), SchemaVersion::new(1, 8, 0));
assert_eq!(instance_version(), SchemaVersion::new(1, 1, 0));
assert_eq!(launcher_schema().id(), SchemaId::LAUNCHER);
// major 仍是 1:迁移链(按 major 走)不需要新步骤,老文件缺键即默认值。
assert_eq!(launcher_version().major, 1);
assert_eq!(instance_version().major, 1);
}
#[test]
fn old_launcher_file_without_new_keys_loads_with_defaults() {
let temp = std::env::temp_dir().join(format!(
"ull-setting-d20-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos())
));
std::fs::create_dir_all(&temp).expect("临时目录");
let path = temp.join("launcher.json");
// 旧版本写的文件:只有 $schema 1.0.0 和一个老键,没有 instances.mode。
std::fs::write(
&path,
serde_json::to_vec_pretty(&json!({
"$schema": "https://schemas.unlucky-launcher.dev/ull/launcher/1.0.0",
"schemaVersion": 1,
"ui": { "scale": 1.25 }
}))
.expect("序列化"),
)
.expect("写文件");
let store = SettingStore::load(&path, launcher_schema());
assert!(store.access().can_read(), "1.0.0 文件必须可读");
assert!(store.problems().is_empty(), "{:?}", store.problems());
assert_eq!(
store.doc().effective_str("instances.mode"),
Some("isolated"),
"缺键取 D20 默认值"
);
assert_eq!(store.doc().effective_f64("ui.scale"), Some(1.25));
let _ = std::fs::remove_dir_all(&temp);
}
#[test]
fn setting_lists_agree_with_the_game_enums() {
// schema 白名单与 game 侧枚举必须一一对应(两处字面量漂移就是静默不生效)。
let mut modes: Vec<&str> = [IsolationMode::Isolated, IsolationMode::Shared]
.iter()
.map(|mode| mode.as_str())
.collect();
modes.sort_unstable();
let mut declared = INSTANCE_MODES.to_vec();
declared.sort_unstable();
assert_eq!(modes, declared);
for value in ISOLATION_OVERRIDES
.iter()
.filter(|value| **value != "inherit")
{
assert!(
Isolation::parse(value).is_some(),
"{value} 在 game 侧解析不出来"
);
assert_eq!(Isolation::parse(value).map(Isolation::as_str), Some(*value));
}
for value in ISOLATION_RESOLVED {
assert!(Isolation::parse(value).is_some(), "{value}");
}
}
#[test]
fn writer_output_loads_without_unknown_or_invalid_keys() {
// 回归 P2-1(review r1):写入者(InstanceConfig + observe_mode)产出的键必须被 schema 全认。
// 之前 `lastModeChange.warned` 没登记,document 层会把真实文件标成 UnknownKey——
// 这正是「schema 只做写入者白名单视图」这条契约的裂缝。
let temp = TempDir::new("d20-writer-shape");
let game = GameLayout::new(temp.child("game"));
let mut config = InstanceConfig::new("11111111-2222-4333-8444-555555555555", "1.20.1");
config.isolation = Some(Isolation::Custom);
config.game_dir = Some(temp.child("elsewhere"));
config.overrides = vec![OverrideKey::MaxMemory];
observe_mode(
&mut config,
&game,
"2026-10-30T00:00:00Z",
Isolation::Custom,
)
.expect("观察");
let raw: serde_json::Value =
serde_json::from_str(&config.to_json_string().expect("序列化")).expect("合法 JSON");
// 写入者确实输出了 warned(P2-1 的前提)。
assert_eq!(raw["lastModeChange"]["warned"], json!(true));
let (document, problems) = SettingDocument::load(&instance_schema(), &raw);
let mut unknown: Vec<String> = Vec::new();
let mut invalid: Vec<String> = Vec::new();
for problem in &problems {
match problem {
LoadProblem::UnknownKey { key } => unknown.push(key.clone()),
LoadProblem::InvalidValue { key, reason } => invalid.push(format!("{key}: {reason}")),
_ => {}
}
}
assert!(unknown.is_empty(), "写入者的键没登记进 schema:{unknown:?}");
assert!(
invalid.is_empty(),
"写入者的值过不了 schema 校验:{invalid:?}"
);
assert_eq!(
document.get("lastModeChange.warned"),
Some(&json!(true)),
"记账键要能读回"
);
// 落盘视图不能把它当「未知键」重复输出。
let rendered = document.render(RenderMode::Full);
assert_eq!(rendered["lastModeChange"]["warned"], json!(true));
assert_eq!(rendered["overrides"], json!(["maxMemory"]));
}
@@ -0,0 +1,150 @@
//! D23 新增的两个设置键:`java.installRoot` / `java.vendorPriority`。
//!
//! 三件事要说清:
//! 1. 两个键都登记在 launcher schema 里(`ull config get` 能看到、AI 白名单受策略约束);
//! 2. `java.vendorPriority` 的允许值集合与 `game::java::vendor::VENDOR_IDS` **同源**
//! (setting 层不依赖 game 层,靠这条契约测试固定,避免两处漂移);
//! 3. schema 版本推到 1.2.0 但 major 仍为 1 → 不需要迁移步骤(默认值不落盘)。
use serde_json::json;
use unlucky_core::game::java::vendor::VENDOR_IDS;
use unlucky_core::setting::keys::{
launcher_schema, launcher_version, JAVA_INSTALL_ROOT_UNSET, JDK_VENDORS,
};
use unlucky_core::setting::schema::{SchemaId, SchemaRef, SchemaVersion};
use unlucky_core::setting::store::SettingStore;
#[test]
fn both_d23_keys_are_registered_with_their_defaults() {
let schema = launcher_schema();
assert_eq!(
schema.default_value("java.installRoot"),
Some(&json!(JAVA_INSTALL_ROOT_UNSET))
);
assert_eq!(
schema.default_value("java.vendorPriority"),
Some(&json!([]))
);
// 空串/空数组都有明确语义:installRoot 空 = 按数据目录模式派生;
// vendorPriority 空 = 按地区默认序(国内 Temurin→Oracle→MS→Zulu)。
assert!(schema.spec("java.installRoot").is_some());
assert!(schema.spec("java.vendorPriority").is_some());
}
#[test]
fn vendor_priority_whitelist_matches_the_vendor_module() {
assert_eq!(JDK_VENDORS, VENDOR_IDS, "两处必须同一份集合");
let schema = launcher_schema();
assert!(schema
.validate("java.vendorPriority", &json!(["oracle"]))
.is_ok());
assert!(schema
.validate("java.vendorPriority", &json!(["temurin", "zulu"]))
.is_ok());
assert!(schema
.validate("java.vendorPriority", &json!(["graalvm"]))
.is_err());
assert!(schema
.validate("java.vendorPriority", &json!([1, 2]))
.is_err());
assert!(schema
.validate("java.vendorPriority", &json!("microsoft"))
.is_err());
}
#[test]
fn install_root_accepts_a_path_and_keeps_empty_as_unset() {
let schema = launcher_schema();
assert!(schema
.validate("java.installRoot", &json!("D:/jdk"))
.is_ok());
// 空串就是默认值,语义是「没设覆盖键」(不是错误):所以类型是 text 而不是 path
// (Path 类型不接受空串,而这里需要一个合法的「未设置」值)。
assert!(schema.validate("java.installRoot", &json!("")).is_ok());
assert!(schema.validate("java.installRoot", &json!(5)).is_err());
}
#[test]
fn schema_version_bumps_minor_only_so_no_migration_step_is_needed() {
// 1.8.0 = M197 的 gh-proxy 覆盖键(`download.gh_proxy`;此前 1.7.0 是 M168 的 `ui.font_cdn`,
// 1.6.0 是 M161 的自更新两键,1.5.0 是 M116 窗口圆角)。每加一批键推一次 minor 是**纪律**,
// 不是巧合:老版本读新文件会判 `ReadOnlyPreserveSchema`,所以版本号本身就是「新键存在」的对外声明。
assert_eq!(launcher_version(), SchemaVersion::new(1, 8, 0));
assert_eq!(
launcher_version().major,
1,
"major 不变 → 迁移链(按 major 走)不需要新步骤"
);
}
#[test]
fn an_old_config_file_without_the_new_keys_still_loads() {
let dir = std::env::temp_dir().join(format!(
"ull-java-keys-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.as_nanos())
));
std::fs::create_dir_all(&dir).expect("建临时目录");
let path = dir.join("launcher.json");
// 1.1.0 的文件(真实 $schema 前缀):没有 java.installRoot / java.vendorPriority,
// 且 core.data_mode 故意写非默认值 standard —— 证明值是「读出来的」不是「默认值撞上的」。
let old_url = SchemaRef::new(SchemaId::LAUNCHER, SchemaVersion::new(1, 1, 0)).url();
std::fs::write(
&path,
format!(r#"{{"$schema":"{old_url}","schemaVersion":1,"core":{{"data_mode":"standard"}}}}"#),
)
.expect("写旧配置");
let store = SettingStore::load(&path, launcher_schema());
assert_eq!(
store.doc().effective_str("core.data_mode"),
Some("standard"),
"旧文件的值必须照读(不是碰巧等于默认值)"
);
// 缺键取默认值,不报错、不写盘(「默认值不落盘」)。
assert_eq!(
store.doc().effective_str("java.installRoot"),
Some(JAVA_INSTALL_ROOT_UNSET)
);
assert_eq!(
store.effective("java.vendorPriority"),
Some(&json!([])),
"缺键 → 空数组 → 按地区默认序"
);
assert!(
store.problems().is_empty(),
"只缺新键不算问题:{:?}",
store.problems()
);
let _ = std::fs::remove_dir_all(&dir);
}
/// `java.auto_install` 的 schema 描述必须与实现一致:**不会**自动下载。
///
/// 这句会进 AI 工具的 JSON Schema `description`(`setting/catalog.rs` 把 `spec.doc` 原样塞进去),
/// 所以它不是注释而是**对外文案**:写「缺 Java 时自动下载」等于让启动器自己的助手对用户说
/// 一件实现刻意不做的事(M197 复审 r1 的 P1)。
///
/// 变异证明:把描述改回「缺 Java 时自动下载(D13 基准 JDK17)」⇒ 本用例红(三句断言都会红)。
#[test]
fn the_auto_install_description_says_it_never_downloads_by_itself() {
let schema = launcher_schema();
let doc = schema
.spec("java.auto_install")
.expect("java.auto_install 必须登记")
.doc;
assert!(
doc.contains("不会自动下载"),
"描述必须写明「不会自动下载」(AI 会照这句话告诉用户):{doc}"
);
assert!(
!doc.contains("缺 Java 时自动下载"),
"描述不许再写「缺 Java 时自动下载」:{doc}"
);
assert!(
!doc.contains("D13"),
"描述里不许出现内部决策编号(用户与助手都不知道 D13 是什么):{doc}"
);
}
Loaded 100 of 1587 files, more files were not shown because too many files have changed in this diff. Show more