M134 复审点名、且都在它 scope 外的收尾。 - `presenter/src/host/launch.rs::loader_label` 删除:全仓零调用点(唯一提及是 i18n 的一条注释),函数体逐字重复 `dto::LoaderKind::label()` 并硬编码「原版」—— 它既是第二份显示词表,又绕过 i18n;留着等于给下一个人示范「照这样写就行」。 随之无用的 `LoaderKind` import 一并收掉。 - `presenter/src/lib.rs` 的 dead_code 豁免说明不再点名已删函数(否则那行注释指向 一个不存在的符号)。 - `i18n/src/bridge.rs:65` 注释改指向唯一那份显示词表(`dto::LoaderKind::label()`)。 - `core/src/game/loader.rs` 与 `presenter/src/dto/instances.rs` 两处「显示路径 没有守卫(已知缺口)」改成 M134 之后的真形态:显示路径是委派(拿 core 的稳定 id 反查 DTO 词表,没有手写 match 可糊),新种类先在 `dto::LoaderKind::from_core` 编 不过,糊错的后果由 `loader_kind_ids_are_the_released_contract` 与按 `ALL` 遍历的 `every_core_loader_kind_reaches_the_tile_as_itself` 钉住。 - `presenter/src/host/game.rs` 无需改动:M134 当时已把它的文档改成新形态,读下来是 真话(`dto_kind` 的 doc 与两个测试的 doc 都对得上现在的代码)。 验证:fmt --all --check 通过;`cargo test -p unlucky-presenter -p unlucky-i18n` = i18n 29 passed / presenter 220 passed(1 ignored);clippy 两 crate --all-targets `-D warnings` 零告警。删后全仓(含 workspace 外的 crates/app)已无 `launch::loader_label` 引用。
185 lines
9.1 KiB
Rust
185 lines
9.1 KiB
Rust
//! 文案边界(示例契约):core / presenter 的**状态**怎么进 UI。
|
||
//!
|
||
//! ## 现状(为什么需要这份契约)
|
||
//!
|
||
//! UI 文案硬编码英文,而 `core` / `presenter` 产出的状态串是中文,于是界面上已经出现
|
||
//! `Launch failed · exit0 静默失败` 这种**一句话里中英混排**的东西,加载器徽标也在 `原版` / `Fabric`
|
||
//! 之间混用。根因不是「译文不够」,是**边界没划**:谁产出文案、谁负责翻译,没有规则。
|
||
//!
|
||
//! ## 三条规则
|
||
//!
|
||
//! **R1 · UI 侧不产生文案,只翻译 key。**
|
||
//! UI 不许把片段拼成句子(`"Launch failed · " + reason`),也不许写中文字面量。
|
||
//! 一句话 = 一个 key([`crate::catalog`]),带变量的用 `{name}` 交给
|
||
//! [`crate::catalog::Catalog::render`]。拼接一旦发生,任何一套语言都拼不对(语序、标点、
|
||
//! 量词都不一样),而且混排会立刻出现在截图里。
|
||
//!
|
||
//! **R2 · core 的中文串不进 UI。**
|
||
//! core / presenter 的中文(`原版`、`静默失败(exit 0 但游戏没起来)`、
|
||
//! `缺少库文件 foo.jar(…)`)是**日志 / CLI / 诊断报告**的文案,不是界面文案。
|
||
//! 界面只能通过两条合法通道拿到「要显示什么」:
|
||
//! 1. **结构化出口**(首选):枚举 / DTO 字段 → 映射到 [`crate::Key`]。
|
||
//! 例:`presenter::dto::LoaderKind::as_str()` 的稳定 id(`vanilla` / `fabric`)→
|
||
//! [`crate::Key::LoaderVanilla`];`LogOutcome` 三态 → `LaunchOutcome*` 三 key。
|
||
//! 好处是**新增一个枚举变体时编译器会逼你补映射**(`match` 非穷尽即报错)。
|
||
//! 2. **过渡表**([`classify`]):只覆盖「已知会漏到界面的少数几个串」,逐条硬编码。
|
||
//! 它是**债**,不是终点——新出现的未知串一律走第三条。
|
||
//!
|
||
//! **R3 · 认不出的串,不许画原文。**
|
||
//! [`CoreText::Untranslated`] 就是那种情况:UI 只许显示 [`crate::Key::CoreTextUntranslated`]
|
||
//! 这个**故意难看**的兜底(`[system text not translated]`),并把原文写进日志。
|
||
//! 这条比「先画上去以后再翻」严格,是因为后者永远不会被回头修——难看才会被修。
|
||
//! 同时它是一件**上游缺陷的证据**:说明 core 欠一个结构化出口,应当报 finding 而不是在 UI 贴创可贴。
|
||
//!
|
||
//! ## 品牌专名(唯一的豁免)
|
||
//!
|
||
//! `Fabric` / `Forge` / `NeoForge` / `Quilt` / `OptiFine` / `LiteLoader` 是专有名词,**不翻译**
|
||
//! (翻成「锻造」就没人认识了)。它们可以原样显示,但必须经 [`CoreText::Brand`] 判定——
|
||
//! 也就是说「原样显示」是一个**显式决定**,不是「反正没译文就画原文」的默认行为。
|
||
//!
|
||
//! ## 诚实边界(抓得住什么 / 抓不住什么)
|
||
//!
|
||
//! - **抓得住**:本表里列到的串(映射正确、未知串不被放行);
|
||
//! - **抓不住**:core 明天新加一句中文——本 crate 不依赖 core(零依赖纪律),没有跨 crate 的一致性检查。
|
||
//! 那条只能靠「core 侧只给枚举」的纪律 + 后续在 `crates/presenter` 里加一条守门测试
|
||
//! (例如:`*_view` 的字符串字段不许含 CJK 字符)。
|
||
|
||
use crate::catalog::Key;
|
||
|
||
/// 一段来自 `core` / `presenter` 的显示文本该怎么处理。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum CoreText {
|
||
/// 品牌专名:原样显示(不翻译,见模块文档的豁免)。
|
||
Brand(&'static str),
|
||
/// 有对应 key:按当前语言取词。
|
||
Key(Key),
|
||
/// 不认识:只许显示 [`Key::CoreTextUntranslated`],原文进日志。
|
||
Untranslated,
|
||
}
|
||
|
||
/// 已知会漏到界面的中文状态串 → key。
|
||
///
|
||
/// 每一条都得在 `crates/presenter` / `crates/core` 里找得到出处(注释里给位置),
|
||
/// 不许凭想象加——凭空加的条目会让 [`classify`] 假装覆盖了实际没覆盖的东西。
|
||
const LEGACY_STATUS: &[(&str, Key)] = &[
|
||
// `presenter::dto::instances::LoaderKind::label()`(全仓唯一那份显示词表)
|
||
("原版", Key::LoaderVanilla),
|
||
// `presenter::host::job.rs`:`LogOutcome::SilentFail` 的展示文字
|
||
("静默失败(exit 0 但游戏没起来)", Key::LaunchOutcomeSilent),
|
||
// `presenter::mock_data.rs` 里的演示数据
|
||
("exit0 静默失败", Key::LaunchOutcomeSilent),
|
||
];
|
||
|
||
/// 品牌专名(原样显示;**不得**翻译或拼进句子,UI 拼装仍然禁止)。
|
||
const BRANDS: &[&str] = &[
|
||
"Fabric",
|
||
"Forge",
|
||
"NeoForge",
|
||
"Quilt",
|
||
"OptiFine",
|
||
"LiteLoader",
|
||
];
|
||
|
||
/// 契约总入口:core / presenter 的显示文本 → 该画什么。
|
||
///
|
||
/// 这是**示例契约**:形状(三分类 + 未知不放行)比表里的具体条目重要,接线 mission 按这个形状
|
||
/// 把每条 UI 路径改过来,条目随上游结构化出口的落地逐条删掉。
|
||
#[must_use]
|
||
pub fn classify(text: &str) -> CoreText {
|
||
let trimmed = text.trim();
|
||
if trimmed.is_empty() {
|
||
return CoreText::Untranslated;
|
||
}
|
||
|
||
if let Some((_, key)) = LEGACY_STATUS.iter().find(|(legacy, _)| *legacy == trimmed) {
|
||
return CoreText::Key(*key);
|
||
}
|
||
|
||
if let Some(brand) = BRANDS.iter().find(|brand| **brand == trimmed) {
|
||
return CoreText::Brand(brand);
|
||
}
|
||
|
||
// `Fabric 0.16.5` 这类「品牌 + 版本号」也是合法显示:品牌之后只跟一个版本号时放行。
|
||
// 注意这里是**保守**放行:只有「一个词 + 空格 + 版本号」的形状才算,含中文的串绝不会进来。
|
||
if let Some((head, tail)) = trimmed.split_once(' ') {
|
||
if let Some(brand) = BRANDS.iter().find(|brand| **brand == head) {
|
||
if !tail.is_empty() && !tail.contains(char::is_whitespace) && !tail.chars().any(is_cjk)
|
||
{
|
||
return CoreText::Brand(brand);
|
||
}
|
||
}
|
||
}
|
||
|
||
CoreText::Untranslated
|
||
}
|
||
|
||
/// 是否 CJK 字符(`classify` 的保守放行判据用)。
|
||
fn is_cjk(ch: char) -> bool {
|
||
// 0x2E80..=0x9FFF 已含 CJK 部首 / 假名 / 统一表意文字与 0x3000 段的中文标点,不再单列。
|
||
matches!(ch as u32, 0x2E80..=0x9FFF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn known_chinese_status_strings_map_to_keys() {
|
||
// 抓得住:过渡表条目的映射写错(把「原版」映到别的 key),以及「同一段意思两个 key」的漂移。
|
||
// 抓不住:core 那边改了这个中文串的措辞——那时这里会落到 Untranslated(**故意的**,
|
||
// 让漏译可见,而不是继续拿旧文案糊过去)。
|
||
assert_eq!(classify("原版"), CoreText::Key(Key::LoaderVanilla));
|
||
assert_eq!(
|
||
classify("静默失败(exit 0 但游戏没起来)"),
|
||
CoreText::Key(Key::LaunchOutcomeSilent)
|
||
);
|
||
assert_eq!(
|
||
classify("exit0 静默失败"),
|
||
CoreText::Key(Key::LaunchOutcomeSilent)
|
||
);
|
||
assert_eq!(
|
||
classify(" 原版 "),
|
||
CoreText::Key(Key::LoaderVanilla),
|
||
"前后空白要吃掉"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn brands_pass_through_and_are_never_translated() {
|
||
// 抓得住:品牌名被当成待翻译文案(`Fabric` → 某个 key 的译文就没人认识它了),
|
||
// 以及「品牌 + 版本号」这种合法徽标被判成未翻译。
|
||
// 抓不住:品牌名与版本号之间该用什么分隔(M3 令牌 / 排版层的事,不是本契约的事)。
|
||
assert_eq!(classify("Fabric"), CoreText::Brand("Fabric"));
|
||
assert_eq!(classify("Fabric 0.16.5"), CoreText::Brand("Fabric"));
|
||
assert_eq!(classify("NeoForge 21.1.72"), CoreText::Brand("NeoForge"));
|
||
assert_eq!(classify("Fabric 中文"), CoreText::Untranslated);
|
||
assert_eq!(classify("Fabric 0.16.5 beta"), CoreText::Untranslated);
|
||
assert_eq!(classify("FabricX"), CoreText::Untranslated);
|
||
}
|
||
|
||
#[test]
|
||
fn unknown_core_prose_is_refused_instead_of_printed_raw() {
|
||
// 抓得住:R3——未知中文(core 的诊断prose、`describe_warning` 的拼句)被放行到界面,
|
||
// 那正是 `Launch failed · exit0 静默失败` 这类混排的来源。
|
||
// 抓不住:UI 拿到 Untranslated 之后有没有真的只显示兜底(页面侧测试 + 截图验收)。
|
||
let leaks = [
|
||
"缺少库文件 lwjgl.jar(C:\\Users\\x\\.minecraft\\libs\\lwjgl.jar)",
|
||
"版本 JSON 没有 mainClass",
|
||
"退出码为 0 但疑似静默失败(3 条错误行):游戏可能刚起来就被 mod 异常终止",
|
||
"",
|
||
" ",
|
||
];
|
||
for leak in leaks {
|
||
assert_eq!(classify(leak), CoreText::Untranslated, "{leak}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn classify_never_returns_a_raw_unknown_string() {
|
||
// 抓得住:将来给 `CoreText` 加了「原样字符串」变体(那等于把 R3 废掉)。
|
||
// 抓不住:枚举里已有变体被误用——那要靠调用点 review。
|
||
let odd = "some untranslated english";
|
||
assert_eq!(classify(odd), CoreText::Untranslated);
|
||
}
|
||
}
|