diff --git a/crates/app-iced/src/app/view/instances.rs b/crates/app-iced/src/app/view/instances.rs index b8b5ea0..da88dbf 100644 --- a/crates/app-iced/src/app/view/instances.rs +++ b/crates/app-iced/src/app/view/instances.rs @@ -32,17 +32,18 @@ pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> S ) } -/// 加载器徽标:原版走词表,其余是品牌 + 版本号(原样)。 +/// 加载器徽标:原版走词表,其余交给 `LoaderInfo::badge()`(品牌 + 版本号,原样)。 +/// +/// **判据只能是「加载器类型本身是原版」**。曾经写成「原版 **或** 版本号为空」⇒ +/// `1.20.1-forge` / `1.20.1-neoforge` / `1.20.1-OptiFine` 这类**取不到版本段**的 id(`badge()` +/// 对它们给的就是品牌名 `Forge` / `NeoForge` / `OptiFine`)会被显示成「原版 / Vanilla」—— +/// 那是 M120 r1 的 p2-1 回归,`the_loader_badge_keeps_the_brand_when_the_version_is_missing` 守着。 fn loader_badge(instance: &InstanceSummary, catalog: Catalog) -> String { - // `badge()` 在「原版 / 版本号为空」两种情况都给 `kind.label()`(中文「原版」),只有这一支要过词表。 - if instance.loader.kind == LoaderKind::Vanilla || instance.loader.version.is_empty() { - catalog.text(Key::LoaderVanilla).to_string() - } else { - format!( - "{} {}", - instance.loader.kind.label(), - instance.loader.version - ) + match instance.loader.kind { + // 唯一需要过词表的一支:`LoaderKind::label()` 对原版返回的是**中文**「原版」 + LoaderKind::Vanilla => catalog.text(Key::LoaderVanilla).to_string(), + // 其余品牌名不翻译;版本号为空时 `badge()` 自己就只给品牌名 + _ => instance.loader.badge(), } } diff --git a/crates/app-iced/src/app/view/tests.rs b/crates/app-iced/src/app/view/tests.rs index 31d6eb3..d011570 100644 --- a/crates/app-iced/src/app/view/tests.rs +++ b/crates/app-iced/src/app/view/tests.rs @@ -1,6 +1,7 @@ //! `app/view.rs` 的守门测试(rebase 到 5adac23 之后 view.rs 贴 400 行上限,按「测试独立成文件」拆出)。 use super::*; +use unlucky_i18n::Key; use unlucky_presenter::dto::{ InstanceRuntime, InstanceSummary, LaunchStage, LoaderInfo, LoaderKind, Progress, }; @@ -253,6 +254,60 @@ fn the_vanilla_badge_follows_the_language() { assert_eq!(zh.instances.rows[0].supporting, "1.20.4 · 原版"); } +/// **品牌名不许被「版本号为空」吞掉**(M120 r1 复审的 p2-1 回归)。 +/// +/// 背景:`host::game::loader_from_id("1.20.1-forge")` 这类 id 里取不到版本段(它按 `forge-` +/// 找,而 id 的写法是 `-forge` 结尾),于是 `LoaderInfo { kind: Forge, version: "" }`。 +/// `LoaderInfo::badge()` 对这种输入给的正是品牌名 `Forge`——所以判据只能是「类型是原版」, +/// 不能是「版本号为空」。 +/// +/// 三个 id 各一条断言(复审点名的三档);`Vanilla` 那一支由 +/// `the_vanilla_badge_follows_the_language` 守,两条合起来把 `loader_badge` 的两侧都钉住。 +/// +/// 抓得住:`loader_badge` 把「非原版 + 空版本号」也送进 `Key::LoaderVanilla` +/// (r1 的真实形状:`left: "1.20.4 · Vanilla" / right: "1.20.4 · Forge"`); +/// 抓不住:`loader_from_id` 认不出自家 `-fabric-` / `-quilt-` 口径这个**更老**的 bug +/// (在 presenter 里,另一条 mission)。 +#[test] +fn the_loader_badge_keeps_the_brand_when_the_version_is_missing() { + // (版本 id, 期望徽标) —— id 形状取自 `host::game::loader_from_id` 的三条真实输入 + let cases = [ + ("1.20.1-forge", LoaderKind::Forge, "Forge"), + ("1.20.1-neoforge", LoaderKind::NeoForge, "NeoForge"), + ("1.20.1-OptiFine", LoaderKind::OptiFine, "OptiFine"), + ]; + + for (id, kind, brand) in cases { + let mut one = instance(InstanceRuntime::Idle); + one.loader = LoaderInfo { + kind, + // `loader_from_id` 对这三条 id 取不到版本段 ⇒ 空串 + version: String::new(), + }; + let mut state = AppState::new(); + state.apply(unlucky_presenter::dto::AppEvent::Core( + unlucky_presenter::dto::CoreEvent::InstancesChanged(vec![one]), + )); + state.navigate(Route::Detail("survival".to_string())); + + // 品牌名两套语言都不翻译 ⇒ 中英逐字相同 + for lang in [Lang::En, Lang::ZhCn] { + let model = at_lang(&state, None, lang); + assert_eq!( + model.instances.rows[0].supporting, + format!("1.20.4 · {brand}"), + "{id} 的徽标被显示成了别的东西(新增的判据「版本号为空 ⇒ 原版」就是这条要拦的)" + ); + let detail = model.instances.detail.expect("有详情"); + assert_eq!( + detail.meta[1].label, + lang.catalog().text(Key::InstancesMetaLoader) + ); + assert_eq!(detail.meta[1].value, brand, "{id} 的 LOADER 卡值"); + } + } +} + #[test] fn the_agent_route_has_no_page() { let mut state = AppState::new(); diff --git a/crates/app-iced/src/pages/instances/tests/i18n.rs b/crates/app-iced/src/pages/instances/tests/i18n.rs index 9808465..4e96787 100644 --- a/crates/app-iced/src/pages/instances/tests/i18n.rs +++ b/crates/app-iced/src/pages/instances/tests/i18n.rs @@ -5,15 +5,16 @@ //! | [`the_chinese_page_has_no_english_words`] | 目录里某条中文忘了翻(或翻完还留着英文词) | //! | [`every_instances_text_changes_with_the_language`] | 两套语言写成一样(= 切换语言时那一处不动,看着像「已 i18n」其实是死文案) | //! | [`the_running_line_keeps_its_number_and_separator`] | `Running · pid 1234` 的数字 / `pid` / `·` 被翻译走样 | -//! | [`the_page_sources_have_no_english_baseline_left`] | 页面里又写回旧英文基线字面量(M120 之前的形状) | +//! | [`every_english_literal_in_the_page_sources_is_whitelisted`] | 页面里写死**任何**英文(旧串或新写的,白名单判定;r1 之前是黑名单,对新写的英文是哑的) | //! | [`every_instances_key_is_referenced_by_the_page`] | 目录里加了 key、页面没人用(死 key,M117 那七条就是这么来的) | //! //! ## 边界(别把这张表读成「视图覆盖」) //! //! iced 0.14 的 `Element` **没有内省接口**(`instances/tests.rs` 里那条「为什么读源码」写的就是它), //! 所以「上屏文本」取样只能从页面取词的那**一个入口** [`Texts`] 拿:页面用它画、测试用同一个 -//! `Texts` 断言,不是另写一份平行实现。仍然抓不住的是「页面又冒出一条**新**硬编码字面量」—— -//! 那由 [`the_page_sources_have_no_english_baseline_left`] 兜已知的那批 + 出图人工看。 +//! `Texts` 断言,不是另写一份平行实现。「页面又冒出一条**新**硬编码英文字面量」由 +//! [`every_english_literal_in_the_page_sources_is_whitelisted`] 兜(白名单);**中文**写死的 +//! 字面量它看不见,那只能靠 `Texts::of(` 的反向锚与出图人工看。 //! 「模型这一层(快照 → 状态行 / 元信息)跟着 `Lang` 变」由 `app/view/tests.rs` 守。 use unlucky_i18n::catalog::MISSING; @@ -41,42 +42,37 @@ const PAGE_SOURCES: [(&str, &str); 5] = [ ), ]; -/// M120 之前页面里那些**英文基线字面量**。它们现在只许活在目录里。 +/// 页面五件套里**允许出现的字符串字面量**(凡是带 ASCII 单词的都必须在这张表里)。 /// -/// 页头标题 `Instances` 不在清单里:它在后面由结构断言「`Header::new` 的实参必须是 -/// `texts.title()`」单独守(`"Instances"` 这三个字在 `text.rs` 里是**族名前缀**,进清单会假红)。 -const OLD_LITERALS: [&str; 29] = [ - "ALL INSTANCES", - "Local versions and their mod loaders", - "New instance", - "Import", - "Back to instances", - "Play", - "Stop", - "Duplicate", - "Open folder", - "Delete", - "No instance selected", - "Present on disk", - "Missing", - "Never", - "Running · pid ", - "Launch failed · ", - "Downloading", - "Verifying", - "Starting", - "Waiting for window", - "VERSION", - "VERSION ID", - "LOADER", - "ISOLATION", - "FILES", - "LAST PLAYED", - "MEMORY", - "JAVA", - "MODS", +/// M120 r1 复审实测出黑名单的哑点:只兜「已知的 29 条旧串」时,往页面里**新写一句英文** +/// 照样 612/612 + 12/12 全绿。判据现在反过来——**白名单**:页面源码里每个带 ASCII 单词的 +/// 字面量都要在这里找到归宿,新写一句英文立刻红。 +/// +/// 全部 6 条都写了理由(剩下一批 `"{} · {}"` / `"{} · {:.0}%"` / `"{} {}"` 这类格式化模板 +/// **连一个词都没有**,所以不需要条目): +/// +/// | 字面量 | 为什么它可以是英文 | +/// |---|---| +/// | `{date} {time} UTC` | `UTC` 是时区缩写,不是界面文案(本地时区换算属时间格式 mission) | +/// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 | +/// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) | +/// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) | +const ALLOWED_LITERALS: [&str; 6] = [ + "{date} {time} UTC", + "isolated", + "shared", + "pid", + "reason", + "Instances", ]; +/// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。 +/// +/// 按 `"` 切开取**奇数段**即可:`a"b"c"d"e` → 段 1 = `b`、段 3 = `d`。 +fn string_literals(source: &str) -> Vec<&str> { + source.split('"').skip(1).step_by(2).collect() +} + /// 把 `{占位符}` 抹掉再切 ASCII 词:占位符是**运行时数据**(pid / 原因 / 时间),不是文案。 fn ascii_words(text: &str) -> Vec { let mut stripped = String::new(); @@ -206,27 +202,39 @@ fn the_running_line_keeps_its_number_and_separator() { assert_eq!(failed_zh, "启动失败 · exit 1"); } -/// **旧串不许回来**:M120 之前页面里的英文基线字面量,现在只许活在目录里。 +/// **页面里不许再写死界面文案**(白名单):带 ASCII 单词的字面量必须在 [`ALLOWED_LITERALS`] 里。 /// -/// 为什么用「已知清单」而不是「扫所有英文串」:合法写法太多了(`Action::Import` 这类枚举、 -/// 品牌名、文档里的反引号代码),扫宽了会假红。清单里这 29 条是**这次真的搬走的那批**, -/// 谁把它们抄回页面,这条就红——那正是「页面又变成中英混排」的唯一来源。 +/// 抓得住:①M120 之前那 29 条旧串被抄回页面(它们都带英文词 ⇒ 不在白名单 ⇒ 红); +/// ②**任何新写的英文字面量**(r1 复审抓到的哑点:黑名单版本把 `"Ready"` 写进页面是全绿的)。 +/// 抓不住:中文写死的字面量(`"实例"` 没有 ASCII 词)——那要靠 `Texts::of(` 的反向锚与出图; +/// 也抓不住夹具数据(`pages/instances/data.rs` 故意不在扫描范围:那是**用户数据**的样子, +/// 不是界面文案,见该文件的模块文档)。 /// -/// **变异实测**(M120 交付时跑过):把 `"ALL INSTANCES"` 写回 `instances.rs` 的页头,立刻红 -/// 并指出是哪个文件、哪一句。 +/// **变异实测(M120 r1 交付时跑过三条)**:写回 `"ALL INSTANCES"` ⇒ 红;写进一条全新的 +/// `"Ready"` ⇒ 红;把 `Texts::of(` 从实例页删掉 ⇒ 反向锚红。 #[test] -fn the_page_sources_have_no_english_baseline_left() { +fn every_english_literal_in_the_page_sources_is_whitelisted() { + let mut checked = 0; for (name, source) in PAGE_SOURCES { - for literal in OLD_LITERALS { - let needle = format!("\"{literal}\""); + for literal in string_literals(source) { + if ascii_words(literal).is_empty() { + continue; + } + checked += 1; assert!( - !source.contains(&needle), - "{name} 里又写回了英文基线字面量 {literal:?}(走 Texts / 目录,别写死)" + ALLOWED_LITERALS.contains(&literal), + "{name} 里的字面量 {literal:?} 带 ASCII 单词但不在白名单里:界面文案要走 Texts / 目录;确属专有名词的话加进 ALLOWED_LITERALS 并写明理由" ); } } - // 反向锚:这些来源**必须**真的在用 Texts / 目录,否则上面那条是空转 + // 反向锚①:扫描器真的扫到了东西(否则上面那个循环是空转) + assert!( + checked >= 6, + "只扫到 {checked} 条带英文的字面量,扫描器可能失效了" + ); + + // 反向锚②:这些来源**必须**真的在用 Texts / 目录 assert!( PAGE_SOURCES[0].1.contains("Texts::of("), "实例页没有从 Texts 取词"