Unlucky Launcher v0.2.1
CI / runtime smoke (macos arm64, probe) (push) Skipped
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
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
CI / runtime smoke (macos arm64, probe) (push) Skipped
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
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
This commit is contained in:
commit
5046f96f7e
1092 files changed
+211068
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! `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 二义;
|
||||
//! - `authlib_api`:Yggdrasil 四动作的报文级对接。
|
||||
|
||||
mod support;
|
||||
|
||||
mod auth_failure_journal;
|
||||
mod authlib_api;
|
||||
mod microsoft_authcode;
|
||||
mod microsoft_diagnostics;
|
||||
mod microsoft_flow;
|
||||
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();
|
||||
}
|
||||
@@ -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,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();
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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"),
|
||||
"超长日志的尾部窗口应被扫描"
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,350 @@
|
||||
//! 引擎端到端(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, .. } => {
|
||||
failed_source = true;
|
||||
assert!(!will_retry, "单次尝试用尽后不应再声明重试");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert!(started && failed_source, "进度事件缺失");
|
||||
server.stop();
|
||||
}
|
||||
@@ -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(®istry),
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
//! 镜像清单与前缀重写的单测(纯字符串,无网络)。
|
||||
|
||||
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");
|
||||
assert_eq!(
|
||||
candidate.url,
|
||||
LIBRARIES_URL.replace(
|
||||
"https://libraries.minecraft.net",
|
||||
"https://bmclapi2.bangbang93.com"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[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, ®ion);
|
||||
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, ®ion);
|
||||
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}"));
|
||||
|
||||
// 清单给设置做默认值。
|
||||
assert_eq!(table.default_gh_proxy(), Some("https://gh-proxy.com/"));
|
||||
}
|
||||
|
||||
#[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, ®ion);
|
||||
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,
|
||||
®ion,
|
||||
);
|
||||
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,
|
||||
®ion,
|
||||
);
|
||||
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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
@@ -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("写文件");
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! 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"]);
|
||||
for id in VENDOR_IDS {
|
||||
assert_eq!(JdkVendor::parse(id).map(|vendor| vendor.id()), Some(*id));
|
||||
}
|
||||
assert!(JdkVendor::parse("graalvm").is_none());
|
||||
}
|
||||
@@ -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()]
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
"错误文案要给用户看得懂"
|
||||
);
|
||||
}
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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\"");
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//! 退出分析测试:退出码语义 + `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_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)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! 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 pipeline_cases;
|
||||
mod prelude;
|
||||
mod process_smoke;
|
||||
mod pump_buffer;
|
||||
mod session_events;
|
||||
mod sink_cases;
|
||||
mod tree_kill_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());
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
//! natives 解压(`launch::natives`)的用例:解析正确性 / 排除项 / 幂等 / 时代参数 / 事件流。
|
||||
//!
|
||||
//! 假 jar 用 `zip` 的 Stored 模式现造(不依赖真下载),真机验收另有人工步骤。
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use zip::write::SimpleFileOptions;
|
||||
use zip::{CompressionMethod, ZipWriter};
|
||||
|
||||
use unlucky_core::launch::natives::{self, NativeJar, NativesEra, NativesPlan, STAMP_FILE};
|
||||
|
||||
use crate::prelude::*;
|
||||
|
||||
/// 造一个 natives jar:`entries` 是(条目名, 内容)。
|
||||
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 的条目集(原生库 + 该被排除的噪音)。
|
||||
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()),
|
||||
]
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
|
||||
/// `--dry-run` 也做环境准备:第二次跑要看到 up_to_date(真机验收的判定依据)。
|
||||
#[test]
|
||||
fn dry_run_prepares_natives_and_is_idempotent() {
|
||||
let root = temp_dir("natives-dry-run");
|
||||
let jar = root.join("natives-windows.jar");
|
||||
make_jar(&jar, &[("lwjgl.dll", b"DLL".as_slice())]);
|
||||
let natives_dir: PathBuf = root.join("versions/1.16.5/natives");
|
||||
|
||||
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-sessions");
|
||||
let launcher = Launcher::new(LauncherConfig::new(&sessions));
|
||||
|
||||
let first = launcher.dry_run(&request).expect("首次 dry-run");
|
||||
let report = first.natives.expect("应有 natives 报告");
|
||||
assert_eq!(report.files, 1, "{report:?}");
|
||||
assert!(!report.up_to_date);
|
||||
assert!(natives_dir.join("lwjgl.dll").is_file());
|
||||
|
||||
let second = launcher.dry_run(&request).expect("二次 dry-run");
|
||||
let report = second.natives.expect("应有 natives 报告");
|
||||
assert!(report.up_to_date, "第二次应命中戳记:{report:?}");
|
||||
assert_eq!(report.skipped, 1);
|
||||
}
|
||||
|
||||
/// 缺 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,347 @@
|
||||
//! 顶层启动管线测试: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() -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
"ping -n 30 127.0.0.1 >nul".to_string()
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
"sleep 30".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);
|
||||
|
||||
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 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());
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! 测试共用设施。
|
||||
//!
|
||||
//! **crate 名只在本文件出现**(tower 定案 `unlucky_core`;若 M11 改包名,只改这里)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 版本");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
@@ -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,
|
||||
5,
|
||||
"1.5.0 登记 M116 的窗口圆角策略键"
|
||||
);
|
||||
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}");
|
||||
}
|
||||
@@ -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, "并发写要能拿到不同的临时名");
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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 提示「数据已迁走」"
|
||||
);
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! 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.5.0 登记 M116 的窗口圆角策略键(1.4.0 = M91 外观下层三键,1.3.0 = D17「外观(高级)」,
|
||||
// 1.2.0 = D23 的 JDK 落点/优先级键)。
|
||||
assert_eq!(launcher_version(), SchemaVersion::new(1, 5, 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,119 @@
|
||||
//! 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() {
|
||||
assert_eq!(launcher_version(), SchemaVersion::new(1, 5, 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);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! 四分区布局、路径拼接、实例 id 白名单、可搬运路径字符串。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::platform::layout::{
|
||||
is_valid_instance_id, LauncherLayout, Partition, CONFIG_DIR, GAME_HOME_DIR, INSTANCES_DIR,
|
||||
LAUNCHER_CONFIG_FILE, LAUNCHER_HOME_DIR, MIGRATION_RECEIPT_FILE, PROFILES_CONFIG_FILE,
|
||||
REDIRECT_FILE,
|
||||
};
|
||||
|
||||
use crate::common::TempDir;
|
||||
|
||||
#[test]
|
||||
fn home_dir_names_match_design() {
|
||||
assert_eq!(LAUNCHER_HOME_DIR, ".ULL");
|
||||
assert_eq!(GAME_HOME_DIR, ".minecraft");
|
||||
assert_eq!(CONFIG_DIR, "config");
|
||||
assert_eq!(INSTANCES_DIR, "instances");
|
||||
assert_eq!(LAUNCHER_CONFIG_FILE, "launcher.json");
|
||||
assert_eq!(PROFILES_CONFIG_FILE, "profiles.json");
|
||||
assert_eq!(REDIRECT_FILE, "redirect.json");
|
||||
assert_eq!(MIGRATION_RECEIPT_FILE, "migration-receipt.json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exposes_four_partitions() {
|
||||
let layout = LauncherLayout::new("/home/u/.ULL");
|
||||
assert_eq!(layout.home(), std::path::Path::new("/home/u/.ULL"));
|
||||
assert_eq!(layout.config(), PathBuf::from("/home/u/.ULL/config"));
|
||||
assert_eq!(layout.data(), PathBuf::from("/home/u/.ULL/data"));
|
||||
assert_eq!(layout.cache(), PathBuf::from("/home/u/.ULL/cache"));
|
||||
assert_eq!(layout.logs(), PathBuf::from("/home/u/.ULL/logs"));
|
||||
|
||||
assert_eq!(Partition::ALL.len(), 4);
|
||||
assert_eq!(Partition::Config.dir_name(), "config");
|
||||
assert!(Partition::Cache.is_disposable());
|
||||
assert!(!Partition::Config.is_disposable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_config_and_cache_paths() {
|
||||
let layout = LauncherLayout::new("/home/u/.ULL");
|
||||
assert_eq!(
|
||||
layout.launcher_config(),
|
||||
PathBuf::from("/home/u/.ULL/config/launcher.json")
|
||||
);
|
||||
assert_eq!(
|
||||
layout.profiles_config(),
|
||||
PathBuf::from("/home/u/.ULL/config/profiles.json")
|
||||
);
|
||||
assert_eq!(
|
||||
layout.sha1_cache("ab"),
|
||||
PathBuf::from("/home/u/.ULL/cache/sha1/ab")
|
||||
);
|
||||
assert_eq!(
|
||||
layout.etag_cache(),
|
||||
PathBuf::from("/home/u/.ULL/cache/etag.json")
|
||||
);
|
||||
assert_eq!(
|
||||
layout.meta_cache(),
|
||||
PathBuf::from("/home/u/.ULL/cache/meta")
|
||||
);
|
||||
assert_eq!(
|
||||
layout.java_managed(),
|
||||
PathBuf::from("/home/u/.ULL/data/java-managed")
|
||||
);
|
||||
assert_eq!(layout.auth_data(), PathBuf::from("/home/u/.ULL/data/auth"));
|
||||
assert_eq!(
|
||||
layout.agent_sessions(),
|
||||
PathBuf::from("/home/u/.ULL/data/agent/sessions")
|
||||
);
|
||||
assert_eq!(
|
||||
layout.redirect_path(),
|
||||
PathBuf::from("/home/u/.ULL/redirect.json")
|
||||
);
|
||||
assert_eq!(
|
||||
layout.migration_receipt(),
|
||||
PathBuf::from("/home/u/.ULL/config/migration-receipt.json")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_config_rejects_path_traversal() {
|
||||
let layout = LauncherLayout::new("/home/u/.ULL");
|
||||
let id = "6f9619ff-8b86-d011-b42d-00c04fc964ff";
|
||||
assert!(is_valid_instance_id(id));
|
||||
assert_eq!(
|
||||
layout.instance_config(id).expect("合法 id"),
|
||||
PathBuf::from(format!("/home/u/.ULL/config/instances/{id}.json"))
|
||||
);
|
||||
assert!(layout.instance_config("../escape").is_err());
|
||||
assert!(layout.instance_config("").is_err());
|
||||
assert!(layout.instance_config("a/b").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_creates_all_partitions() {
|
||||
let temp = TempDir::new("layout");
|
||||
let layout = LauncherLayout::new(temp.child(".ULL"));
|
||||
layout.ensure().expect("建目录");
|
||||
for partition in Partition::ALL {
|
||||
assert!(
|
||||
layout.partition(partition).is_dir(),
|
||||
"{}",
|
||||
partition.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_path_strings_keep_absolute_and_normalise_relative() {
|
||||
use unlucky_core::platform::paths::{from_portable_string, to_portable_string};
|
||||
|
||||
assert_eq!(
|
||||
to_portable_string(std::path::Path::new(".minecraft")),
|
||||
".minecraft"
|
||||
);
|
||||
assert_eq!(
|
||||
to_portable_string(std::path::Path::new("versions\\1.20.1")),
|
||||
"versions/1.20.1"
|
||||
);
|
||||
let absolute = std::env::temp_dir();
|
||||
assert_eq!(to_portable_string(&absolute), absolute.to_string_lossy());
|
||||
assert_eq!(
|
||||
from_portable_string("versions/1.20.1"),
|
||||
PathBuf::from("versions/1.20.1")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! M12(core::setting + platform)集成测试入口。
|
||||
//!
|
||||
//! cargo 把 `tests/<name>/main.rs` 识别为独立的 test target(target 名 = `setting`),
|
||||
//! 因此本目录下每个子模块就是一个测试主题,避免单文件超过 400 行。
|
||||
|
||||
mod appearance_keys;
|
||||
mod atomic_write;
|
||||
mod common;
|
||||
mod debounce;
|
||||
mod deprecated_keys;
|
||||
mod dir_migration;
|
||||
mod dirs;
|
||||
mod document;
|
||||
mod isolation_keys;
|
||||
mod java_install_keys;
|
||||
mod layout;
|
||||
mod migration_chain;
|
||||
mod redirect;
|
||||
mod schema_compat;
|
||||
mod spec_ai;
|
||||
mod store;
|
||||
@@ -0,0 +1,295 @@
|
||||
//! 迁移链:N→N+1 顺序校验、真实迁移跑通、收据闸门防重放、store 接线。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
use unlucky_core::platform::receipt::{
|
||||
decide, fingerprint_bytes, fingerprint_file, read as read_receipt, MigrationReceipt,
|
||||
ReceiptKind, ReplayDecision,
|
||||
};
|
||||
use unlucky_core::setting::catalog::SettingSchema;
|
||||
use unlucky_core::setting::migrate::{
|
||||
MigrationChain, MigrationGate, MigrationReport, SettingMigration,
|
||||
};
|
||||
use unlucky_core::setting::schema::{
|
||||
SchemaId, SchemaRef, SchemaVersion, SCHEMA_KEY, SCHEMA_VERSION_KEY,
|
||||
};
|
||||
use unlucky_core::setting::spec::{AiPolicy, SettingSpec};
|
||||
use unlucky_core::setting::store::SettingStore;
|
||||
|
||||
use crate::common::TempDir;
|
||||
|
||||
/// 1.0.0 → 2.0.0:`download.concurrency` 改名 `download.threads`。
|
||||
struct RenameThreads;
|
||||
|
||||
impl SettingMigration for RenameThreads {
|
||||
fn from(&self) -> SchemaVersion {
|
||||
SchemaVersion::new(1, 0, 0)
|
||||
}
|
||||
|
||||
fn to(&self) -> SchemaVersion {
|
||||
SchemaVersion::new(2, 0, 0)
|
||||
}
|
||||
|
||||
fn describe(&self) -> &'static str {
|
||||
"download.concurrency → download.threads"
|
||||
}
|
||||
|
||||
fn apply(&self, root: &mut Map<String, Value>) -> Result<(), String> {
|
||||
let Some(download) = root.get_mut("download").and_then(Value::as_object_mut) else {
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(value) = download.remove("concurrency") {
|
||||
download.insert("threads".to_owned(), value);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 2.0.0 → 3.0.0:补一个镜像偏好默认值。
|
||||
struct AddMirrorPreference;
|
||||
|
||||
impl SettingMigration for AddMirrorPreference {
|
||||
fn from(&self) -> SchemaVersion {
|
||||
SchemaVersion::new(2, 0, 0)
|
||||
}
|
||||
|
||||
fn to(&self) -> SchemaVersion {
|
||||
SchemaVersion::new(3, 0, 0)
|
||||
}
|
||||
|
||||
fn describe(&self) -> &'static str {
|
||||
"补齐 download.mirror_preference"
|
||||
}
|
||||
|
||||
fn apply(&self, root: &mut Map<String, Value>) -> Result<(), String> {
|
||||
let Some(download) = root.get_mut("download").and_then(Value::as_object_mut) else {
|
||||
return Err("缺少 download 对象".to_owned());
|
||||
};
|
||||
download
|
||||
.entry("mirror_preference".to_owned())
|
||||
.or_insert_with(|| json!("auto"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn two_step_chain() -> MigrationChain {
|
||||
let mut chain = MigrationChain::new();
|
||||
chain.push(Box::new(RenameThreads)).expect("第一环");
|
||||
chain.push(Box::new(AddMirrorPreference)).expect("第二环");
|
||||
chain
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chain_requires_contiguous_major_steps() {
|
||||
let mut chain = MigrationChain::new();
|
||||
chain
|
||||
.push(Box::new(AddMirrorPreference))
|
||||
.expect("首环随意起点");
|
||||
assert_eq!(chain.len(), 1);
|
||||
|
||||
let err = chain.push(Box::new(RenameThreads)).expect_err("逆序要报错");
|
||||
assert!(err.to_string().contains("顺序错误"), "{err}");
|
||||
assert_eq!(chain.latest(), Some(SchemaVersion::new(3, 0, 0)));
|
||||
assert_eq!(chain.describe().len(), 1);
|
||||
assert!(!chain.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_and_skipping_steps_are_rejected() {
|
||||
let mut chain = MigrationChain::new();
|
||||
chain.push(Box::new(RenameThreads)).expect("首环");
|
||||
assert!(chain.push(Box::new(RenameThreads)).is_err(), "重复起始版本");
|
||||
assert!(chain.push(Box::new(AddMirrorPreference)).is_ok());
|
||||
|
||||
struct SkipMajor;
|
||||
impl SettingMigration for SkipMajor {
|
||||
fn from(&self) -> SchemaVersion {
|
||||
SchemaVersion::new(3, 0, 0)
|
||||
}
|
||||
fn to(&self) -> SchemaVersion {
|
||||
SchemaVersion::new(5, 0, 0)
|
||||
}
|
||||
fn describe(&self) -> &'static str {
|
||||
"跳版本(非法)"
|
||||
}
|
||||
fn apply(&self, _root: &mut Map<String, Value>) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let err = chain
|
||||
.push(Box::new(SkipMajor))
|
||||
.expect_err("跨两个 major 要报错");
|
||||
assert!(err.to_string().contains("major"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrates_sequentially_and_bumps_schema_version() {
|
||||
let chain = two_step_chain();
|
||||
let mut root = json!({ "download": { "concurrency": 32 } })
|
||||
.as_object()
|
||||
.cloned()
|
||||
.expect("对象");
|
||||
root.insert(SCHEMA_VERSION_KEY.to_owned(), json!(1));
|
||||
|
||||
let report = chain
|
||||
.migrate(SchemaVersion::new(1, 0, 0), &mut root)
|
||||
.expect("迁移");
|
||||
assert_eq!(
|
||||
report,
|
||||
MigrationReport {
|
||||
from: SchemaVersion::new(1, 0, 0),
|
||||
to: SchemaVersion::new(3, 0, 0),
|
||||
applied: vec![
|
||||
"download.concurrency → download.threads",
|
||||
"补齐 download.mirror_preference",
|
||||
],
|
||||
}
|
||||
);
|
||||
assert!(!report.is_noop());
|
||||
assert_eq!(root["download"]["threads"], json!(32));
|
||||
assert_eq!(root["download"]["mirror_preference"], json!("auto"));
|
||||
assert!(root["download"].get("concurrency").is_none());
|
||||
assert_eq!(root[SCHEMA_VERSION_KEY], json!(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_start_version_breaks_the_chain() {
|
||||
let chain = two_step_chain();
|
||||
let mut root = Map::new();
|
||||
let err = chain
|
||||
.migrate(SchemaVersion::new(9, 0, 0), &mut root)
|
||||
.expect_err("没有 9.0.0 起始步");
|
||||
assert!(err.to_string().contains("链里没有"), "{err}");
|
||||
assert!(chain.plan_from(SchemaVersion::new(2, 0, 0)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failing_step_reports_migration_error() {
|
||||
let mut chain = MigrationChain::new();
|
||||
chain.push(Box::new(RenameThreads)).expect("第一环");
|
||||
chain.push(Box::new(AddMirrorPreference)).expect("第二环");
|
||||
|
||||
let mut root = json!({ "download": 3 }).as_object().cloned().expect("对象");
|
||||
let err = chain
|
||||
.migrate(SchemaVersion::new(1, 0, 0), &mut root)
|
||||
.expect_err("download 不是对象");
|
||||
assert!(err.to_string().contains("迁移"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receipt_gate_prevents_replay() {
|
||||
let temp = TempDir::new("migration-gate");
|
||||
let source = temp.write("legacy/hmcl.json", "{\"x\":1}");
|
||||
let target = temp.child("config/launcher.json");
|
||||
let fingerprint = fingerprint_file(&source).expect("算指纹").expect("文件在");
|
||||
|
||||
let receipt_path = temp.child("config/migration-receipt.json");
|
||||
let gate = MigrationGate::new(&receipt_path, 1_700_000_000_000);
|
||||
assert_eq!(gate.now_ms(), 1_700_000_000_000);
|
||||
assert!(gate.should_run(&fingerprint).expect("无收据"));
|
||||
|
||||
let receipt =
|
||||
MigrationReceipt::legacy_config(source, target, fingerprint.clone(), gate.now_ms());
|
||||
gate.record(&receipt).expect("写收据");
|
||||
assert!(!gate.should_run(&fingerprint).expect("同源指纹应跳过"));
|
||||
assert_eq!(
|
||||
gate.evaluate(&fingerprint).expect("评估"),
|
||||
ReplayDecision::SkipSameSource
|
||||
);
|
||||
assert_eq!(
|
||||
gate.evaluate("其他指纹").expect("评估"),
|
||||
ReplayDecision::ReplayChangedSource
|
||||
);
|
||||
|
||||
let stored = read_receipt(&receipt_path)
|
||||
.expect("读收据")
|
||||
.expect("有收据");
|
||||
assert_eq!(stored.kind, ReceiptKind::LegacyConfig);
|
||||
assert_eq!(stored.source_fingerprint, fingerprint);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receipt_decisions_and_fingerprints() {
|
||||
let receipt = MigrationReceipt::legacy_config(
|
||||
PathBuf::from("/a"),
|
||||
PathBuf::from("/b"),
|
||||
fingerprint_bytes(b"abc"),
|
||||
7,
|
||||
);
|
||||
assert_eq!(decide(None, "x"), ReplayDecision::ReplayNoReceipt);
|
||||
assert_eq!(
|
||||
decide(Some(&receipt), &fingerprint_bytes(b"abc")),
|
||||
ReplayDecision::SkipSameSource
|
||||
);
|
||||
assert_eq!(
|
||||
decide(Some(&receipt), "changed"),
|
||||
ReplayDecision::ReplayChangedSource
|
||||
);
|
||||
assert_eq!(ReplayDecision::SkipSameSource.as_str(), "skip_same_source");
|
||||
assert!(!ReplayDecision::SkipSameSource.should_replay());
|
||||
assert_eq!(fingerprint_bytes(b"abc").len(), 64);
|
||||
assert_ne!(
|
||||
unlucky_core::platform::receipt::fingerprint_data_dir_move(
|
||||
&PathBuf::from("/a"),
|
||||
&PathBuf::from("/b")
|
||||
),
|
||||
unlucky_core::platform::receipt::fingerprint_data_dir_move(
|
||||
&PathBuf::from("/a"),
|
||||
&PathBuf::from("/c")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
fn schema_v2() -> SettingSchema {
|
||||
SettingSchema::new(SchemaId::LAUNCHER, SchemaVersion::new(2, 0, 0)).with(SettingSpec::int(
|
||||
"download.threads",
|
||||
8,
|
||||
(1, 256),
|
||||
AiPolicy::silent(),
|
||||
"下载并发数(2.0.0 起的键名)",
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_applies_chain_then_saves_new_version() {
|
||||
let temp = TempDir::new("migration-store");
|
||||
let old_url = SchemaRef::new(SchemaId::LAUNCHER, SchemaVersion::new(1, 0, 0)).url();
|
||||
temp.write(
|
||||
"config/launcher.json",
|
||||
&json!({
|
||||
SCHEMA_KEY: old_url,
|
||||
SCHEMA_VERSION_KEY: 1,
|
||||
"download": { "concurrency": 32 }
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let mut store = SettingStore::load(temp.child("config/launcher.json"), schema_v2());
|
||||
assert_eq!(store.doc().version(), SchemaVersion::new(1, 0, 0));
|
||||
assert!(!store.is_dirty(), "加载不是改动");
|
||||
|
||||
let mut chain = MigrationChain::new();
|
||||
chain.push(Box::new(RenameThreads)).expect("一环足矣");
|
||||
let report = store.apply_chain(&chain).expect("迁移").expect("有迁移");
|
||||
assert_eq!(report.to, SchemaVersion::new(2, 0, 0));
|
||||
assert_eq!(store.doc().version(), SchemaVersion::new(2, 0, 0));
|
||||
assert_eq!(
|
||||
store.effective("download.threads"),
|
||||
Some(&json!(32)),
|
||||
"值要跟着键名一起搬"
|
||||
);
|
||||
assert!(store.is_dirty(), "迁移结果必须回写");
|
||||
|
||||
store.save().expect("保存");
|
||||
let saved: Value = serde_json::from_str(&temp.read("config/launcher.json")).expect("JSON");
|
||||
assert_eq!(saved[SCHEMA_VERSION_KEY], json!(2));
|
||||
assert!(saved[SCHEMA_KEY]
|
||||
.as_str()
|
||||
.expect("$schema")
|
||||
.ends_with("launcher/2.0.0"));
|
||||
assert_eq!(saved["download"]["threads"], json!(32));
|
||||
|
||||
let mut up_to_date = SettingStore::load(temp.child("config/launcher.json"), schema_v2());
|
||||
assert!(up_to_date.apply_chain(&chain).expect("已最新").is_none());
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! `redirect.json`:写/读/跟随/成环/损坏/版本更高。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::platform::data_dir::DataMode;
|
||||
use unlucky_core::platform::error::PlatformError;
|
||||
use unlucky_core::platform::redirect::{
|
||||
follow, read, redirect_path, remove, write, RedirectPointer, REDIRECT_SCHEMA_VERSION,
|
||||
};
|
||||
|
||||
use crate::common::TempDir;
|
||||
|
||||
fn pointer(target: &str) -> RedirectPointer {
|
||||
RedirectPointer {
|
||||
schema_version: REDIRECT_SCHEMA_VERSION,
|
||||
mode: DataMode::Standard,
|
||||
launcher_home: PathBuf::from(target),
|
||||
game_home: PathBuf::from(target).join(".minecraft"),
|
||||
migrated_at_ms: 1_700_000_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_pointer_file() {
|
||||
let temp = TempDir::new("redirect");
|
||||
let origin = temp.child(".ULL");
|
||||
assert_eq!(read(&origin).expect("读空目录"), None);
|
||||
|
||||
let written = write(&origin, &pointer("/elsewhere/.ULL")).expect("写指针");
|
||||
assert_eq!(written, redirect_path(&origin));
|
||||
assert!(origin.join("redirect.json").is_file());
|
||||
|
||||
let loaded = read(&origin).expect("读指针").expect("有指针");
|
||||
assert_eq!(loaded, pointer("/elsewhere/.ULL"));
|
||||
assert!(remove(&origin).expect("删指针"));
|
||||
assert!(!remove(&origin).expect("再删一次"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follows_chain_to_final_target() {
|
||||
let temp = TempDir::new("redirect-chain");
|
||||
let first = temp.child("a");
|
||||
let second = temp.child("b");
|
||||
let third = temp.child("c");
|
||||
|
||||
write(&first, &pointer(&second.to_string_lossy())).expect("写 a");
|
||||
write(&second, &pointer(&third.to_string_lossy())).expect("写 b");
|
||||
|
||||
let followed = follow(&first, 8).expect("跟随").expect("有指针");
|
||||
assert_eq!(followed.hops, 2);
|
||||
assert_eq!(followed.pointer.launcher_home, third);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_redirect_cycle() {
|
||||
let temp = TempDir::new("redirect-cycle");
|
||||
let first = temp.child("a");
|
||||
let second = temp.child("b");
|
||||
write(&first, &pointer(&second.to_string_lossy())).expect("写 a");
|
||||
write(&second, &pointer(&first.to_string_lossy())).expect("写 b");
|
||||
|
||||
let err = follow(&first, 8).expect_err("应当成环报错");
|
||||
assert!(matches!(err, PlatformError::RedirectCycle { .. }), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_corrupt_and_newer_pointer() {
|
||||
let temp = TempDir::new("redirect-corrupt");
|
||||
let dir = temp.child(".ULL");
|
||||
std::fs::create_dir_all(&dir).expect("建目录");
|
||||
std::fs::write(dir.join("redirect.json"), b"{not json").expect("写坏文件");
|
||||
assert!(matches!(
|
||||
read(&dir).expect_err("坏文件应报错"),
|
||||
PlatformError::RedirectCorrupt { .. }
|
||||
));
|
||||
|
||||
let mut newer = pointer("/newer/.ULL");
|
||||
newer.schema_version = REDIRECT_SCHEMA_VERSION + 1;
|
||||
std::fs::write(
|
||||
dir.join("redirect.json"),
|
||||
serde_json::to_vec(&newer).expect("序列化"),
|
||||
)
|
||||
.expect("写文件");
|
||||
assert!(matches!(
|
||||
read(&dir).expect_err("新版本指针应报错"),
|
||||
PlatformError::RedirectCorrupt { .. }
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! schema 版本标识、8 态兼容判定、三档访问权映射。
|
||||
|
||||
use serde_json::json;
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::schema::{
|
||||
FileAccess, SchemaCompat, SchemaId, SchemaRef, SchemaVersion, SCHEMA_KEY,
|
||||
};
|
||||
|
||||
fn expected() -> SchemaRef {
|
||||
SchemaRef::new(SchemaId::LAUNCHER, SchemaVersion::new(1, 2, 3))
|
||||
}
|
||||
|
||||
fn compat_of(version: SchemaVersion) -> SchemaCompat {
|
||||
let url = SchemaRef::new(SchemaId::LAUNCHER, version).url();
|
||||
SchemaCompat::check(&json!({ SCHEMA_KEY: url }), expected())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_parsing_and_ordering() {
|
||||
let version = SchemaVersion::parse("1.2.3").expect("解析");
|
||||
assert_eq!(version, SchemaVersion::new(1, 2, 3));
|
||||
assert_eq!(version.to_string(), "1.2.3");
|
||||
assert_eq!(version.next_major(), SchemaVersion::new(2, 0, 0));
|
||||
assert!(SchemaVersion::parse("1.2").is_none());
|
||||
assert!(SchemaVersion::parse("1.2.3.4").is_none());
|
||||
assert!(SchemaVersion::new(1, 2, 3) < SchemaVersion::new(1, 3, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_url_round_trip() {
|
||||
let reference = launcher_schema().schema_ref();
|
||||
let url = reference.url();
|
||||
assert!(
|
||||
url.ends_with(&format!("launcher/{}", reference.version)),
|
||||
"{url}"
|
||||
);
|
||||
assert_eq!(SchemaRef::parse_url(&url), Some(reference));
|
||||
assert!(SchemaRef::parse_url("https://example.com/x").is_none());
|
||||
assert!(SchemaRef::parse_url("garbage").is_none());
|
||||
assert_eq!(SchemaId::parse("instance"), Some(SchemaId::INSTANCE));
|
||||
assert!(SchemaId::parse("nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_or_older_versions_are_read_write() {
|
||||
assert_eq!(
|
||||
compat_of(SchemaVersion::new(1, 2, 3)),
|
||||
SchemaCompat::ReadWrite
|
||||
);
|
||||
assert_eq!(
|
||||
compat_of(SchemaVersion::new(1, 2, 2)),
|
||||
SchemaCompat::ReadWrite
|
||||
);
|
||||
assert_eq!(
|
||||
compat_of(SchemaVersion::new(1, 0, 0)),
|
||||
SchemaCompat::ReadWrite
|
||||
);
|
||||
assert_eq!(
|
||||
compat_of(SchemaVersion::new(0, 9, 0)),
|
||||
SchemaCompat::ReadWrite
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_patch_keeps_original_schema() {
|
||||
let compat = compat_of(SchemaVersion::new(1, 2, 4));
|
||||
assert_eq!(compat, SchemaCompat::ReadWritePreserveSchema);
|
||||
assert!(compat.preserve_schema());
|
||||
assert_eq!(compat.access(), FileAccess::ReadWrite);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_minor_is_read_only() {
|
||||
let compat = compat_of(SchemaVersion::new(1, 3, 0));
|
||||
assert_eq!(compat, SchemaCompat::ReadOnlyPreserveSchema);
|
||||
assert!(compat.preserve_schema());
|
||||
assert_eq!(compat.access(), FileAccess::ReadOnly);
|
||||
assert!(!compat.access().can_save());
|
||||
assert!(compat.access().blocks_editing());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_major_is_unreadable() {
|
||||
let compat = compat_of(SchemaVersion::new(2, 0, 0));
|
||||
assert_eq!(compat, SchemaCompat::UnsupportedMajor);
|
||||
assert!(!compat.readable());
|
||||
assert_eq!(compat.access(), FileAccess::Unreadable);
|
||||
assert!(!compat.access().can_read());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_ids_and_broken_urls_are_rejected() {
|
||||
let instance_url = SchemaRef::new(SchemaId::INSTANCE, SchemaVersion::new(1, 0, 0)).url();
|
||||
assert_eq!(
|
||||
SchemaCompat::check(&json!({ SCHEMA_KEY: instance_url }), expected()),
|
||||
SchemaCompat::UnexpectedId
|
||||
);
|
||||
assert_eq!(
|
||||
SchemaCompat::check(&json!({ SCHEMA_KEY: "not-a-url" }), expected()),
|
||||
SchemaCompat::Invalid
|
||||
);
|
||||
assert_eq!(
|
||||
SchemaCompat::check(&json!({ "schemaVersion": 1 }), expected()),
|
||||
SchemaCompat::Missing
|
||||
);
|
||||
assert_eq!(SchemaCompat::Unparseable.access(), FileAccess::Unreadable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_three_tiers() {
|
||||
assert!(FileAccess::ReadWrite.can_read() && FileAccess::ReadWrite.can_save());
|
||||
assert!(FileAccess::ReadOnly.can_read() && !FileAccess::ReadOnly.can_save());
|
||||
assert!(!FileAccess::Unreadable.can_read());
|
||||
assert!(FileAccess::Unreadable.blocks_editing());
|
||||
assert_eq!(FileAccess::ReadOnly.as_str(), "read_only");
|
||||
assert_eq!(
|
||||
SchemaCompat::ReadOnlyPreserveSchema.as_str(),
|
||||
"read_only_preserve_schema"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//! 键元数据:AI 可写白名单、敏感分级、类型/范围校验、工具 JSON Schema。
|
||||
|
||||
use serde_json::json;
|
||||
use unlucky_core::setting::catalog::SettingSchema;
|
||||
use unlucky_core::setting::instance_keys::{instance_schema, profiles_schema};
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::schema::{SchemaId, SchemaVersion};
|
||||
use unlucky_core::setting::spec::{AiPolicy, Sensitivity, SettingSpec, ValueKind};
|
||||
|
||||
#[test]
|
||||
fn ai_whitelist_excludes_credentials() {
|
||||
let schema = launcher_schema();
|
||||
let writable = schema.ai_writable_keys();
|
||||
assert!(writable.contains(&"download.concurrency"));
|
||||
assert!(writable.contains(&"appearance.theme_mode"));
|
||||
assert!(!writable.contains(&"ai.api_key"), "凭据永不可写(D9)");
|
||||
|
||||
assert!(schema.accepts_ai_write("java.selected_path"));
|
||||
assert!(!schema.accepts_ai_write("ai.api_key"));
|
||||
assert!(!schema.accepts_ai_write("not.a.key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensitivity_grades_are_flagged() {
|
||||
let schema = launcher_schema();
|
||||
assert_eq!(
|
||||
schema.sensitivity("core.data_mode"),
|
||||
Some(Sensitivity::High)
|
||||
);
|
||||
assert_eq!(
|
||||
schema.sensitivity("download.concurrency"),
|
||||
Some(Sensitivity::Low)
|
||||
);
|
||||
assert_eq!(schema.sensitivity("ai.model"), Some(Sensitivity::Medium));
|
||||
assert_eq!(schema.sensitivity("ai.api_key"), Some(Sensitivity::High));
|
||||
assert!(Sensitivity::High.requires_confirmation());
|
||||
assert!(!Sensitivity::Low.requires_confirmation());
|
||||
assert_eq!(Sensitivity::Medium.as_str(), "medium");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_types_ranges_and_enums() {
|
||||
let schema = launcher_schema();
|
||||
assert!(schema.validate("download.concurrency", &json!(8)).is_ok());
|
||||
assert!(schema.validate("download.concurrency", &json!(0)).is_err());
|
||||
assert!(schema
|
||||
.validate("download.concurrency", &json!(257))
|
||||
.is_err());
|
||||
assert!(schema
|
||||
.validate("download.concurrency", &json!("8"))
|
||||
.is_err());
|
||||
assert!(schema
|
||||
.validate("appearance.theme_mode", &json!("dark"))
|
||||
.is_ok());
|
||||
assert!(schema
|
||||
.validate("appearance.theme_mode", &json!("solarized"))
|
||||
.is_err());
|
||||
// 废弃键仍然「读兼容合法」——旧文件里的值不该被判成非法。
|
||||
assert!(schema.validate("ui.theme", &json!("metro_dark")).is_ok());
|
||||
assert!(schema.validate("ui.theme", &json!("auto")).is_err());
|
||||
assert!(schema.validate("ui.scale", &json!(1.5)).is_ok());
|
||||
assert!(schema.validate("ui.scale", &json!(9.0)).is_err());
|
||||
|
||||
let err = schema.validate("nope.key", &json!(1)).expect_err("未知键");
|
||||
assert!(err.to_string().contains("nope.key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_kinds_cover_all_variants() {
|
||||
assert!(ValueKind::Int { min: 1, max: 2 }.check(&json!(2)).is_ok());
|
||||
assert!(ValueKind::Int { min: 1, max: 2 }.check(&json!(3)).is_err());
|
||||
assert!(ValueKind::Float { min: 0.0, max: 1.0 }
|
||||
.check(&json!(0.5))
|
||||
.is_ok());
|
||||
assert!(ValueKind::Text { max_len: 2 }.check(&json!("ab")).is_ok());
|
||||
assert!(ValueKind::Text { max_len: 2 }.check(&json!("abc")).is_err());
|
||||
assert!(ValueKind::Path.check(&json!(" ")).is_err());
|
||||
assert!(ValueKind::Path
|
||||
.check(&json!("C:/java/bin/java.exe"))
|
||||
.is_ok());
|
||||
assert!(ValueKind::TextList.check(&json!(["a", "b"])).is_ok());
|
||||
assert!(ValueKind::TextList.check(&json!([1])).is_err());
|
||||
assert!(ValueKind::Any.check(&json!({"whatever": 1})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_schema_projection_feeds_tool_specs() {
|
||||
let schema = launcher_schema();
|
||||
let projected = schema.to_json_schema();
|
||||
assert_eq!(projected["type"], json!("object"));
|
||||
assert_eq!(projected["additionalProperties"], json!(false));
|
||||
|
||||
let properties = projected["properties"].as_object().expect("properties");
|
||||
assert!(properties.contains_key("download.concurrency"));
|
||||
assert!(!properties.contains_key("ai.api_key"), "锁定键不进工具参数");
|
||||
assert_eq!(properties["download.concurrency"]["type"], json!("integer"));
|
||||
assert_eq!(properties["download.concurrency"]["default"], json!(8));
|
||||
assert_eq!(
|
||||
properties["download.concurrency"]["x-ai-sensitivity"],
|
||||
json!("low")
|
||||
);
|
||||
assert_eq!(properties["appearance.theme_mode"]["type"], json!("string"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_keys_and_defaults() {
|
||||
let schema = launcher_schema();
|
||||
let tops = schema.top_level_keys();
|
||||
for expected in ["core", "download", "java", "ai", "ui", "log"] {
|
||||
assert!(tops.contains(expected), "缺少顶层键组 {expected}");
|
||||
}
|
||||
assert_eq!(
|
||||
schema.default_value("download.concurrency"),
|
||||
Some(&json!(8))
|
||||
);
|
||||
assert_eq!(
|
||||
schema.default_value("java.selected_path"),
|
||||
Some(&json!(null))
|
||||
);
|
||||
assert!(schema.spec("download.concurrency").is_some());
|
||||
assert!(schema.has_prefix(&["download"]));
|
||||
assert!(!schema.has_prefix(&["nope"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_and_profiles_schemas_are_usable() {
|
||||
let instance = instance_schema();
|
||||
assert_eq!(instance.id(), SchemaId::INSTANCE);
|
||||
// D20:实例键表统一成 camelCase 扁平形态(与 game::install::instance 的 serde 逐键一致)。
|
||||
assert!(instance.is_known("versionId"));
|
||||
assert!(instance.is_known("isolation"));
|
||||
assert!(instance.is_known("lastModeChange.probe.isolatedSide"));
|
||||
assert!(instance.accepts_ai_write("isolation"));
|
||||
assert!(
|
||||
!instance.accepts_ai_write("lastModeChange.at"),
|
||||
"切换记录由启动器写,AI 不许改"
|
||||
);
|
||||
assert!(instance.validate("isolation", &json!("isolated")).is_ok());
|
||||
assert!(instance.validate("isolation", &json!("inherit")).is_ok());
|
||||
assert!(instance.validate("isolation", &json!("maybe")).is_err());
|
||||
assert!(
|
||||
instance
|
||||
.validate("isolation", &json!("per_version"))
|
||||
.is_err(),
|
||||
"旧字面量由 game 侧读兼容,schema 白名单只认新值"
|
||||
);
|
||||
|
||||
let profiles = profiles_schema();
|
||||
assert_eq!(profiles.id(), SchemaId::PROFILES);
|
||||
assert_eq!(
|
||||
profiles.spec("profiles").expect("profiles 键").kind,
|
||||
ValueKind::Any
|
||||
);
|
||||
assert!(
|
||||
!profiles.accepts_ai_write("profiles"),
|
||||
"列表本体不许 AI 整体改写"
|
||||
);
|
||||
assert!(profiles.accepts_ai_write("selected_profile_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "重复的配置键")]
|
||||
fn duplicate_key_is_a_table_bug() {
|
||||
let schema = SettingSchema::new(SchemaId::LAUNCHER, SchemaVersion::new(9, 0, 0))
|
||||
.with(SettingSpec::bool(
|
||||
"dup.key",
|
||||
true,
|
||||
AiPolicy::silent(),
|
||||
"第一次",
|
||||
))
|
||||
.with(SettingSpec::bool(
|
||||
"dup.key",
|
||||
false,
|
||||
AiPolicy::silent(),
|
||||
"第二次",
|
||||
));
|
||||
assert_eq!(schema.specs().len(), 2);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//! 配置文件的加载降级、只写脏字段、坏文件备份、只读/不可读三档行为。
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use unlucky_core::setting::document::LoadProblem;
|
||||
use unlucky_core::setting::keys::{launcher_schema, launcher_version};
|
||||
use unlucky_core::setting::schema::{
|
||||
FileAccess, SchemaCompat, SchemaId, SchemaRef, SchemaVersion, SCHEMA_KEY, SCHEMA_VERSION_KEY,
|
||||
};
|
||||
use unlucky_core::setting::store::{SaveOutcome, SettingStore, SkipReason};
|
||||
|
||||
use crate::common::TempDir;
|
||||
|
||||
fn schema_url(version: SchemaVersion) -> String {
|
||||
SchemaRef::new(SchemaId::LAUNCHER, version).url()
|
||||
}
|
||||
|
||||
fn read_json(temp: &TempDir, name: &str) -> Value {
|
||||
serde_json::from_str(&temp.read(name)).expect("配置文件是合法 JSON")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_run_starts_writable_with_defaults() {
|
||||
let temp = TempDir::new("store-new");
|
||||
let path = temp.child("config/launcher.json");
|
||||
let store = SettingStore::load(&path, launcher_schema());
|
||||
|
||||
assert_eq!(store.access(), FileAccess::ReadWrite);
|
||||
assert_eq!(store.compat(), SchemaCompat::Missing);
|
||||
assert!(store.problems().is_empty());
|
||||
assert_eq!(
|
||||
store.effective("download.concurrency"),
|
||||
Some(&json!(8)),
|
||||
"首次运行用默认值"
|
||||
);
|
||||
assert!(!path.exists(), "只读加载不该创建文件");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saves_only_when_dirty_and_writes_schema_markers() {
|
||||
let temp = TempDir::new("store-save");
|
||||
let path = temp.child("config/launcher.json");
|
||||
let mut store = SettingStore::load(&path, launcher_schema());
|
||||
|
||||
assert_eq!(
|
||||
store.save().expect("空保存"),
|
||||
SaveOutcome::Skipped(SkipReason::NotDirty)
|
||||
);
|
||||
|
||||
store
|
||||
.set("download.concurrency", json!(16))
|
||||
.expect("改配置");
|
||||
let outcome = store.save().expect("保存");
|
||||
assert!(outcome.wrote());
|
||||
|
||||
let raw = read_json(&temp, "config/launcher.json");
|
||||
assert_eq!(raw["download"]["concurrency"], json!(16));
|
||||
assert_eq!(raw[SCHEMA_VERSION_KEY], json!(1));
|
||||
assert!(
|
||||
raw[SCHEMA_KEY]
|
||||
.as_str()
|
||||
.expect("$schema")
|
||||
.ends_with(&format!("launcher/{}", launcher_schema().version())),
|
||||
"保存写当前 schema 版本"
|
||||
);
|
||||
assert!(raw.get("ai").is_none(), "默认值不进文件");
|
||||
assert!(!store.is_dirty(), "保存后清脏");
|
||||
|
||||
let reloaded = SettingStore::load(&path, launcher_schema());
|
||||
assert_eq!(reloaded.effective("download.concurrency"), Some(&json!(16)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_unknown_keys_across_save() {
|
||||
let temp = TempDir::new("store-unknown");
|
||||
let raw = json!({
|
||||
SCHEMA_KEY: schema_url(SchemaVersion::new(1, 0, 0)),
|
||||
SCHEMA_VERSION_KEY: 1,
|
||||
"download": { "concurrency": 4 },
|
||||
"from_the_future": { "flag": true }
|
||||
});
|
||||
temp.write("config/launcher.json", &raw.to_string());
|
||||
|
||||
let mut store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema());
|
||||
assert!(
|
||||
store.problems().iter().any(
|
||||
|problem| matches!(problem, LoadProblem::UnknownKey { key } if key == "from_the_future")
|
||||
),
|
||||
"陌生顶层子树按最上层节点报一次: {:?}",
|
||||
store.problems()
|
||||
);
|
||||
|
||||
store
|
||||
.set("download.concurrency", json!(12))
|
||||
.expect("改配置");
|
||||
store.save().expect("保存");
|
||||
|
||||
let after = read_json(&temp, "config/launcher.json");
|
||||
assert_eq!(after["download"]["concurrency"], json!(12));
|
||||
assert_eq!(after["from_the_future"]["flag"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_file_is_backed_up_before_overwrite() {
|
||||
let temp = TempDir::new("store-corrupt");
|
||||
temp.write("config/launcher.json", "{ definitely not json");
|
||||
|
||||
let mut store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema());
|
||||
assert_eq!(store.compat(), SchemaCompat::Unparseable);
|
||||
assert_eq!(
|
||||
store.access(),
|
||||
FileAccess::ReadWrite,
|
||||
"坏文件要能修,否则用户被永久卡住"
|
||||
);
|
||||
assert_eq!(store.effective("download.concurrency"), Some(&json!(8)));
|
||||
|
||||
store.set("ui.language", json!("en_us")).expect("改配置");
|
||||
store.save().expect("保存");
|
||||
|
||||
assert!(temp.exists("config/launcher.json.1"), "坏文件先备份成 .1");
|
||||
let raw = read_json(&temp, "config/launcher.json");
|
||||
assert_eq!(raw["ui"]["language"], json!("en_us"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_minor_is_read_only_and_keeps_its_schema() {
|
||||
let temp = TempDir::new("store-readonly");
|
||||
// 比当前 schema 高一个 minor(**相对构造**:版本号涨了这条也不会假绿):只读且保留原 $schema。
|
||||
let current = launcher_version();
|
||||
let url = schema_url(SchemaVersion::new(current.major, current.minor + 1, 0));
|
||||
temp.write(
|
||||
"config/launcher.json",
|
||||
&json!({ SCHEMA_KEY: url, "download": { "concurrency": 32 } }).to_string(),
|
||||
);
|
||||
|
||||
let mut store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema());
|
||||
assert_eq!(store.compat(), SchemaCompat::ReadOnlyPreserveSchema);
|
||||
assert_eq!(store.access(), FileAccess::ReadOnly);
|
||||
assert_eq!(
|
||||
store.effective("download.concurrency"),
|
||||
Some(&json!(32)),
|
||||
"只读档的值照读"
|
||||
);
|
||||
assert!(store.set("download.concurrency", json!(1)).is_err());
|
||||
assert_eq!(
|
||||
store.save().expect("只读保存"),
|
||||
SaveOutcome::Skipped(SkipReason::ReadOnly)
|
||||
);
|
||||
|
||||
store.save_forced().expect("用户确认强写");
|
||||
assert_eq!(store.access(), FileAccess::ReadWrite);
|
||||
let raw = read_json(&temp, "config/launcher.json");
|
||||
assert_eq!(
|
||||
raw[SCHEMA_KEY].as_str().expect("$schema"),
|
||||
url,
|
||||
"更 minor 的文件强写后也要保留原 $schema(与构造出来的那个 url 逐字相同)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn higher_major_is_unreadable_until_forced() {
|
||||
let temp = TempDir::new("store-unreadable");
|
||||
let url = schema_url(SchemaVersion::new(2, 0, 0));
|
||||
temp.write(
|
||||
"config/launcher.json",
|
||||
&json!({ SCHEMA_KEY: url, "brand_new": { "thing": 1 } }).to_string(),
|
||||
);
|
||||
|
||||
let mut store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema());
|
||||
assert_eq!(store.compat(), SchemaCompat::UnsupportedMajor);
|
||||
assert_eq!(store.access(), FileAccess::Unreadable);
|
||||
assert!(
|
||||
store.set("appearance.theme_mode", json!("dark")).is_err(),
|
||||
"不可读档全程不写"
|
||||
);
|
||||
assert_eq!(
|
||||
store.save().expect("不可读保存"),
|
||||
SaveOutcome::Skipped(SkipReason::Unreadable)
|
||||
);
|
||||
|
||||
store.save_forced().expect("用户确认强写");
|
||||
assert!(temp.exists("config/launcher.json.1"));
|
||||
assert_eq!(store.access(), FileAccess::ReadWrite);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_discards_in_memory_changes() {
|
||||
let temp = TempDir::new("store-reload");
|
||||
let path = temp.write(
|
||||
"config/launcher.json",
|
||||
&json!({ SCHEMA_KEY: schema_url(SchemaVersion::new(1, 0, 0)), "appearance": { "theme_mode": "light" } })
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let mut store = SettingStore::load(&path, launcher_schema());
|
||||
store
|
||||
.set("appearance.theme_mode", json!("dark"))
|
||||
.expect("改配置");
|
||||
assert!(store.is_dirty());
|
||||
store.reload();
|
||||
assert!(!store.is_dirty());
|
||||
assert_eq!(
|
||||
store.effective("appearance.theme_mode"),
|
||||
Some(&json!("light"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_paths_increment() {
|
||||
let temp = TempDir::new("store-backup");
|
||||
temp.write("config/launcher.json.1", "old");
|
||||
temp.write("config/launcher.json.2", "older");
|
||||
let store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema());
|
||||
let next = store.next_backup_path();
|
||||
assert!(next.to_string_lossy().ends_with(".3"), "{next:?}");
|
||||
}
|
||||
|
||||
/// finding 20261030-worker-java2-bug-schema-launcher-json-loadproblem:
|
||||
/// `$schema` 前缀写错(手写/脚本生成的配置最常见的错)曾让整份文件静默回落默认值。
|
||||
/// M40 起:值照读 + `UnknownSchema` 告警 + 文件只读(不悄悄改写我们看不懂的文件)。
|
||||
#[test]
|
||||
fn unknown_schema_prefix_warns_but_keeps_file_values() {
|
||||
let temp = TempDir::new("store-unknown-schema");
|
||||
let url = "https://unlucky-launcher.local/schema/launcher/1.2.0.json";
|
||||
temp.write(
|
||||
"config/launcher.json",
|
||||
&json!({
|
||||
SCHEMA_KEY: url,
|
||||
"core": { "data_mode": "standard" },
|
||||
"download": { "concurrency": 32 },
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let mut store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema());
|
||||
assert_eq!(store.compat(), SchemaCompat::Invalid);
|
||||
assert_eq!(store.access(), FileAccess::ReadOnly);
|
||||
assert_eq!(
|
||||
store.effective("download.concurrency"),
|
||||
Some(&json!(32)),
|
||||
"认不出 $schema 也要读文件里的值"
|
||||
);
|
||||
assert!(
|
||||
store.problems().iter().any(
|
||||
|problem| matches!(problem, LoadProblem::UnknownSchema { url: found } if found == url)
|
||||
),
|
||||
"{:?}",
|
||||
store.problems()
|
||||
);
|
||||
assert!(
|
||||
store.problems()[0].to_string().contains("$schema"),
|
||||
"{}",
|
||||
store.problems()[0]
|
||||
);
|
||||
assert!(store.set("download.concurrency", json!(1)).is_err());
|
||||
assert_eq!(
|
||||
store.save().expect("只读保存"),
|
||||
SaveOutcome::Skipped(SkipReason::ReadOnly)
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user