diff --git a/crates/cli/src/commands/instances.rs b/crates/cli/src/commands/instances.rs index 023d3f9..5e77503 100644 --- a/crates/cli/src/commands/instances.rs +++ b/crates/cli/src/commands/instances.rs @@ -144,3 +144,31 @@ fn loader_json(version_id: &str) -> Value { #[cfg(test)] mod tests; + +/// `ull help instances` 的词表行是**对外文档契约**,不许成为第二个说假话的地方(r2)。 +/// +/// 单列在这里而不是塞进 `instances/tests.rs`:那几个断言钉的是本模块文档承诺的词表,与命令 +/// 行为无关(`tests.rs` 的职责是「跑真入口看输出」),而且这样 help 的契约与被承诺的那段 +/// 文档就在同一个文件里。 +#[cfg(test)] +mod help_vocabulary_tests { + use unlucky_core::game::LoaderKind; + + /// help 的词表必须覆盖**每一支**稳定 id(含原版与两家只继承、不安装的)。 + /// + /// 抓得住:help 的词表行被改字或删项(`neoforge` → `neo_forge`);**新增一家加载器而忘了 + /// 更新 help**——下面按 [`LoaderKind::ALL`] 遍历而不是手抄七项,所以新分支漏写必红。 + /// 抓不住:措辞与排版(只做子串断言);「kind 是识别口径不是安装口径」那段语义说明是否 + /// 仍然准确,只能靠人读。 + #[test] + fn help_lists_every_stable_id() { + let topic = crate::cli::help::topic("instances").expect("help instances 存在"); + for kind in LoaderKind::ALL { + assert!( + topic.contains(kind.as_str()), + "help instances 的词表行漏了 `{}`:\n{topic}", + kind.as_str() + ); + } + } +} diff --git a/crates/core/src/addon/loader/tests/vocabulary.rs b/crates/core/src/addon/loader/tests/vocabulary.rs index b6e0458..59b6664 100644 --- a/crates/core/src/addon/loader/tests/vocabulary.rs +++ b/crates/core/src/addon/loader/tests/vocabulary.rs @@ -61,3 +61,24 @@ fn modrinth_alias_is_input_only() { assert_eq!(LoaderKind::parse(raw), None, "`{raw}` 不该被安装侧接受"); } } + +/// [`LoaderKind::all`] 是安装侧唯一还手写的清单(CF id 前缀匹配、CLI `--loader` 提示用), +/// 它与 [`LoaderKind::from_core`] 认可的集合必须一致——不重抄字面量,直接对映射本身。 +/// +/// 抓得住:往 core 加一家**能装**的加载器,`from_core` 补了却忘了 `all()`(CLI 提示会漏一家、 +/// CF id 不认识它的前缀);反过来 `all()` 里多一家而 `from_core` 说装不了。 +/// 抓不住:`all()` 的顺序(那是 CLI 提示的展示序,没有对外契约意义)。 +#[test] +fn installable_list_matches_the_from_core_mapping() { + let mut derived: Vec = Wide::ALL + .into_iter() + .filter_map(LoaderKind::from_core) + .collect(); + derived.sort_unstable(); + let mut listed = LoaderKind::all().to_vec(); + listed.sort_unstable(); + assert_eq!( + listed, derived, + "`LoaderKind::all()` 必须正好是 `from_core` 认可的那几家" + ); +} diff --git a/crates/core/src/game/loader.rs b/crates/core/src/game/loader.rs index 27f86b6..da9d86c 100644 --- a/crates/core/src/game/loader.rs +++ b/crates/core/src/game/loader.rs @@ -17,29 +17,29 @@ //! //! 契约(0.2.0 已发布形状,**不许改名**):`vanilla` / `fabric` / `quilt` / `forge` / //! `neoforge` / `optifine` / `liteloader`。生产代码里这七个串只有**一处**:下面那次 -//! `loader_kind_vocab!` 展开——枚举、`ALL`、`as_str`、`has_marker` 全从同一张表长出来, -//! 所以「加了一支却漏进清单」**物理上做不到**(手写枚举 + 手写清单时它既不报错也不会被测试 -//! 抓住:测试同样从 `ALL` 出发,新加载器被**静默**当成「认不出」)。安装侧与 presenter 的 -//! `as_str` / `parse` 都是**委派**,CLI 直接调;唯一不在词表里的字面量是安装侧收的输入别名 -//! `neo-forge`(Modrinth 拼法,只进不出)。 +//! `loader_kind_vocab!` 展开——枚举、`ALL`、`as_str`、`has_marker` 全从同一张表长出来,所以 +//! 「加了一支却漏进清单」物理上做不到(理由见宏的注释)。安装侧与 presenter 的 `as_str` / +//! `parse` 都是**委派**,CLI 直接调;唯一不在词表里的字面量是安装侧收的输入别名 `neo-forge` +//! (Modrinth 拼法,只进不出)。 //! //! 加一家加载器要动:① `loader_kind_vocab!` 表里加一行(id + 认不认标记)→ 枚举 / `ALL` / //! `as_str` / `has_marker` / 解析顺序一起长出来,漏不了;② presenter 的 `to_core`·`from_core` //! **编不过**(逼你决定 DTO 怎么显示它),能装的话安装侧 `as_str`·`from_core` 也各差一支; -//! ③ 契约测试(presenter 的 `loader_kind_ids_are_the_released_contract`、CLI 的 -//! `json_rows_expose_loader_kind_and_version`、`ull help instances` 的词表行)**会红**——那 -//! 是逼你确认「对外词表真的要多一个 id」,因为 0.2.0 已经发出去过。 +//! ③ 契约测试**会红**:presenter 的 `loader_kind_ids_are_the_released_contract` 把 core 的 +//! `ALL` 与独立手写的七个 id 做**集合比较**,CLI 的 `help_lists_every_stable_id` 按 `ALL` +//! 遍历 help 词表,`json_rows_*` 钉真二进制输出面——三处都逼你确认「对外词表真的要多一个 +//! id」,因为 0.2.0 已经发出去过。 //! -//! 分工:**改名**(`neoforge` → `neo_forge`)不会被往返测试抓住(词表与解析同源、改名自洽), -//! 只有第 ③ 步的契约测试会红——那里**故意**再抄一遍七个串,那是唯一抓得住改名的东西;往返 -//! 测试抓的是「规则把 id 认错」。 +//! ⚠️ 分工要看准:**编译器只拦「漏 match」**。新种类在三处消费方用最省事的 `=> Vanilla` 糊 +//! 过去是**编得过**的(那时它会被静默当成原版);真正拦住这件事的是 ③ 里那条集合断言,不是 +//! 编译器。**改名**(`neoforge` → `neo_forge`)同理:往返测试是绿的(词表与解析同源、改名自 +//! 洽),只有 ③ 会红——那里**故意**再抄一遍七个串。 //! //! # 解析规则(按整段前缀匹配,大小写不敏感,**最靠左的标记胜**) //! //! 1. 把 id 按 `-` 切段,从左往右找第一段,它的**前缀**是某个加载器标记 -//! (`neoforge` / `fabric` / `quilt` / `forge` / `optifine` / `liteloader`); -//! 标记后面紧跟的 `loader` 段(`fabric-loader-…`)、或同一段里的 `_loader` 尾巴, -//! 都算标记的一部分而不是版本段; +//! (`neoforge` / `fabric` / `quilt` / `forge` / `optifine` / `liteloader`);紧跟的 `loader` +//! 段(`fabric-loader-…`)或同段里的 `_loader` 尾巴算标记的一部分,不是版本段; //! 2. 该段里标记之后剩下的部分,加上它右边的所有段,按 `-` 拼回版本段 //! (`_` 之类的原样保留,所以 `1.13.2-OptiFine_HD_U_E6` 的版本是 `HD_U_E6`); //! 3. 例外:版本段有 **≥2 段** 且**最后一段**长得像 MC 版本(`1.20.1` 这种纯数字点号)时 @@ -50,11 +50,10 @@ /// 一张表生成整份词表:枚举、[`LoaderKind::ALL`]、`as_str`、`has_marker` 一起长出来。 /// -/// 为什么非要用宏:Rust 没有「枚举有哪几支」的编译期反射,手写枚举 + 手写清单时「加了一支 -/// 却忘了进清单」既不报错也不会被测试抓住(测试同样从清单出发),新加载器被**静默**当成 -/// 「认不出」。让两者从同一张表展开,这件事在物理上不可能发生。 -/// -/// 表里的顺序就是标记的匹配优先级;带 `marker` 的条目参与 id 识别,不带的(原版)不参与。 +/// 为什么非要用宏:Rust 没有「枚举有哪几支」的编译期反射,手写枚举 + 手写清单时「加了一支却 +/// 忘了进清单」既不报错也不会被测试抓住(测试同样从清单出发),新加载器被**静默**当成「认不 +/// 出」;让两者从同一张表展开,这件事在物理上不可能发生。表里的顺序就是标记的匹配优先级; +/// 带 `marker` 的条目参与 id 识别,不带的(原版)不参与。 macro_rules! loader_kind_vocab { ( $(#[$meta:meta])* @@ -231,8 +230,8 @@ fn join_version(parts: &[&str]) -> String { kept.join("-") } -/// 这一段长得像不像 MC 版本(`1.20.1` 这种全数字 + 点号)。只用于「砍 HMCL 尾巴」,那一条 -/// 要求前面还有内容(`≥2` 段),所以单独的 `0.16.5` 不会被砍,`0.20.0-beta.9` 也不会。 +/// 这一段长得像不像 MC 版本(`1.20.1` 这种全数字 + 点号);只用于「砍 HMCL 尾巴」(要求前面 +/// 还有内容,所以 `0.16.5` 与 `0.20.0-beta.9` 都不受影响)。 fn looks_like_mc_version(text: &str) -> bool { text.contains('.') && text @@ -375,11 +374,9 @@ mod tests { /// 往返锚:**每个对外 id 喂回解析都要认成它自己**,标记顺序也得留优先级。 /// - /// 抓得住:标记匹配退化(段前缀变整段全等、`loader` 后缀/MC 尾巴规则被改坏);标记串与 - /// 词表脱节;`neoforge` 被排到 `forge` 后面。 - /// 抓不住:**改名**(`neoforge` → `neo_forge`)——词表与解析同源、改名自洽,本条是绿的; - /// 抓改名是对外契约测试的职责(`cli::commands::instances::tests:: - /// json_rows_expose_loader_kind_and_version`)。 + /// 抓得住:标记匹配退化(段前缀变整段全等、`loader` 后缀/MC 尾巴规则被改坏)、标记串与词表 + /// 脱节、`neoforge` 被排到 `forge` 后面。抓不住:**改名**——词表与解析同源、改名自洽,本条 + /// 是绿的;抓改名是 presenter 的 `loader_kind_ids_are_the_released_contract`。 #[test] fn every_stable_id_parses_back_to_its_kind() { for kind in LoaderKind::ALL { diff --git a/crates/presenter/src/dto/instances.rs b/crates/presenter/src/dto/instances.rs index 4e72e31..341ed14 100644 --- a/crates/presenter/src/dto/instances.rs +++ b/crates/presenter/src/dto/instances.rs @@ -281,14 +281,19 @@ mod tests { assert_eq!(LoaderKind::parse("unknown"), None); } - /// DTO 的 `as_str` / `parse` 是 core 词表的**委派**(M126),而词表本身是**对外契约**: - /// 七个 id 在这里独立抄一遍(0.2.0 已发布形状,改名等于违约)。 + /// 对外契约:七个 id 在测试里**独立抄一遍**(0.2.0 已发布形状),并与 core 的 `ALL` 做 + /// **集合比较**——这是「加了一支枚举却没人更新对外词表」的唯一守卫。 /// - /// 抓得住: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`)。 + /// 为什么不是编译器管:编译期只拦得到「漏 match」,而新种类在三处消费方用最省事的 + /// `=> Vanilla` 糊过去**编得过**,那时新加载器会静默显示成原版(`game::loader` 模块文档 + /// 第 ③ 步)。真正拦住它的是下面第 ② 段的集合断言。 + /// + /// 抓得住:改词表(`neoforge` → `neo_forge`);**新增一家加载器而没主动更新这份契约** + /// (core 的 `ALL` 多一支 ⇒ 集合不等);DTO 委派接错支(`OptiFine` 映到 `LiteLoader`); + /// `parse` 收进不该有的别名。 + /// 抓不住:core 的解析规则(那是 `game::loader` 的 + /// `every_stable_id_parses_back_to_its_kind` 等测试的职责),以及真二进制输出面的 JSON + /// (CLI 的 `json_rows_expose_loader_kind_and_version`)。 #[test] fn loader_kind_ids_are_the_released_contract() { const CONTRACT: [(LoaderKind, &str); 7] = [ @@ -300,12 +305,28 @@ mod tests { (LoaderKind::OptiFine, "optifine"), (LoaderKind::LiteLoader, "liteloader"), ]; + + // ① DTO 这一层:每支的 id、反查、大小写。 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}`"); } + + // ② core 的词表 ↔ 这份契约:**集合相等**才过(多一支、少一支、改一个字都红)。 + let mut actual: Vec<&str> = CoreLoaderKind::ALL + .iter() + .map(|kind| kind.as_str()) + .collect(); + actual.sort_unstable(); + let mut expected: Vec<&str> = CONTRACT.iter().map(|(_, id)| *id).collect(); + expected.sort_unstable(); + assert_eq!( + actual, expected, + "core 的 `LoaderKind::ALL` 必须逐字等于对外契约:加一家加载器时先改这里 + help" + ); + // GUI 输入别名(不是对外 id):不装加载器有三种写法;别名与拼法都不许混进词表。 for alias in ["none", "", " "] { assert_eq!( @@ -314,8 +335,7 @@ mod tests { "`{alias}`" ); } - assert_eq!(LoaderKind::parse("neo-forge"), None); - assert_eq!(LoaderKind::parse("rift"), None); + assert_eq!(LoaderKind::parse("neo-forge"), None, "别名只归安装侧"); } #[test]