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

+123 -10
View File
@@ -1,7 +1,6 @@
//! `app/view.rs` 的守门测试(rebase 到 5adac23 之后 view.rs 贴 400 行上限,按「测试独立成文件」拆出)。
use super::*;
// 拆分后 `view.rs` 不再 import 这三个(它们只被搬走的实例页转换用),用到的地方自己引
use unlucky_presenter::dto::{
InstanceRuntime, InstanceSummary, LaunchStage, LoaderInfo, LoaderKind, Progress,
};
@@ -31,9 +30,9 @@ fn instance(runtime: InstanceRuntime) -> InstanceSummary {
/// 与「取证密闭」正好相反,而且图看起来完全正常);② 加了变体却映射到别的态这种复制粘贴错。
/// 抓不住:`shell::overridden`(标题栏 / 导航轨按钮走的**另一处** match,断言在 `shell/tests.rs`),
/// 也抓不住不消费 `model.forced` 的裸 `iced::button`(设置页「外观」区就是)。
/// 测试用的薄包装:默认宽度 / 默认分类 / 空草稿。
fn at(state: &AppState, forced: Option<crate::cli::ForcedState>) -> pages::Model {
let (draft, lang) = (pages::InstallDraft::default(), Lang::En);
/// 测试用的薄包装:默认宽度 / 默认分类 / 空草稿 / **给定语言**。
fn at_lang(state: &AppState, forced: Option<crate::cli::ForcedState>, lang: Lang) -> pages::Model {
let draft = pages::InstallDraft::default();
let page = pages::content::PageState::default();
model(
state,
@@ -46,6 +45,11 @@ fn at(state: &AppState, forced: Option<crate::cli::ForcedState>) -> pages::Model
)
}
/// 同 [`at_lang`],语言取英文(绝大多数断言只关心「快照 → 模型」的映射,不关心译文)。
fn at(state: &AppState, forced: Option<crate::cli::ForcedState>) -> pages::Model {
at_lang(state, forced, Lang::En)
}
#[test]
fn the_forced_state_maps_to_the_expected_interaction() {
let state = AppState::new();
@@ -122,22 +126,131 @@ fn failed_instances_can_be_retried() {
reason: "exit 1".to_string(),
});
assert_eq!(
runtime_status(&failed.runtime).as_deref(),
runtime_status(&failed.runtime, Lang::En.catalog()).as_deref(),
Some("Launch failed · exit 1")
);
assert!(detail_of(&failed).can_launch, "失败后要能重试");
assert!(
detail_of(&failed, Lang::En.catalog()).can_launch,
"失败后要能重试"
);
}
#[test]
fn timestamps_are_sliced_not_guessed() {
let en = Lang::En.catalog();
assert_eq!(
last_played(Some("2026-10-29T19:22:00Z")),
last_played(Some("2026-10-29T19:22:00Z"), en),
"2026-10-29 19:22 UTC"
);
assert_eq!(last_played(None), "Never");
assert_eq!(last_played(None, en), "Never");
// 短串 / 非 ASCII 原样返回(不 panic)
assert_eq!(last_played(Some("2026-10")), "2026-10");
assert_eq!(last_played(Some("昨天")), "昨天");
assert_eq!(last_played(Some("2026-10"), en), "2026-10");
assert_eq!(last_played(Some("昨天"), en), "昨天");
// 「从未游玩」是唯一走词表的那一支
assert_eq!(last_played(None, Lang::ZhCn.catalog()), "从未游玩");
}
/// **快照 → 页面文案跟着 `App::lang` 走**(M120 的核心接线断言,模型这一层)。
///
/// 抓得住:`view::model` 又把语言写死成 `Lang::En`(M106 之前的原样)、阶段/元信息标签还留着
/// 英文基线字面量、原版徽标直接画 presenter 的中文「原版」。这三种都不会让任何别的测试红。
/// 抓不住:页面把 key 画没画上屏(那是 `pages/instances/tests/i18n.rs` 的取样口 + 出图)。
#[test]
fn the_runtime_lines_and_meta_labels_follow_the_language() {
let mut state = AppState::new();
state.apply(unlucky_presenter::dto::AppEvent::Core(
unlucky_presenter::dto::CoreEvent::InstancesChanged(vec![
instance(InstanceRuntime::Running { pid: 1234 }),
instance(InstanceRuntime::Busy {
stage: LaunchStage::Verify,
progress: Progress {
done: 750,
total: 1_500,
},
}),
]),
));
state.navigate(Route::Detail("survival".to_string()));
let en = at_lang(&state, None, Lang::En);
let zh = at_lang(&state, None, Lang::ZhCn);
// 列表 overline:中英各一份,数字与分隔符不动
assert_eq!(
en.instances.rows[0].status.as_deref(),
Some("Running · pid 1234")
);
assert_eq!(
zh.instances.rows[0].status.as_deref(),
Some("运行中 · pid 1234")
);
assert_eq!(
en.instances.rows[1].status.as_deref(),
Some("Verifying · 50%")
);
assert_eq!(zh.instances.rows[1].status.as_deref(), Some("校验中 · 50%"));
// 元信息卡的标签与值
let (en_detail, zh_detail) = (
en.instances.detail.expect("有详情"),
zh.instances.detail.expect("有详情"),
);
let labels = |detail: &pages::Detail| -> Vec<String> {
detail.meta.iter().map(|cell| cell.label.clone()).collect()
};
assert_eq!(
labels(&en_detail),
vec![
"VERSION",
"LOADER",
"ISOLATION",
"VERSION ID",
"FILES",
"LAST PLAYED"
]
);
assert_eq!(
labels(&zh_detail),
vec!["版本", "加载器", "隔离方式", "版本标识", "文件", "上次游玩"]
);
// 值里只有「是否在磁盘上」与「上次游玩」是可翻译的;版本号 / 隔离方式 / 版本标识是数据
// 隔离档位是 core 的稳定 id(`isolated` / `shared`),也要换成人话;认不出来的原样上屏
assert_eq!(en_detail.meta[2].value, "Isolated");
assert_eq!(zh_detail.meta[2].value, "独立目录");
assert_eq!(en_detail.meta[4].value, "Present on disk");
assert_eq!(zh_detail.meta[4].value, "已在磁盘上");
assert_eq!(
en_detail.meta[5].value, "2026-10-29 19:22 UTC",
"时间戳是数据:只切片,不随语言变形"
);
assert_eq!(zh_detail.meta[5].value, "2026-10-29 19:22 UTC");
// 详情状态行与列表 overline 同源
assert_eq!(zh_detail.status.as_deref(), Some("运行中 · pid 1234"));
}
/// 原版徽标:`LoaderInfo::badge()` 对原版返回**中文**「原版」,英文界面里必须换回 `Vanilla`。
///
/// 抓得住:`loader_badge` 退回直接调 `badge()`(M90 ~ M119 的形状)——那样英文界面会漏中文,
/// 而中文界面又是对的,所以只有这条双语断言抓得住它。
#[test]
fn the_vanilla_badge_follows_the_language() {
let mut bare = instance(InstanceRuntime::Idle);
bare.loader = LoaderInfo {
kind: LoaderKind::Vanilla,
version: String::new(),
};
let mut state = AppState::new();
state.apply(unlucky_presenter::dto::AppEvent::Core(
unlucky_presenter::dto::CoreEvent::InstancesChanged(vec![bare]),
));
state.navigate(Route::Detail("survival".to_string()));
let en = at_lang(&state, None, Lang::En);
let zh = at_lang(&state, None, Lang::ZhCn);
assert_eq!(en.instances.rows[0].supporting, "1.20.4 · Vanilla");
assert_eq!(zh.instances.rows[0].supporting, "1.20.4 · 原版");
}
#[test]