Files
Unlucky-Launcher/crates/core/tests/adopt/plan_cases.rs
T
AskaEth 9f5c001387 feat(game): 从其他启动器只读登记已有版本(ull instances scan|adopt)+ 登记可一并定隔离档(M221)
盘上已经铺好的版本(多半是 PCL/HMCL 装的)今天在 ULL 里看不见,用户只能重装一遍。这个
commit 把它们登记成实例,**只读外部、只写自己的配置**:不搬文件、不补清单、不改任何外部
文件的 mtime(versions/ libraries/ assets/ 一个字节都不写)。

core(`game::adopt`,新模块):
- `scan` / `inspect`:只读扫描 + 逐项检查,坏 JSON(带行列号)/ 缺 id / id 与目录名不一致 /
  缺父 / 成环 / 读不出来 / 越界 / 同名冲突一律**结构化**报错(稳定 `code`),不吞成空列表;
  `versions/` 不存在才是空结果,读不了就是错误;
- 越界判据用 canonicalize 后的前缀比较,同时覆盖**符号链接、Windows 目录联接(junction)与
  祖先链接**(junction 不需要管理员权限,是最常见的「把别人版本目录挂进来」);越界条目在
  边界检查就停手,一个字节都不去读;
- `plan`(零写盘,喂 dry-run 与后续 GUI 预检)/ `adopt`(plan + 只写自己的配置):
  **缺省运行目录落 ULL 数据区 `<data>/instances/<实例目录名>`(custom 档),绝不沿用全局
  `isolated`**——那条缺省的运行目录正是 `versions/<versionId>/`,会在他人的版本目录里长出
  mods/saves/config;`shared` / `isolated` 必须显式选,且 `target_notes()` 给出后果话术;
- 缺省运行目录已存在且非空 ⇒ 拒绝(不覆盖、不猜「合并进已有实例」);数据区里已有的祖先
  若解析到数据根之外(符号链接/联接)⇒ 拒绝,不沿它写盘;
- 登记与隔离档一次写清:`install/instance` 拆出 `register` 子模块,新增
  `register_with(InstanceSettings)`;写盘顺序改成「先实例设置、后 profiles」(中断只留孤立
  设置文件,不会留下按全局档启动的实例),profiles 写失败会撤掉刚写的设置。`register`
  签名与行为不变(`register` = `register_with(inherit)`;同版本同名的幂等复用照旧)。

CLI:
- `ull instances scan`(只读列出候选与原因)、`ull instances adopt <version-id> [--name N]
  [--isolation shared|isolated|custom] [--game-dir DIR] [--dry-run]`;`--game-dir` 单独给即
  custom,相对路径在解析期就报用法错误;
- 人读/`--json`/help 三条出口齐备(`error.detail.problems` 逐条可分流),已有命令未改。

测试(真磁盘夹具,`cargo test -p unlucky-core --test adopt` 22 passed /
`cargo test -p unlucky-cli --test adopt_test` 10 passed):
- 一份「别的启动器铺好的」`.minecraft`(versions/libraries/assets/mods/saves/config +
  launcher_profiles.json),扫描与登记前后做「路径集合 + 字节数 + 内容哈希 + mtime」全等断言;
- 正常 / 缺清单 / 坏 JSON / 缺父 / 成环 / id 不匹配 / 非法 id / 符号链接越界 / 根内符号链接
  放行 / **junction 越界** / 数据区 junction 写盘边界 / 缺省非 isolated / shared 与 isolated
  的话术 / 重名 / 重复 / 非空目录 / 空目录复用 / dry-run 零写盘;
- CLI 侧验 `launch --dry-run` 的 `--gameDir` 指向选定运行目录(argv 已脱敏,比尾段;
  `data.game_dir` 全等);
- 变异核对(只改实现、跑完即回滚):缺省档改 `VersionDir` ⇒ 红;边界判据摘掉 ⇒ 符号链接与
  junction 两条红;写盘边界摘掉 ⇒ 数据区 junction 那条红;缺父当链终点 ⇒ 两条红。

界限:没有启动真实 Minecraft;GUI(presenter/app-iced)接线另派;D20 单游戏根模型未改
(不扫第二个 `.minecraft`)。
2026-11-09 22:21:37 +08:00

113 lines
4.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.
//! `plan` / `inspect` 的**只读**判据与拒绝理由:零写盘、缺父/不存在/非法目标的报错形状。
//!
//! 与 `register_cases` 的分工:那边验「登记写出了什么」,这边验「还没登记时算出了什么、拒绝得对不对」。
use std::path::PathBuf;
use unlucky_core::game::adopt::{self, AdoptError, AdoptTarget};
use unlucky_core::game::install::instance::Isolation;
use crate::fixture::{assert_unchanged, foreign_minecraft, request, snapshot, TempRoot};
/// 缺省档位本身就是「数据区独立目录」,而且**不是** `isolated`(判据的单一真相源)。
///
/// 抓得住:把 `#[default]` 挪到 `VersionDir`/`Shared`(第一条断言红),或让缺省档映射到
/// `Isolated`(第二条断言红)——后者正是「导入别人的版本后第一次启动就在他的 `versions/<id>/`
/// 里长 mods/saves」的那个坑。
/// 抓不住:CLI 是否真的用 `AdoptTarget::default()`(那条由 cli/adopt_test 的行为断言钉着)。
#[test]
fn the_default_target_is_an_own_dir_not_the_global_isolated_mode() {
let target = AdoptTarget::default();
assert_eq!(target, AdoptTarget::InstanceDir);
assert_eq!(target.as_str(), "instance_dir");
assert_eq!(target.isolation(), Isolation::Custom);
assert_ne!(target.isolation(), Isolation::Isolated);
}
/// `--isolation custom --game-dir <相对路径>` ⇒ 明确报错(不猜、不悄悄改成绝对路径)。
///
/// 抓得住:相对路径被默默接受(`TargetInvalid` 变成成功)。
/// 抓不住:Windows 上 `C:foo` 这种「带盘符但非绝对」的形状,只看 `is_absolute`。
#[test]
fn custom_target_requires_an_absolute_directory() {
let root = TempRoot::new("custom-rel");
foreign_minecraft(&root.game_root());
let error = adopt::adopt(
&root.game(),
&root.launcher(),
&request("1.20.1", AdoptTarget::Custom(PathBuf::from("relative/dir"))),
)
.expect_err("相对路径应被拒");
assert_eq!(error.code(), "adopt_target_invalid");
assert!(error.to_string().contains("绝对路径"), "{error}");
}
/// `--dry-run` 的 core 形态(`plan`):一个文件都不写、目录也不建。
///
/// 抓得住:`plan` 顺手建了运行目录或写了档案(两种断言都会红)。
/// 抓不住:CLI 的 `--dry-run` 是否真的只调 `plan`(那条在 cli 侧用例里)。
#[test]
fn plan_writes_nothing_at_all() {
let root = TempRoot::new("plan");
foreign_minecraft(&root.game_root());
let before = snapshot(root.path());
let plan = adopt::plan(
&root.game(),
&root.launcher(),
&request("1.20.1", AdoptTarget::InstanceDir),
)
.expect("计划应成功");
let after = snapshot(root.path());
assert_unchanged(&before, &after, "plan 之后(含 .ULL)");
assert!(plan.candidate.adoptable());
assert!(!plan.run_dir.exists(), "plan 不该建目录");
assert!(!root.path().join(".ULL").exists(), "plan 不该建配置目录");
}
/// 不可登记的版本(缺父)⇒ 一条都不登记,问题清单原样带出。
///
/// 抓得住:缺父的版本被登记(启动必失败却显示「导入成功」)、问题被折叠成一句空话。
/// 抓不住:用户在原来的启动器里修好之后重试(那条由重试路径自然覆盖)。
#[test]
fn not_adoptable_versions_are_refused_with_their_reasons() {
let root = TempRoot::new("refuse");
foreign_minecraft(&root.game_root());
let error = adopt::adopt(
&root.game(),
&root.launcher(),
&request("1.20.1-orphan", AdoptTarget::InstanceDir),
)
.expect_err("缺父版本应被拒");
assert_eq!(error.code(), "adopt_not_adoptable");
let codes: Vec<&str> = error
.problems()
.iter()
.map(|problem| problem.code())
.collect();
assert_eq!(codes, vec!["missing_parent"]);
assert!(
!root.path().join(".ULL/config/profiles.json").exists(),
"拒绝时不许留档案"
);
}
/// 不存在的版本 ⇒ `adopt_version_not_found`(不是「帮你在网上下一个」)。
///
/// 抓得住:不存在的 id 被当成可登记(档案指向空版本)。
/// 抓不住:`versions/` 整个不存在的情形(上一条扫描用例覆盖)。
#[test]
fn an_unknown_version_is_reported_as_not_found() {
let root = TempRoot::new("unknown");
foreign_minecraft(&root.game_root());
let error = adopt::adopt(
&root.game(),
&root.launcher(),
&request("9.9.9", AdoptTarget::InstanceDir),
)
.expect_err("不存在的版本应报错");
assert!(matches!(error, AdoptError::UnknownVersion { .. }));
assert_eq!(error.code(), "adopt_version_not_found");
}