From ebfc08368bc4689fad32b9555c56174f24e76e65 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:08:34 +0800 Subject: [PATCH] fix(i18n): a number in a search keyword list must survive translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Searching Settings for `16` found ANSI colours in English and Japanese and nothing at all in Chinese. Both other languages carry "16" in that row's keyword list; the Chinese one did not, and neither does the Chinese title ("ANSI 颜色"), so the row was unreachable by the number people actually type for it. Keywords are match data rather than prose. A digit is the part of them that does not translate — "16" is "16" in every language — so dropping one silently narrows what a speaker of that language can find. The guard holds every keyword list to carrying each number English does. A translation may add its own; it may not lose one. Only keyword lists: digits in ordinary copy are phrasing, and the Japanese for "one per line" carries a 1 the English has no reason to. Found by comparing numeric literals across the three tables, which is a check nothing else does — the existing i18n tests hold placeholders, plural branches and vocabulary, all of which this passes. --- src/ui/i18n/mod.rs | 7 +++++ src/ui/i18n/zh.rs | 2 +- src/ui/settings.rs | 66 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 0c064214..c1673635 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1471,6 +1471,13 @@ fn current_locale_index() -> usize { CURRENT.load(Ordering::Relaxed) as usize } +/// [`translate`] for one named locale, so a test can compare the languages +/// against each other without steering the process-wide one. +#[cfg(test)] +pub(crate) fn translate_for_test(locale_idx: usize, key: L10nKey) -> &'static str { + translate(locale_idx, key) +} + fn translate(locale_idx: usize, key: L10nKey) -> &'static str { if let Some(lang) = SUPPORTED_LANGUAGES.get(locale_idx) && let Some(text) = (lang.translate_fn)(key) diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 4b4a5724..dcce3918 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -678,7 +678,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { "代理 proxy http https socks socks5 clash v2ray 网络 下载 更新" } L10nKey::SettingsSearchAnsiColorsKeywords => { - "ANSI颜色 调色板 终端颜色 主题 ansi colors palette terminal theme" + "ANSI颜色 调色板 终端颜色 16色 主题 ansi colors palette terminal theme" } L10nKey::SettingsSearchBackgroundImageKeywords => { "背景图片 背景图 壁纸 图片 照片 主题 background image wallpaper picture theme" diff --git a/src/ui/settings.rs b/src/ui/settings.rs index bb7c19ff..1ead6d03 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -8507,3 +8507,69 @@ mod forward_bind_gpui_tests { assert_eq!(panel.bind_host, rule.bind.host, "the two forms agree"); } } + +#[cfg(test)] +mod search_keyword_tests { + use super::*; + + /// A number in a search keyword list must survive translation. + /// + /// Keywords are match data, not prose: they exist so someone typing what + /// they would call the thing lands on its row. A digit is the part of that + /// which does not translate — "16" is "16" in every language — so a + /// translation that drops one silently narrows what its speakers can find. + /// + /// `SettingsSearchAnsiColorsKeywords` was the case. English and Japanese + /// both carried "16"; Chinese did not, so searching `16` under `zh-CN` + /// matched nothing at all while the other two found ANSI colours. + /// + /// Only the keyword strings. Digits in ordinary copy are phrasing — the + /// Japanese for "one per line" contains a 1 the English has no reason to — + /// and holding those to the English would be nonsense. + #[test] + fn a_number_in_a_keyword_list_survives_translation() { + use crate::ui::i18n::{SUPPORTED_LANGUAGES, translate_for_test}; + + fn digits(text: &str) -> std::collections::BTreeSet { + let mut out = std::collections::BTreeSet::new(); + let mut cur = String::new(); + for c in text.chars() { + if c.is_ascii_digit() { + cur.push(c); + } else if !cur.is_empty() { + out.insert(std::mem::take(&mut cur)); + } + } + if !cur.is_empty() { + out.insert(cur); + } + out + } + + let mut checked = 0usize; + for entry in settings_search_entries() { + let want = digits(translate_for_test(0, entry.keywords)); + if want.is_empty() { + continue; + } + checked += 1; + for (idx, lang) in SUPPORTED_LANGUAGES.iter().enumerate().skip(1) { + let got = digits(translate_for_test(idx, entry.keywords)); + let dropped: Vec<&String> = want.difference(&got).collect(); + // A translation may add numbers of its own; it may not lose one. + assert!( + dropped.is_empty(), + "{:?}: the {} keywords drop {:?}, which English carries, so a search \ + for it finds nothing in that language", + entry.title, + lang.code, + dropped + ); + } + } + assert!( + checked > 0, + "no keyword list carries a number, so this guard is checking nothing" + ); + } +}