Files
Unlucky-Launcher/crates/presenter/src/host/game/tests.rs
T
AskaEth 0156adb44d fix(presenter): M306 r1 —— 「编译器一声不吭」是错的:改准两处文档 + 补一条源码级守卫
r1 复审实测:只删 `live.rs::discovered` 那三行 ⇒ `unused_imports` 点名
`legacy_external_instance_id`,`clippy -- -D warnings` 直接红(EXIT=101)。
`#![allow(dead_code)]` 只盖「函数变死码」,盖不住「use 行变空」——那是 `unused` 组另一条 lint。

- `live.rs` / `game/tests.rs` 两处文档改成准确版本:删掉那三行 ⇒ `cargo test` 仍全绿,但
  `unused_imports` 会让 CI 红;而**这道保险可绕过**(连 import 一起删 / 加 `#[allow(unused_imports)]`
  ⇒ 编译期一条 warning 都没有),所以行为用例之外再加一道源码网
- `game/tests.rs` 新增 `the_legacy_pass_through_is_still_in_the_source`:`include_str!` 扫
  `live.rs` 的**代码**(每行剥掉 `//` 之后的部分——注释里残留的那条 `if` 原文不算数),断言
  放行分支与 import 原样还在、且没有 `unused_imports` 豁免
- 变异实测:把 import 里的名字一起删掉(编译器真不吭声,`grep -c '^warning'` = 0)⇒ 新守卫红
  「分支在 = false,import 在 = false」,行为用例同时红;两条网互补
2026-11-13 22:56:44 +08:00

348 lines
18 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.
use super::*;
/// 版本 id → DTO 加载器的接口面(解析在 core,这里只验翻译没走样)。
///
/// 抓得住:显示路径把某个种类翻错/翻塌(例如 Quilt 写成 Forge)——`dto_kind` 的委派与
/// `dto` 的 `from_core` 都落在这一条上;以及 core 解析端退回老口径(`fabric-loader-…` 才认、
/// 自家 `1.20.4-fabric-0.16.5` 认不出)——后两条正是 M122 的 bug 面。
/// 抓不住:core 解析规则本身(真值表在 `unlucky_core::game::loader` 的测试里,那边才是
/// 真相源);也抓不住「版本号为空时不显示品牌名」那条(在 `LoaderInfo::badge` 与
/// app-iced 的 `loader_badge`,M120 已修,别在这里重复)。
#[test]
fn loader_parsing_covers_all_shapes() {
let cases = [
("1.20.4", LoaderKind::Vanilla, ""),
// 自家口径(生成端 `LoaderKind::version_id`):`<mc>-<kind>-<loader>`
("1.20.4-fabric-0.16.5", LoaderKind::Fabric, "0.16.5"),
("1.20.4-quilt-0.27.0", LoaderKind::Quilt, "0.27.0"),
("1.20.1-forge-47.2.0", LoaderKind::Forge, "47.2.0"),
("1.21.1-neoforge-21.1.80", LoaderKind::NeoForge, "21.1.80"),
// 老 HMCL 口径(存量实例还会带着它们)
("fabric-loader-0.16.5-1.20.1", LoaderKind::Fabric, "0.16.5"),
("quilt-loader-0.24.0-1.20.1", LoaderKind::Quilt, "0.24.0"),
// 第三方口径
("1.13.2-OptiFine_HD_U_E6", LoaderKind::OptiFine, "HD_U_E6"),
("1.12.2-LiteLoader", LoaderKind::LiteLoader, ""),
];
for (id, kind, version) in cases {
let info = loader_from_id(id);
assert_eq!(info.kind, kind, "{id}");
assert_eq!(info.version, version, "{id}");
assert!(!info.badge().is_empty());
}
}
/// 显示路径的**全量**锚:core 的每一种加载器,从自家版本 id 走到磁贴时仍然是**它自己**。
///
/// 清单取自 [`unlucky_core::game::LoaderKind::ALL`],**不手抄**:M126 r3 复审实测出的缺口正是
/// 「手抄的清单跟着漏」——core 加第八家、契约与 help 都补齐、只在 `dto_kind` 那一支写
/// `=> Vanilla`,三个 crate 全绿而磁贴静默显示「原版」。`ALL` 由 core 的宏表长出来,加一家
/// 自动进圈,测试作者不需要(也没法)记得补一行。
///
/// 抓得住:显示路径任何一环把种类翻塌成原版——`dto_kind` 又变回手写映射、`dto` 的
/// `from_core` 那一支接错、`label()` 被写成「原版」;**包括新加的那种**(那时 `ALL` 先变长)。
/// 抓不住:core 的 id 解析规则本身(真值表在 `unlucky_core::game::loader`);版本号为空的
/// 徽标判据(M120 的 `loader_badge`,本 crate 不渲染)。
#[test]
fn every_core_loader_kind_reaches_the_tile_as_itself() {
use unlucky_core::game::LoaderKind as Core;
for kind in Core::ALL {
let info = loader_from_id(&format!("1.20.4-{}-0.16.5", kind.as_str()));
assert_eq!(
info.kind.as_str(),
kind.as_str(),
"core 的 {kind:?} 走到磁贴时变成了 `{}`(徽标 `{}`)",
info.kind.as_str(),
info.badge()
);
assert_eq!(
info.badge().starts_with(LoaderKind::Vanilla.label()),
kind == Core::Vanilla,
"core 的 {kind:?} 在磁贴上的徽标是 `{}`",
info.badge()
);
}
}
/// 端到端(数据层):真装出来的四个加载器实例,走 [`instances`] 的快照各自带对加载器。
///
/// 造的是本启动器**真实产物**的形状:`profiles.json` 里 `versionId` 是自家 id,
/// `versions/<id>/<id>.json` 里 `inheritsFrom` 指向原版层(生成端见
/// `unlucky_core::addon::loader`)。原版版本号由此取自继承链根,不是从 id 里切的。
///
/// 抓得住:`describe_version` ↔ `loader_from_id` ↔ `instances` 这条链上任何一环把加载器
/// 丢掉(磁贴会显示「原版」——M122 的用户可见症状);以及继承链根取错(磁贴的版本号)。
/// 抓不住:实例页真出图(走 app-iced 的视图模型与 `loader_badge`,本 crate 不渲染);
/// 也抓不住「真下载安装了 Fabric/Quilt/Forge/NeoForge」——这里只伪造落盘结果。
#[test]
fn own_installed_instances_snapshot_carries_their_loader() {
let dir = std::env::temp_dir().join(format!("ull-host-game-own-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.config()).expect("config 目录");
let cases = [
(
"1.20.4-fabric-0.16.5",
"1.20.4",
LoaderKind::Fabric,
"Fabric 0.16.5",
),
(
"1.20.4-quilt-0.27.0",
"1.20.4",
LoaderKind::Quilt,
"Quilt 0.27.0",
),
(
"1.20.1-forge-47.2.0",
"1.20.1",
LoaderKind::Forge,
"Forge 47.2.0",
),
(
"1.21.1-neoforge-21.1.80",
"1.21.1",
LoaderKind::NeoForge,
"NeoForge 21.1.80",
),
];
for (version_id, mc_version, _, _) in cases {
for (id, inherits) in [(mc_version, None), (version_id, Some(mc_version))] {
let json = match inherits {
Some(parent) => format!(r#"{{"id":"{id}","inheritsFrom":"{parent}"}}"#),
None => format!(r#"{{"id":"{id}"}}"#),
};
let path = env.game.version_json(id);
std::fs::create_dir_all(path.parent().expect("版本目录")).expect("版本目录");
std::fs::write(&path, json).expect("写版本 JSON");
}
}
let profiles = ProfileList {
profiles: cases
.iter()
.map(
|(version_id, _, _, _)| unlucky_core::game::InstanceProfile {
id: unlucky_core::game::ProfileId::generate(),
name: format!("装好的 {version_id}"),
version_id: (*version_id).to_string(),
game_dir: None,
running_dir: None,
java_path: None,
created_at: None,
last_played_at: None,
extra: BTreeMap::new(),
},
)
.collect(),
..ProfileList::default()
};
unlucky_core::game::install::instance::write_profiles(&env.layout, &profiles).expect("写清单");
let snapshot = instances(&env);
// M273 之后 `versions/*` 也是实例来源:这个夹具为了造 `inheritsFrom` 还写了三个父版本目录
// (`1.20.4` / `1.20.1` / `1.21.1`)而**没有**给它们建档案,于是它们会作为「外部实例」出现。
// 这条用例守的是「这条链把加载器/版本号翻对」——所以只看它自己那四个版本,条数也在这一层断言;
// 「盘上有版本就出现在列表里」由 `tests_union.rs` 独占。
let snapshot: Vec<_> = snapshot
.into_iter()
.filter(|tile| cases.iter().any(|case| case.0 == tile.version_id))
.collect();
assert_eq!(snapshot.len(), cases.len());
for ((version_id, mc_version, kind, badge), tile) in cases.iter().zip(&snapshot) {
assert_eq!(tile.version_id.as_str(), *version_id);
assert_eq!(
tile.mc_version.as_str(),
*mc_version,
"{version_id} 的版本号取错"
);
assert_eq!(tile.loader.kind, *kind, "{version_id} 的加载器");
assert_eq!(tile.loader.badge().as_str(), *badge, "{version_id} 的徽标");
assert!(tile.version_present, "{version_id} 的载荷应当存在");
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn isolation_write_then_read_round_trips() {
let dir = std::env::temp_dir().join(format!("ull-host-game-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.config()).expect("config 目录");
// 直接建一份 profiles.json(register 需要版本本体,这里只测隔离写读)
let profiles = ProfileList {
profiles: vec![unlucky_core::game::InstanceProfile {
id: unlucky_core::game::ProfileId::generate(),
name: "demo".to_string(),
version_id: "1.20.1".to_string(),
game_dir: None,
running_dir: None,
java_path: None,
created_at: None,
last_played_at: None,
extra: BTreeMap::new(),
}],
..ProfileList::default()
};
unlucky_core::game::install::instance::write_profiles(&env.layout, &profiles).expect("写清单");
let id = profiles.profiles[0].id.as_str().to_string();
// 有档案的实例:基底档 = 全局档(`isolated`),与 M273 之前一字不差。
assert_eq!(isolation_of(&env, &env.game, &id, "1.20.1"), "isolated");
set_isolation(&env, &id, "shared").expect("切档位");
assert_eq!(isolation_of(&env, &env.game, &id, "1.20.1"), "shared");
assert!(set_isolation(&env, &id, "nope").is_err());
std::fs::remove_dir_all(&dir).ok();
}
/// **实例 id 的哈希公式钉死**(M304 r1:把「老档案放行」那条判据挂在这两个函数上,公式一漂就会
/// 误杀所有存量实例)。
///
/// 两个字面量是 FNV-1a 64 的独立复算结果(`ext-` 前缀 + 16 位小写十六进制):
///
/// | 输入 | 期望 |
/// |---|---|
/// | `legacy_external_instance_id("1.20.4")` | `ext-52d6191436663b4e`(= M273 的「只哈希版本目录名」)|
/// | `external_instance_id("c:/mc/.minecraft", "1.20.4")` | `ext-1e6c24c50b4a95c2`(= root + `0` + version)|
///
/// 抓得住:漏掉段间那次乘法、或改动迭代顺序——那时**所有**存量 `ext-` id 都会换一个值
/// (`profiles.json` 里旧的 `ext-` 档案一律反查不到)。段间那个分隔符**本身**不单独进哈希:它
/// 等价于多乘一次素数(`hash ^= 0` 是恒等),痕迹只在这一次乘法上——所以「分隔符」与「那一次
/// 乘法」是同一件事,不是两处可分别改动的输入。M304 把哈希抽成 [`super::discovered`] 里的一个
/// 多段函数,这条就是那次重构的回归网。
#[test]
fn instance_id_hashes_are_the_released_ones() {
assert_eq!(
super::discovered::legacy_external_instance_id("1.20.4"),
"ext-52d6191436663b4e",
"M273 老档案的 id 公式变了(存量实例会被判成「根不见了」)"
);
assert_eq!(
super::discovered::external_instance_id("c:/mc/.minecraft", "1.20.4"),
"ext-1e6c24c50b4a95c2",
"M274 的 id 公式变了(存量 ext- 档案一律反查不到)"
);
}
/// 一条 `ext-` 磁贴,版本本体**不在盘上**(这个临时根里没有 `versions/`)。
///
/// 走生产的构造函数([`super::discovered::external_summary`],id = M274 的 `fnv(根 ‖ 0 ‖ 版本)`),
/// 调用方只把 id 换成要判的那一种写法——这样「磁贴的形状」与界面上的永远同源。`roots[0]` 恒为
/// 配置根([`super::roots::all`] 的不变式:配置根永远第一位)。
fn ext_tile(env: &HostEnv, version_id: &str) -> InstanceSummary {
let roots = super::roots::all(env);
super::discovered::external_summary(env, &roots[0], version_id, &BTreeMap::new())
}
/// 建一个只有 `config/` 的临时根(用例自己的,`pid` + 标签让并行的两条不撞)。
fn bare_root(label: &str) -> (HostEnv, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("ull-host-game-{label}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let env = HostEnv::at_root(&dir);
std::fs::create_dir_all(env.layout.config()).expect("config 目录");
(env, dir)
}
/// **M273 老档案必须放行**(M306:给 `refresh/live.rs::discovered` 最后那三行挂网)。
///
/// 判据:`ext-` id 只哈希版本目录名(M274 之前的公式)⇒ `roots::all` 的每个根都反查不到它。
/// 这种 id 的语义本来就是「按配置根解析」,真没版本时启动链给具名阶段失败(进程一个都没起来)
/// ——所以这里必须 `Ok(())`。拒绝它 = 所有存量 M273 用户的 `ext-` 磁贴集体点不动,还配一句错的
/// 原因(「所属的游戏目录已不在」)。
///
/// 抓得住:**只**删掉 `live.rs::discovered` 里
/// `if legacy_external_instance_id(&instance.version_id) == instance.id { return Ok(()); }` 这三行
/// ——本条立即红(M306 实测:`Err("实例 `1.20.4` 所属的游戏目录已不在…")`)。
///
/// 但**别把编译器当第二道网**(M306 r1 复审实测,本文件早先那句「编译器一声不吭」是错的):
/// `#![allow(dead_code)]` 只盖「函数变死码」,盖不住「use 行变空」——`unused_imports` 属于
/// `unused` 组里另一条 lint,删掉那三行时它会点名 `legacy_external_instance_id`,CI 的
/// `-D warnings` 当场红。可**这道保险也能绕过**:连 import 一起删、或在那行加
/// `#[allow(unused_imports)]`,编译器就真的不吭声了(那时 `cargo test` 依旧全绿)。
/// 所以源码侧由下面的 `the_legacy_pass_through_is_still_in_the_source` 兜底。
/// 抓不住:反查落空后「该拒绝」那一侧(下一条用例管)。
#[test]
fn a_m273_legacy_ext_id_is_allowed() {
let (env, dir) = bare_root("legacy-ok");
let mut tile = ext_tile(&env, "1.20.4");
tile.id = super::discovered::legacy_external_instance_id("1.20.4");
// 先清临时根再断言:断言失败也不在 /tmp 留空壳。
let verdict = Scan::revalidate(&env, &tile);
std::fs::remove_dir_all(&dir).ok();
assert!(
verdict.is_ok(),
"M273 老档案(id 只哈希版本目录名)必须放行,否则存量 `ext-` 磁贴全部点不动:{verdict:?}"
);
}
/// **换个根的 `ext-` id、反查落空,必须拒绝并具名**(同一条分叉的另一侧)。
///
/// 与上一条**同一个版本**,只把 id 换成 `external_instance_id(别的根, 版本)`:盘上根本没有那个根
/// ⇒ 一个根都对不上,而它也不是老档案 ⇒ 必须 `Err`。放行会让 `game_for` 退回配置根,配置根里
/// 恰好有同名版本就把**另一份**拉起来(gameDir / 存档都在另一边)。
///
/// 抓得住:把那条拒绝改写成 `Ok(())`(复审现场的另一半)。它与上一条各钉分叉的一侧,所以
/// 只删老档案那三行时本条**不会**假红——红色只会出现在上一条。
#[test]
fn an_ext_id_from_no_visible_root_is_refused() {
let (env, dir) = bare_root("legacy-refused");
let mut tile = ext_tile(&env, "1.20.4");
// 盘上不存在的根:`roots::all` 只给配置根 ⇒ 这个 id 一个根都对不上,且它不是老档案的公式。
tile.id = super::discovered::external_instance_id("d:/m306-no-such-root/.minecraft", "1.20.4");
let verdict = Scan::revalidate(&env, &tile);
std::fs::remove_dir_all(&dir).ok();
let reason = verdict.expect_err(
"反查落空又不是老档案 ⇒ 必须拒绝(放行会让 game_for 退回配置根,把同名版本拉起来)",
);
assert!(
reason.contains("游戏目录已不在") && reason.contains("1.20.4"),
"具名原因要说清是哪条实例、为什么(现在是:{reason})"
);
}
/// `live.rs` 的**代码**:每行剥掉 `//` 之后的部分(`//` / `///` / `//!` 都从此处走)。
///
/// 必须剥:`live.rs` 的文档里写着那条 `if` 的原文(就是给下一个「清死代码」的人看的),注释里
/// 出现不算数——否则下面的守卫会**自证**地永远绿(M306 r1 复审要的正是这道网,别让它空转)。
fn live_code() -> String {
include_str!("refresh/live.rs")
.lines()
.map(|line| line.split("//").next().unwrap_or_default())
.collect::<Vec<_>>()
.join("\n")
}
/// **源码级守卫**:`live.rs` 里那条「M273 老档案放行」必须**原样还在**,且没人把编译器的提醒关掉。
///
/// 为什么行为用例不够(M306 r1 复审证明):删掉那三行时编译器**会**给信号(`unused_imports`
/// 点名 `legacy_external_instance_id`,`-D warnings` 判红)——**但这道保险可绕过**:把 import
/// 一起删掉、或加 `#[allow(unused_imports)]`,编译期**一条 warning 都不打**(M306 r1 实测:
/// `grep -c '^warning'` = 0),`cargo test` 也依旧全绿。
/// 那时只有扫源码能说话:`a_m273_legacy_ext_id_is_allowed` 判**行为**,本条判**判据还在不在**。
///
/// 抓得住:那三行被删(注释里残留的原文不算)/import 里的名字被删/有人加 `unused_imports` 豁免。
/// 抓不住:把判据换个写法搬去别处——那时本条会红,请顺手把下面的字面量改成新形状(别把守卫删了)。
#[test]
fn the_legacy_pass_through_is_still_in_the_source() {
let code = live_code();
let branch = "if legacy_external_instance_id(&instance.version_id) == instance.id {";
let import =
"use super::super::discovered::{external_instance_id, legacy_external_instance_id};";
assert!(
code.contains(branch) && code.contains(import),
"live.rs::discovered 里那条「M273 老档案放行」不见了(分支在 = {},import 在 = {}):\
删掉它会让所有存量 M273 用户的 `ext-` 磁贴点不动,还配一句错的「所属的游戏目录已不在」",
code.contains(branch),
code.contains(import)
);
assert!(
!code.contains("unused_imports"),
"live.rs 里出现了 `unused_imports` 豁免:它会把「删掉放行分支后 use 行变空」这条唯一的\
编译期提醒关掉——那是本条守卫存在的理由,不许豁免"
);
}