Files
Unlucky-Launcher/crates/cli/tests/output_test.rs
T
AskaEth 944b1610c8 feat(core): LiteLoader 安装路线(官方版本表 + 从零合成 launchwrapper 清单)
按 M218 SURVEY-p1 §4.1 的清单接上 LiteLoader 的安装路线,不动 OptiFine。

- core: 新增 addon::loader::liteloader(官方 versions.json 解析 + 合成清单)与
  addon::loader::parent(父版本链读盘定位原版版本);LoaderKind 加 LiteLoader;
  LoaderRelease 加 parent_version_id、LoaderPlanInput::vanilla_installed → parent_installed;
  LoaderInstallPlan::build 改返回 Result(父版本缺失具名报错);LoaderSource::resolve_on
  支持指定父版本(仅 LiteLoader,其它组合具名 unsupported)。
- 继承边界(本 mission 第一步核实):arguments.game 父在前子在后追加,minecraftArguments
  子覆盖父,启动顺序 legacy(24) → arguments.game(25)。所以 tweakClass 只写 arguments.game,
  绝不写 minecraftArguments(会顶掉 Forge 1.12.2 的 FMLTweaker)。
- maven/fabric 的 `_ =>` 通配改成穷尽分支:非 maven 族不再静默回落 Forge 的仓库;
  maven::{metadata_url,installer_url} 改返回 Option<String>。
- presenter: supported() 收 LiteLoader、wizard_choices 六选、dto::instances 按关注点拆成
  instances/{loader,runtime,create,tests}.rs(原文件 395 行,动手前先拆)。
- cli: --loader 区分「认不出」(loader_unknown) 与「认得但装不了」(loader_unsupported);
  <version> 位写加载器版本 id 时按父版本处理(只有 liteloader 接受);help 只改 install 段。
- app-iced: 下载页加载器 chip 加 LiteLoader(1.5.2~1.12.2,两头按官方没有构建拒),
  chip 清单改为委派 Loader::ALL。
- 测试: 真实响应的裁剪夹具(dl.liteloader.com 与 repo.mumfrey.com 的 maven-metadata)
  驱动「解析→计划→落盘→继承→启动参数顺序」全链;Forge 父链有回归(FMLTweaker 必须还在
  且排在 LiteLoader 之前);父版本缺失/组合不支持/取消/坏表各自具名错误。
2026-11-10 19:34:09 +08:00

298 lines
9.7 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.
//! `--json` 输出形状与真实 core 轻量路径(只读命令 + dry-run)。
#[path = "support/mod.rs"]
mod support;
use support::{parse_json, run_cli, write_profiles, write_session, write_version, TempDir};
#[test]
fn version_json_has_os_summary() {
let (code, stdout, _) = run_cli(&["--version", "--json"]);
assert_eq!(code, 0);
let value = parse_json(&stdout);
assert_eq!(value["ok"], true);
// M82:自称的名字跟着被调用的二进制走(test harness 里是测试二进制名,`ull` 里是 `ull`);
// argv[0] → 名字的映射由 cli::program 的单测钉死。
assert_eq!(
value["data"]["name"],
unlucky_cli::cli::program::name(),
"{stdout}"
);
assert_eq!(value["data"]["version"], env!("CARGO_PKG_VERSION"));
// D19/D21:os 对象来自 core::platform::os_info(family + 两档 support)。
assert!(value["data"]["os"]["family"].is_string());
let support = value["data"]["os"]["support"].as_str().unwrap_or_default();
assert!(matches!(support, "one" | "unsupported"), "{support}");
}
#[test]
fn welcome_without_subcommand_prints_version_and_help() {
let (code, stdout, _) = run_cli(&[]);
assert_eq!(code, 0);
// banner 用被调用的二进制名(M82),不写死 `ull`。
let banner = format!("{} ", unlucky_cli::cli::program::name());
assert!(stdout.starts_with(&banner), "{stdout}");
assert!(stdout.contains("用法:"), "{stdout}");
let (code, stdout, _) = run_cli(&["--json"]);
assert_eq!(code, 0);
let value = parse_json(&stdout);
assert!(value["data"]["help"]
.as_str()
.unwrap_or_default()
.contains("用法:"));
}
#[test]
fn instances_list_json_shape() {
let dir = TempDir::new("instances");
write_version(dir.path(), "1.20.1");
write_profiles(dir.path(), &[("生存服", "1.20.1"), ("空实例", "1.19.4")]);
let (code, stdout, _) = run_cli(&["instances", "list", "--json", "--data-dir", &dir.arg()]);
assert_eq!(code, 0);
let value = parse_json(&stdout);
let instances = value["data"]["instances"]
.as_array()
.expect("instances 数组");
assert_eq!(instances.len(), 2);
assert_eq!(instances[0]["name"], "生存服");
assert_eq!(instances[0]["version_present"], true);
assert_eq!(instances[1]["version_present"], false);
let installed = value["data"]["installed_versions"]
.as_array()
.expect("已安装版本");
assert_eq!(installed.len(), 1);
assert_eq!(installed[0], "1.20.1");
}
#[test]
fn install_rejects_unknown_loader_before_touching_network() {
let dir = TempDir::new("install-loader");
// 样本必须是一个**任何词表里都没有**的拼法:`liteloader` 自 M222 起是受支持加载器,
// 拿它当「未知」会让这条用例变成真去拉 dl.liteloader.com 的联网测试。
let (code, stdout, _) = run_cli(&[
"install",
"1.20.1",
"--loader",
"ull-invalid-loader-test",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 1, "未知加载器是用户错误");
let value = parse_json(&stdout);
assert_eq!(value["error"]["code"], "loader_unknown");
assert!(
!dir.path()
.join(".ULL")
.join("config")
.join("profiles.json")
.exists(),
"拒绝时不该登记任何实例"
);
}
#[test]
fn install_reports_name_conflict_without_downloading() {
let dir = TempDir::new("install-conflict");
write_profiles(dir.path(), &[("生存服", "1.19.4")]);
// 同名但绑定别的版本 → 冲突,且这一步在联网/下载之前就返回。
let (code, stdout, _) = run_cli(&[
"install",
"1.20.1",
"--name",
"生存服",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 1);
let value = parse_json(&stdout);
assert_eq!(value["error"]["code"], "instance_exists");
assert!(
value["error"]["message"]
.as_str()
.unwrap_or_default()
.contains("1.19.4"),
"错误里应说明冲突版本:{stdout}"
);
}
#[test]
fn install_requires_a_version_id() {
let dir = TempDir::new("install-empty");
let (code, _, stderr) = run_cli(&["install", " ", "--data-dir", &dir.arg()]);
assert_eq!(code, 1);
assert!(stderr.contains("版本 id"), "stderr={stderr}");
}
#[test]
fn launch_dry_run_prints_assembled_command() {
let dir = TempDir::new("dryrun");
write_version(dir.path(), "1.20.1");
write_profiles(dir.path(), &[("生存服", "1.20.1")]);
let (code, stdout, _) = run_cli(&[
"launch",
"生存服",
"--dry-run",
"--username",
"Steve",
"--max-memory",
"2048",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0, "{stdout}");
let value = parse_json(&stdout);
let data = &value["data"];
assert_eq!(data["dry_run"], true);
assert_eq!(data["instance_found"], true);
let argv = data["argv"].as_array().expect("argv 数组");
let joined = argv
.iter()
.filter_map(|item| item.as_str())
.collect::<Vec<_>>()
.join(" ");
assert!(joined.contains("-Xmx2048"), "{joined}");
assert!(
joined.contains("net.minecraft.client.main.Main"),
"{joined}"
);
assert!(joined.contains("--username Steve"), "{joined}");
// 凭据绝不落到 dry-run 输出里。
assert!(!joined.contains("accessToken "), "{joined}");
assert!(data["classpath_entries"].as_u64().unwrap_or(0) >= 1);
}
#[test]
fn sessions_logs_and_diag_read_the_index() {
let dir = TempDir::new("sessions");
let sid = write_session(dir.path());
let (code, stdout, _) = run_cli(&["sessions", "list", "--json", "--data-dir", &dir.arg()]);
assert_eq!(code, 0);
let value = parse_json(&stdout);
let sessions = value["data"]["sessions"].as_array().expect("sessions 数组");
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0]["session_id"], sid);
assert_eq!(sessions[0]["exit_code"], 1);
let (code, stdout, _) = run_cli(&[
"logs",
&sid,
"--tail",
"1",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0);
let value = parse_json(&stdout);
let events = value["data"]["events"].as_array().expect("events 数组");
assert_eq!(events.len(), 1);
assert_eq!(events[0]["kind"], "stderr");
// 前缀匹配唯一即可(脚本里手打全 id 太长)。
let (code, stdout, _) = run_cli(&["diag", "20261030", "--json", "--data-dir", &dir.arg()]);
assert_eq!(code, 0, "{stdout}");
let value = parse_json(&stdout);
assert_eq!(value["data"]["session_id"], sid);
let candidates = value["data"]["diagnosis"]["candidates"]
.as_array()
.expect("candidates 数组");
assert!(!candidates.is_empty(), "OOM 日志应命中规则:{stdout}");
assert_eq!(value["data"]["diagnosis"]["needs_more_info"], false);
}
#[test]
fn logs_json_payload_is_redacted_like_human_output() {
// P2-1:`--json` 是机器消费路径,不该比人类输出拿到更多原文(主目录用户名段)。
let dir = TempDir::new("logs-redact");
let sid = write_session(dir.path());
let events = dir
.path()
.join(".ULL")
.join("data")
.join("agent")
.join("sessions")
.join("game")
.join(format!("{sid}.jsonl"));
let extra = serde_json::json!({
"v": 1, "seq": 9, "ts": "2026-10-30T10:15:07Z", "t_ms": 6000,
"kind": "stdout", "session_id": sid,
"payload": { "line": r"cat C:\Users\Bob\secret.txt failed", "level": "info" }
});
let mut text = std::fs::read_to_string(&events).expect("读事件流");
text.push_str(&serde_json::to_string(&extra).expect("序列化事件"));
text.push('\n');
std::fs::write(&events, text).expect("写事件流");
let (code, stdout, stderr) = run_cli(&["logs", &sid, "--json", "--data-dir", &dir.arg()]);
assert_eq!(code, 0, "{stderr}");
assert!(!stdout.contains("Bob"), "JSON 载荷泄出用户名:{stdout}");
assert!(stdout.contains("***"), "JSON 载荷未脱敏:{stdout}");
}
#[test]
fn config_get_set_round_trip() {
let dir = TempDir::new("config");
let data_dir = dir.arg();
let (code, stdout, _) = run_cli(&[
"config",
"get",
"ui.language",
"--json",
"--data-dir",
&data_dir,
]);
assert_eq!(code, 0);
let value = parse_json(&stdout);
assert_eq!(value["data"]["key"], "ui.language");
assert_eq!(value["data"]["value"], "zh_cn");
let (code, stdout, _) = run_cli(&[
"config",
"set",
"ui.language",
"en_us",
"--json",
"--data-dir",
&data_dir,
]);
assert_eq!(code, 0, "{stdout}");
let value = parse_json(&stdout);
assert_eq!(value["data"]["old"], "zh_cn");
assert_eq!(value["data"]["new"], "en_us");
let (_, stdout, _) = run_cli(&[
"config",
"get",
"ui.language",
"--json",
"--data-dir",
&data_dir,
]);
assert_eq!(parse_json(&stdout)["data"]["value"], "en_us");
}
#[test]
fn error_envelope_shape() {
let dir = TempDir::new("errors");
let (code, stdout, _) = run_cli(&["launch", "nope", "--json", "--data-dir", &dir.arg()]);
assert_eq!(code, 1);
let value = parse_json(&stdout);
assert_eq!(value["ok"], false);
assert_eq!(value["error"]["code"], "not_found");
assert_eq!(value["error"]["kind"], "user");
assert!(value["error"]["message"]
.as_str()
.unwrap_or_default()
.contains("nope"));
assert!(value["error"]["hint"].is_string());
}