- cli/cli/model.rs:147 `InstallArgs::loader` 文档补 `liteloader`(第六个可装取值), 并点明 `optifine` 是「认得出但装不了」→ `loader_unsupported`; - commands/instances/tests.rs:103 删掉「只在认得出但装不了的那两家会红」的旧口径, 改成真实理由(第三方前缀形状、没有 `<mc>-<kind>-<ver>` 段);:231 同句副本改成 「optifine 装不了 / liteloader 自 M222 起能装」; - addon/loader/liteloader.rs:44 `DEFAULT_REPO` 文档由「BMCLAPI 有 bmclapi-liteloader 改写规则」改为「直连官方」:该规则自 M225 起 mirrors.json 里是 enabled=false (镜像侧实测 404),并按 M225 finding 的方向补上「库走 CENTRAL_REPO」。 纯文档,零行为变化。
252 lines
11 KiB
Rust
252 lines
11 KiB
Rust
//! [`super`] 的单测:`instances list --json` 的 `loader` 字段 + 人类可读输出不变。
|
||
//!
|
||
//! 走的是真入口 [`crate::run`](`--data-dir` 钉在临时目录里),断言的是**用户/脚本拿到的那份
|
||
//! 输出**,不是内部函数返回值——这样 JSON 信封(`ok` / `data` / `warnings`)与人类行都在射程内。
|
||
//! 解析端本身(id → 加载器)的真值表在 `core::game::loader` 的测试里,这里只验「CLI 有没有把它
|
||
//! 如实搬到输出面上」,不重复抄一份 id 真值。
|
||
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
use std::time::{SystemTime, UNIX_EPOCH};
|
||
|
||
use serde_json::Value;
|
||
|
||
/// 一次性临时目录(`Drop` 时清理;cli 没有 dev-dependency 的 tempfile)。
|
||
struct TempDir(PathBuf);
|
||
|
||
impl TempDir {
|
||
fn new(tag: &str) -> Self {
|
||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||
let nanos = SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.map_or(0, |duration| duration.as_nanos());
|
||
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||
let path = std::env::temp_dir().join(format!(
|
||
"ull-instances-test-{tag}-{}-{seq}-{nanos}",
|
||
std::process::id()
|
||
));
|
||
std::fs::create_dir_all(&path).expect("建临时目录");
|
||
Self(path)
|
||
}
|
||
|
||
fn path(&self) -> &Path {
|
||
&self.0
|
||
}
|
||
|
||
fn arg(&self) -> String {
|
||
self.0.display().to_string()
|
||
}
|
||
}
|
||
|
||
impl Drop for TempDir {
|
||
fn drop(&mut self) {
|
||
let _ = std::fs::remove_dir_all(&self.0);
|
||
}
|
||
}
|
||
|
||
/// 写 `.ULL/config/profiles.json`(实例名 → 版本 id;id 用 UUID 形状,互不重复)。
|
||
fn write_profiles(data_root: &Path, profiles: &[(&str, &str)]) {
|
||
let dir = data_root.join(".ULL").join("config");
|
||
std::fs::create_dir_all(&dir).expect("建配置目录");
|
||
let entries: Vec<Value> = profiles
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(index, (name, version))| {
|
||
serde_json::json!({
|
||
"id": format!("11111111-2222-4333-8444-{:012}", index + 1),
|
||
"name": name,
|
||
"version_id": version,
|
||
})
|
||
})
|
||
.collect();
|
||
let document = serde_json::json!({ "schema_version": 1, "profiles": entries });
|
||
std::fs::write(
|
||
dir.join("profiles.json"),
|
||
serde_json::to_vec_pretty(&document).expect("序列化 profiles.json"),
|
||
)
|
||
.expect("写 profiles.json");
|
||
}
|
||
|
||
/// 跑一次 CLI,返回(退出码, stdout, stderr)。
|
||
fn run_cli(args: &[&str]) -> (i32, String, String) {
|
||
let owned: Vec<String> = args.iter().map(|arg| (*arg).to_string()).collect();
|
||
let mut out: Vec<u8> = Vec::new();
|
||
let mut err: Vec<u8> = Vec::new();
|
||
let code = crate::run(&owned, &mut out, &mut err);
|
||
(
|
||
code,
|
||
String::from_utf8_lossy(&out).into_owned(),
|
||
String::from_utf8_lossy(&err).into_owned(),
|
||
)
|
||
}
|
||
|
||
/// 跑 `instances list --json` 并返回 `data.instances` 数组。
|
||
fn list_instances(dir: &TempDir) -> Vec<Value> {
|
||
let (code, stdout, stderr) =
|
||
run_cli(&["instances", "list", "--json", "--data-dir", &dir.arg()]);
|
||
assert_eq!(code, 0, "stderr: {stderr}");
|
||
let value: Value = serde_json::from_str(&stdout).expect("输出不是 JSON");
|
||
assert_eq!(value["ok"], true, "{stdout}");
|
||
value["data"]["instances"]
|
||
.as_array()
|
||
.expect("instances 数组")
|
||
.clone()
|
||
}
|
||
|
||
/// 四家自家 id + 原版 + 两家第三方前缀:JSON 里的 `loader.kind` / `loader.version`。
|
||
///
|
||
/// 样本与 `core::game::loader` 的真值表**同源**(自家样本取自 `LoaderKind::version_id` 的
|
||
/// `<mc>-<kind>-<loader>` 口径,第三方样本取自 PCL 的真实 id 形状),但断言只钉在输出面上。
|
||
///
|
||
/// 抓得住:CLI 没调 `InstanceLoader::parse`(自己另算一遍或干脆不派生);种类 → 稳定 id 的
|
||
/// 词表被改(`neoforge` 写成 `neo_forge`);版本段被截断/连标记一起搬;`optifine` /
|
||
/// `liteloader` 被漏成 `vanilla`(这两家的 id 是第三方前缀形状,没有 `<mc>-<kind>-<ver>` 段,
|
||
/// 只按自家四种形状解析的实现在这两行会红)。
|
||
/// 抓不住:解析规则本身退化(那是 core 的单测射程);`version_id` 大小写/空格的边角;
|
||
/// 真实实例里 `libraries[]` 与 id 不一致的情形(本命令只看 id)。
|
||
#[test]
|
||
fn json_rows_expose_loader_kind_and_version() {
|
||
let dir = TempDir::new("loader");
|
||
let cases: [(&str, &str, &str); 7] = [
|
||
("Fabric 实例", "1.20.4-fabric-0.16.5", "fabric"),
|
||
("Quilt 实例", "1.20.4-quilt-0.27.0", "quilt"),
|
||
("Forge 实例", "1.20.1-forge-47.2.0", "forge"),
|
||
("NeoForge 实例", "1.21.1-neoforge-21.1.80", "neoforge"),
|
||
("原版实例", "1.20.1", "vanilla"),
|
||
("OptiFine 实例", "1.13.2-OptiFine_HD_U_E6", "optifine"),
|
||
("LiteLoader 实例", "1.12.2-LiteLoader", "liteloader"),
|
||
];
|
||
let expected_versions = ["0.16.5", "0.27.0", "47.2.0", "21.1.80", "", "HD_U_E6", ""];
|
||
let profiles: Vec<(&str, &str)> = cases
|
||
.iter()
|
||
.map(|(name, version_id, _)| (*name, *version_id))
|
||
.collect();
|
||
write_profiles(dir.path(), &profiles);
|
||
|
||
let rows = list_instances(&dir);
|
||
assert_eq!(rows.len(), cases.len(), "{rows:?}");
|
||
for (index, (name, version_id, kind)) in cases.iter().enumerate() {
|
||
let row = &rows[index];
|
||
assert_eq!(row["name"], *name, "{row}");
|
||
assert_eq!(row["version_id"], *version_id, "{row}");
|
||
assert_eq!(
|
||
row["loader"]["kind"], *kind,
|
||
"`{version_id}` 的 loader.kind 应是 `{kind}`:{row}"
|
||
);
|
||
assert_eq!(
|
||
row["loader"]["version"], expected_versions[index],
|
||
"`{version_id}` 的 loader.version:{row}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 反向锚:原版的 `loader` 是 `{"kind":"vanilla","version":""}`,**不缺键、不为 `null`**。
|
||
///
|
||
/// 这条钉住的是模块文档里的「约定一」:脚本可以无条件读 `row.loader.kind`。顺带把「原版 id
|
||
/// 不许被认成加载器」也验一遍——反面是 core 那边 `plain_version_ids_stay_vanilla` 的职责,
|
||
/// 这里验的是它有没有被 CLI 的翻译层改坏(例如把空版本当成「没解析出东西」而塞 `null`)。
|
||
///
|
||
/// 抓得住:有人把原版改回「省略 `loader` 键」或「给 `null`」(两种都会让 `row["loader"]["kind"]`
|
||
/// 变成 `null`);`loader` 对象里被塞进多余的键(下面的键集合断言)。
|
||
/// 抓不住:`vanilla` 与「id 里没痕迹的整合包」的区分——**用户视角两者本来就不可区分**,
|
||
/// 见模块文档「约定三」,只能靠读 `libraries[]`。
|
||
#[test]
|
||
fn vanilla_rows_keep_a_loader_object_never_null() {
|
||
let dir = TempDir::new("vanilla");
|
||
write_profiles(
|
||
dir.path(),
|
||
&[
|
||
("原版", "1.20.1"),
|
||
("快照", "23w45a"),
|
||
("预发布", "1.20.4-pre1"),
|
||
],
|
||
);
|
||
|
||
let rows = list_instances(&dir);
|
||
assert_eq!(rows.len(), 3, "{rows:?}");
|
||
for row in &rows {
|
||
let loader = row.get("loader").expect("原版也必须带 loader 键");
|
||
assert!(!loader.is_null(), "loader 不许是 null:{row}");
|
||
assert_eq!(loader["kind"], "vanilla", "{row}");
|
||
assert_eq!(loader["version"], "", "{row}");
|
||
let mut keys: Vec<&str> = loader
|
||
.as_object()
|
||
.expect("loader 是对象")
|
||
.keys()
|
||
.map(String::as_str)
|
||
.collect();
|
||
keys.sort_unstable();
|
||
assert_eq!(keys, ["kind", "version"], "loader 只有这两个键:{row}");
|
||
}
|
||
}
|
||
|
||
/// 向后兼容:人类可读输出逐字不变,`--json` 的旧字段一个没少。
|
||
///
|
||
/// 抓得住:把 `loader` 顺手塞进人类行(`instances list` 的人读格式被改);旧七个 JSON 键
|
||
/// 被删或改名(`last_played_at` 这类「看着没用」的最容易被顺手清掉)。
|
||
/// 抓不住:人类行的**空白/顺序**之外的美化(例如多打一行汇总)——下面按整段逐行比对,
|
||
/// 所以大部分改动会红,但只在两行之间加空行这类不涉及内容的变化仍可能被容忍(本测试用的是
|
||
/// 定长片段列表,多出来的行一定红;只有同位置改写才需要人看)。
|
||
#[test]
|
||
fn human_output_and_old_json_fields_are_unchanged() {
|
||
let dir = TempDir::new("compat");
|
||
write_profiles(dir.path(), &[("生存服", "1.13.2-OptiFine_HD_U_E6")]);
|
||
|
||
let (code, stdout, stderr) = run_cli(&["instances", "list", "--data-dir", &dir.arg()]);
|
||
assert_eq!(code, 0, "stderr: {stderr}");
|
||
let launcher_home = dir.path().join(".ULL");
|
||
let expected = format!(
|
||
"实例 1 个(数据根 {})\n \
|
||
生存服 [1.13.2-OptiFine_HD_U_E6] 载荷缺失 \
|
||
11111111-2222-4333-8444-000000000001\n\
|
||
已安装版本: (无)\n",
|
||
launcher_home.display()
|
||
);
|
||
assert_eq!(stdout, expected, "人类可读输出应逐字不变");
|
||
assert!(
|
||
!stdout.contains("loader") && !stdout.contains("OptiFine 版本"),
|
||
"人读输出里不该出现加载器派生列:{stdout}"
|
||
);
|
||
|
||
let rows = list_instances(&dir);
|
||
let row = &rows[0];
|
||
for key in [
|
||
"id",
|
||
"name",
|
||
"version_id",
|
||
"version_present",
|
||
"game_dir",
|
||
"java_path",
|
||
"last_played_at",
|
||
] {
|
||
assert!(row.get(key).is_some(), "旧键 `{key}` 不许删:{row}");
|
||
}
|
||
// 顺手钉住新键的值,避免「旧键都在、新键是空对象」这种假绿。
|
||
assert_eq!(row["loader"]["kind"], "optifine", "{row}");
|
||
}
|
||
|
||
/// `--json` 是事实上的对外契约:字段约定必须能在 `ull help instances` 里读到。
|
||
///
|
||
/// 抓得住:`loader` 字段被加进输出、契约说明被从 help 里删掉(只留在源码注释里等于没写——
|
||
/// 用户读不到);「识别口径 ≠ 安装口径」这条警告被顺手删掉(`optifine` 装不了、`liteloader`
|
||
/// 自 M222 起能装,两件事都只在 help 里说,JSON 契约里看不出来)。
|
||
/// 抓不住:措辞本身——下面只按键词(`loader` / `kind` / `version` / `vanilla` / `optifine` /
|
||
/// `install --loader`)检查,换个说法仍会绿;help 的排版正确性也不在射程内。
|
||
#[test]
|
||
fn help_documents_the_loader_field_contract() {
|
||
let topic = crate::cli::help::topic("instances").expect("help instances");
|
||
for needle in [
|
||
"loader",
|
||
"kind",
|
||
"version",
|
||
"vanilla",
|
||
"optifine",
|
||
"install --loader",
|
||
] {
|
||
assert!(
|
||
topic.contains(needle),
|
||
"help instances 缺 `{needle}`:\n{topic}"
|
||
);
|
||
}
|
||
}
|