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

+10 -24
View File
@@ -8,8 +8,10 @@
//!
//! 每一行实例带一个 `loader` 对象:`{"kind": <稳定 id>, "version": <版本段>}`。它是
//! **`version_id` 的解析结果**,解析本身不在这里——[`unlucky_core::game::InstanceLoader::parse`]
//! 是全仓唯一的 id → 加载器入口(M122 收敛)。CLI 只做「种类 → 稳定 id」的翻译,**不重写
//! 任何 id 匹配**:那份规则一旦两处各算一遍就会漂(M120 的「刚装的 Fabric 显示成原版」)。
//! 是全仓唯一的 id → 加载器入口(M122 收敛)。种类 → 稳定 id 的词表同样不在本 crate:
//! [`unlucky_core::game::LoaderKind::as_str`] 是全仓唯一一份 id 字面量(M126 收敛),这里直接
//! 调它,**连翻译层都没有**——词表与 id 匹配一样,两处各算一遍就会漂(M120 的「刚装的
//! Fabric 显示成原版」)。
//!
//! ## 约定一:`loader` 恒存在,原版是 `{"kind":"vanilla","version":""}`
//!
@@ -44,7 +46,7 @@ use std::path::PathBuf;
use serde_json::{json, Value};
use unlucky_core::game::install::instance::read_profiles as read_profiles_from_layout;
use unlucky_core::game::{InstanceLoader, LoaderKind, ProfileList};
use unlucky_core::game::{InstanceLoader, ProfileList};
use crate::context::Ctx;
use crate::error::CliError;
@@ -128,33 +130,17 @@ fn option_path(path: Option<&PathBuf>) -> Option<String> {
/// 版本 id → `loader` 对象(`{"kind": …, "version": …}`)。
///
/// 解析走 [`InstanceLoader::parse`](全仓唯一入口);这里**不写任何 id 匹配**,只把解析结果
/// 翻成 JSON。原版同样给出对象(`kind = vanilla`、`version = ""`),不缺键、不为 `null`
/// ——见模块文档的「约定一」。
/// 解析走 [`InstanceLoader::parse`](全仓唯一入口);种类 → 稳定 id 走
/// [`unlucky_core::game::LoaderKind::as_str`](词表的唯一真相源,M126)。这里既**不写 id
/// 匹配**,也**不写词表**:两样都只该有一份,且都在 core。原版同样给出对象
/// (`kind = vanilla`、`version = ""`),不缺键、不为 `null`——见模块文档的「约定一」。
fn loader_json(version_id: &str) -> Value {
let parsed = InstanceLoader::parse(version_id);
json!({
"kind": loader_kind_id(parsed.kind),
"kind": parsed.kind.as_str(),
"version": parsed.version,
})
}
/// 加载器种类 → 稳定 id。
///
/// 词表与 `--loader` 同源(`vanilla` / `fabric` / `quilt` / `forge` / `neoforge`),另加两个
/// **只能识别、不能安装**的 `optifine` / `liteloader`。穷尽 match:core 加一支就编不过,
/// 不会静默漏报成 `vanilla`。
fn loader_kind_id(kind: LoaderKind) -> &'static str {
match kind {
LoaderKind::Vanilla => "vanilla",
LoaderKind::Fabric => "fabric",
LoaderKind::Quilt => "quilt",
LoaderKind::Forge => "forge",
LoaderKind::NeoForge => "neoforge",
LoaderKind::OptiFine => "optifine",
LoaderKind::LiteLoader => "liteloader",
}
}
#[cfg(test)]
mod tests;
+10 -5
View File
@@ -74,14 +74,19 @@ impl LoaderKind {
}
}
/// 稳定字符串 id(进版本 id、JSON 输出与日志,**不要改名**)。
/// 稳定字符串 id(进版本 id、JSON 输出与日志)。
///
/// 词表**不在本模块**:[`crate::game::LoaderKind::as_str`] 是「种类 → 对外稳定 id」的唯一
/// 真相源(M126)。这里只做**结构映射**(一个 id 字面量都没有)——安装侧拼出来的
/// `<mc>-<kind>-<loader>` 必须能被 [`crate::game::InstanceLoader::parse`] 认回来,委派让
/// 「两边各写一份、改一边忘了另一边」这件事物理上做不到。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Fabric => "fabric",
Self::Quilt => "quilt",
Self::NeoForge => "neoforge",
Self::Forge => "forge",
Self::Fabric => crate::game::LoaderKind::Fabric.as_str(),
Self::Quilt => crate::game::LoaderKind::Quilt.as_str(),
Self::NeoForge => crate::game::LoaderKind::NeoForge.as_str(),
Self::Forge => crate::game::LoaderKind::Forge.as_str(),
}
}
+148 -86
View File
@@ -1,31 +1,32 @@
//! 版本 id → 加载器:**全仓唯一**的 id 解析入口(M122)。
//! 版本 id → 加载器:**全仓唯一**的 id 解析入口(M122),外加「种类 → 对外稳定 id」的
//! **全仓唯一**命名词表(M126,见 [`LoaderKind::as_str`])。
//!
//! # 为什么要有这一个模块
//! # 为什么要这一个模块
//!
//! 「这个实例装的什么加载器」在 UI 上是一句话,在代码里却有两个可能的来源:版本 JSON 的
//! 「这个实例装的什么加载器」在 UI 上是一句话,在代码里却有两个来源:版本 JSON 的
//! `libraries[]`(权威但要多读一次盘)与**版本 id 的字符串**(磁贴/列表最省事的那条)。
//! 只要有人按 id 猜,就必须只有**一处**在猜——两处各算一遍,迟早一边先漂,而用户看到的
//! 是「我刚装的 Fabric 显示成原版」这种信任问题(M120 复审发现的老 bug:解析端只认
//! `fabric-loader-…`,认不出本启动器自己生成的 `1.20.4-fabric-0.16.5`)。
//! 只要有人按 id 猜就必须只有**一处**在猜——两处各算一遍迟早一边先漂,用户看到的就是
//! 「我刚装的 Fabric 显示成原版」(M120 复审的老 bug:解析端只认 `fabric-loader-…`,认不出
//! 自家生成的 `1.20.4-fabric-0.16.5`)。所以 presenter 的实例页、CLI 的实例总览、以后任何
//! 「按 id 认加载器」的地方一律调 [`InstanceLoader::parse`],**不许**再写一份标记匹配。
//! 生成端([`crate::addon::loader::LoaderKind::version_id`] 拼 `<mc>-<kind>-<loader>`)与
//! 解析端的一致性由 [`tests::own_version_ids_parse_back_to_their_loader`] 钉住——那条测试
//! **从生成端取 id**,不手抄。OptiFine / LiteLoader 本启动器不能装(见
//! `presenter::host::install`),只能从别的启动器继承进来。
//!
//! 因此:presenter 的实例页、CLI 的实例总览、以后任何「按 id 认加载器」的地方,
//! 一律调 [`InstanceLoader::parse`]。**不许**在别处再写一份标记匹配。
//! # 命名词表只有一份字面量(M126)
//!
//! # id 的生成端(真相的另一半)
//! 契约(0.2.0 已发布形状,**不许改名**):`vanilla` / `fabric` / `quilt` / `forge` /
//! `neoforge` / `optifine` / `liteloader`。其中只有 [`LoaderKind::as_str`] 是字面量;安装侧与
//! presenter 的 `as_str` 是**委派**(结构映射,写不进串),CLI 直接调,`MARKERS` 的标记串也
//! 取自它。加一家加载器要动:
//!
//! 本启动器自己装的加载器,id 由 [`crate::addon::loader::LoaderKind::version_id`] 拼成
//! `<mc>-<kind>-<loader>`:
//!
//! | 加载器 | 真实 id 样本 |
//! |--------|--------------|
//! | Fabric | `1.20.4-fabric-0.16.5` |
//! | Quilt | `1.20.4-quilt-0.27.0`(beta 版如 `1.20.1-quilt-0.20.0-beta.9`) |
//! | Forge | `1.20.1-forge-47.2.0` |
//! | NeoForge | `1.21.1-neoforge-21.1.80` |
//!
//! 生成端与解析端的一致性由 [`tests::own_version_ids_parse_back_to_their_loader`] 钉住:
//! 那条测试**从生成端取 id**,不是手抄字符串。OptiFine / LiteLoader 本启动器不能装
//! (见 `presenter::host::install` 的范围说明),只能从别的启动器继承进来,口径见下。
//! 1. `LoaderKind` 加一支 → **[`LoaderKind::as_str`] / [`LoaderKind::has_marker`] /
//! presenter 的 `to_core`·`from_core` 编不过**(能装的话再加安装侧 `as_str` 一支);
//! 2. [`LoaderKind::ALL`] 加一格、`MARKERS` 加一行、尺寸锚改成 8 → **只能靠锚与测试**
//! (Rust 没有「枚举有哪几支」的编译期反射),漏了是「有 id 但认不出」,由
//! [`tests::markers_cover_every_kind_that_has_one`] 抓;
//! 3. `ull help instances` 的词表行与 CLI 的 `json_rows_expose_loader_kind_and_version` 一起红。
//!
//! # 解析规则(按整段前缀匹配,大小写不敏感,**最靠左的标记胜**)
//!
@@ -38,18 +39,8 @@
//! 3. 例外:版本段有 **≥2 段** 且**最后一段**长得像 MC 版本(`1.20.1` 这种纯数字点号)时
//! 砍掉最后一段——那是老 HMCL 的 `fabric-loader-<loader>-<mc>` 写法。一段都不剩的
//! `0.16.5` 不会被砍;`0.20.0-beta.9` 的末段不是数字,也不会被砍;
//! 4. 一个标记都没找到 ⇒ 原版(版本段空串)。
//!
//! # 认得出、也认不出的
//!
//! 认得出:上面四种自家 id;老 HMCL 的 `fabric-loader-0.16.5-1.20.1` /
//! `quilt-loader-0.24.0-1.20.1`;CF 风格的 `forge-47.2.0`;PCL 的
//! `1.13.2-OptiFine_HD_U_E6` 与 `1.12.2-LiteLoader`(LiteLoader 的 id 里没有版本段,
//! 拿到空串是正常的)。
//!
//! 认不出:把加载器信息只写进版本 JSON、id 里一点痕迹都没有的实例(例如整包导入时把
//! 版本重命名成 `我的整合包`)——那种只能去读 `libraries[]`,不在本模块的能力范围内,
//! 结果会诚实地返回「原版」。
//! 4. 一个标记都没找到 ⇒ 原版(版本段空串)。只把加载器写进版本 JSON、id 里没痕迹的实例
//! (整包导入时被重命名成 `我的整合包` 那种)会**诚实**返回「原版」——那不等于没装加载器。
/// 实例面向的加载器种类(比 [`crate::addon::loader::LoaderKind`] 宽)。
///
@@ -74,6 +65,82 @@ pub enum LoaderKind {
LiteLoader,
}
impl LoaderKind {
/// 全部种类,**顺序即标记的匹配优先级**;原版没有标记,排最后。
///
/// `NeoForge` 必须在 `Forge` 前(两者都是段前缀匹配,今天互不干扰,但顺序写死能防以后
/// 有人把匹配放宽成「包含」)。这张表同时是枚举的清单,漏掉一格编译器抓不到。
pub const ALL: [Self; 7] = [
Self::NeoForge,
Self::Fabric,
Self::Quilt,
Self::Forge,
Self::OptiFine,
Self::LiteLoader,
Self::Vanilla,
];
/// 对外稳定 id(`--json` 的 `loader.kind`、GUI 词表、`--loader` 的取值)。
///
/// **全仓唯一的一份 id 字面量**,也是 0.2.0 已发布契约。穷尽 match:加一支枚举就编不过。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Vanilla => "vanilla",
Self::Fabric => "fabric",
Self::Quilt => "quilt",
Self::Forge => "forge",
Self::NeoForge => "neoforge",
Self::OptiFine => "optifine",
Self::LiteLoader => "liteloader",
}
}
/// 这一支会不会从版本 id 的标记里认出来(原版不会)。穷尽 match:加一支必须表态。
#[must_use]
pub const fn has_marker(self) -> bool {
match self {
Self::Vanilla => false,
Self::Fabric
| Self::Quilt
| Self::Forge
| Self::NeoForge
| Self::OptiFine
| Self::LiteLoader => true,
}
}
/// 稳定 id → 种类([`Self::as_str`] 的反查;整串全等,大小写不敏感,不认前缀与别名)。
///
/// 别和 [`InstanceLoader::parse`] 搞混:那个认的是**版本 id**(切段、认前缀、给版本段),
/// 这个认的是**词表里的一个 id**。
#[must_use]
pub fn parse(raw: &str) -> Option<Self> {
let wanted = raw.trim();
Self::ALL
.into_iter()
.find(|kind| kind.as_str().eq_ignore_ascii_case(wanted))
}
}
/// 词表尺寸是契约的一部分:加一支枚举并放进 [`LoaderKind::ALL`] 时这行会红,逼作者确认
/// 「对外词表真的要多一个 id 了」。
const _: () = assert!(LoaderKind::ALL.len() == 7);
/// 标记 → 种类,**顺序即优先级**(同一段里先命中的算数)。
///
/// 标记串取自 [`LoaderKind::as_str`](没有第二份字面量);成员与
/// [`LoaderKind::has_marker`] 是否一致由 [`tests::markers_cover_every_kind_that_has_one`]
/// 钉住——加一支枚举而漏加一行,那条会红。
const MARKERS: [(LoaderKind, &'static str); 6] = [
(LoaderKind::NeoForge, LoaderKind::NeoForge.as_str()),
(LoaderKind::Fabric, LoaderKind::Fabric.as_str()),
(LoaderKind::Quilt, LoaderKind::Quilt.as_str()),
(LoaderKind::Forge, LoaderKind::Forge.as_str()),
(LoaderKind::OptiFine, LoaderKind::OptiFine.as_str()),
(LoaderKind::LiteLoader, LoaderKind::LiteLoader.as_str()),
];
/// 一个版本 id 解析出来的加载器:种类 + 版本段(原版为空串)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstanceLoader {
@@ -83,24 +150,10 @@ pub struct InstanceLoader {
pub version: String,
}
/// 标记 → 种类,**顺序即优先级**(同一段里先命中的算数)。
///
/// `neoforge` 必须排在 `forge` 前面:两者都是「段前缀」匹配,`neoforge…` 段若先按
/// `forge` 判,`neoforge` 里没有 `forge` 前缀不会误判,但把顺序写死能防以后有人把匹配
/// 改成「包含」时踩坑。
const MARKERS: [(LoaderKind, &str); 6] = [
(LoaderKind::NeoForge, "neoforge"),
(LoaderKind::Fabric, "fabric"),
(LoaderKind::Quilt, "quilt"),
(LoaderKind::Forge, "forge"),
(LoaderKind::OptiFine, "optifine"),
(LoaderKind::LiteLoader, "liteloader"),
];
impl InstanceLoader {
/// 从版本 id 解析加载器(纯字符串计算,不读盘)。
///
/// 规则见模块文档;认不出加载器标记时返回 [`LoaderKind::Vanilla`] + 空版本段。
/// 规则见模块文档;认不出标记时返回 [`LoaderKind::Vanilla`] + 空版本段。
#[must_use]
pub fn parse(version_id: &str) -> Self {
let segments: Vec<&str> = version_id.trim().split('-').collect();
@@ -132,7 +185,7 @@ impl InstanceLoader {
}
}
/// 这一段的**前缀**是哪个加载器标记;命中时连「标记之后剩下的部分」一起给出。
/// 这一段的**前缀**是哪个加载器标记;命中时连它后面剩下的部分一起给出。
fn marker_of(segment: &str) -> Option<(LoaderKind, &str)> {
for (kind, marker) in MARKERS {
let Some(rest) = strip_prefix_ascii_ci(segment, marker) else {
@@ -143,8 +196,7 @@ fn marker_of(segment: &str) -> Option<(LoaderKind, &str)> {
None
}
/// 去掉标记后面可选的 `loader` 后缀(`fabric-loader` 的版本段要从 `0.16.5` 起算,不是
/// 从 `loader-0.16.5` 起算)。分隔符 `-` / `_` 一并吞掉。
/// 去掉标记后面可选的 `loader` 后缀(版本段从 `0.16.5` 起算,不是 `loader-0.16.5`)。
fn strip_loader_suffix(rest: &str) -> &str {
let trimmed = trim_separators(rest);
match strip_prefix_ascii_ci(trimmed, "loader") {
@@ -177,11 +229,8 @@ fn join_version(parts: &[&str]) -> String {
kept.join("-")
}
/// 这一段长得像不像 MC 版本(`1.20` / `1.20.1` / `1.7.10`:全数字 + 至少一个点)。
///
/// 只用于「砍 HMCL 尾巴」那一条:它要求版本段前面还有内容(`≥2` 段),所以 `0.16.5`
/// 这种单独的加载器版本不会被当成 MC 版本砍掉。`0.20.0-beta.9` 的末段 `beta.9` 不是纯
/// 数字点号,也不会被砍。
/// 这一段长得像不像 MC 版本(`1.20.1` 这种全数字 + 点号)。只用于「砍 HMCL 尾巴」,那一条
/// 要求前面还有内容(`≥2` 段),所以单独的 `0.16.5` 不会被砍,`0.20.0-beta.9` 也不会。
fn looks_like_mc_version(text: &str) -> bool {
text.contains('.')
&& text
@@ -195,12 +244,9 @@ mod tests {
/// 自家 id 的真值表:**从生成端取 id**,不手抄。
///
/// 抓得住:生成端换了 id 形状(改了 [`crate::addon::loader::LoaderKind::version_id`]
/// 的分隔符/顺序)而解析端没跟——这条会红;以及解析端只认老 HMCL 口径、认不出自家
/// `-fabric-` / `-quilt-`(M122 修的那个 bug,改回旧口径这条必红)。
/// 抓不住:加载器版本里带 `-` 时版本段被截断的**同类**情形——`0.16.5-beta.1` 与
/// `0.20.0-beta.9` 两个样本是专门为它写的;Forge/NeoForge 的版本号在真实世界里不带
/// `-`,所以那边没有样本,将来真出现也只能靠人补。
/// 抓得住:生成端换了 id 形状而解析端没跟、解析端退回只认老 HMCL 口径(M122 的 bug)。
/// 抓不住:加载器版本自己带 `-` 的**其他**情形(`0.16.5-beta.1` / `0.20.0-beta.9` 两个
/// 样本专为它写;Forge/NeoForge 的真实版本号不带 `-`,出现了只能靠人补样本)。
#[test]
fn own_version_ids_parse_back_to_their_loader() {
use crate::addon::loader::LoaderKind as InstallKind;
@@ -238,17 +284,11 @@ mod tests {
/// 第三方前缀(OptiFine / LiteLoader):它们**不是** `<mc>-<kind>-<ver>` 口径。
///
/// 样本来源(本仓库里可查的真代码/真仓库):
/// - PCL:`PCL/Plain Craft Launcher 2/Modules/Minecraft/ModDownload.vb:441`
/// `Entry.Inherit & "-OptiFine_" & …` ⇒ `1.13.2-OptiFine_HD_U_E6`;
/// - PCL:`PCL/Plain Craft Launcher 2/Pages/PageDownload/ModDownloadLib.vb:793`
/// `DownloadInfo.Inherit & "-LiteLoader"` ⇒ `1.12.2-LiteLoader`(**没有版本段**);
/// - `_pre1` 尾巴来自 OptiFine 自己的 preview 命名(PCL 的 `preview_OptiFine_…` jar 名)。
///
/// 抓得住:`optifine` / `liteloader` 的标记被当成「必须带 `-`」或「必须小写」
/// (旧口径 `split_once("optifine")` 对 `OptiFine_…` 大小写敏感、对 `_` 不认);
/// 抓不住:OptiFine 的 maven 坐标形式(`optifine:OptiFine:1.12.2_HD_U_C7`)——那不是
/// 版本 id,出现在 id 里时按下面「版本段」的规则处理,未做特判。
/// 样本来源:PCL 的 `Modules/Minecraft/ModDownload.vb:441`(`-OptiFine_…`)与
/// `Pages/PageDownload/ModDownloadLib.vb:793`(`-LiteLoader`,**没有版本段**);`_pre1`
/// 尾巴来自 OptiFine 自己的 preview 命名。
/// 抓得住:标记被当成「必须带 `-`」或「必须小写」(旧口径 `split_once("optifine")`)。
/// 抓不住:OptiFine 的 maven 坐标形式(`optifine:OptiFine:1.12.2_HD_U_C7`),未做特判。
#[test]
fn third_party_prefixes_are_recognized() {
let cases = [
@@ -269,14 +309,12 @@ mod tests {
}
}
/// 老 HMCL / CF 口径(`fabric-loader-<loader>-<mc>`、`forge-<ver>`),本轮不制造新写法,
/// 但存量实例还会按它们出现,行为不许退化。
/// 老 HMCL / CF 口径(`fabric-loader-<loader>-<mc>`、`forge-<ver>`):存量实例还会按它们
/// 出现,行为不许退化。
///
/// 抓得住:砍 MC 尾巴的规则被删(版本段会变成 `0.16.5-1.20.1`);以及 `loader` 后缀被
/// 当成版本段的开头(会变成 `loader-0.16.5`)。
/// 抓不住:`forge-47.2.0-1.20.1` 这类「forge 也带 MC 尾巴」的写法——见下面那行断言,
/// 末段会被砍掉,但这是本测试**手工声明**的口径,不是从代码里查到的真实格式
/// (本仓库的老 HMCL 分支已改成「清单 + patch」,不再生成 per-loader 版本 id)。
/// 抓得住:砍 MC 尾巴的规则被删(版本段变成 `0.16.5-1.20.1`)、`loader` 后缀被当成版本段。
/// 抓不住:`forge-47.2.0-1.20.1` 是**手工声明**的口径,不是查到的真实格式(本仓库的老
/// HMCL 分支已改成「清单 + patch」)。
#[test]
fn foreign_loader_style_ids_still_parse() {
let cases = [
@@ -299,11 +337,9 @@ mod tests {
/// 反向锚:普通版本 id(原版、快照、预发布)**不许**被认成加载器。
///
/// 抓得住:解析端退化成「只要有 id 就当加载器」或标记匹配放宽成「包含子串」
/// (`1.20.4-pre1` 之类一旦被某个标记咬住就会红——这条列出的样本都不含标记,真正的
/// 子串误伤要另外构造,见下条断言的注释)。
/// 抓不住:**含加载器名字样的原版 id**(例如快照 `23w45a` 没事,但假设将来出现
/// `1.20.4-forge` 这样的原版 id,只能靠人的判断——本模块按标记优先,会认成 Forge)。
/// 抓得住:解析端退化成「有 id 就当加载器」、匹配放宽成「包含子串」。
/// 抓不住:**含加载器名字样的原版 id**(真出现 `1.20.4-forge` 这种原版 id 会被认成
/// Forge——本模块按标记优先)。
#[test]
fn plain_version_ids_stay_vanilla() {
for id in [
@@ -334,4 +370,30 @@ mod tests {
assert_eq!(parsed.kind, LoaderKind::Vanilla, "id `{id}`");
}
}
/// 词表 ↔ 解析表:`MARKERS` 必须与 [`LoaderKind::ALL`] / [`has_marker`] / [`as_str`]
/// 逐格对齐,并且按标记串能 round-trip 回同一支。
///
/// 抓得住:加一支枚举漏加 `MARKERS` 一行(`Vec` 逐项比对,顺序也算,所以 `NeoForge`
/// 被排到 `Forge` 后面同样红);`MARKERS` 被改回手写字面量并写错串。抓不住:词表字面值
/// 本身(改名时两侧同源不会红)——那是对外契约测试的职责,见
/// `cli::commands::instances::tests::json_rows_expose_loader_kind_and_version`。
///
/// [`has_marker`]: LoaderKind::has_marker
/// [`as_str`]: LoaderKind::as_str
#[test]
fn markers_cover_every_kind_that_has_one() {
let listed: Vec<LoaderKind> = MARKERS.iter().map(|(kind, _)| *kind).collect();
let expected: Vec<LoaderKind> = LoaderKind::ALL
.into_iter()
.filter(|kind| kind.has_marker())
.collect();
assert_eq!(listed, expected, "`MARKERS` 必须正好是「认标记」的那几支");
for (kind, marker) in MARKERS {
assert_eq!(marker, kind.as_str(), "标记串必须就是稳定 id");
let parsed = InstanceLoader::parse(&format!("1.20.4-{marker}-0.16.5"));
assert_eq!(parsed.kind, kind, "标记 `{marker}` 的版本 id");
assert_eq!(parsed.version, "0.16.5", "标记 `{marker}` 的版本段");
}
}
}
+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);