Unlucky Launcher v0.2.0
CI / runtime smoke (macos arm64, probe) (push) Skipped
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
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s

首个 iced + Material 3 Expressive 版本的源码快照。
完整历史与日常提交在 dev 分支;main 只放阶段性快照。
This commit is contained in:
AskaEth committed 2026-11-05 14:22:11 +08:00
commit 8a8e5ca0c0
1079 files changed
+208035

No files matched your search

+256
View File
@@ -0,0 +1,256 @@
//! 规则求值、参数展开、natives 选择(launch 直接消费这些结果)。
use std::collections::BTreeMap;
use unlucky_core::game::arguments::{
matches_simple_pattern, rules_allow, Argument, ArgumentValue, ConditionalArgument, Environment,
OsRestriction, Rule, RuleAction,
};
use unlucky_core::game::library::Library;
use crate::helpers::parse;
fn env(os: &str, arch: &str) -> Environment {
Environment {
os: os.to_owned(),
os_version: "10.0".to_owned(),
arch: arch.to_owned(),
features: BTreeMap::new(),
}
}
fn allow_os(name: &str) -> Rule {
Rule {
action: RuleAction::Allow,
os: Some(OsRestriction {
name: Some(name.to_owned()),
..OsRestriction::default()
}),
features: None,
}
}
fn disallow_os(name: &str) -> Rule {
Rule {
action: RuleAction::Disallow,
..allow_os(name)
}
}
#[test]
fn empty_rules_always_allow() {
assert!(rules_allow(&[], &env("linux", "x86_64")));
}
#[test]
fn allow_rule_must_match_environment() {
let rules = [allow_os("windows")];
assert!(rules_allow(&rules, &env("windows", "x86_64")));
assert!(!rules_allow(&rules, &env("linux", "x86_64")));
}
#[test]
fn later_rules_override_earlier_ones() {
// 白名单语义:列表非空时从 DISALLOW 起步。
let allow_after_disallow = [disallow_os("windows"), allow_os("windows")];
assert!(rules_allow(
&allow_after_disallow,
&env("windows", "x86_64")
));
let disallow_after_allow = [allow_os("windows"), disallow_os("windows")];
assert!(!rules_allow(
&disallow_after_allow,
&env("windows", "x86_64")
));
}
#[test]
fn feature_gate_blocks_argument() {
let mut features = BTreeMap::new();
features.insert("has_custom_resolution".to_owned(), true);
let rule = Rule {
action: RuleAction::Allow,
os: None,
features: Some(features),
};
let enabled = env("windows", "x86_64").with_feature("has_custom_resolution", true);
let disabled = env("windows", "x86_64");
assert_eq!(rule.action_in(&enabled), Some(RuleAction::Allow));
assert_eq!(rule.action_in(&disabled), None);
}
#[test]
fn host_environment_is_mojang_shaped() {
let host = Environment::host();
assert!(matches!(host.os.as_str(), "windows" | "osx" | "linux"));
assert!(!host.arch.is_empty());
assert!(!host.feature("has_custom_resolution"));
// 空串是「没探」,真值来自 platform::os_info::current();只要探得到就不能再是空的。
let probed = unlucky_core::platform::os_info::current();
assert_eq!(host.os_version, probed.os_version());
assert_eq!(host.os, probed.family.manifest_name());
assert_eq!(host.arch, probed.arch);
}
#[test]
fn environment_from_an_injected_os_snapshot_carries_os_version() {
use unlucky_core::platform::linux_caps::SessionKind;
use unlucky_core::platform::os_info::{support_level_for, OsFamily, OsInfo};
use unlucky_core::platform::vk_probe::VkProbe;
let fake = OsInfo {
family: OsFamily::Windows,
arch: "x86_64".to_string(),
major_minor: Some((10, 0)),
build: Some(19_045),
product: "Windows 10".to_string(),
version_string: "10.0.19045".to_string(),
session: SessionKind::Unknown,
gl: unlucky_core::platform::linux_caps::GlProbe::Unknown,
vk: VkProbe::Available,
kernel: None,
glibc: None,
support_level: support_level_for(OsFamily::Windows, Some((10, 0)), None, None),
};
let environment = Environment::from_os_info(&fake);
assert_eq!(environment.os, "windows");
assert_eq!(environment.os_version, "10.0.19045");
assert_eq!(environment.arch, "x86_64");
assert!(environment.features.is_empty(), "注入快照不该带特性开关");
// 系统版本真的参与规则求值(这正是不再用空串的原因)。
let rule = Rule {
os: Some(OsRestriction {
name: None,
version: Some("^10\\.".to_string()),
arch: None,
}),
..Rule::default()
};
assert_eq!(rule.action_in(&environment), Some(RuleAction::Allow));
let mac_rule = Rule {
os: Some(OsRestriction {
name: None,
version: Some("^11\\.".to_string()),
arch: None,
}),
..Rule::default()
};
assert_eq!(mac_rule.action_in(&environment), None);
}
#[test]
fn os_version_patterns_match_conservatively() {
assert!(matches_simple_pattern("^10\\.", "10.0"));
assert!(!matches_simple_pattern("^10\\.", "11.0"));
assert!(matches_simple_pattern("x86_64", "x86_64"));
assert!(!matches_simple_pattern("x86_64", "x86"));
assert!(matches_simple_pattern("10\\.0$", "10.0"));
// 不认识的复杂正则放宽为匹配(宁可多带参数也不漏参数)。
assert!(matches_simple_pattern("^(10|11)\\.", "11.0"));
}
#[test]
fn expand_arguments_by_environment() {
let version = parse(
r#"{
"id": "x",
"arguments": {
"game": [
"--username",
{ "rules": [{ "action": "allow", "features": { "is_demo_user": true } }],
"value": "--demo" },
{ "value": ["--pair", "1"] }
],
"jvm": [ "-Dfoo=1" ]
}
}"#,
);
let arguments = version.arguments.as_ref().expect("arguments 应解析");
let plain = arguments.expand(&env("windows", "x86_64"));
assert_eq!(plain.game, vec!["--username", "--pair", "1"]);
assert_eq!(plain.jvm, vec!["-Dfoo=1"]);
let demo = env("windows", "x86_64").with_feature("is_demo_user", true);
assert!(arguments.expand(&demo).game.contains(&"--demo".to_owned()));
}
#[test]
fn conditional_argument_expands_single_value() {
let argument = Argument::Conditional(ConditionalArgument {
rules: vec![allow_os("windows")],
value: ArgumentValue::One("--single".to_owned()),
});
let mut out = Vec::new();
argument.expand_into(&env("windows", "x86_64"), &mut out);
assert_eq!(out, vec!["--single"]);
let mut out = Vec::new();
argument.expand_into(&env("linux", "x86_64"), &mut out);
assert!(out.is_empty());
}
#[test]
fn native_classifier_expands_arch() {
let version = parse(
r#"{ "id": "x",
"libraries": [{
"name": "org.lwjgl:lwjgl:3.3.2",
"natives": { "windows": "natives-windows-${arch}", "linux": "natives-linux" },
"downloads": {
"artifact": { "path": "org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2.jar" },
"classifiers": {
"natives-windows-64": { "path": "org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-windows-64.jar" }
}
}
}]
}"#,
);
let library = &version.libraries.as_ref().expect("libraries 应解析")[0];
assert_eq!(
library.native_classifier(&env("windows", "x86_64")),
Some("natives-windows-64".to_owned())
);
assert_eq!(
library.native_classifier(&env("windows", "x86")),
Some("natives-windows-".to_owned())
);
assert_eq!(
library.native_classifier(&env("linux", "x86_64")),
Some("natives-linux".to_owned())
);
assert_eq!(library.native_classifier(&env("osx", "arm64")), None);
let classifier = "natives-windows-64";
assert!(library.native_artifact(classifier).is_some());
assert_eq!(
library
.native_relative_path(classifier)
.expect("路径应可推导"),
std::path::PathBuf::from("org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-windows-64.jar")
);
}
#[test]
fn library_rules_and_local_hint() {
let library = Library {
name: "com.example:inst:1".to_owned(),
rules: vec![allow_os("linux")],
hint: Some("local".to_owned()),
..Library::default()
};
assert!(library.applies(&env("linux", "x86_64")));
assert!(!library.applies(&env("windows", "x86_64")));
assert!(library.is_local());
assert_eq!(
library.relative_path().expect("路径应可推导"),
std::path::PathBuf::from("com/example/inst/1/inst-1.jar")
);
}
+66
View File
@@ -0,0 +1,66 @@
//! 测试公用件:fixture 解析、内存父版本表、临时目录守卫。
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use unlucky_core::game::manifest::VersionJson;
/// 解析 fixture JSON(fixture 是测试常量,解析失败即 panic)。
pub fn parse(json: &str) -> VersionJson {
VersionJson::from_json_str(json).expect("fixture 必须可解析")
}
/// 用 `(id, json)` 对建内存父版本表,喂给 `inherit::resolve`。
pub fn source(pairs: &[(&str, &str)]) -> BTreeMap<String, VersionJson> {
pairs
.iter()
.map(|(id, json)| ((*id).to_owned(), parse(json)))
.collect()
}
/// 唯一临时目录守卫:`drop` 时递归删除。
pub struct TempDir {
path: PathBuf,
}
impl TempDir {
/// 建一个唯一临时目录。
pub fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"ull-game-{tag}-{}-{nanos}-{seq}",
std::process::id()
));
std::fs::create_dir_all(&path).expect("临时目录应可创建");
Self { path }
}
/// 目录路径。
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
/// 建一个游戏根布局(纯路径计算,不建目录)。
pub fn layout(root: &Path) -> unlucky_core::game::GameLayout {
unlucky_core::game::GameLayout::new(root)
}
/// 造 `dir/<name>` 并写一个文件进去(模拟玩家内容:`mods`/`saves`/`config` 的条目)。
pub fn seed(dir: &Path, name: &str) {
let target = dir.join(name);
std::fs::create_dir_all(&target).expect("建目录");
std::fs::write(target.join("entry.txt"), b"x").expect("写文件");
}
+265
View File
@@ -0,0 +1,265 @@
//! `inheritsFrom` 解析:逐字段 merge 语义、patch 优先级、环检测、缺失父版本。
use unlucky_core::game::error::GameError;
use unlucky_core::game::inherit::{apply_patches, merge, resolve, sorted_patches};
use unlucky_core::game::version::ReleaseType;
use crate::helpers::{parse, source};
const VANILLA: &str = r#"{
"id": "1.20.1",
"type": "release",
"releaseTime": "2023-06-12T13:25:51+00:00",
"mainClass": "net.minecraft.client.main.Main",
"minecraftArguments": "--username ${auth_player_name}",
"assets": "5",
"assetIndex": { "id": "5" },
"complianceLevel": 1,
"minimumLauncherVersion": 21,
"javaVersion": { "component": "java-runtime-gamma", "majorVersion": 17 },
"libraries": [ { "name": "lib:a:1" }, { "name": "lib:b:1" } ],
"downloads": {
"client": { "url": "https://example.invalid/client.jar", "sha1": "c", "size": 1 },
"server": { "url": "https://example.invalid/server.jar", "sha1": "s", "size": 1 }
},
"arguments": { "game": ["--game-parent"], "jvm": ["-Dparent=1"] },
"logging": { "client": { "argument": "-Dlog4j", "file": { "id": "client-1.12.xml" } } }
}"#;
const FORGE: &str = r#"{
"id": "1.20.1-forge-47.2.0",
"inheritsFrom": "1.20.1",
"mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher",
"time": "2024-01-01T00:00:00+00:00",
"minimumLauncherVersion": 14,
"javaVersion": { "component": "java-runtime-delta", "majorVersion": 21 },
"libraries": [ { "name": "net.minecraftforge:forge:1" } ],
"downloads": { "server": { "url": "https://example.invalid/server2.jar" } },
"arguments": { "game": ["--game-child"], "jvm": ["-Dchild=1"] }
}"#;
fn vanilla_and_forge(
) -> std::collections::BTreeMap<String, unlucky_core::game::manifest::VersionJson> {
source(&[("1.20.1", VANILLA), ("1.20.1-forge-47.2.0", FORGE)])
}
#[test]
fn merge_table_follows_analyse_03() {
let sources = vanilla_and_forge();
let child = parse(FORGE);
let resolved = resolve(&sources, &child).expect("继承链应可解析");
// 子覆盖父。
assert_eq!(
resolved.main_class.as_deref(),
Some("cpw.mods.bootstraplauncher.BootstrapLauncher")
);
// 子没写就继承父。
assert_eq!(
resolved.minecraft_arguments.as_deref(),
Some("--username ${auth_player_name}")
);
assert_eq!(resolved.assets.as_deref(), Some("5"));
assert_eq!(resolved.version_type, Some(ReleaseType::Release));
// arguments 父在前、子在后追加。
let arguments = resolved.arguments.as_ref().expect("arguments 应保留");
assert_eq!(arguments.game.len(), 2);
assert_eq!(arguments.jvm.len(), 2);
// libraries 父在前、子在后。
let libraries = resolved.libraries.as_ref().expect("libraries 应保留");
let names: Vec<&str> = libraries.iter().map(|l| l.name.as_str()).collect();
assert_eq!(
names,
vec!["lib:a:1", "lib:b:1", "net.minecraftforge:forge:1"]
);
// downloads 整体覆盖:子只写 server,client 就没了(非直觉点 1,必须显式守门)。
let downloads = resolved.downloads.as_ref().expect("downloads 应存在");
assert!(downloads.client().is_none(), "整体覆盖会让父层 client 消失");
assert_eq!(
downloads.server().and_then(|s| s.url.as_deref()),
Some("https://example.invalid/server2.jar")
);
// complianceLevel 只取子(非直觉点 2):父层 1 被丢弃。
assert_eq!(resolved.compliance_level, None);
// javaVersion 子覆盖父。
let java = resolved.java_version.as_ref().expect("javaVersion 应存在");
assert_eq!(java.major_version, Some(21));
// minimumLauncherVersion 取 max(21, 14)=21。
assert_eq!(resolved.minimum_launcher_version, Some(21));
// logging 子没有 → 继承父。
assert!(resolved.logging.is_some());
// 解析结果的不变量。
assert_eq!(resolved.jar.as_deref(), Some("1.20.1-forge-47.2.0"));
assert!(resolved.inherits_from.is_none());
assert!(resolved.patches.is_none());
assert_eq!(resolved.root, Some(true));
assert!(resolved.is_resolved());
}
#[test]
fn three_level_chain_orders_ancestor_first() {
const A: &str = r#"{ "id": "a", "mainClass": "a.Main", "libraries": [{ "name": "l:a:1" }] }"#;
const B: &str = r#"{ "id": "b", "inheritsFrom": "a", "libraries": [{ "name": "l:b:1" }] }"#;
const C: &str = r#"{ "id": "c", "inheritsFrom": "b", "mainClass": "c.Main",
"libraries": [{ "name": "l:c:1" }] }"#;
let sources = source(&[("a", A), ("b", B), ("c", C)]);
let resolved = resolve(&sources, &parse(C)).expect("三层链应可解析");
assert_eq!(resolved.main_class.as_deref(), Some("c.Main"));
let names: Vec<&str> = resolved
.libraries
.as_ref()
.expect("libraries 应存在")
.iter()
.map(|l| l.name.as_str())
.collect();
assert_eq!(names, vec!["l:a:1", "l:b:1", "l:c:1"]);
}
#[test]
fn patch_priority_sorts_ascending() {
let root = parse(
r#"{
"id": "patched",
"inheritsFrom": "1.20.1",
"patches": [
{ "id": "forge", "priority": 30000, "mainClass": "loader.Main",
"libraries": [{ "name": "loader:a:1" }] },
{ "id": "base", "mainClass": "base.Main",
"arguments": { "game": ["--base"] } }
]
}"#,
);
let resolved = resolve(&vanilla_and_forge(), &root).expect("带 patches 的清单应可解析");
// 无 priority 的补丁排最前(Integer.MIN_VALUE 语义),loader 后合并 → 覆盖 mainClass。
assert_eq!(resolved.main_class.as_deref(), Some("loader.Main"));
let libraries = resolved.libraries.as_ref().expect("libraries 应存在");
assert_eq!(
libraries.last().map(|l| l.name.as_str()),
Some("loader:a:1")
);
let arguments = resolved.arguments.as_ref().expect("arguments 应存在");
let flat: Vec<String> = arguments.game.iter().map(|a| format!("{a:?}")).collect();
assert!(flat.iter().any(|a| a.contains("--base")));
}
#[test]
fn equal_priority_patches_keep_insertion_order() {
let root = parse(
r#"{
"id": "patched",
"patches": [
{ "id": "first", "priority": 10, "mainClass": "first.Main" },
{ "id": "second", "priority": 10, "mainClass": "second.Main" }
]
}"#,
);
let resolved = resolve(&source(&[]), &root).expect("无继承的清单应可解析");
assert_eq!(
resolved.main_class.as_deref(),
Some("second.Main"),
"同优先级按插入顺序合并,后者覆盖前者"
);
}
#[test]
fn sorted_patches_puts_missing_priority_first() {
let patches = vec![
parse(r#"{ "id": "loader", "priority": 30000 }"#),
parse(r#"{ "id": "base" }"#),
parse(r#"{ "id": "mc", "priority": 0 }"#),
];
let order: Vec<&str> = sorted_patches(&patches)
.iter()
.filter_map(|p| p.id.as_deref())
.collect();
assert_eq!(order, vec!["base", "mc", "loader"]);
}
#[test]
fn apply_patches_stacks_layers() {
let base =
parse(r#"{ "id": "root", "mainClass": "base.Main", "libraries": [{ "name": "l:a:1" }] }"#);
let patched = apply_patches(
base,
&[parse(
r#"{ "id": "loader", "priority": 30000, "mainClass": "loader.Main",
"libraries": [{ "name": "l:loader:1" }] }"#,
)],
);
assert_eq!(patched.main_class.as_deref(), Some("loader.Main"));
let names: Vec<&str> = patched
.libraries
.as_ref()
.expect("libraries 应存在")
.iter()
.map(|l| l.name.as_str())
.collect();
assert_eq!(names, vec!["l:a:1", "l:loader:1"]);
}
#[test]
fn circular_inheritance_is_reported() {
let sources = source(&[
("a", r#"{ "id": "a", "inheritsFrom": "b" }"#),
("b", r#"{ "id": "b", "inheritsFrom": "a" }"#),
]);
let error = resolve(&sources, &parse(r#"{ "id": "a", "inheritsFrom": "b" }"#))
.expect_err("成环必须报错而不是静默降级");
match error {
GameError::CircularInheritance { chain } => {
assert_eq!(chain, vec!["a", "b", "a"], "链要能看出环的位置");
}
other => panic!("期望 CircularInheritance,得到 {other:?}"),
}
}
#[test]
fn missing_parent_is_reported() {
let error = resolve(&source(&[]), &parse(FORGE)).expect_err("缺失父版本必须报错");
match error {
GameError::MissingParent {
parent,
required_by,
} => {
assert_eq!(parent, "1.20.1");
assert_eq!(required_by, "1.20.1-forge-47.2.0");
}
other => panic!("期望 MissingParent,得到 {other:?}"),
}
}
#[test]
fn manifest_without_id_is_rejected() {
let error =
resolve(&source(&[]), &parse(r#"{ "mainClass": "a.B" }"#)).expect_err("缺 id 应报错");
assert!(matches!(error, GameError::MissingVersionId));
}
#[test]
fn merge_preserves_unknown_fields_with_child_winning() {
let parent = parse(r#"{ "id": "p", "customParentOnly": 1, "shared": "parent" }"#);
let child = parse(r#"{ "id": "c", "shared": "child" }"#);
let merged = merge(&child, &parent);
assert_eq!(
merged.extra.get("customParentOnly"),
Some(&serde_json::json!(1))
);
assert_eq!(
merged.extra.get("shared"),
Some(&serde_json::json!("child"))
);
assert_eq!(merged.id.as_deref(), Some("c"));
}
+194
View File
@@ -0,0 +1,194 @@
//! D20 实例隔离:覆盖链派生、双侧切换检测、稀疏落盘不变量。
//!
//! 对应 mission 的验收点:三档派生 / 覆盖链优先级 / 切换检测(双侧空、单侧、双侧)/
//! `inherit` 不落盘 / 恒共享层不可配。
use std::path::Path;
use unlucky_core::game::install::instance::{InstanceConfig, Isolation};
use unlucky_core::game::isolation::{
derive_game_dir, probe_both_sides, probe_side, IsolationMode, IsolationSource,
PROBED_CONTENT_DIRS, SHARED_LAYERS,
};
use unlucky_core::game::GameError;
use super::helpers::{layout, seed, TempDir};
// ---------------------------------------------------------------- 派生(三档)
#[test]
fn isolated_mode_derives_version_dir() {
let temp = TempDir::new("iso-derived");
let game = layout(temp.path());
let derived = derive_game_dir(&game, "1.20.1", IsolationMode::Isolated, None, None)
.expect("isolated 一定能派生");
assert_eq!(derived.isolation, Isolation::Isolated);
assert_eq!(derived.game_dir, game.version_dir("1.20.1"));
assert_eq!(derived.source, IsolationSource::Global);
}
#[test]
fn shared_mode_derives_game_root() {
let temp = TempDir::new("iso-shared");
let game = layout(temp.path());
let derived = derive_game_dir(&game, "1.20.1", IsolationMode::Shared, None, None)
.expect("shared 一定能派生");
assert_eq!(derived.isolation, Isolation::Shared);
assert_eq!(derived.game_dir, game.root());
}
#[test]
fn custom_mode_uses_instance_game_dir() {
let temp = TempDir::new("iso-custom");
let game = layout(temp.path());
let custom = temp.path().join("elsewhere").join("farm");
let mut config = InstanceConfig::new("id-1", "1.20.1");
config.isolation = Some(Isolation::Custom);
config.game_dir = Some(custom.clone());
let derived = derive_game_dir(
&game,
"1.20.1",
IsolationMode::Isolated,
Some(&config),
None,
)
.expect("custom 有目录就能派生");
assert_eq!(derived.isolation, Isolation::Custom);
assert_eq!(derived.game_dir, custom);
assert_eq!(derived.source, IsolationSource::Instance);
}
#[test]
fn custom_without_game_dir_is_an_error() {
let temp = TempDir::new("iso-custom-missing");
let game = layout(temp.path());
let mut config = InstanceConfig::new("id-missing", "1.20.1");
config.isolation = Some(Isolation::Custom);
let error = derive_game_dir(
&game,
"1.20.1",
IsolationMode::Isolated,
Some(&config),
None,
)
.expect_err("custom 缺目录必须报错,不许猜默认值");
assert!(matches!(error, GameError::CustomIsolationWithoutDir { .. }));
assert!(error.to_string().contains("id-missing"), "{error}");
}
// ------------------------------------------------------------ 覆盖链(优先级)
#[test]
fn instance_isolation_beats_global_mode() {
let temp = TempDir::new("iso-priority");
let game = layout(temp.path());
let mut config = InstanceConfig::new("id-2", "1.20.1");
config.isolation = Some(Isolation::Shared);
let derived = derive_game_dir(
&game,
"1.20.1",
IsolationMode::Isolated,
Some(&config),
None,
)
.expect("派生");
assert_eq!(derived.isolation, Isolation::Shared);
assert_eq!(derived.game_dir, game.root());
assert_eq!(derived.source, IsolationSource::Instance);
}
#[test]
fn inherit_falls_back_to_global_mode() {
let temp = TempDir::new("iso-inherit");
let game = layout(temp.path());
let config = InstanceConfig::new("id-3", "1.20.1");
assert!(config.inherits_isolation(), "新实例默认 inherit");
for (mode, expected) in [
(IsolationMode::Isolated, game.version_dir("1.20.1")),
(IsolationMode::Shared, game.root().to_path_buf()),
] {
let derived = derive_game_dir(&game, "1.20.1", mode, Some(&config), None).expect("派生");
assert_eq!(
derived.game_dir,
expected,
"全局 {} 应决定 inherit",
mode.as_str()
);
assert_eq!(derived.source, IsolationSource::Global);
}
}
#[test]
fn temporary_override_wins_over_everything() {
let temp = TempDir::new("iso-temporary");
let game = layout(temp.path());
let mut config = InstanceConfig::new("id-4", "1.20.1");
config.isolation = Some(Isolation::Isolated);
let forced = temp.path().join("forced");
let derived = derive_game_dir(
&game,
"1.20.1",
IsolationMode::Shared,
Some(&config),
Some(&forced),
)
.expect("派生");
assert_eq!(derived.game_dir, forced);
assert_eq!(derived.source, IsolationSource::Temporary);
// 档位本身仍是实例档位(临时参数只改目录,不改语义)。
assert_eq!(derived.isolation, Isolation::Isolated);
// 空路径 = 没给临时参数。
let derived = derive_game_dir(
&game,
"1.20.1",
IsolationMode::Shared,
Some(&config),
Some(Path::new("")),
)
.expect("派生");
assert_eq!(derived.source, IsolationSource::Instance);
}
#[test]
fn shared_layers_are_fixed_and_unconfigurable() {
assert_eq!(SHARED_LAYERS, ["versions", "assets", "libraries"]);
// natives 不在恒共享层里:它按实例解包(两家启动器一致)。
assert!(!SHARED_LAYERS.contains(&"natives"));
assert_eq!(PROBED_CONTENT_DIRS, ["mods", "saves", "config"]);
}
// -------------------------------------------------------------- 切换检测(探测)
#[test]
fn probe_counts_only_player_content() {
let temp = TempDir::new("iso-probe");
seed(temp.path(), "mods");
seed(temp.path(), "saves");
seed(temp.path(), "screenshots");
let side = probe_side(temp.path()).expect("探测");
assert_eq!(side.entries, 2, "screenshots 不算玩家内容");
assert!(side.occupied());
}
#[test]
fn probe_missing_dir_counts_as_zero() {
let temp = TempDir::new("iso-probe-empty");
let side = probe_side(&temp.path().join("nope")).expect("目录不存在不算错误");
assert_eq!(side.entries, 0);
assert!(!side.occupied());
}
#[test]
fn probe_both_sides_reads_isolated_and_shared() {
let temp = TempDir::new("iso-probe-both");
let game = layout(temp.path());
seed(&game.version_dir("1.20.1"), "saves");
seed(game.root(), "mods");
seed(game.root(), "config");
let (isolated, shared) = probe_both_sides(&game, "1.20.1").expect("探测");
assert_eq!(isolated.entries, 1);
assert_eq!(shared.entries, 2);
assert_eq!(isolated.path, game.version_dir("1.20.1"));
assert_eq!(shared.path, game.root());
}
+222
View File
@@ -0,0 +1,222 @@
//! D20 切换检测与稀疏落盘:observe/warn 语义、`inherit` 与 `lastModeChange` 不落盘、
//! 旧字面量读兼容(从 `isolation.rs` 拆出,保持单文件 ≤400 行)。
use std::path::PathBuf;
use unlucky_core::game::install::instance::{InstanceConfig, Isolation};
use unlucky_core::game::isolation::{
derive_game_dir, observe_mode, IsolationMode, IsolationSource,
};
use super::helpers::{layout, seed, TempDir};
// ------------------------------------------------- 切换记录(幂等 + 只提示一次)
#[test]
fn observe_mode_records_baseline_without_warning() {
let temp = TempDir::new("iso-observe-baseline");
let game = layout(temp.path());
seed(game.root(), "saves");
let mut config = InstanceConfig::new("id-5", "1.20.1");
let changed = observe_mode(
&mut config,
&game,
"2026-10-30T00:00:00Z",
Isolation::Isolated,
)
.expect("观察");
assert!(changed, "首次观察要建基线");
let change = config.last_mode_change.clone().expect("基线");
assert_eq!(change.from, change.to);
assert!(!change.is_real_change());
assert!(change.warned, "基线不该弹提示");
assert_eq!(change.probe.shared_side, 1);
assert!(
config.take_mode_change_warning().is_none(),
"基线即使两侧都有内容也不提示"
);
}
#[test]
fn observe_mode_is_idempotent() {
let temp = TempDir::new("iso-observe-idempotent");
let game = layout(temp.path());
let mut config = InstanceConfig::new("id-6", "1.20.1");
assert!(
observe_mode(&mut config, &game, "t1", Isolation::Isolated).expect("观察"),
"首次写入"
);
assert!(
!observe_mode(&mut config, &game, "t2", Isolation::Isolated).expect("观察"),
"同一档位不重复记"
);
assert_eq!(
config.last_mode_change.expect("记录仍在").at,
"t1",
"时间戳不该被刷新"
);
}
#[test]
fn mode_change_with_both_sides_warns_exactly_once() {
let temp = TempDir::new("iso-warn-once");
let game = layout(temp.path());
seed(&game.version_dir("1.20.1"), "saves");
seed(&game.version_dir("1.20.1"), "mods");
seed(game.root(), "saves");
let mut config = InstanceConfig::new("id-7", "1.20.1");
config.isolation = Some(Isolation::Shared);
observe_mode(&mut config, &game, "t0", Isolation::Shared).expect("基线");
config.isolation = Some(Isolation::Isolated);
assert!(
observe_mode(&mut config, &game, "t1", Isolation::Isolated).expect("记切换"),
"档位变了要写记录"
);
let change = config.last_mode_change.clone().expect("记录");
assert_eq!(change.from, Isolation::Shared);
assert_eq!(change.to, Isolation::Isolated);
assert!(change.is_real_change());
assert_eq!(change.probe.isolated_side, 2);
assert_eq!(change.probe.shared_side, 1);
let warning = config
.take_mode_change_warning()
.expect("两侧都有内容要提示");
assert!(warning.probe.both_occupied());
let message = warning.message();
assert!(message.contains("isolated"), "{message}");
assert!(
message.contains("不会自动搬"),
"文案必须说清不迁移:{message}"
);
assert!(
config.take_mode_change_warning().is_none(),
"同一次切换只提示一次"
);
assert!(config.last_mode_change.expect("记录").warned);
}
#[test]
fn mode_change_with_one_empty_side_is_quiet() {
let temp = TempDir::new("iso-warn-single-side");
let game = layout(temp.path());
// 只有共享侧有内容:隔离侧空 → 不提示(切到共享还能看见存档,反之亦然)。
seed(game.root(), "saves");
let mut config = InstanceConfig::new("id-8", "1.20.1");
observe_mode(&mut config, &game, "t0", Isolation::Shared).expect("基线");
observe_mode(&mut config, &game, "t1", Isolation::Isolated).expect("记切换");
assert!(config.take_mode_change_warning().is_none());
// 两侧都空同样安静。
let empty = TempDir::new("iso-warn-both-empty");
let game = layout(empty.path());
let mut config = InstanceConfig::new("id-9", "1.20.1");
observe_mode(&mut config, &game, "t0", Isolation::Shared).expect("基线");
observe_mode(&mut config, &game, "t1", Isolation::Isolated).expect("记切换");
assert!(config.take_mode_change_warning().is_none());
}
// -------------------------------------------------------------- 稀疏落盘不变量
#[test]
fn inherit_and_no_change_stay_out_of_the_file() {
let config = InstanceConfig::new("id-10", "1.20.1");
let json = config.to_json_string().expect("序列化");
assert!(!json.contains("isolation"), "inherit 不许落盘:{json}");
assert!(!json.contains("lastModeChange"), "没切过就不写:{json}");
assert!(!json.contains("gameDir"), "没目录就不写:{json}");
let back = InstanceConfig::from_json_str(&json).expect("回读");
assert_eq!(back, config);
}
#[test]
fn last_mode_change_round_trips_in_camel_case() {
let temp = TempDir::new("iso-roundtrip");
let game = layout(temp.path());
let mut config = InstanceConfig::new("id-11", "1.20.1");
observe_mode(&mut config, &game, "t0", Isolation::Shared).expect("基线");
let json = config.to_json_string().expect("序列化");
assert!(json.contains("\"lastModeChange\""), "{json}");
assert!(json.contains("\"isolatedSide\""), "{json}");
assert!(json.contains("\"sharedSide\""), "{json}");
let back = InstanceConfig::from_json_str(&json).expect("回读");
assert_eq!(back, config);
}
#[test]
fn legacy_per_version_value_reads_as_isolated() {
let raw = r#"{
"id": "id-12",
"versionId": "1.20.1",
"isolation": "per_version"
}"#;
let config = InstanceConfig::from_json_str(raw).expect("旧字面量读兼容");
assert_eq!(config.isolation, Some(Isolation::Isolated));
assert_eq!(Isolation::parse("per_version"), Some(Isolation::Isolated));
assert_eq!(Isolation::parse("isolated"), Some(Isolation::Isolated));
assert_eq!(Isolation::parse("nonsense"), None);
}
#[test]
fn effective_isolation_resolves_inherit() {
let config = InstanceConfig::new("id-13", "1.20.1");
assert_eq!(
config.effective_isolation(IsolationMode::Shared),
Isolation::Shared
);
let mut config = InstanceConfig::new("id-14", "1.20.1");
config.isolation = Some(Isolation::Custom);
assert_eq!(
config.effective_isolation(IsolationMode::Isolated),
Isolation::Custom
);
}
#[test]
fn custom_dir_from_relative_path_is_kept_verbatim() {
// custom 目录不做规范化(可能是相对路径或系统符号链接),派生层只透传。
let temp = TempDir::new("iso-custom-relative");
let game = layout(temp.path());
let mut config = InstanceConfig::new("id-15", "1.20.1");
config.isolation = Some(Isolation::Custom);
config.game_dir = Some(PathBuf::from("relative/mc"));
let derived = derive_game_dir(
&game,
"1.20.1",
IsolationMode::Isolated,
Some(&config),
None,
)
.expect("派生");
assert_eq!(derived.game_dir, PathBuf::from("relative/mc"));
}
#[test]
fn explicit_inherit_reads_back_as_absent() {
// 回归 P2-2(review r1):schema 允许写 `"isolation": "inherit"`,
// 读路径必须把它折叠成「键不存在」,否则设置层写回默认值后启动会报 unknown variant。
let raw = r#"{
"id": "id-16",
"versionId": "1.20.1",
"isolation": "inherit"
}"#;
let config = InstanceConfig::from_json_str(raw).expect("显式 inherit 必须能读");
assert!(config.inherits_isolation());
assert_eq!(config.isolation, None);
let json = config.to_json_string().expect("序列化");
assert!(
!json.contains("isolation"),
"写回时把 inherit 落成键缺失:{json}"
);
// 走到派生层就是「跟随全局」,不再是错误路径。
let temp = TempDir::new("iso-inherit-explicit");
let game = layout(temp.path());
let derived =
derive_game_dir(&game, "1.20.1", IsolationMode::Shared, Some(&config), None).expect("派生");
assert_eq!(derived.isolation, Isolation::Shared);
assert_eq!(derived.source, IsolationSource::Global);
// 其他非法字面量仍然报错(不静默取默认值)。
let bad = r#"{ "id": "id-17", "versionId": "1.20.1", "isolation": "maybe" }"#;
assert!(InstanceConfig::from_json_str(bad).is_err());
}
+129
View File
@@ -0,0 +1,129 @@
//! `core::game::java::discover`:标准安装位置扫描与来源优先级。
//!
//! 真机上扫到什么取决于本机装了什么,所以断言只钉**不变量**:
//! 扫描结果形状、来源优先级(env 胜过扫盘)、去重保留高优先级、缺目录不报错。
use unlucky_core::game::java::discover;
use unlucky_core::game::java::mapping::{JavaRequirement, RequirementSource};
use unlucky_core::game::java::{pick, JavaOrigin, JavaRuntime};
fn runtime(path: &str, major: u32, origin: JavaOrigin) -> JavaRuntime {
JavaRuntime {
binary: std::path::PathBuf::from(path),
origin,
version: Some(format!("openjdk version \"{major}.0.1\"")),
major: Some(major),
bits: 64,
vendor: None,
}
}
fn at_least(minimum: u32) -> JavaRequirement {
JavaRequirement::new(minimum, None, RequirementSource::Table)
}
#[test]
fn scanned_paths_always_point_at_a_java_binary() {
// 本机可能一个都没有(裸机/容器)——那也算通过,只是列表为空。
for binary in discover::scan_java_binaries() {
assert!(
binary.is_file(),
"扫描结果必须是真实存在的文件:{}",
binary.display()
);
assert_eq!(
binary
.file_name()
.map(|name| name.to_string_lossy().into_owned()),
Some(unlucky_core::game::java::java_binary_name().to_string()),
"扫描结果必须是 bin/java[.exe]:{}",
binary.display()
);
}
}
#[test]
fn standard_roots_are_absolute_and_unique() {
let roots = discover::standard_roots();
for root in &roots {
assert!(
root.is_absolute(),
"候选根必须是绝对路径:{}",
root.display()
);
}
let mut keys: Vec<String> = roots.iter().map(|root| discover::path_key(root)).collect();
let total = keys.len();
keys.sort();
keys.dedup();
assert_eq!(keys.len(), total, "候选根不该重复");
}
#[test]
fn environment_java_beats_scanned_java_when_both_satisfy() {
// D13 的「优先级链不变」:环境变量是用户意图,扫盘只是候选。
let candidates = [
runtime(
"C:/Program Files/Eclipse Adoptium/jdk-21/bin/java.exe",
21,
JavaOrigin::System,
),
runtime(
"C:/Program Files/Microsoft/jdk-21/bin/java.exe",
21,
JavaOrigin::JavaHome,
),
];
let picked = pick(&candidates, &at_least(17)).expect("应有候选");
assert_eq!(picked.origin, JavaOrigin::JavaHome);
}
#[test]
fn scanned_java_is_still_used_when_nothing_else_matches() {
// MSI 装了 Temurin 21、没设环境变量、PATH 上是 Java 8:1.20.5 只认扫盘那个。
let candidates = [
runtime("java", 8, JavaOrigin::Path),
runtime(
"C:/Program Files/Java/jdk-21/bin/java.exe",
21,
JavaOrigin::System,
),
];
let picked = pick(&candidates, &at_least(21)).expect("应有候选");
assert_eq!(picked.origin, JavaOrigin::System);
}
#[test]
fn dedupe_collapses_one_install_found_twice() {
// `JAVA_HOME` 指向 Program Files 里的 JDK → 同一个 java 不应列两次。
// 第二条的「另一种写法」随平台给:Windows 上大小写和分隔符都可能不一致(NTFS 不区分
// 大小写),POSIX 上大小写是有效区别,只能靠分隔符拼出同一路径的第二种写法。
let duplicate = if cfg!(windows) {
"c:/program files/java/jdk-21/bin/java.exe"
} else {
"C:\\Program Files\\Java\\jdk-21/bin/java.exe"
};
let candidates = discover::dedupe(vec![
runtime(
"C:/Program Files/Java/jdk-21/bin/java.exe",
21,
JavaOrigin::JavaHome,
),
runtime(duplicate, 21, JavaOrigin::System),
]);
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].origin, JavaOrigin::JavaHome);
}
#[test]
fn registry_absence_is_tolerated() {
// 非 Windows 必然为空;Windows 上没装老 Oracle JDK 也是空。
let homes = discover::registry_homes();
for home in &homes {
assert!(
home.is_absolute(),
"注册表里的 JavaHome 应是绝对路径:{}",
home.display()
);
}
}
+291
View File
@@ -0,0 +1,291 @@
//! `core::game::java` 的安装侧集成测试:归档解压(幂等 / 顶层剥离 / 越界拒绝)、
//! 托管目录命名、下载请求装配、平台归档形态。
//!
//! 镜像选源见 `java_mirrors.rs`,MC↔Java 映射见 `java_mapping.rs`。
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use flate2::write::GzEncoder;
use flate2::Compression;
use unlucky_core::download::ResourceKind;
use unlucky_core::game::java::{
self, extract, install, probe, AdoptiumTarget, JdkArchive, JdkRelease,
};
/// 64 位十六进制(解压戳记只当字符串比对,不校验内容)。
const SHA_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
/// 另一个「新版本」的摘要。
const SHA_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
fn temp_dir(tag: &str) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.as_nanos());
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"ull-java-{tag}-{}-{nanos}-{seq}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建临时目录");
dir
}
/// 造一个 Temurin 形状的 zip(顶层 `jdk-21.0.13+9/`)。
fn write_zip(path: &Path, entries: &[(&str, &[u8])]) {
let file = File::create(path).expect("建 zip");
let mut writer = zip::ZipWriter::new(file);
let options =
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, body) in entries {
writer.start_file(*name, options).expect("写 zip 条目");
writer.write_all(body).expect("写 zip 内容");
}
writer.finish().expect("收尾 zip");
}
/// 造一个 Temurin 形状的 tar.gz。
fn write_tar_gz(path: &Path, entries: &[(&str, &[u8])]) {
let file = File::create(path).expect("建 tar.gz");
let encoder = GzEncoder::new(file, Compression::fast());
let mut builder = tar::Builder::new(encoder);
for (name, body) in entries {
let mut header = tar::Header::new_gnu();
header.set_size(body.len() as u64);
header.set_mode(0o755);
header.set_entry_type(tar::EntryType::Regular);
builder
.append_data(&mut header, name, *body)
.expect("写 tar 条目");
}
let encoder = builder.into_inner().expect("收尾 tar");
encoder.finish().expect("收尾 gzip");
}
fn java_bin(root: &Path) -> PathBuf {
root.join("bin").join(java::java_binary_name())
}
#[test]
fn zip_archive_loses_the_temurin_top_level_directory() {
let dir = temp_dir("zip");
let archive = dir.join("jdk.zip");
let top = "jdk-21.0.13+9";
write_zip(
&archive,
&[
(
&format!("{top}/bin/{}", java::java_binary_name()),
b"#!/bin/true\n",
),
(&format!("{top}/release"), b"JAVA_VERSION=\"21.0.13\"\n"),
],
);
let dest = dir.join("jdk-21-temurin");
let report = extract::extract_archive(&archive, SHA_A, 21, &dest).expect("解压应成功");
assert!(!report.skipped);
assert_eq!(report.files, 2);
assert!(java_bin(&dest).is_file(), "bin/java 应在顶层");
assert!(!dest.join(top).exists(), "顶层目录必须被剥掉");
let stamp = extract::read_stamp(&dest).expect("应写戳记");
assert_eq!(stamp.major, 21);
assert_eq!(stamp.sha256, SHA_A);
assert_eq!(stamp.files, 2);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn second_extract_with_same_sha_touches_nothing() {
let dir = temp_dir("idem");
let archive = dir.join("jdk.zip");
write_zip(
&archive,
&[(
&format!("jdk-21.0.13+9/bin/{}", java::java_binary_name()),
b"bin\n",
)],
);
let dest = dir.join("jdk-21-temurin");
extract::extract_archive(&archive, SHA_A, 21, &dest).expect("首解");
// 用户手工动过的痕迹:幂等命中时不能被抹掉。
let marker = dest.join("安装后手工放的.txt");
std::fs::write(&marker, b"keep").expect("写标记");
let again = extract::extract_archive(&archive, SHA_A, 21, &dest).expect("重解");
assert!(again.skipped, "同一 sha256 应整体跳过");
assert_eq!(again.files, 0);
assert!(marker.is_file(), "幂等命中时不应重写目录");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn new_sha_replaces_the_managed_directory() {
let dir = temp_dir("upgrade");
let archive = dir.join("jdk.zip");
write_zip(
&archive,
&[(
&format!("jdk-21.0.13+9/bin/{}", java::java_binary_name()),
b"v1\n",
)],
);
let dest = dir.join("jdk-21-temurin");
extract::extract_archive(&archive, SHA_A, 21, &dest).expect("首解");
let marker = dest.join("旧版残留");
std::fs::write(&marker, b"old").expect("写标记");
let report = extract::extract_archive(&archive, SHA_B, 21, &dest).expect("新摘要重解");
assert!(!report.skipped);
assert!(!marker.exists(), "官方发新版后旧目录应被替换");
assert_eq!(
extract::read_stamp(&dest).map(|stamp| stamp.sha256),
Some(SHA_B.to_string())
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn tar_gz_archive_is_supported_for_linux_and_mac() {
let dir = temp_dir("targz");
let archive = dir.join("OpenJDK21U-jdk_x64_linux_hotspot_21.0.13_9.tar.gz");
let top = "jdk-21.0.13+9";
write_tar_gz(
&archive,
&[
(&format!("{top}/bin/{}", java::java_binary_name()), b"bin\n"),
(&format!("{top}/lib/modules"), b"modules\n"),
],
);
let dest = dir.join("jdk-21-temurin");
let report = extract::extract_archive(&archive, SHA_A, 21, &dest).expect("解压 tar.gz");
assert_eq!(report.files, 2);
assert!(dest.join("bin").join(java::java_binary_name()).is_file());
assert!(dest.join("lib").join("modules").is_file());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn archive_without_a_single_top_directory_is_promoted_as_is() {
// 有的魔改发行版直接把 bin/ 放在根上:不能硬剥一层。
let dir = temp_dir("flat");
let archive = dir.join("flat.zip");
write_zip(
&archive,
&[
(
&format!("bin/{}", java::java_binary_name()),
b"bin\n".as_slice(),
),
("release", b"x\n".as_slice()),
],
);
let dest = dir.join("jdk-17-temurin");
extract::extract_archive(&archive, SHA_A, 17, &dest).expect("解压");
assert!(dest.join("bin").join(java::java_binary_name()).is_file());
assert!(dest.join("release").is_file());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn foreign_directory_is_never_clobbered() {
let dir = temp_dir("foreign");
let archive = dir.join("jdk.zip");
write_zip(
&archive,
&[(
&format!("jdk-21.0.13+9/bin/{}", java::java_binary_name()),
b"bin\n",
)],
);
let dest = dir.join("jdk-21-temurin");
std::fs::create_dir_all(&dest).expect("建目标目录");
std::fs::write(dest.join("用户的文件.txt"), b"mine").expect("写用户文件");
let error = extract::extract_archive(&archive, SHA_A, 21, &dest).expect_err("应拒绝覆盖");
assert_eq!(error.kind(), java::JavaErrorKind::Io);
assert!(dest.join("用户的文件.txt").is_file(), "用户文件必须还在");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn zip_slip_entries_do_not_escape_the_target_directory() {
let dir = temp_dir("slip");
let archive = dir.join("evil.zip");
write_zip(
&archive,
&[
(
&format!("jdk-21.0.13+9/bin/{}", java::java_binary_name()),
b"bin\n",
),
("../../escaped.txt", b"nope"),
],
);
let dest = dir.join("jdk-21-temurin");
let report = extract::extract_archive(&archive, SHA_A, 21, &dest).expect("越界条目只跳过");
assert_eq!(report.files, 1, "越界条目不应被写盘");
assert!(!dir.join("escaped.txt").exists());
assert!(!dir.parent().unwrap_or(&dir).join("escaped.txt").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn managed_layout_names_carry_major_and_vendor() {
let root = Path::new("/tmp/java-managed");
let dest = install::managed_root_dir(root, 21, java::JDK_VENDOR);
assert_eq!(dest, root.join("jdk-21-temurin"));
assert_eq!(
probe::parse_managed_dir("jdk-21-temurin"),
Some((21, "temurin"))
);
assert_eq!(probe::managed_dir_name(8, "temurin"), "jdk-8-temurin");
}
#[test]
fn archive_request_carries_sha256_and_the_java_runtime_kind() {
let release = JdkRelease {
vendor: java::JdkVendor::Temurin,
major: 21,
version: "21.0.13+9".to_string(),
semver: Some("21.0.13+9.0.LTS".to_string()),
release_name: "jdk-21.0.13+9".to_string(),
archive: JdkArchive {
name: "OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip".to_string(),
url: "https://github.com/adoptium/temurin21-binaries/releases/download/x.zip"
.to_string(),
sha256: SHA_A.to_string(),
size: 1024,
},
};
let dest = Path::new("/tmp/x.zip");
let request = install::archive_request(&release, dest.to_path_buf());
assert_eq!(request.kind, ResourceKind::JavaRuntime);
assert_eq!(request.expected_size, Some(1024));
assert_eq!(
request
.integrity
.as_ref()
.map(|integrity| integrity.hex.as_str()),
Some(SHA_A)
);
assert!(request.validate().is_ok());
assert_eq!(
install::archive_path(Path::new("/tmp/java-managed"), &release),
Path::new("/tmp/java-managed/.cache/OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip")
);
}
#[test]
fn host_target_picks_the_platform_archive_shape() {
let target = AdoptiumTarget::host().expect("宿主应受支持");
assert_eq!(target.is_zip(), cfg!(windows));
assert_eq!(
target.archive_ext(),
if cfg!(windows) { "zip" } else { "tar.gz" }
);
}
+136
View File
@@ -0,0 +1,136 @@
//! `core::game::java::location`:落点三模式(Portable / Standard / Custom)+ 孤儿目录清理。
//!
//! 三模式的口径来自 D23/D6:Portable 保持 v1 布局、Standard 走 OS 标准目录的
//! `<发行方>/<版本>` 分层、Custom 由 `java.installRoot` 覆盖;清理只碰我们自己的形状。
use std::path::PathBuf;
use unlucky_core::game::java::location::{
cleanup_orphans, sanitize_version, standard_java_root, JavaInstallRoot, LocationMode,
};
use unlucky_core::game::java::JdkVendor;
use unlucky_core::platform::layout::LauncherLayout;
use unlucky_core::platform::paths::{MapEnv, Platform};
fn temp_dir(tag: &str) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.as_nanos());
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"ull-java-loc-{tag}-{}-{nanos}-{seq}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建临时目录");
dir
}
#[test]
fn standard_root_follows_each_platform_convention() {
let env = MapEnv::new()
.with("APPDATA", "C:/Users/u/AppData/Roaming")
.with("HOME", "/home/u")
.with("USERPROFILE", "C:/Users/u");
// 期望值一律用 `join` 拼(Windows 上的分隔符与 `Path` 比较细节交给标准库)。
let unix_home = PathBuf::from("/home/u");
assert_eq!(
standard_java_root(Platform::Windows, &env),
Some(PathBuf::from("C:/Users/u/AppData/Roaming").join("Java")),
"Windows 用户原话:%APPDATA%\\Java"
);
assert_eq!(
standard_java_root(Platform::MacOs, &env),
Some(unix_home.join("Library/Application Support").join("Java"))
);
assert_eq!(
standard_java_root(Platform::Linux, &env),
Some(unix_home.join(".local/share").join("java")),
"Linux 走 XDG 约定(无 XDG_DATA_HOME 时回落 ~/.local/share)"
);
// `Path::is_absolute()` 的口径是编译目标决定的:Windows 上 `C:/data/u` 才是绝对路径,
// 而 `/data/u` 只是「rooted」。夹具按目标平台给,才能同时验到「绝对 → 采用」。
let xdg_root = if cfg!(windows) {
"C:/data/u"
} else {
"/data/u"
};
let xdg = MapEnv::new()
.with("HOME", "/home/u")
.with("XDG_DATA_HOME", xdg_root);
assert_eq!(
standard_java_root(Platform::Linux, &xdg),
Some(PathBuf::from(xdg_root).join("java"))
);
// 相对路径的 XDG_DATA_HOME 按规范忽略 → 回落 HOME。
let relative = MapEnv::new()
.with("HOME", "/home/u")
.with("XDG_DATA_HOME", "data/u");
assert_eq!(
standard_java_root(Platform::Linux, &relative),
Some(unix_home.join(".local/share").join("java"))
);
assert_eq!(standard_java_root(Platform::Linux, &MapEnv::new()), None);
}
#[test]
fn portable_and_custom_modes_derive_the_documented_directories() {
let launcher = LauncherLayout::new("/data/.ULL");
let portable = JavaInstallRoot::portable(&launcher);
assert_eq!(portable.mode, LocationMode::Portable);
assert_eq!(
portable.jdk_dir(21, JdkVendor::Temurin, "21.0.13+9"),
PathBuf::from("/data/.ULL/data/java-managed/jdk-21-temurin"),
"便携模式必须保持 v1 布局(M33/M35 兼容)"
);
let custom = JavaInstallRoot::custom("/opt/java");
assert_eq!(custom.mode, LocationMode::Custom);
assert_eq!(
custom.jdk_dir(21, JdkVendor::Oracle, "21.0.2"),
PathBuf::from("/opt/java/oracle/21.0.2"),
"Custom 用 <vendor>/<version>(D23)"
);
assert_eq!(custom.cache_dir(), PathBuf::from("/opt/java/.cache"));
assert!(custom.describe().contains("custom"));
}
#[test]
fn version_strings_are_sanitized_for_directory_names() {
assert_eq!(sanitize_version("21.0.13+9"), "21.0.13+9");
assert_eq!(sanitize_version("21.0.13.0.2"), "21.0.13.0.2");
assert_eq!(sanitize_version("21.0.13+3"), "21.0.13+3");
assert_eq!(sanitize_version(" 21.0.2 "), "21.0.2");
assert_eq!(sanitize_version("../evil"), ".._evil");
assert_eq!(sanitize_version(""), "unknown");
}
#[test]
fn cleanup_removes_staging_and_empty_shells_but_never_foreign_dirs() {
let root = temp_dir("cleanup");
// 半成品:解压中断的暂存目录。
std::fs::create_dir_all(root.join(".staging-jdk-21-temurin")).expect("建暂存");
std::fs::write(root.join(".staging-jdk-21-temurin/bin"), b"x").expect("写暂存文件");
// 空壳 vendor 目录(Standard 布局的残留)。
std::fs::create_dir_all(root.join("oracle")).expect("建 vendor");
// 用户自己的目录(非空、无戳记):绝不能删。
std::fs::create_dir_all(root.join("my-own-jdk")).expect("建用户目录");
std::fs::write(root.join("my-own-jdk/notes.txt"), b"mine").expect("写用户文件");
// 归档缓存目录:保留。
std::fs::create_dir_all(root.join(".cache")).expect("建缓存");
let report = cleanup_orphans(&root);
assert!(
!root.join(".staging-jdk-21-temurin").exists(),
"暂存目录应删"
);
assert!(!root.join("oracle").exists(), "空 vendor 目录应删");
assert!(
root.join("my-own-jdk/notes.txt").is_file(),
"用户目录必须还在"
);
assert!(root.join(".cache").is_dir(), "缓存目录不能删");
assert_eq!(report.removed.len(), 2, "{:?}", report.removed);
let _ = std::fs::remove_dir_all(&root);
}
+106
View File
@@ -0,0 +1,106 @@
//! MC↔Java 映射:版本 JSON 的 `javaVersion` 优先(含继承链),上限取自静态表。
use std::path::PathBuf;
use unlucky_core::game::java::mapping;
fn temp_dir(tag: &str) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.as_nanos());
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"ull-java-{tag}-{}-{nanos}-{seq}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建临时目录");
dir
}
#[test]
fn version_json_java_version_beats_the_static_table() {
let root = temp_dir("mapping");
let layout = unlucky_core::game::GameLayout::new(&root);
let write = |id: &str, json: serde_json::Value| {
let dir = root.join("versions").join(id);
std::fs::create_dir_all(&dir).expect("建版本目录");
std::fs::write(
dir.join(format!("{id}.json")),
serde_json::to_vec(&json).expect("序列化"),
)
.expect("写版本 JSON");
};
// 1.17 的清单自己说是 16(静态表也是 16,但来源必须标 manifest)。
write(
"1.17.1",
serde_json::json!({"id": "1.17.1", "javaVersion": {"component": "java-runtime-alpha", "majorVersion": 16}}),
);
let manifest = mapping::requirement_for_version(&layout, "1.17.1");
assert_eq!(manifest.minimum, 16);
assert_eq!(manifest.source.as_str(), "manifest");
// 魔改实例覆盖父版本:21 应当生效。
write("1.20.1", serde_json::json!({"id": "1.20.1"}));
write(
"1.20.1-forge",
serde_json::json!({"id": "1.20.1-forge", "inheritsFrom": "1.20.1", "javaVersion": {"majorVersion": 21}}),
);
assert_eq!(
mapping::requirement_for_version(&layout, "1.20.1-forge").minimum,
21
);
// 清单没写 javaVersion:父版本也没有 → 静态表兜底。
let table = mapping::requirement_for_version(&layout, "1.20.1");
assert_eq!(table.minimum, 17);
assert_eq!(table.source.as_str(), "table");
// 1.12.2:清单只说「最低 8」,但上限(LaunchWrapper 只能 Java 8)由静态表补上。
write(
"1.12.2",
serde_json::json!({"id": "1.12.2", "javaVersion": {"component": "jre-legacy", "majorVersion": 8}}),
);
let legacy = mapping::requirement_for_version(&layout, "1.12.2");
assert_eq!((legacy.minimum, legacy.maximum), (8, Some(8)));
assert_eq!(legacy.source.as_str(), "manifest");
assert!(
mapping::check(21, &legacy).is_some(),
"1.12.2 + Java 21 必须报不符"
);
// 版本根本没装:按 id 走静态表,不报错。
let missing = mapping::requirement_for_version(&layout, "26.3");
assert_eq!(missing.minimum, 25);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn parent_java_version_is_inherited() {
let root = temp_dir("inherit");
let layout = unlucky_core::game::GameLayout::new(&root);
let write = |id: &str, json: serde_json::Value| {
let dir = root.join("versions").join(id);
std::fs::create_dir_all(&dir).expect("建版本目录");
std::fs::write(
dir.join(format!("{id}.json")),
serde_json::to_vec(&json).expect("序列化"),
)
.expect("写版本 JSON");
};
write(
"1.20.5",
serde_json::json!({"id": "1.20.5", "javaVersion": {"component": "java-runtime-delta", "majorVersion": 21}}),
);
write(
"1.20.5-fabric",
serde_json::json!({"id": "1.20.5-fabric", "inheritsFrom": "1.20.5"}),
);
let requirement = mapping::requirement_for_version(&layout, "1.20.5-fabric");
assert_eq!(requirement.minimum, 21);
assert_eq!(requirement.source.as_str(), "manifest");
let _ = std::fs::remove_dir_all(&root);
}
+96
View File
@@ -0,0 +1,96 @@
//! 镜像选源(D7 地区策略注入样本):国内镜像优先 / 国外官方优先,候选里都留兜底。
use unlucky_core::download::mirrors::MirrorTable;
use unlucky_core::download::region::RegionProfile;
use unlucky_core::download::ResourceKind;
#[test]
fn domestic_region_puts_college_mirrors_first() {
let table = MirrorTable::builtin();
let url = "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.13%2B9/OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip";
let domestic = table.expand(
url,
ResourceKind::JavaRuntime,
&RegionProfile::mirror_first(0),
);
assert_eq!(
domestic.first().map(|candidate| candidate.source.as_str()),
Some("tuna")
);
assert_eq!(
domestic.first().map(|candidate| candidate.url.as_str()),
Some("https://mirrors.tuna.tsinghua.edu.cn/Adoptium/21/jdk/x64/windows/OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip")
);
let sources: Vec<&str> = domestic
.iter()
.map(|candidate| candidate.source.as_str())
.collect();
// USTC/SJTU/BFSU 没有这套目录树(实测 404)、华为云是门户假阳性(M34 实测)
// → 清单里全停用,不该出现在候选里。
for dead in ["ustc", "sjtu", "bfsu", "huaweicloud"] {
assert!(!sources.contains(&dead), "{dead} 应已停用:{sources:?}");
}
assert_eq!(sources.last().copied(), Some("official"), "官方兜底在最后");
}
#[test]
fn foreign_region_keeps_official_first_but_still_has_mirrors() {
let table = MirrorTable::builtin();
let url = "https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.13%2B11/OpenJDK17U-jdk_aarch64_linux_hotspot_17.0.13_11.tar.gz";
let abroad = table.expand(url, ResourceKind::JavaRuntime, &RegionProfile::direct(0));
assert_eq!(
abroad.first().map(|candidate| candidate.source.as_str()),
Some("official")
);
assert_eq!(
abroad.first().map(|candidate| candidate.url.as_str()),
Some(url)
);
// 镜像仍然排在后面(国外也可能是镜像更快;健康度会让它上位)。
assert!(abroad.len() > 1);
assert!(abroad
.iter()
.any(|candidate| candidate.url.contains("/Adoptium/17/jdk/aarch64/linux/")));
}
#[test]
fn vendor_cdn_and_mirror_urls_are_labelled_by_their_real_host() {
let table = MirrorTable::builtin();
let direct = RegionProfile::direct(0);
// Oracle 走国内镜像时,直连 URL 就是华为云——不能标成 official(否则用户以为在啃官方 5.3KB/s)。
let oracle =
"https://mirrors.huaweicloud.com/openjdk/21.0.2/openjdk-21.0.2_windows-x64_bin.zip";
assert_eq!(
table
.expand(oracle, ResourceKind::JavaRuntime, &direct)
.first()
.map(|candidate| candidate.source.as_str()),
Some("huawei")
);
// MS 的 aka.ms 与 Zulu 的 CDN 同理。
let microsoft = "https://aka.ms/download-jdk/microsoft-jdk-21.0.13.0.2-windows-x64.zip";
assert_eq!(
table
.expand(microsoft, ResourceKind::JavaRuntime, &direct)
.first()
.map(|candidate| candidate.source.as_str()),
Some("microsoft")
);
let zulu = "https://cdn.azul.com/zulu/bin/zulu21.54.109-ca-jdk21.0.13.0.3-win_x64.zip";
assert_eq!(
table
.expand(zulu, ResourceKind::JavaRuntime, &direct)
.first()
.map(|candidate| candidate.source.as_str()),
Some("azul")
);
// Mojang 系资源仍是 official(别把官方源误标成厂商)。
let mojang = "https://piston-data.mojang.com/v1/objects/abc/client.jar";
assert_eq!(
table
.expand(mojang, ResourceKind::ClientJar, &direct)
.first()
.map(|candidate| candidate.source.as_str()),
Some("official")
);
}
+183
View File
@@ -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());
}
+143
View File
@@ -0,0 +1,143 @@
//! `.minecraft` 目录布局:路径规则、Maven 坐标、已安装版本枚举。
use std::path::PathBuf;
use unlucky_core::game::error::GameError;
use unlucky_core::game::layout::GameLayout;
use unlucky_core::game::library::MavenCoordinate;
use crate::helpers::TempDir;
fn layout() -> (TempDir, GameLayout) {
let temp = TempDir::new("layout");
let layout = GameLayout::new(temp.path().to_path_buf());
(temp, layout)
}
#[test]
fn version_paths_follow_mojang_layout() {
let (_temp, layout) = layout();
let root = layout.root();
assert_eq!(layout.versions_dir(), root.join("versions"));
assert_eq!(layout.version_dir("1.20.1"), root.join("versions/1.20.1"));
assert_eq!(
layout.version_json("1.20.1"),
root.join("versions/1.20.1/1.20.1.json")
);
assert_eq!(
layout.version_jar("1.20.1"),
root.join("versions/1.20.1/1.20.1.jar")
);
// ULL 扩展:实例私有状态与 natives 解压目录。
assert_eq!(
layout.version_state_dir("1.20.1"),
root.join("versions/1.20.1/.ull")
);
assert_eq!(
layout.natives_dir("1.20.1"),
root.join("versions/1.20.1/natives")
);
assert_eq!(
layout.version_libraries_dir("1.20.1"),
root.join("versions/1.20.1/libraries")
);
assert_eq!(layout.ull_root(), root.join(".ull"));
}
#[test]
fn asset_paths_use_two_level_sharding() {
let (_temp, layout) = layout();
let root = layout.root();
assert_eq!(layout.asset_index("5"), root.join("assets/indexes/5.json"));
let hash = "a3b3b1b0e0d8f2a8f0fbe1a4b0a8f6a1c6c6a7a2";
assert_eq!(
layout.asset_object(hash).expect("哈希合法"),
root.join("assets/objects/a3").join(hash)
);
assert_eq!(
layout.log_config("client-1.12.xml"),
root.join("assets/log_configs/client-1.12.xml")
);
let error = layout.asset_object("a").expect_err("短哈希应报错");
assert!(matches!(error, GameError::InvalidAssetHash { .. }));
}
#[test]
fn maven_coordinates_parse_and_lay_out() {
let coordinate = MavenCoordinate::parse("org.lwjgl:lwjgl:3.3.2").expect("坐标应可解析");
assert_eq!(coordinate.group, "org.lwjgl");
assert_eq!(coordinate.group_path(), "org/lwjgl");
assert_eq!(coordinate.file_name(), "lwjgl-3.3.2.jar");
assert_eq!(
coordinate.relative_path(),
PathBuf::from("org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2.jar")
);
let natives =
MavenCoordinate::parse("org.lwjgl:lwjgl:3.3.2:natives-windows-64").expect("带分类器");
assert_eq!(
natives.relative_path(),
PathBuf::from("org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-windows-64.jar")
);
let non_jar = MavenCoordinate::parse("net.java:jinput:1.0.0@zip").expect("带扩展名");
assert_eq!(non_jar.file_name(), "jinput-1.0.0.zip");
}
#[test]
fn invalid_coordinates_are_rejected() {
for raw in ["a:b", "a::1", "a:b:c:d:e"] {
let error = MavenCoordinate::parse(raw).expect_err("不合法坐标应报错");
assert!(
matches!(error, GameError::InvalidCoordinate { .. }),
"{raw}"
);
}
}
#[test]
fn library_paths_land_under_libraries() {
let (_temp, layout) = layout();
let root = layout.root();
let coordinate = MavenCoordinate::parse("com.mojang:logging:1.1.1").expect("坐标应可解析");
assert_eq!(
layout.library(&coordinate),
root.join("libraries/com/mojang/logging/1.1.1/logging-1.1.1.jar")
);
assert_eq!(
layout.version_library("1.20.1", &coordinate),
root.join("versions/1.20.1/libraries/com/mojang/logging/1.1.1/logging-1.1.1.jar")
);
}
#[test]
fn installed_versions_requires_manifest_file() {
let (_temp, layout) = layout();
assert!(
layout
.installed_versions()
.expect("缺目录视为空")
.is_empty(),
"目录不存在时不能报错"
);
let version_dir = layout.version_dir("1.20.1");
std::fs::create_dir_all(&version_dir).expect("建目录");
std::fs::write(layout.version_json("1.20.1"), "{}").expect("写清单");
// 只有 jar 没有 json 的目录不算已安装。
let broken = layout.version_dir("1.19.4");
std::fs::create_dir_all(&broken).expect("建目录");
std::fs::write(layout.version_jar("1.19.4"), b"jar").expect("写 jar");
assert!(layout.is_version_installed("1.20.1"));
assert!(!layout.is_version_installed("1.19.4"));
assert_eq!(
layout.installed_versions().expect("应可枚举"),
vec!["1.20.1".to_owned()]
);
}
+30
View File
@@ -0,0 +1,30 @@
//! `core::game` 的集成测试入口(阶段 A:先写好,mod.rs 接线在阶段 B 完成)。
//!
//! 每个子模块对应 mission 清单里的一项:
//! - `manifest_parse`:版本 JSON 解析与保真
//! - `inherit_chain`:inheritsFrom 解析 / 环检测 / 缺失父 / merge 语义 / patch 优先级
//! - `arguments_rules`:规则求值、参数展开、natives 选择
//! - `version_list`:清单 v2 解析、分类、排序、unlisted 合并
//! - `layout`:`.minecraft` 目录布局
//! - `snapshot`:不可变快照 + 单写者草稿
//! - `profiles`:实例清单与稳定 UUID
//! - `vulkan_capability`:版本库表 → `has_vulkan_backend` 能力位
mod arguments_rules;
mod helpers;
mod inherit_chain;
mod isolation;
mod isolation_switch;
mod java_discover;
mod java_install;
mod java_location;
mod java_mapping;
mod java_mirrors;
mod java_vendor_chain;
mod layout;
mod manifest_parse;
mod profiles;
mod snapshot;
mod unlisted;
mod version_list;
mod vulkan_capability;
+186
View File
@@ -0,0 +1,186 @@
//! 版本 JSON 解析:宽松解析、未知字段保真、旧版字段并存。
use unlucky_core::game::arguments::{Argument, ArgumentValue, RuleAction};
use unlucky_core::game::manifest::VersionJson;
use unlucky_core::game::version::ReleaseType;
use crate::helpers::parse;
const VANILLA_1_20_1: &str = r#"{
"id": "1.20.1",
"type": "release",
"time": "2023-06-12T13:25:51+00:00",
"releaseTime": "2023-06-12T13:25:51+00:00",
"complianceLevel": 1,
"minimumLauncherVersion": 21,
"mainClass": "net.minecraft.client.main.Main",
"assets": "5",
"assetIndex": {
"id": "5",
"sha1": "a3b3b1b0e0d8f2a8f0fbe1a4b0a8f6a1c6c6a7a2",
"size": 404297,
"totalSize": 598385820,
"url": "https://piston-meta.mojang.com/v1/packages/sha/5.json"
},
"javaVersion": { "component": "java-runtime-gamma", "majorVersion": 17 },
"downloads": {
"client": { "sha1": "abc", "size": 100, "url": "https://example.invalid/client.jar" },
"client_mappings": { "sha1": "def", "size": 50, "url": "https://example.invalid/mappings.txt" }
},
"arguments": {
"game": [
"--username",
"${auth_player_name}",
{ "rules": [{ "action": "allow", "features": { "has_custom_resolution": true } }],
"value": ["--width", "${resolution_width}"] }
],
"jvm": [
{ "rules": [{ "action": "allow", "os": { "name": "windows", "version": "^10\\." } }],
"value": "-Dos.name=Windows 10" }
]
},
"libraries": [
{ "name": "com.mojang:logging:1.1.1",
"downloads": { "artifact": { "path": "com/mojang/logging/1.1.1/logging-1.1.1.jar",
"sha1": "aaa", "size": 10,
"url": "https://example.invalid/logging.jar" } } }
],
"logging": {
"client": { "argument": "-Dlog4j.configurationFile=${path}",
"file": { "id": "client-1.12.xml", "sha1": "bbb", "size": 20,
"url": "https://example.invalid/log4j.xml" },
"type": "log4j2-xml" }
},
"tlauncherVersion": 1,
"MMC-hint": "local"
}"#;
#[test]
fn parses_full_version_json() {
let version = parse(VANILLA_1_20_1);
assert_eq!(version.id.as_deref(), Some("1.20.1"));
assert_eq!(version.version_type, Some(ReleaseType::Release));
assert_eq!(
version.main_class.as_deref(),
Some("net.minecraft.client.main.Main")
);
assert_eq!(version.compliance_level, Some(1));
assert_eq!(version.minimum_launcher_version, Some(21));
assert_eq!(version.assets_id(), "5");
let asset_index = version.asset_index.as_ref().expect("assetIndex 应解析");
assert_eq!(asset_index.id, "5");
assert_eq!(asset_index.total_size, Some(598_385_820));
let java = version.java_version.as_ref().expect("javaVersion 应解析");
assert_eq!(java.component.as_deref(), Some("java-runtime-gamma"));
assert_eq!(java.major_version, Some(17));
let downloads = version.downloads.as_ref().expect("downloads 应解析");
assert_eq!(downloads.len(), 2, "未知键也要保留在映射里");
assert_eq!(
downloads.client().and_then(|c| c.url.as_deref()),
Some("https://example.invalid/client.jar")
);
assert!(downloads.get("client_mappings").is_some());
}
#[test]
fn keeps_unknown_fields_verbatim() {
let version = parse(VANILLA_1_20_1);
// 第三方启动器私有字段(rawJson 保真,analyse/03 §4.2)。
assert_eq!(
version.extra.get("tlauncherVersion"),
Some(&serde_json::json!(1))
);
assert_eq!(
version.extra.get("MMC-hint"),
Some(&serde_json::json!("local"))
);
let roundtrip = version.to_json_string().expect("应可序列化");
let reparsed = VersionJson::from_json_str(&roundtrip).expect("应可再解析");
assert_eq!(reparsed.extra, version.extra);
}
#[test]
fn parses_arguments_with_rules() {
let version = parse(VANILLA_1_20_1);
let arguments = version.arguments.as_ref().expect("arguments 应解析");
assert_eq!(arguments.game.len(), 3);
assert_eq!(arguments.jvm.len(), 1);
assert!(matches!(&arguments.game[0], Argument::Plain(value) if value == "--username"));
let conditional = match &arguments.game[2] {
Argument::Conditional(cond) => cond,
Argument::Plain(_) => panic!("第三项应是条件参数"),
};
assert_eq!(conditional.rules.len(), 1);
assert_eq!(conditional.rules[0].action, RuleAction::Allow);
assert_eq!(
conditional.rules[0]
.features
.as_ref()
.and_then(|f| f.get("has_custom_resolution")),
Some(&true)
);
assert_eq!(
conditional.value,
ArgumentValue::Many(vec!["--width".into(), "${resolution_width}".into()])
);
// jvm 里的 os 限制带正则版本模式。
let jvm = match &arguments.jvm[0] {
Argument::Conditional(cond) => cond,
Argument::Plain(_) => panic!("jvm 第一项应是条件参数"),
};
let os = jvm.rules[0].os.as_ref().expect("应有 os 限制");
assert_eq!(os.name.as_deref(), Some("windows"));
assert_eq!(os.version.as_deref(), Some("^10\\."));
}
#[test]
fn parses_libraries_and_logging() {
let version = parse(VANILLA_1_20_1);
let libraries = version.libraries.as_ref().expect("libraries 应解析");
assert_eq!(libraries.len(), 1);
let artifact = libraries[0].artifact().expect("应有 artifact");
assert_eq!(
artifact.path.as_deref(),
Some("com/mojang/logging/1.1.1/logging-1.1.1.jar")
);
let logging = version.logging.as_ref().expect("logging 应解析");
let client = logging.client().expect("应有 client 日志配置");
assert_eq!(client.kind.as_deref(), Some("log4j2-xml"));
assert_eq!(client.file.id.as_deref(), Some("client-1.12.xml"));
}
#[test]
fn tolerant_defaults_for_minimal_json() {
let version = parse(r#"{ "id": "b1.7.3" }"#);
assert_eq!(version.id.as_deref(), Some("b1.7.3"));
assert_eq!(version.version_type, None, "未知 type 不报错");
assert_eq!(version.client_jar(), None, "老 JSON 没有 downloads.client");
assert_eq!(version.assets_id(), "legacy", "assets 缺失按 legacy");
assert!(!version.is_resolved());
}
#[test]
fn unknown_type_value_falls_back_to_unknown() {
let version = parse(r#"{ "id": "x", "type": "some_new_kind" }"#);
assert_eq!(version.version_type, Some(ReleaseType::Unknown));
}
#[test]
fn missing_id_is_reported() {
let version = parse(r#"{ "mainClass": "a.B" }"#);
let error = version.require_id().expect_err("缺 id 应报错");
assert_eq!(
error.to_string(),
"版本 JSON 缺少 id",
"错误文案要给用户看得懂"
);
}
+109
View File
@@ -0,0 +1,109 @@
//! 实例清单:稳定 UUID、增删查、落盘格式兼容。
use unlucky_core::game::profiles::{
InstanceProfile, ProfileId, ProfileList, PROFILE_SCHEMA_VERSION,
};
#[test]
fn generated_ids_are_unique_and_uuid_shaped() {
let ids: Vec<ProfileId> = (0..256).map(|_| ProfileId::generate()).collect();
for id in &ids {
assert!(id.is_uuid_shaped(), "id 形状不对: {id}");
}
let mut unique: Vec<&str> = ids.iter().map(ProfileId::as_str).collect();
let total = unique.len();
unique.sort_unstable();
unique.dedup();
assert_eq!(unique.len(), total, "同一批生成的 id 不能重复");
}
#[test]
fn id_shape_validation_rejects_junk() {
assert!(!ProfileId::new("not-a-uuid").is_uuid_shaped());
assert!(!ProfileId::new("").is_uuid_shaped());
assert!(ProfileId::new("7c9e6679-7425-40de-944b-e07fc1f90ae7").is_uuid_shaped());
}
#[test]
fn upsert_and_remove_keep_one_entry_per_id() {
let mut list = ProfileList::default();
assert_eq!(list.schema_version, PROFILE_SCHEMA_VERSION);
assert!(list.is_empty());
let mut profile = InstanceProfile::new("主世界", "1.20.1");
assert_eq!(list.upsert(profile.clone()), None, "首次插入没有旧值");
assert_eq!(list.len(), 1);
profile.name = "主世界(Fabric)".to_owned();
let replaced = list.upsert(profile.clone()).expect("同 id 应替换");
assert_eq!(replaced.name, "主世界");
assert_eq!(list.len(), 1);
assert_eq!(
list.get(&profile.id).map(|p| p.name.as_str()),
Some("主世界(Fabric)")
);
let removed = list.remove(&profile.id).expect("应可删除");
assert_eq!(removed.id, profile.id);
assert!(list.is_empty());
assert!(list.remove(&profile.id).is_none());
}
#[test]
fn profiles_group_by_version() {
let mut list = ProfileList::default();
list.upsert(InstanceProfile::new("a", "1.20.1"));
list.upsert(InstanceProfile::new("b", "1.20.1"));
list.upsert(InstanceProfile::new("c", "1.19.4"));
assert_eq!(list.for_version("1.20.1").len(), 2);
assert_eq!(list.for_version("1.19.4").len(), 1);
assert!(list.for_version("1.18.2").is_empty());
}
#[test]
fn roundtrips_profiles_json() {
let mut list = ProfileList::default();
let mut profile = InstanceProfile::new("整合包", "1.20.1-forge-47.2.0");
profile.running_dir = Some(std::path::PathBuf::from("D:/mc/runs/pack"));
profile
.extra
.insert("thirdPartyFlag".to_owned(), serde_json::json!(true));
list.upsert(profile.clone());
let json = list.to_json_string().expect("应可序列化");
let reparsed = ProfileList::from_json_str(&json).expect("应可再解析");
assert_eq!(reparsed, list);
assert_eq!(reparsed.schema_version, PROFILE_SCHEMA_VERSION);
}
#[test]
fn reads_minimal_profile_file() {
// 用户手改/旧版本写下的最小档案:只有三个必填语义字段,其它缺省。
let list = ProfileList::from_json_str(
r#"{ "profiles": [ { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"name": "旧档案", "version_id": "1.12.2" } ] }"#,
)
.expect("最小档案应可解析");
assert_eq!(
list.schema_version, PROFILE_SCHEMA_VERSION,
"schemaVersion 缺省补齐"
);
assert_eq!(list.len(), 1);
let profile = &list.profiles[0];
assert_eq!(profile.name, "旧档案");
assert!(profile.running_dir.is_none());
assert!(profile.id.is_uuid_shaped());
}
#[test]
fn invalid_profiles_json_reports_context() {
let error = ProfileList::from_json_str("{").expect_err("非法 JSON 应报错");
match error {
unlucky_core::game::error::GameError::Json { context, .. } => {
assert_eq!(context, "profiles")
}
other => panic!("期望 Json 错误,得到 {other:?}"),
}
}
+75
View File
@@ -0,0 +1,75 @@
//! 快照仓库:不可变读、单写者草稿、发布即指针交换。
use std::sync::Arc;
use unlucky_core::game::error::GameError;
use unlucky_core::game::snapshot::{Snapshot, Store};
#[test]
fn snapshot_survives_draft_publish() {
let store = Store::new(vec!["1.20.1".to_owned()]);
let before = store.snapshot();
let mut draft = store.open_draft().expect("首个草稿应可打开");
draft.edit().push("1.20.2".to_owned());
let published = draft.commit();
// 旧快照不变(读端遍历时不会看到半新半旧的状态)。
assert_eq!(before.get().len(), 1);
assert_eq!(published.get().len(), 2);
assert_eq!(store.snapshot().get().len(), 2);
}
#[test]
fn only_one_draft_at_a_time() {
let store = Store::new(0u32);
let draft = store.open_draft().expect("首个草稿应可打开");
match store.open_draft() {
Err(GameError::DraftBusy) => {}
other => panic!("第二个草稿必须被拒绝,得到 {other:?}"),
}
drop(draft);
let reopened = store.open_draft();
assert!(reopened.is_ok(), "放弃草稿后写权必须释放");
}
#[test]
fn dropping_draft_discards_changes() {
let store = Store::new(vec![1u32]);
{
let mut draft = store.open_draft().expect("草稿应可打开");
draft.edit().push(2);
}
assert_eq!(store.snapshot().get(), &vec![1u32]);
}
#[test]
fn replace_publishes_whole_table() {
let store = Store::new(vec!["old".to_owned()]);
let snapshot = store.replace(vec!["new".to_owned()]).expect("替换应成功");
assert_eq!(snapshot.get(), &vec!["new".to_owned()]);
assert_eq!(store.snapshot().get(), &vec!["new".to_owned()]);
}
#[test]
fn clones_share_one_allocation() {
let store = Store::new(vec![1u32]);
let first = store.snapshot();
let second = first.clone();
assert!(Arc::ptr_eq(&first.shared(), &second.shared()));
assert_eq!(first, second);
let published = store.replace(vec![2u32]).expect("替换应成功");
assert!(!Arc::ptr_eq(&first.shared(), &published.shared()));
assert_eq!(first.get(), &vec![1u32], "旧快照保持旧内容");
}
#[test]
fn snapshot_new_wraps_value() {
let snapshot = Snapshot::new("payload".to_owned());
assert_eq!(snapshot.get(), "payload");
assert_eq!(snapshot.clone().get(), "payload");
}
+90
View File
@@ -0,0 +1,90 @@
//! 内嵌补充清单 `unlisted-versions.json`:条目数、分类分布、合并规则。
//!
//! 这些断言同时是**数据完整性守门**:内嵌数据换版本时如果条目数/分类崩了,
//! 这里先红,而不是等用户在旧版列表里少看到几十个版本。
use unlucky_core::game::unlisted::{self, EMBEDDED_VERSION_COUNT};
use unlucky_core::game::version::{ReleaseType, RemoteVersion, VersionCategory, VersionList};
#[test]
fn embedded_list_has_expected_shape() {
let embedded = unlisted::versions();
assert_eq!(embedded.len(), EMBEDDED_VERSION_COUNT);
assert_eq!(
embedded.versions.len(),
unlisted::entries().len(),
"借用视图与清单一致"
);
assert!(embedded.latest.is_none(), "补充清单没有 latest 对象");
for entry in unlisted::entries() {
assert!(!entry.id.is_empty());
assert!(!entry.url.is_empty(), "每条都要有下载地址: {}", entry.id);
assert_ne!(
entry.version_type,
ReleaseType::Unknown,
"内嵌数据的 type 必须能归类: {}",
entry.id
);
}
}
#[test]
fn embedded_categories_match_analysis() {
// analyse/03 §2.3 实测口径:old_alpha 88 / old_beta 16 / snapshot 70 /
// pending 20 / unobfuscated 11。
let counts = |category: VersionCategory| {
unlisted::entries()
.iter()
.filter(|v| v.version_type.category() == category)
.count()
};
assert_eq!(counts(VersionCategory::Old), 88 + 16);
assert_eq!(counts(VersionCategory::Snapshot), 70);
assert_eq!(counts(VersionCategory::Pending), 20);
assert_eq!(counts(VersionCategory::Unobfuscated), 11);
assert_eq!(counts(VersionCategory::Release), 0);
}
#[test]
fn embedded_ids_are_unique() {
let mut ids: Vec<&str> = unlisted::entries().iter().map(|v| v.id.as_str()).collect();
let total = ids.len();
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), total, "同 id 重复会让合并结果不确定");
}
#[test]
fn merge_adds_missing_entries_only() {
let mut official = VersionList {
latest: None,
versions: vec![RemoteVersion {
id: "a1.1.1".to_owned(),
version_type: ReleaseType::OldAlpha,
url: "https://example.invalid/official-a1.1.1.json".to_owned(),
time: None,
release_time: None,
sha1: Some("official".to_owned()),
size: Some(1),
}],
};
let added = unlisted::merge_into(&mut official);
assert_eq!(added, EMBEDDED_VERSION_COUNT - 1, "只补官方缺的条目");
assert_eq!(official.len(), EMBEDDED_VERSION_COUNT);
// 同 id 冲突时官方条目胜出(我们与 HMCL 的差异,见 unlisted 模块注释)。
assert_eq!(
official.find("a1.1.1").map(|v| v.url.as_str()),
Some("https://example.invalid/official-a1.1.1.json")
);
}
#[test]
fn merge_into_empty_list_is_identity() {
let mut empty = VersionList::default();
assert!(empty.is_empty());
assert_eq!(unlisted::merge_into(&mut empty), EMBEDDED_VERSION_COUNT);
assert_eq!(empty, *unlisted::versions());
}
+137
View File
@@ -0,0 +1,137 @@
//! 版本清单(`version_manifest_v2.json`):解析、分类、排序、与补充清单合并。
use unlucky_core::game::error::GameError;
use unlucky_core::game::version::{ReleaseType, RemoteVersion, VersionCategory, VersionList};
const MANIFEST_V2: &str = r#"{
"latest": { "release": "1.20.1", "snapshot": "23w31a" },
"versions": [
{ "id": "1.20.1", "type": "release", "url": "https://example.invalid/1.20.1.json",
"time": "2023-06-12T13:25:51+00:00", "releaseTime": "2023-06-12T13:25:51+00:00",
"sha1": "aaa", "size": 1000 },
{ "id": "23w31a", "type": "snapshot", "url": "https://example.invalid/23w31a.json",
"time": "2023-08-02T12:00:00+00:00", "releaseTime": "2023-08-02T12:00:00+00:00",
"sha1": "bbb", "size": 900 },
{ "id": "b1.7.3", "type": "old_beta", "url": "https://example.invalid/b1.7.3.json",
"time": "2011-07-08T00:00:00+00:00", "releaseTime": "2011-07-08T00:00:00+00:00" }
]
}"#;
fn manifest() -> VersionList {
VersionList::from_json_str(MANIFEST_V2).expect("清单应可解析")
}
#[test]
fn parses_manifest_v2_with_checksums() {
let list = manifest();
assert_eq!(list.len(), 3);
let latest = list.latest.as_ref().expect("latest 应解析");
assert_eq!(latest.release.as_deref(), Some("1.20.1"));
assert_eq!(latest.snapshot.as_deref(), Some("23w31a"));
let release = list.find("1.20.1").expect("1.20.1 应存在");
assert!(release.is_verifiable());
assert_eq!(release.size, Some(1000));
// 内嵌旧清单没有 sha1/size(v1 形态)。
assert!(!list.find("b1.7.3").expect("b1.7.3 应存在").is_verifiable());
}
#[test]
fn classifies_release_types() {
assert_eq!(
ReleaseType::from_json_str("release").category(),
VersionCategory::Release
);
assert_eq!(
ReleaseType::from_json_str("old_beta").category(),
VersionCategory::Old
);
assert_eq!(
ReleaseType::from_json_str("old_alpha").category(),
VersionCategory::Old
);
assert_eq!(
ReleaseType::from_json_str("pending").category(),
VersionCategory::Pending
);
assert_eq!(
ReleaseType::from_json_str("unobfuscated").category(),
VersionCategory::Unobfuscated
);
assert_eq!(
ReleaseType::from_json_str("未见过").category(),
VersionCategory::Uncategorized
);
// 序列化回原始字符串,保证能原样写回清单。
assert_eq!(ReleaseType::OldBeta.as_json_str(), "old_beta");
assert_eq!(ReleaseType::Unknown.as_json_str(), "unknown");
}
#[test]
fn sorts_newest_first() {
let mut list = manifest();
list.sort_newest_first();
let ids: Vec<&str> = list.versions.iter().map(|v| v.id.as_str()).collect();
assert_eq!(ids, vec!["23w31a", "1.20.1", "b1.7.3"]);
}
#[test]
fn filters_by_ui_category() {
let list = manifest();
assert_eq!(list.filter_category(VersionCategory::Release).len(), 1);
assert_eq!(list.filter_category(VersionCategory::Snapshot).len(), 1);
assert_eq!(list.filter_category(VersionCategory::Old).len(), 1);
assert!(list.filter_category(VersionCategory::Pending).is_empty());
}
#[test]
fn merging_keeps_existing_entries() {
let mut list = manifest();
let added = list.merge_missing(vec![
// 同 id 冲突:已有条目(官方)胜出,URL 不被覆盖。
RemoteVersion {
id: "1.20.1".to_owned(),
version_type: ReleaseType::Release,
url: "https://mirror.invalid/1.20.1.json".to_owned(),
time: None,
release_time: None,
sha1: None,
size: None,
},
RemoteVersion {
id: "a1.1.1".to_owned(),
version_type: ReleaseType::OldAlpha,
url: "https://mirror.invalid/a1.1.1.json".to_owned(),
time: None,
release_time: None,
sha1: None,
size: None,
},
]);
assert_eq!(added, 1, "只应新增缺失条目");
assert_eq!(list.len(), 4);
assert_eq!(
list.find("1.20.1").map(|v| v.url.as_str()),
Some("https://example.invalid/1.20.1.json")
);
assert!(list.find("a1.1.1").is_some());
}
#[test]
fn roundtrips_manifest_json() {
let list = manifest();
let json = list.to_json_string().expect("应可序列化");
let reparsed = VersionList::from_json_str(&json).expect("应可再解析");
assert_eq!(reparsed, list);
}
#[test]
fn invalid_json_reports_context() {
let error = VersionList::from_json_str("not json").expect_err("非法 JSON 应报错");
match error {
GameError::Json { context, .. } => assert_eq!(context, "version_manifest"),
other => panic!("期望 Json 错误,得到 {other:?}"),
}
}
+125
View File
@@ -0,0 +1,125 @@
//! 版本能力位:从版本 JSON 的库表推 `has_vulkan_backend`。
//!
//! 事实基线(`research/05-mc-vulkan.md` §2.2,piston-meta 921 个版本逐版扫过):
//! `org.lwjgl:lwjgl-vulkan` 从 **26.2-snapshot-1** 起出现,26.1 全线(含 `26w14a`)没有。
//! 所以「库在不在」是判断版本带不带 Vulkan 后端的可靠依据。
use unlucky_core::game::capability::{
is_vulkan_library, library_names_have_vulkan, VersionCapabilities,
};
use unlucky_core::game::manifest::VersionJson;
use crate::helpers::parse;
/// 26.2-snapshot-1 的库表形态(Vulkan 三件套 + 常规库,实测结构)。
const VULKAN_ERA_JSON: &str = r#"{
"id": "26.2-snapshot-1",
"type": "snapshot",
"mainClass": "net.minecraft.client.main.Main",
"libraries": [
{ "name": "org.lwjgl:lwjgl:3.4.1", "downloads": { "artifact": { "path": "org/lwjgl/lwjgl/3.4.1/lwjgl-3.4.1.jar" } } },
{ "name": "org.lwjgl:lwjgl-vulkan:3.4.1", "downloads": { "artifact": { "path": "org/lwjgl/lwjgl-vulkan/3.4.1/lwjgl-vulkan-3.4.1.jar" } } },
{ "name": "org.lwjgl:lwjgl-vulkan:3.4.1:natives-macos" },
{ "name": "org.lwjgl:lwjgl-shaderc:3.4.1" },
{ "name": "com.mojang:brigadier:1.3.10" }
]
}"#;
/// 26.1(含 `26w14a` 周快照那一段)的库表:只有 opengl,没有 vulkan。
const PRE_VULKAN_JSON: &str = r#"{
"id": "26.1",
"type": "release",
"mainClass": "net.minecraft.client.main.Main",
"libraries": [
{ "name": "org.lwjgl:lwjgl:3.4.1" },
{ "name": "org.lwjgl:lwjgl-glfw:3.4.1" },
{ "name": "org.lwjgl:lwjgl-opengl:3.4.1" }
]
}"#;
#[test]
fn version_json_with_lwjgl_vulkan_carries_the_capability() {
let version = parse(VULKAN_ERA_JSON);
assert!(version.has_vulkan_backend());
let capabilities = VersionCapabilities::from_json(&version);
assert!(capabilities.has_vulkan_backend());
}
#[test]
fn version_json_without_lwjgl_vulkan_lacks_the_capability() {
let version = parse(PRE_VULKAN_JSON);
assert!(!version.has_vulkan_backend());
assert_eq!(
VersionCapabilities::from_json(&version),
VersionCapabilities::default()
);
}
#[test]
fn missing_libraries_field_is_not_a_capability() {
let version = parse(r#"{ "id": "custom", "mainClass": "a.B" }"#);
assert!(!version.has_vulkan_backend());
}
#[test]
fn coordinate_forms_are_all_recognised() {
// 主构件、带 classifier 的 natives、带 @ext 的写法都算命中(只比对 group:artifact)。
assert!(is_vulkan_library("org.lwjgl:lwjgl-vulkan:3.4.3"));
assert!(is_vulkan_library(
"org.lwjgl:lwjgl-vulkan:3.4.3:natives-macos"
));
assert!(is_vulkan_library("org.lwjgl:lwjgl-vulkan:3.4.3@jar"));
// 相邻但不该命中的(前缀相同 / group 不同)。
assert!(!is_vulkan_library("org.lwjgl:lwjgl-opengl:3.4.3"));
assert!(!is_vulkan_library("org.lwjgl:lwjgl:3.4.3"));
assert!(!is_vulkan_library("com.mojang:lwjgl-vulkan:1.0"));
assert!(!is_vulkan_library("lwjgl-vulkan"));
assert!(!is_vulkan_library(""));
}
#[test]
fn library_name_slice_helper_matches_the_json_path() {
let names = ["org.lwjgl:lwjgl:3.4.1", "org.lwjgl:lwjgl-vulkan:3.4.1"];
assert!(library_names_have_vulkan(names));
assert!(!library_names_have_vulkan(["org.lwjgl:lwjgl:3.4.1"]));
assert!(!library_names_have_vulkan(std::iter::empty()));
// 与 game 侧的库表判定同源:同一份 JSON 两条路径给同一结论。
let version = parse(VULKAN_ERA_JSON);
assert_eq!(
library_names_have_vulkan(
version
.libraries
.as_deref()
.unwrap_or_default()
.iter()
.map(|l| l.name.as_str())
),
version.has_vulkan_backend()
);
}
#[test]
fn capability_bit_is_not_the_parameter_gate() {
// 26.2-snapshot-1 已经有库,却还没有 `--graphicsBackend`(实测差两天)。
// 能力位是「必要条件」,下发门限另有精确版本门(见 launch 侧
// `graphics_backend_allowed`),两者不能被压成一个判断。
let snapshot_one = parse(VULKAN_ERA_JSON);
assert!(
snapshot_one.has_vulkan_backend(),
"26.2-snapshot-1 有 Vulkan 库"
);
assert_eq!(snapshot_one.id.as_deref(), Some("26.2-snapshot-1"));
}
#[test]
fn version_json_accessor_delegates_to_capability_module() {
for json in [VULKAN_ERA_JSON, PRE_VULKAN_JSON] {
let version: VersionJson = parse(json);
assert_eq!(
version.has_vulkan_backend(),
VersionCapabilities::from_json(&version).has_vulkan_backend()
);
}
}