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
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:
commit
8a8e5ca0c0
1079 files changed
+208035
No files matched your search
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user