Files
Unlucky-Launcher/crates/cli/tests/pack_test.rs
T
AskaEth 4e1323212b fix(coexist): 复审 r1 返工——帮助文案跟上新落点 + 「替换了清单」进人读通道 + 导入建实例绑到内容目录(M216 P2/P3/§5)
P2-1 ② 改了缺省落点,CLI 自己的文档还在写旧行为
- `cli/help.rs`「目标目录缺省 = 数据根的共享游戏目录(.minecraft/)」→ 新实例目录
  `<数据根>/instances/<实例名>`(并登记实例);
- `cli/model.rs` 的 `PackImportArgs::target` 字段文档同步。
- 用例 `help_describes_the_new_default_target`(断言帮助含新落点、不含旧落点)。

P2-2 ③ 的「显式说明」进不了任何人读通道
- CLI:`human_lines` 原只遍历 `plan.warnings`,而运行期告警(含「已替换版本清单 …旧的那份
  备份在 …」)在 `report.warnings` 里 ⇒ `ull install` 人读输出从没说过这句话。现在有 report 就
  报 report.warnings(它开头就是 plan 告警的副本),`manifest=Replaced` 再单独打一行备份路径。
- GUI:`install.rs` 新增 `Job::journal_report`(把 `report.warnings` 与 `manifest` 的备份路径写进
  安装日志),`install/routes.rs` 两处 `ensure_complete` 之前各加一行调用(tower 批的两行)。
  此前 `report.manifest` 全仓零消费者。
- 用例 `a_replaced_manifest_is_said_out_loud_in_the_human_output`(走真 `InstallPlan` 排版)。

P3-1 缺省目录被占的 hint 删掉不成立的 `--overwrite` 那半句(它绕过不了这条预检)。
P3-2 GUI 落点改成委托 core 的 `pack_dir::instance_dir`(此前 presenter 自己拼 `data()/instances/…`);
`convert::dir_name` 缩成 `#[cfg(test)]` 的委托入口(生产侧已无调用点,留着继续钉归一化判据)。
P3-3 补 `the_default_target_is_a_new_instance_dir_and_the_instance_is_bound_to_it`:不带 `--target`
跑通整条 CLI,断言落点、内容、登记、以及实例设置里的 `isolation`/`gameDir`。

§5 导入建出来的实例原先「导入成功但内容永远不会被启动用到」
- 新增 `pack_dir::bind_instance_dir`(`InstanceConfig.isolation = Custom` + `game_dir = 导入目录`)
  与 `pack_dir::bind_created_instance`(判据+文案,CLI 与 GUI 同一份);
- CLI 在缺省建实例后、GUI 在 `import.rs::run` 里各调一次;只在「我们新建了实例目录且档案是新建」
  时绑(显式 `--target` / `--no-register` / 复用已有档案都不动);
- 运行目录 = 导入目录(saves/mods/logs/config),**versions/libraries/assets 仍按 D20 恒共享**;
- 未改 `derive_game_dir`。
- 用例:正向 `binding_an_imported_instance_points_it_at_the_import_dir`、反向
  `a_freshly_registered_instance_still_inherits_the_global_mode`。
2026-11-09 19:46:41 +08:00

287 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `ull pack import` 的三态契约:退出码 0 / 3 / 1 + `--json` 信封恒有结构。
//!
//! **全部离线、全部不依赖 zip**:
//!
//! - MultiMC / Prism 的真实形态就是**目录**,直接拷 → `0`;
//! - mrpack 也能以**目录**打开(`PackArchive` 对目录与 zip 一视同仁),造一条 `downloads` 为空的
//! `files[]` 条目 ⇒ 补不出来 ⇒ `3`,不会发任何请求;
//! - 坏包用**一个不是 zip 的普通文件** ⇒ 开包即拒 ⇒ `1`(具体的恶意样本分类在 core 的
//! `addon/tests/import_failures.rs`,这里只钉 CLI 的退出码与 JSON 契约)。
#[path = "support/mod.rs"]
mod support;
use std::path::{Path, PathBuf};
use serde_json::Value;
use support::{run_cli, TempDir};
/// 写一个文件(自动建父目录)。
fn write_file(path: &Path, bytes: &[u8]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("建目录");
}
std::fs::write(path, bytes).expect("写文件");
}
/// 造一个最小 MultiMC / Prism 实例目录。
fn multimc_instance(root: &Path) -> PathBuf {
let instance = root.join("mmc");
write_file(
&instance.join("mmc-pack.json"),
br#"{"formatVersion": 1, "components": [
{"uid": "net.minecraft", "version": "1.20.1"},
{"uid": "net.fabricmc.fabric-loader", "version": "0.15.11"},
{"uid": "org.lwjgl3", "version": "3.3.2"}]}"#,
);
write_file(
&instance.join("instance.cfg"),
b"InstanceType=OneSix\nname=cli pack\n",
);
write_file(&instance.join(".minecraft/mods/a.jar"), b"jar bytes");
instance
}
/// 造一个目录形式的 mrpack:一条远端条目,但 `downloads` 为空(补不出来 ⇒ 缺文件)。
fn mrpack_directory(root: &Path) -> PathBuf {
let source = root.join("mr-src");
write_file(
&source.join("modrinth.index.json"),
br#"{"game": "minecraft", "formatVersion": 1, "versionId": "1.0.0",
"name": "cli mrpack", "summary": "dir-form mrpack",
"files": [{"path": "mods/ghost.jar",
"hashes": {"sha1": "0123456789abcdef0123456789abcdef01234567",
"sha512": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"},
"env": {"client": "required", "server": "required"},
"downloads": [], "fileSize": 11}],
"dependencies": {"minecraft": "1.20.1", "fabric-loader": "0.15.11"}}"#,
);
write_file(&source.join("overrides/config/known.toml"), b"k = 1\n");
source
}
fn parse_json(stdout: &str) -> Value {
serde_json::from_str(stdout)
.unwrap_or_else(|error| panic!("stdout 不是合法 JSON({error}):{stdout}"))
}
#[test]
fn imports_a_multimc_instance_and_exits_zero() {
let data = TempDir::new("pack-ok");
let source = multimc_instance(data.path());
let target = data.path().join("inst");
let (code, stdout, stderr) = run_cli(&[
"pack",
"import",
&source.display().to_string(),
"--target",
&target.display().to_string(),
"--data-dir",
&data.arg(),
"--no-register",
"--json",
]);
assert_eq!(code, 0, "stdout={stdout} stderr={stderr}");
let value = parse_json(&stdout);
assert_eq!(value["ok"], Value::Bool(true));
assert_eq!(value["data"]["status"], Value::String("success".into()));
assert_eq!(value["data"]["format"], Value::String("multimc".into()));
assert_eq!(
value["data"]["loader"]["kind"],
Value::String("fabric".into())
);
// 走过的阶段都记下来了。
let stages = value["data"]["stages"].as_array().expect("stages 数组");
assert!(stages.iter().any(|stage| stage == "probe"), "{stages:?}");
assert!(stages.iter().any(|stage| stage == "commit"), "{stages:?}");
// 内容真的落地了。
assert_eq!(
std::fs::read(target.join("mods/a.jar")).expect("a.jar"),
b"jar bytes"
);
// 未支持的 component 逐项进 JSON。
assert_eq!(
value["data"]["rejected_components"][0]["uid"],
Value::String("org.lwjgl3".into())
);
}
#[test]
fn missing_files_make_it_partial_and_exit_three() {
let data = TempDir::new("pack-partial");
let source = mrpack_directory(data.path());
let target = data.path().join("inst");
let (code, stdout, stderr) = run_cli(&[
"pack",
"import",
&source.display().to_string(),
"--target",
&target.display().to_string(),
"--data-dir",
&data.arg(),
"--no-register",
"--json",
]);
assert_eq!(
code, 3,
"部分成功是 3,不是 0/1:stdout={stdout} stderr={stderr}"
);
let value = parse_json(&stdout);
assert_eq!(value["data"]["status"], Value::String("partial".into()));
assert_eq!(
value["data"]["missing"].as_array().map(Vec::len),
Some(1),
"补不出来的条目要如实记缺:{stdout}"
);
// 覆盖内容照装。
assert!(target.join("config/known.toml").is_file());
}
#[test]
fn failed_import_exits_one_and_still_emits_structured_json() {
let data = TempDir::new("pack-fail");
let pack = data.path().join("broken.zip");
// 一个根本不是 zip 的文件:开包即拒。
write_file(&pack, b"this is not a zip archive");
let target = data.path().join("inst");
let (code, stdout, stderr) = run_cli(&[
"pack",
"import",
&pack.display().to_string(),
"--target",
&target.display().to_string(),
"--data-dir",
&data.arg(),
"--no-register",
"--json",
]);
assert_eq!(code, 1, "stdout={stdout} stderr={stderr}");
// 失败也**必须**是结构化 JSON(不抛栈、不打人类可读文本到 stdout)。
let value = parse_json(&stdout);
assert_eq!(value["ok"], Value::Bool(true));
assert_eq!(value["data"]["status"], Value::String("failed".into()));
assert!(
value["data"]["error"]["code"].is_string(),
"失败要带稳定码:{stdout}"
);
assert_eq!(value["data"]["rolled_back"], Value::Bool(true));
// 零残留。
assert!(!target.exists(), "致命失败后目标目录必须不存在");
// 失败也不是"崩了":stderr 里不该出现 panic 之类的栈信息。
assert!(!stderr.contains("panicked"), "{stderr}");
}
#[test]
fn other_commands_still_exit_zero() {
// `Outcome::exit_code` 是给 pack import 用的;其它命令的行为不许变。
let data = TempDir::new("pack-regression");
let (code, stdout, _) = run_cli(&["instances", "list", "--data-dir", &data.arg()]);
assert_eq!(code, 0, "{stdout}");
let (code, _, _) = run_cli(&["--help"]);
assert_eq!(code, 0);
let (code, _, _) = run_cli(&["config", "get", "--data-dir", &data.arg()]);
assert_eq!(code, 0);
}
#[test]
fn help_lists_the_pack_command() {
let (code, stdout, _) = run_cli(&["help", "pack"]);
assert_eq!(code, 0);
assert!(stdout.contains("pack import"), "{stdout}");
assert!(stdout.contains("部分成功"), "{stdout}");
let (code, stdout, _) = run_cli(&["--help"]);
assert_eq!(code, 0);
assert!(stdout.contains("pack import"), "总帮助里也要有:{stdout}");
}
/// **M216 P3-3 + §5**:不带 `--target` 时落点是**新实例目录**(不是共享 `.minecraft` 根),
/// 而且登记出来的实例被绑到那个目录(`isolation=custom` + `gameDir`),否则导入内容永远不会
/// 被启动用到。
///
/// 抓得住:CLI 自己兜底回 `ctx.game().root()`(第一条断言会变成 `.minecraft`);不建实例目录而
/// 直接铺共享根;不做绑定(`isolation` 仍是 `null`);绑定写错目录。抓不住:真实启动那一条
/// `derive_game_dir` 的派生(core 的 `isolation.rs` 用例钉,这里只断言设置文件的内容)。
#[test]
fn the_default_target_is_a_new_instance_dir_and_the_instance_is_bound_to_it() {
let data = TempDir::new("pack-default-target");
let source = multimc_instance(data.path());
let (code, stdout, stderr) = run_cli(&[
"pack",
"import",
&source.display().to_string(),
"--name",
"My Pack",
"--data-dir",
&data.arg(),
"--json",
]);
assert_eq!(code, 0, "stdout={stdout} stderr={stderr}");
let instance_dir = data.path().join(".ULL/data/instances/My-Pack");
assert!(
instance_dir.join("mods/a.jar").is_file(),
"缺省落点应当是 <data>/instances/My-Pack:{stdout}"
);
assert!(
!data.path().join(".minecraft/mods/a.jar").exists(),
"不许再往共享 .minecraft 根铺:{stdout}"
);
// 登记的实例被绑到这个目录(§5)。
let profiles: Value = serde_json::from_str(
&std::fs::read_to_string(data.path().join(".ULL/config/profiles.json"))
.expect("profiles.json"),
)
.expect("profiles.json 是 JSON");
assert_eq!(
profiles["profiles"][0]["name"],
Value::String("My Pack".into())
);
let id = profiles["profiles"][0]["id"]
.as_str()
.expect("登记出来的实例有 id")
.to_string();
let config: Value = serde_json::from_str(
&std::fs::read_to_string(
data.path()
.join(".ULL/config/instances")
.join(format!("{id}.json")),
)
.expect("实例设置文件"),
)
.expect("实例设置是 JSON");
assert_eq!(
config["isolation"],
Value::String("custom".into()),
"isolation 应当是 custom:{config}"
);
assert_eq!(
config["gameDir"].as_str().map(Path::new),
Some(instance_dir.as_path()),
"gameDir 应当指向导入目录:{config}"
);
}
/// **M216 P2-1**:`--help` 里的缺省落点必须与新行为一致(写旧行为 = 用户照读会以为文件进了
/// `.minecraft`)。
///
/// 抓得住:文案退回「共享游戏目录」;新落点没写进去。抓不住:`model.rs` 的字段文档
/// (那是编译期文档,测试读不到)——所以断言落在这里只覆盖用户能看见的那一份。
#[test]
fn help_describes_the_new_default_target() {
let (code, stdout, _) = run_cli(&["help", "pack"]);
assert_eq!(code, 0);
assert!(
stdout.contains("instances/<实例名>"),
"帮助里没写新缺省落点:{stdout}"
);
assert!(
!stdout.contains("缺省 = 数据根的共享游戏目录"),
"帮助里还留着旧缺省落点:{stdout}"
);
}