feat(app-iced): M120 实例页与启动阶段标签跟着界面语言走

来源:M106 r1 复审点名——中文机器上首屏(实例页)仍是英中混排(页头 / New instance /
Import / ALL INSTANCES / VERSION / Present on disk / Running · pid / Launch failed)。
用户的原话是「我系统语言是中文 为什么打开软件是英文」。

改了什么
- i18n:catalog/instances.rs 从 6 条死 key 补到 34 条(页头 / 列表列头 / 详情动作 /
  窄窗说明 / 元信息标签与值 / 隔离档位 / 运行状态 / 四个启动阶段)。族在装配链第 2 位,
  新 key 追加在本族末尾,golden 对应位置插入(不是文件末尾追加)。
- 页面:新增 pages/instances/text.rs(Texts)——页面唯一的取词入口,实例页里不再有
  任何可见英文字面量;detail / demo 也改从它取。
- 阶段标签:view/instances.rs 的 Running / Launch failed / 四阶段全部走目录;
  **数字与分隔符不随语言变形**(运行中 · pid 1234 与 Running · pid 1234 只换前半句)。
- 顺手修掉两处「语言反向泄漏」:原版徽标此前直接画 presenter 的中文「原版」(英文界面
  会漏中文),现在走 LoaderVanilla;隔离档位 isolated/shared 也从 core 的稳定 id 换成人话。
- 删掉 InstancesLastPlayed:M117 加的整句 key,从未接线,形状与「标签 + 值」的元信息卡
  不符,由 InstancesMetaLastPlayed + InstancesNeverPlayed 取代。

守门(pages/instances/tests/i18n.rs + app/view/tests.rs)
- 中文态无 ASCII 英文单词(品牌 Java / 技术标识 pid 白名单);
- 每一条 Instances* 的两套译文必须不同(切语言时真的变);
- Running 的数字与 · pid 分隔符在两套语言里逐字保留;
- 页面源码里 29 条旧英文基线不许回来;每条 key 都必须被页面引用(死 key)。

出图:DevDocs/artifacts/m120/01~12(有实例 / 空态 / 运行中 × 浅深 × 中英)。
This commit is contained in:
AskaEth committed 2026-11-05 13:36:44 +08:00
1 parent 2f491c31f9
commit 1a4e1fee12
17 files changed
+924 -119

No files matched your search

+17 -10
View File
@@ -48,6 +48,7 @@
mod data;
mod demo;
mod detail;
mod text;
#[cfg(test)]
mod tests;
@@ -55,6 +56,7 @@ mod tests;
#[cfg(test)]
pub(crate) use data::{DEMO, NOTE, SELECTED};
pub(super) use demo::demo;
pub(super) use text::Texts;
use iced::widget::{column, container, row, scrollable};
use iced::{Alignment, Background, Border, Element, Length, Padding};
@@ -92,22 +94,26 @@ pub(super) const ROW_SPACING: Option<Space> = None;
pub(super) const LIST_BOTTOM_INSET: Space = Space::X2;
/// 整页(`block.detail = None` 时详情区显示空态)。
///
/// 文案全部走 [`Texts`](`model.lang` 的语言):这个函数是实例页唯一的取词入口,
/// 页面里不留任何可见英文字面量(守门见 `instances/tests/i18n.rs`)。
pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, model: &'a Model) -> Element<'a, Message> {
let block = &model.instances;
let forced = model.forced;
let header = Header::new("Instances")
.subtitle("Local versions and their mod loaders")
let texts = Texts::of(model.lang.catalog());
let header = Header::new(texts.title())
.subtitle(texts.subtitle())
.action(buttons::text(
ui,
forced_state(
Button::new("New instance", Message::Action(Action::NewInstance)),
Button::new(texts.new_instance(), Message::Action(Action::NewInstance)),
forced,
),
))
.action(buttons::text(
ui,
forced_state(
Button::new("Import", Message::Action(Action::Import)),
Button::new(texts.import(), Message::Action(Action::Import)),
forced,
),
));
@@ -115,15 +121,15 @@ pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, model: &'a Model) -> Element<
let body = match metrics.layout {
PageLayout::ListDetail => frame::panes(
metrics,
list_column(ui, block, forced),
list_column(ui, block, forced, texts),
match &block.detail {
Some(detail) => detail::detail_column(ui, metrics, detail, forced),
None => detail::detail_empty(ui),
Some(detail) => detail::detail_column(ui, metrics, detail, forced, texts),
None => detail::detail_empty(ui, texts),
},
),
_ => match &block.detail {
Some(detail) => detail::narrow_detail(ui, metrics, detail, forced),
None => frame::body(metrics, detail::detail_empty(ui)),
Some(detail) => detail::narrow_detail(ui, metrics, detail, forced, texts),
None => frame::body(metrics, detail::detail_empty(ui, texts)),
},
};
@@ -136,6 +142,7 @@ fn list_column<'a>(
ui: &'a Ui,
block: &'a Instances,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
let head = container(
row![
@@ -143,7 +150,7 @@ fn list_column<'a>(
ui,
TypeRole::LabelMedium,
false,
"ALL INSTANCES",
texts.all_title(),
ui.color(Role::OnSurfaceVariant),
))
.width(Length::Fill)
+9 -2
View File
@@ -1,9 +1,16 @@
//! 实例页的**演示数据**(骨架期没有业务状态机,也没有业务数据)。
//!
//! 12 条,**故意比可视区高**:列表列的独立滚动条要能在截图里看见(单测钉住这条)。
//! 文案是英文基线:i18n 是后续 mission,干净 CI 上没有中文字体,写中文会变豆腐块。
//!
//! **这里的数据是夹具(用户数据),不是界面文案**:`1.20.4` / `Fabric 0.15.7` / `4 GiB` /
//! `2 hours ago` 这些是「实例清单里会长成什么样」的样本,M120 没有把它们搬进词表——它们不是
//! 界面说的那句话。界面文案(标题 / 按钮 / 元信息标签 / 空态 / 阶段)走 `catalog/instances.rs`,
//! 见 `text.rs` 与 `app/view/instances.rs`。
//!
//! 这条路径(`pages_demo` harness / 单测)**恒英文**:`pages::demo_model` 走 `Model::new`,
//! `lang` 是 `Lang::En`。产品路径不读这份数据,它画的是 presenter 快照。
/// 演示条目(骨架期没有业务数据;文案是英文基线,i18n 是后续 mission)。
/// 演示条目(骨架期没有业务数据;夹具文案,见模块文档)。
pub(crate) struct Entry {
pub(crate) name: &'static str,
pub(crate) version: &'static str,
+22 -6
View File
@@ -3,9 +3,25 @@
//! 只被 [`crate::pages::demo_model`] 用:产品路径的转换在 `app/view.rs`(presenter 快照 → 模型)。
//! 两份数据源喂同一个渲染函数,这是 [`crate::pages::model`] 存在的理由。
use unlucky_i18n::{Catalog, Key, Lang};
use super::super::model::{Detail, Instances, Meta, Row};
use super::data::{DEMO, NOTE, SELECTED};
/// 演示路径的目录:**恒英文**。
///
/// 为什么不是 `model.lang`:本函数只被 `pages::demo_model` 调用,而那条路(`pages_demo` harness /
/// 单测)走 `Model::new`,`lang` 恒为 `Lang::En`(harness 没有换语言的开关)。产品路径不读这里
/// ——它走 `app/view.rs` 的快照转换。等 harness 有 `--lang` 时把这一行换成参数即可。
fn catalog() -> Catalog {
Lang::En.catalog()
}
/// 元信息卡的标签(与 `app/view/instances.rs` 的 `detail_of` 同一批 key,只是这里恒英文)。
fn label(key: Key) -> &'static str {
catalog().text(key)
}
/// 把 M84 的演示条目 [`DEMO`] 转成视图模型(真页面走 `app/view.rs` 的 `AppState` 转换)。
pub(crate) fn demo() -> Instances {
let rows = DEMO
@@ -24,12 +40,12 @@ pub(crate) fn demo() -> Instances {
name: entry.name.to_string(),
subtitle: format!("{} · {}", entry.version, entry.loader),
meta: vec![
meta("VERSION", entry.version),
meta("LOADER", entry.loader),
meta("MEMORY", entry.memory),
meta("JAVA", entry.java),
meta("MODS", entry.mods),
meta("LAST PLAYED", entry.last_played),
meta(label(Key::InstancesMetaVersion), entry.version),
meta(label(Key::InstancesMetaLoader), entry.loader),
meta(label(Key::InstancesMetaMemory), entry.memory),
meta(label(Key::InstancesMetaJava), entry.java),
meta(label(Key::InstancesMetaMods), entry.mods),
meta(label(Key::InstancesMetaLastPlayed), entry.last_played),
],
note: Some(NOTE.to_string()),
status: None,
+21 -18
View File
@@ -23,6 +23,7 @@ use super::super::layout::{self, FrameMetrics};
use super::super::model::Detail;
use super::super::{Action, Message};
use super::forced_state;
use super::Texts;
// --------------------------------------------------------------- 详情列
@@ -31,9 +32,10 @@ pub(super) fn detail_column<'a>(
metrics: FrameMetrics,
detail: &'a Detail,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
let mut body = column![
detail_head(ui, detail, forced),
detail_head(ui, detail, forced, texts),
meta_grid(ui, metrics, detail),
divider::full(ui),
]
@@ -43,7 +45,7 @@ pub(super) fn detail_column<'a>(
body = push_text(ui, metrics, body, detail);
// 页头 + 元信息网格 + **一条**分隔线以下才是正文与操作区(M84 那两条线是占位时期的排版残留)
frame::left_aligned(metrics.column, body.push(action_row(ui, forced)))
frame::left_aligned(metrics.column, body.push(action_row(ui, forced, texts)))
}
pub(super) fn narrow_detail<'a>(
@@ -51,19 +53,20 @@ pub(super) fn narrow_detail<'a>(
metrics: FrameMetrics,
detail: &'a Detail,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
// 窄窗:列表退成上一屏,本屏只放详情(官方 compact 的 list-detail 口径)。
let mut body = column![
row![buttons::text(
ui,
forced_state(
Button::new("Back to instances", Message::Navigate(Route::Instances)),
Button::new(texts.back(), Message::Navigate(Route::Instances)),
forced,
),
)]
.width(Length::Fill)
.align_y(Alignment::Center),
detail_head(ui, detail, forced),
detail_head(ui, detail, forced, texts),
meta_grid(ui, metrics, detail),
divider::full(ui),
]
@@ -76,12 +79,11 @@ pub(super) fn narrow_detail<'a>(
ui,
TypeRole::BodySmall,
false,
"Below 840 px the list and the detail share one column, so opening an instance replaces \
this screen (full-page switch). Widen the window to see both panes again.",
texts.narrow_note(),
ui.color(Role::OnSurfaceVariant),
));
frame::body(metrics, body.push(action_row(ui, forced)))
frame::body(metrics, body.push(action_row(ui, forced, texts)))
}
/// 正文段(演示数据才有)与运行状态行(真数据才有)——两者都是可选的,顺序固定。
@@ -122,6 +124,7 @@ fn detail_head<'a>(
ui: &'a Ui,
detail: &'a Detail,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
let titles = column![
frame::text_role(
@@ -147,23 +150,23 @@ fn detail_head<'a>(
buttons::filled(
ui,
forced_state(
Button::new("Play", Message::Launch(detail.id.clone())),
Button::new(texts.play(), Message::Launch(detail.id.clone())),
forced,
),
)
} else {
buttons::filled(ui, Button::disabled("Play"))
buttons::filled(ui, Button::disabled(texts.play()))
};
let stop = if detail.can_stop {
buttons::text(
ui,
forced_state(
Button::new("Stop", Message::Stop(detail.id.clone())),
Button::new(texts.stop(), Message::Stop(detail.id.clone())),
forced,
),
)
} else {
buttons::text(ui, Button::disabled("Stop"))
buttons::text(ui, Button::disabled(texts.stop()))
};
row![titles, launch, stop]
@@ -213,26 +216,26 @@ fn meta_grid<'a>(ui: &'a Ui, metrics: FrameMetrics, detail: &'a Detail) -> Eleme
grid.into()
}
fn action_row<'a>(ui: &'a Ui, forced: Option<Interaction>) -> Element<'a, Message> {
fn action_row<'a>(ui: &'a Ui, forced: Option<Interaction>, texts: Texts) -> Element<'a, Message> {
row![
buttons::text(
ui,
forced_state(
Button::new("Duplicate", Message::Action(Action::Duplicate)),
Button::new(texts.duplicate(), Message::Action(Action::Duplicate)),
forced,
)
),
buttons::text(
ui,
forced_state(
Button::new("Open folder", Message::Action(Action::OpenFolder)),
Button::new(texts.open_folder(), Message::Action(Action::OpenFolder)),
forced,
)
),
buttons::text(
ui,
forced_state(
Button::new("Delete", Message::Action(Action::Delete)),
Button::new(texts.delete(), Message::Action(Action::Delete)),
forced
),
),
@@ -242,12 +245,12 @@ fn action_row<'a>(ui: &'a Ui, forced: Option<Interaction>) -> Element<'a, Messag
.into()
}
pub(super) fn detail_empty<'a>(ui: &'a Ui) -> Element<'a, Message> {
pub(super) fn detail_empty<'a>(ui: &'a Ui, texts: Texts) -> Element<'a, Message> {
frame::empty_state(
ui,
IconKind::Instances,
"No instance selected",
"Pick an instance on the left to see its version, loader and launch options.",
texts.empty_title(),
texts.empty_body(),
Vec::new(),
)
}
@@ -1,6 +1,9 @@
//! `instances` 的断言:演示数据、窄窗退化,以及**页面摆法**(源码结构断言)。
use super::*;
/// M120 的文案守门(中文无英文词 / 切语言真的变 / 旧串不许回来)。
mod i18n;
use super::super::layout::source;
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass};
@@ -0,0 +1,263 @@
//! 实例页的**文案守门**(M120):中文态不许漏英文、切语言必须真的变、运行状态的数字不许变形。
//!
//! | 用例 | 抓得住什么 |
//! |---|---|
//! | [`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_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`] 兜已知的那批 + 出图人工看。
//! 「模型这一层(快照 → 状态行 / 元信息)跟着 `Lang` 变」由 `app/view/tests.rs` 守。
use unlucky_i18n::catalog::MISSING;
use unlucky_i18n::{Catalog, Key, Lang};
use super::super::Texts;
/// 中文界面里允许出现的 ASCII 词:**品牌名与技术标识**。
///
/// 每一条都要说清为什么:
///
/// - `Java`:运行时品牌名(`JAVA` 那格的值域里也全是 `Temurin 21` 这类品牌串),翻成「爪哇」是错的;
/// - `pid`:进程号的技术标识。用户拿它去任务管理器里搜进程,翻成「进程」就丢了这条可操作性。
const ALLOWED: &[&str] = &["Java", "pid"];
/// 页面取词的五个源码文件(死 key 扫描与旧串扫描共用)。
const PAGE_SOURCES: [(&str, &str); 5] = [
("instances.rs", include_str!("../../instances.rs")),
("instances/detail.rs", include_str!("../detail.rs")),
("instances/demo.rs", include_str!("../demo.rs")),
("instances/text.rs", include_str!("../text.rs")),
(
"app/view/instances.rs",
include_str!("../../../app/view/instances.rs"),
),
];
/// M120 之前页面里那些**英文基线字面量**。它们现在只许活在目录里。
///
/// 页头标题 `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",
];
/// 把 `{占位符}` 抹掉再切 ASCII 词:占位符是**运行时数据**(pid / 原因 / 时间),不是文案。
fn ascii_words(text: &str) -> Vec<String> {
let mut stripped = String::new();
let mut depth = 0usize;
for ch in text.chars() {
match ch {
'{' => depth += 1,
'}' if depth > 0 => depth -= 1,
_ if depth > 0 => {}
_ => stripped.push(ch),
}
}
let mut words = Vec::new();
let mut current = String::new();
for ch in stripped.chars() {
if ch.is_ascii_alphabetic() {
current.push(ch);
} else if !current.is_empty() {
words.push(std::mem::take(&mut current));
}
}
if !current.is_empty() {
words.push(current);
}
words
}
/// 有没有中日韩统一表意文字(「这条文案真的翻成中文了吗」的最直接判据)。
fn has_cjk(text: &str) -> bool {
text.chars().any(|c| {
matches!(c, '\u{3400}'..='\u{4DBF}' | '\u{4E00}'..='\u{9FFF}' | '\u{3000}'..='\u{303F}')
})
}
/// **中文界面无英文词**:实例页这一族的每一条中文译文,除了 [`ALLOWED`] 之外不许出现 ASCII 单词。
///
/// 抓得住:漏翻(中英写成一样)、翻了一半(`Present on disk` → `已在磁盘上 Present`)、
/// 新加 key 时中文那栏顺手抄了英文。
/// 抓不住:译文本身读着别扭;也抓不住「页面另写了一条字面量」(见模块文档的边界一节)。
///
/// **变异实测**(M120 交付时跑过):把 `InstancesPresentOnDisk` 的中文改回 `Present on disk`,
/// 这条立刻红并指出 key 名。
#[test]
fn the_chinese_page_has_no_english_words() {
let zh = Lang::ZhCn.catalog();
let mut offenders = Vec::new();
let mut checked = 0;
for key in Texts::keys() {
let text = zh.text(key);
assert_ne!(text, MISSING, "{} 没有中文译文", key.name());
checked += 1;
for word in ascii_words(text) {
if !ALLOWED.contains(&word.as_str()) {
offenders.push(format!("{} · 出现 {word:?} ← 「{text}」", key.name()));
}
}
}
assert!(
offenders.is_empty(),
"中文界面上这些英文词会漏出来(品牌名 / 技术标识请加进 ALLOWED 并写明理由):\n{}",
offenders.join("\n")
);
assert!(
checked >= 30,
"实例页的 key 不该被成批删掉(只查到 {checked} 条)"
);
}
/// **切换语言,文本真的变**:这一族的每一条中英译文都必须不同。
///
/// 抓得住:翻完没改(两栏抄同一句)——那种 key 在切语言时**一个字都不动**,界面上看起来
/// 「已经国际化」,其实那一处永远是英文。今夜反复出现的「测试绿着但什么都没守住」就是这一类。
/// 抓不住:某条译文翻错了词。
#[test]
fn every_instances_text_changes_with_the_language() {
let en = Lang::En.catalog();
let zh = Lang::ZhCn.catalog();
for key in Texts::keys() {
let (a, b) = (en.text(key), zh.text(key));
assert_ne!(a, MISSING, "{} 没有英文译文", key.name());
assert_ne!(b, MISSING, "{} 没有中文译文", key.name());
assert_ne!(
a,
b,
"{} 的两套语言写成了一样({a:?})——切语言时这一处不会变",
key.name()
);
// 中文那栏必须真的有汉字——**整句只有品牌名时例外**(`InstancesMetaJava` = "Java":
// 那一格的值域是 `Temurin 21` 这种品牌串,翻成「爪哇」是错的)。
let brand_only = {
let words = ascii_words(b);
!words.is_empty() && words.iter().all(|word| ALLOWED.contains(&word.as_str()))
};
assert!(
has_cjk(b) || brand_only,
"{} 的中文译文里一个汉字都没有:{b:?}",
key.name()
);
}
}
/// **数字与分隔符不随语言变形**:`Running · pid 1234` 的两套语言只换前半句。
///
/// 抓得住:把 `pid` 翻成「进程」、把 `·` 换成 `-`、把 `{pid}` 写成固定文字、少一个空格。
/// 抓不住:真实 pid 的取值(那是 core 的事,出图那台机器上也没真跑过)。
#[test]
fn the_running_line_keeps_its_number_and_separator() {
let en = Catalog::of(Lang::En).render(Key::InstancesRunning, &[("pid", "1234")]);
let zh = Catalog::of(Lang::ZhCn).render(Key::InstancesRunning, &[("pid", "1234")]);
assert_eq!(en, "Running · pid 1234");
assert_eq!(zh, "运行中 · pid 1234");
assert_ne!(en, zh, "切换语言时这一行要变(只换标签本体)");
for text in [en, zh] {
assert!(text.contains("1234"), "数字丢了:{text}");
assert!(text.contains(" · pid "), "分隔符或 pid 变形了:{text}");
}
// 失败那一行同理:core 给的原因原文原样保留,只翻前半句
let failed_en = Catalog::of(Lang::En).render(Key::InstancesFailed, &[("reason", "exit 1")]);
let failed_zh = Catalog::of(Lang::ZhCn).render(Key::InstancesFailed, &[("reason", "exit 1")]);
assert_eq!(failed_en, "Launch failed · exit 1");
assert_eq!(failed_zh, "启动失败 · exit 1");
}
/// **旧串不许回来**:M120 之前页面里的英文基线字面量,现在只许活在目录里。
///
/// 为什么用「已知清单」而不是「扫所有英文串」:合法写法太多了(`Action::Import` 这类枚举、
/// 品牌名、文档里的反引号代码),扫宽了会假红。清单里这 29 条是**这次真的搬走的那批**,
/// 谁把它们抄回页面,这条就红——那正是「页面又变成中英混排」的唯一来源。
///
/// **变异实测**(M120 交付时跑过):把 `"ALL INSTANCES"` 写回 `instances.rs` 的页头,立刻红
/// 并指出是哪个文件、哪一句。
#[test]
fn the_page_sources_have_no_english_baseline_left() {
for (name, source) in PAGE_SOURCES {
for literal in OLD_LITERALS {
let needle = format!("\"{literal}\"");
assert!(
!source.contains(&needle),
"{name} 里又写回了英文基线字面量 {literal:?}(走 Texts / 目录,别写死)"
);
}
}
// 反向锚:这些来源**必须**真的在用 Texts / 目录,否则上面那条是空转
assert!(
PAGE_SOURCES[0].1.contains("Texts::of("),
"实例页没有从 Texts 取词"
);
assert!(
PAGE_SOURCES[0].1.contains("Header::new(texts.title())"),
"页头标题必须是词表里的那条(`Header::new` 里写死字符串 = 中文界面上顶着英文标题)"
);
assert!(
PAGE_SOURCES[3].1.contains("Key::Instances"),
"text.rs 没有引用本族的 key"
);
assert!(
PAGE_SOURCES[4].1.contains("Key::InstancesStage"),
"阶段标签没有走目录"
);
}
/// **死 key 守门**:目录里每一条 `Instances*` 都必须被页面(或产品快照转换)真的引用。
///
/// M117 加的那七条就是这么沉的:目录里写着,页面上一个都没用。扫描是字面出现的
/// `Key::<名字>`,所以用宏拼 key 名能骗过它——这个局限写在这里,不假装它更强。
#[test]
fn every_instances_key_is_referenced_by_the_page() {
for key in Texts::keys() {
let needle = format!("Key::{}", key.name());
assert!(
PAGE_SOURCES
.iter()
.any(|(_, source)| source.contains(&needle)),
"{needle} 在目录里定义了,但实例页没有任何地方用它(死 key,删掉或接上)"
);
}
}
+102
View File
@@ -0,0 +1,102 @@
//! 实例页的**文案入口**:页面上一处可见英文字面量都不留,全部从目录取。
//!
//! ## 为什么要有这一层(而不是在控件树里直接 `catalog.text(..)`)
//!
//! 页面要的文案有 20 多条,直接写的话每个调用点都是「key 名 + 这一处画什么」的重复劳动,
//! 而且**守门测试没法取样**:iced 0.14 的 `Element` 没有内省接口(`instances/tests.rs` 里
//! 「读源码」那条注释解释过同一件事),所以「上屏文本」只能从**页面取词的那一个入口**拿。
//! 于是所有取词收进 [`Texts`],页面用 `texts.xxx()` 画,测试用同一个 `Texts` 断言——
//! 一处定义、两处消费,不存在「测试抄了一份平行实现」。
//!
//! ## 它抓得住什么 / 抓不住什么
//!
//! - **抓得住**:目录里某条中文忘了翻(`pages/instances/tests/i18n.rs` 的「无 ASCII 英文单词」)、
//! 某条中英写成一样(「切换语言文本真的变」)、`Running · pid {pid}` 的数字与分隔符被翻译走样;
//! - **抓不住**:页面上又冒出一条**新**硬编码字面量——那由同文件里「旧串不许回来」的源码扫描
//! 兜已知的那批,加上出图人工看(`DevDocs/artifacts/m120/`);
//! - **抓不住**:译文本身翻得顺不顺(那只能人工审图)。
//!
//! ## 口径
//!
//! 品牌名不翻译(`Java` / `Fabric` / `Forge` 原样);`pid` 是进程号的技术标识,两套语言都写
//! `pid`(改成「进程」会丢掉「拿它去任务管理器里搜」这条可操作性)。
//!
//! **启动阶段(下载中 / 校验中 / …)不在这张表里**:它在 `app/view/instances.rs` 拼进
//! `Row.status`(「阶段 · 百分比」是一整句),与元信息卡同一层——页面只画模型给的字符串。
use unlucky_i18n::{Catalog, Key};
/// 实例页 + 详情列的文案(一个 `Catalog` 的薄包装,`Copy`)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Texts(Catalog);
impl Texts {
/// 从目录取一套(调用方给 `model.lang.catalog()`)。
pub(super) fn of(catalog: Catalog) -> Self {
Self(catalog)
}
// ------------------------------------------------------------- 页头
pub(super) fn title(self) -> &'static str {
self.text(Key::InstancesTitle)
}
pub(super) fn subtitle(self) -> &'static str {
self.text(Key::InstancesSubtitle)
}
pub(super) fn new_instance(self) -> &'static str {
self.text(Key::InstancesNew)
}
pub(super) fn import(self) -> &'static str {
self.text(Key::InstancesImport)
}
// --------------------------------------------------------------- 列表列
pub(super) fn all_title(self) -> &'static str {
self.text(Key::InstancesAllTitle)
}
// --------------------------------------------------------------- 详情列
pub(super) fn back(self) -> &'static str {
self.text(Key::InstancesBack)
}
pub(super) fn play(self) -> &'static str {
self.text(Key::InstancesPlay)
}
pub(super) fn stop(self) -> &'static str {
self.text(Key::InstancesStop)
}
pub(super) fn duplicate(self) -> &'static str {
self.text(Key::InstancesDuplicate)
}
pub(super) fn open_folder(self) -> &'static str {
self.text(Key::InstancesOpenFolder)
}
pub(super) fn delete(self) -> &'static str {
self.text(Key::InstancesDelete)
}
pub(super) fn empty_title(self) -> &'static str {
self.text(Key::InstancesEmptyTitle)
}
pub(super) fn empty_body(self) -> &'static str {
self.text(Key::InstancesEmptyBody)
}
pub(super) fn narrow_note(self) -> &'static str {
self.text(Key::InstancesNarrowNote)
}
fn text(self, key: Key) -> &'static str {
self.0.text(key)
}
/// 这一页的全部 key(守门测试的取样口:**从 `Key::ALL` 过滤出来**,不是手抄一份清单)。
///
/// 域判据是前缀 `Instances`——本族的 key 名是契约(`catalog/instances.rs` 里一条一条写着),
/// 新加的 key 只要还在这个族里就自动进守门范围,不需要有人记得来登记。
#[cfg(test)]
pub(super) fn keys() -> impl Iterator<Item = Key> {
Key::ALL
.iter()
.copied()
.filter(|key| key.name().starts_with("Instances"))
}
}