refactor(core): M126 加载器稳定 id 词表收敛到 game::LoaderKind::as_str

M122 把「id → 加载器」的解析收到 core::game::loader::InstanceLoader::parse,但
「种类 → 对外稳定 id」的命名词表还散在多处,其中 install 侧那一份只靠一句「不要改名」
的注释约束,MARKERS 更是第四张无守卫字面量表。

- core::game::LoaderKind 新增 as_str(**全仓唯一一份 id 字面量**)、has_marker
  (穷尽 match:新种类必须表态认不认 id 标记)、parse(as_str 的反查)与 ALL(清单,
  顺序即标记优先级);MARKERS 的标记串改为取自 as_str,不再是独立字面量。
- addon::loader::LoaderKind::as_str 改成委派(结构映射,无字面量):安装侧拼出的
  <mc>-<kind>-<loader> 与解析侧从此同源。
- presenter::dto::instances::LoaderKind 的 as_str/parse 委派给 core(to_core/from_core
  是结构映射),删掉第四份词表;CLI 删掉 loader_kind_id 翻译层,直接调 core。
- 测试:core 新增 markers_cover_every_kind_that_has_one(MARKERS↔ALL↔has_marker↔as_str
  逐格对齐 + 按标记串 round-trip + neoforge 先于 forge);presenter 新增
  loader_kind_ids_are_the_released_contract(七个 id 逐字钉住 + 反查 + 别名隔离)。
- 对外输出未变:真二进制对同一组 7 类实例的 `instances list --json` 输出逐字节相同
  (md5 0341752658ca28e020ba48c31a51a124),人读输出同样逐字节相同。

file 行数:core/game/loader.rs 337→399(≤400 硬约束,文档按密度重排、事实未丢)。
This commit is contained in:
AskaEth committed 2026-11-05 18:08:56 +08:00
1 parent 84f85bbc79
commit be37c17962
4 files changed
+253 -134

No files matched your search

+85 -19
View File
@@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use unlucky_core::game::LoaderKind as CoreLoaderKind;
/// 实例标识:core::game 生成的稳定 id,app 视为不透明字符串。
pub type InstanceId = String;
@@ -40,31 +42,58 @@ impl LoaderKind {
}
}
/// 稳定 id(`vanilla` / `fabric` / …;GUI 与 CLI 的 `--loader` 同词表)。
pub fn as_str(self) -> &'static str {
/// 本 DTO 种类 → core 的宽种类。
///
/// **结构映射,一个 id 字面量都没有**:词表只有一个真相源
/// ([`CoreLoaderKind::as_str`]),本 crate 写不进第二份。加一支 DTO 种类这里编不过。
const fn to_core(self) -> CoreLoaderKind {
match self {
Self::Vanilla => "vanilla",
Self::Fabric => "fabric",
Self::Quilt => "quilt",
Self::Forge => "forge",
Self::NeoForge => "neoforge",
Self::OptiFine => "optifine",
Self::LiteLoader => "liteloader",
Self::Vanilla => CoreLoaderKind::Vanilla,
Self::Fabric => CoreLoaderKind::Fabric,
Self::Quilt => CoreLoaderKind::Quilt,
Self::Forge => CoreLoaderKind::Forge,
Self::NeoForge => CoreLoaderKind::NeoForge,
Self::OptiFine => CoreLoaderKind::OptiFine,
Self::LiteLoader => CoreLoaderKind::LiteLoader,
}
}
/// core 的宽种类 → 本 DTO 种类([`Self::to_core`] 的反向,同样不含字面量)。
///
/// core 给 `game::LoaderKind` 加一支、而 DTO 还没跟上时,这里编不过——这正是要的:
/// UI 不许「新加载器默默显示成原版」。
const fn from_core(kind: CoreLoaderKind) -> Self {
match kind {
CoreLoaderKind::Vanilla => Self::Vanilla,
CoreLoaderKind::Fabric => Self::Fabric,
CoreLoaderKind::Quilt => Self::Quilt,
CoreLoaderKind::Forge => Self::Forge,
CoreLoaderKind::NeoForge => Self::NeoForge,
CoreLoaderKind::OptiFine => Self::OptiFine,
CoreLoaderKind::LiteLoader => Self::LiteLoader,
}
}
/// 稳定 id(`vanilla` / `fabric` / …;GUI 与 `--json` 同词表)。
///
/// **委派**给 [`CoreLoaderKind::as_str`](M126):整张词表在 core 只有一份字面量
/// (0.2.0 已发布契约),这里只剩一次结构映射。
#[must_use]
pub fn as_str(self) -> &'static str {
self.to_core().as_str()
}
/// 从稳定 id 解析(未知给 `None`,由调用方决定报错文案)。
///
/// 七个 id 的匹配委派给 [`CoreLoaderKind::parse`];本方法只额外收 `none` / 空串这两个
/// **GUI 输入别名**(表单里「不装加载器」的写法)——它们不是对外输出的 id,也绝不参与
/// [`Self::as_str`]。
pub fn parse(raw: &str) -> Option<Self> {
Some(match raw.trim().to_ascii_lowercase().as_str() {
"vanilla" | "none" | "" => Self::Vanilla,
"fabric" => Self::Fabric,
"quilt" => Self::Quilt,
"forge" => Self::Forge,
"neoforge" => Self::NeoForge,
"optifine" => Self::OptiFine,
"liteloader" => Self::LiteLoader,
_ => return None,
})
let wanted = raw.trim();
if wanted.is_empty() || wanted.eq_ignore_ascii_case("none") {
return Some(Self::Vanilla);
}
Some(Self::from_core(CoreLoaderKind::parse(wanted)?))
}
/// 向导里的可选加载器(顺序即展示顺序)。
@@ -252,6 +281,43 @@ mod tests {
assert_eq!(LoaderKind::parse("unknown"), None);
}
/// DTO 的 `as_str` / `parse` 是 core 词表的**委派**(M126),而词表本身是**对外契约**:
/// 七个 id 在这里独立抄一遍(0.2.0 已发布形状,改名等于违约)。
///
/// 抓得住:core 的 `as_str` 或委派接线改错(`OptiFine` 映到 `LiteLoader`、映射漏了哪支);
/// 词表改字/改大小写(`neoforge` → `neo_forge`);`parse` 收进了不该有的别名(`neo-forge`)。
/// 抓不住:core 的 `ALL` / `MARKERS` 之间对不对得上(那是 core 的
/// `markers_cover_every_kind_that_has_one` 的职责),以及真二进制输出面上的 JSON
/// (那是 CLI 的 `json_rows_expose_loader_kind_and_version`)。
#[test]
fn loader_kind_ids_are_the_released_contract() {
const CONTRACT: [(LoaderKind, &str); 7] = [
(LoaderKind::Vanilla, "vanilla"),
(LoaderKind::Fabric, "fabric"),
(LoaderKind::Quilt, "quilt"),
(LoaderKind::Forge, "forge"),
(LoaderKind::NeoForge, "neoforge"),
(LoaderKind::OptiFine, "optifine"),
(LoaderKind::LiteLoader, "liteloader"),
];
for (kind, id) in CONTRACT {
assert_eq!(kind.as_str(), id, "{kind:?} 的稳定 id 变了");
assert_eq!(LoaderKind::parse(id), Some(kind), "`{id}` 认不回来");
let upper = id.to_ascii_uppercase();
assert_eq!(LoaderKind::parse(&upper), Some(kind), "大写 `{upper}`");
}
// GUI 输入别名(不是对外 id):不装加载器有三种写法;别名与拼法都不许混进词表。
for alias in ["none", "", " "] {
assert_eq!(
LoaderKind::parse(alias),
Some(LoaderKind::Vanilla),
"`{alias}`"
);
}
assert_eq!(LoaderKind::parse("neo-forge"), None);
assert_eq!(LoaderKind::parse("rift"), None);
}
#[test]
fn progress_ratio_handles_unknown_total() {
assert_eq!(Progress { done: 1, total: 0 }.ratio(), 0.0);