Files
Unlucky-Launcher/crates/i18n/src/catalog.rs
T
AskaEth 0eb069fa37 fix(app-iced): M95 r4 —— 归属判据回归 / 置信度档 / 运行中页头
代码复审 r3 的四条 p2,修三条、登记一条(P2-4 页脚余量 13px,后置)。

**P2-1(回归修复)**:r3 把归属判据写成「有选中就只认选中」,把**导入崩溃文件**与 `open_session`
这两条路径的诊断一并关掉了——`559ba20` 是**无条件**画的,所以这是 r3 引入的**代码级回归**,
不是既有缺口(今天 iced 侧没有 dispatch 站点,那些还在遗留的 Slint shell 里,故复审未判 p1)。
判据改成三分支(`app/view/logs.rs`):

| live_session | selected_session | 报告算数的条件 |
|---|---|---|
| Some(s) | 任意 | `report.session == s`(只有它在线时日志行是它在追加,别人的报告挂上来就是串场) |
| None | Some(s) | `report.session == s` **或** 报告不属于任何已知会话(导入用 `file:<文件名>`,永不在索引里) |
| None | None | 全部算数(此时日志行只可能来自 `open_session`/导入,与报告同一次调用发出) |

新增两条测试(都走真实 reducer):`an_imported_crash_file_still_shows_its_findings`、
`a_live_run_says_it_is_running_instead_of_nothing_runs`。
残留(今天不可达,已登记):`open_session(A)` 在 `selected_session` 仍停在上场 B 时会被挡掉——
真要修得让 presenter 在 `LogLinesChanged` 记下「日志行属于哪一场」。

**P2-2 置信度档(视觉变更)**:`Severity`(借 core 的 `critical/warning/notice` 词汇)→
`Confidence`(`High/Medium/Low`)+ 新 key `LogsConfidenceHigh/Medium/Low`(中英齐,标题明确写
「高/中/低置信度」)。阈值仍是 80/50,但它现在是**档位定义**而不是「严重度代理」。
摘要里的 `findings=rule:high(85)` 同步换档位 slug。DTO 补齐 `severity` 之后应换回真严重度(finding)。

**P2-3 运行中的页头**:session 索引只在**结束**时追加,运行中的那一场不在 `log_sessions` 里 ⇒
拿不到实例名与结局 ⇒ r3 之前页头在日志滚动时一直说「当前没有运行中的实例」(demo 伪造了身份,
截图照不出来)。`Logs` 新增 `live: bool`(`live_session.is_some()`),`with_status` 三级回退:
有实例+结局 → `实例 · 时间 · 结局`;运行中但没结局 → 新 key `LogsSubtitleLive`(「Running now —
logs stream as they arrive」);都没有 → `LogsSubtitleIdle`。
新增 `a_live_run_says_it_is_running_instead_of_nothing_runs`(真实 reducer:只有
`LogAppended(running=true)`,断言副标题不是 Idle 文案且含 `Running now`)。
新增 demo 状态 `ULL_LOGS_FIXTURE=running` 用于出图。

**变异实测**(每条:回退 → 红 → 放回 → 绿):见报告。

验证:`fmt --check` 通过;`clippy -p unlucky-app-iced -p unlucky-i18n --all-targets -D warnings` 干净;
`cargo test` **280 + 6 + 23 passed / 0 failed**;行数最大 `pages/mod.rs` 367、`app/view.rs` 324、
`app/view/logs.rs` 337。
2026-11-02 22:27:01 +08:00

311 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 消息目录:`Key` 枚举 + 中英两套常量表 + 取词 / 占位符渲染 / 缺译诊断。
//!
//! ## 为什么 key 是枚举而不是字符串
//!
//! 字符串 key(`"nav.instances"`)拼错了编译器一声不响,运行时才知道——本仓的说法是「运行时静默回落」。
//! 枚举 key 把这类错误前移到**编译期**:`Key::NavInstance`(真名是 `NavInstances`)直接
//! `error[E0599]`,不可能跑到运行时。
//!
//! **这里没有守卫测试**:曾经写过一个 `compile_fail` 文档测试想守这条,但复审实测它对
//! 「把 `Key` 换成字符串包装类型」这类变异仍然全绿——它守的是「这段代码编译不过」,而不是
//! 「编译期校验还在」。说不清真假的守卫比没有更糟,所以删了;这条纪律只靠枚举这个事实本身。
//!
//! ## 为什么两套译文写在**同一行**
//!
//! ```ignore
//! NavInstances => ("Instances", "实例"),
//! ```
//!
//! 一行就是「一个 key 的全部语言」。于是「加了 key 忘了译文」在结构上不可能发生——这比「运行时发现
//! 缺译再诊断」更强。缺译诊断([`Catalog::diagnose`])因此有两个真实用途:
//!
//! 1. **注入目录**:后续 mission 用 [`Catalog::from_entries`] 加第三套(繁体 / 更多语言)时,
//! 表格是手写的,缺条目的风险回来了;
//! 2. **回归守门**:万一有人把这张表改回「两套各写一遍」,[`Catalog::missing`] 的守卫测试立刻红。
//!
//! ## 缺译时给什么
//!
//! **不回落**(绝不拿另一种语言顶替,用户会看到「一半英文一半中文」的界面而不自知):
//!
//! - [`Catalog::get`] → `None`(严格调用方用它);
//! - [`Catalog::text`] → [`MISSING`](`⟪missing⟫`,UI 上刺眼但一眼可辨);
//! - [`Catalog::render`] → `⟪missing:KeyName⟫`(带上 key 名,方便照着日志补译)。
use crate::lang::Lang;
/// 缺译占位符(`text()` 的返回;故意刺眼——宁可难看,也不许静默顶替)。
pub const MISSING: &str = "⟪missing⟫";
/// 生成 `Key` 枚举 + 两套常量表(一行 = 一个 key 的全部语言)。
macro_rules! catalog {
($($variant:ident => ($en:literal, $zh:literal)),+ $(,)?) => {
/// 可翻译文案的 key。**不存在时编译失败**(枚举变体名即契约)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Key {
$(
#[doc = concat!("en: `", $en, "` / zh-CN: `", $zh, "`")]
$variant,
)+
}
impl Key {
/// 全部 key(缺译诊断与完整性测试的遍历源)。
pub const ALL: &'static [Key] = &[$(Key::$variant),+];
/// 变体名(日志 / 缺译诊断里用来指认 key;不是文案)。
#[must_use]
pub const fn name(self) -> &'static str {
match self {
$(Key::$variant => stringify!($variant),)+
}
}
}
const EN: &[(Key, &str)] = &[$((Key::$variant, $en)),+];
const ZH_CN: &[(Key, &str)] = &[$((Key::$variant, $zh)),+];
};
}
catalog! {
// ── 导航轨(`pages::Route` 的四项)
NavInstances => ("Instances", "实例"),
NavDownload => ("Downloads", "下载"),
NavLogs => ("Logs", "日志"),
NavSettings => ("Settings", "设置"),
// ── 实例页
InstancesNew => ("New instance", "新建实例"),
InstancesImport => ("Import", "导入"),
InstancesBack => ("Back to instances", "返回实例列表"),
InstancesPlay => ("Play", "启动"),
InstancesLastPlayed => ("Last played {when}", "上次游玩 {when}"),
InstancesEmptyTitle => ("No instance selected", "未选择实例"),
InstancesEmptyBody => ("Pick an instance to see its details.", "选择一个实例查看详情。"),
// ── 加载器(品牌名不翻译:Fabric / Forge / Quilt 一律原样,只有「原版」是词)
LoaderVanilla => ("Vanilla", "原版"),
// ── 启动结局(原来 UI 把 "Launch failed" 与 core 的「静默失败」拼成一句话,现在一句话 = 一个 key)
LaunchOutcomeNormal => ("Finished normally", "正常结束"),
LaunchOutcomeCrash => ("The game crashed", "游戏崩溃"),
LaunchOutcomeSilent => (
"Suspected silent failure: exit code 0, but the game never really started",
"疑似静默失败:退出码 0,但游戏没真正跑起来"
),
LaunchFailedWithCode => ("Launch failed (exit {code}): {reason}", "启动失败(退出码 {code}):{reason}"),
// ── 中文字体(D28:中文环境缺字体时的下载提示 / 失败回退)
FontCjkNeedsDownload => (
"No Chinese font found on this system. Download an open-source (OFL) Chinese font?",
"系统里没找到中文字体。要下载一套开源(OFL)中文字体吗?"
),
FontCjkDownloading => ("Downloading Chinese font… {percent}%", "正在下载中文字体… {percent}%"),
FontCjkDownloadFailed => (
"Chinese font download failed; the interface stays in English.",
"中文字体下载失败,界面暂时保持英文。"
),
FontCjkReady => ("Chinese font ready: {family}", "中文字体已就绪:{family}"),
// ── 设置
SettingsLanguage => ("Language", "界面语言"),
SettingsLanguageHint => (
"Switches the interface language. Chinese text needs a Chinese font.",
"切换界面语言。显示中文需要中文字体。"
),
// ── core / presenter 只给了中文串、UI 又不许画原文时的兜底(见 `bridge`)
CoreTextUntranslated => ("[system text not translated]", "[未翻译的系统文案]"),
// --- download page (M94) ---
// 下载页(`crates/app-iced/src/pages/download`)。一句话一个 key:带变量的句子用 `{name}`,
// 页面只做 `Catalog::render`,不拼片段(`bridge` 的规矩 1)。
//
// 文案口径(M94 r1 / P2-2):**只说这个页面真能做到的动作**(切分类、刷新、看日志),
// 不写「稍后会自动重试」这类 UI 没有的能力,也不拿文案去补功能。
DownloadTitle => ("Downloads", "下载"),
DownloadSubtitle => ("Versions you can install", "可以安装的版本"),
DownloadRefresh => ("Refresh", "刷新"),
DownloadInstall => ("Install", "安装"),
DownloadCancel => ("Cancel install", "取消安装"),
DownloadInstalled => ("Installed", "已安装"),
DownloadCount => ("{count} versions", "{count} 个版本"),
DownloadRowSupporting => ("{kind} · released {date}", "{kind} · 发布于 {date}"),
DownloadSelectHint => ("Pick a version, then install", "选一个版本再安装"),
DownloadLoading => ("Loading versions…", "正在加载版本列表…"),
DownloadLoadingHint => (
"If this stays here, press Refresh in the top right.",
"如果一直停在这里,点右上角的刷新。"
),
DownloadEmptyTitle => ("No versions to show", "没有可显示的版本"),
DownloadEmptyBody => (
"Pick another category, or refresh the list.",
"换一个分类,或刷新一次列表。"
),
DownloadCategoryAll => ("All", "全部"),
DownloadCategoryRelease => ("Release", "正式版"),
DownloadCategorySnapshot => ("Snapshot", "预览版"),
DownloadCategoryAncient => ("Ancient", "远古版"),
DownloadStagePlanning => ("Planning", "解析版本"),
DownloadStageDownloading => ("Downloading files", "下载文件"),
DownloadStageInstalling => ("Building the version", "生成版本"),
DownloadStageRegistering => ("Registering the instance", "登记实例"),
DownloadStageDone => ("Done", "已完成"),
DownloadStageFailed => ("Install failed", "安装失败"),
DownloadStageCancelled => ("Install cancelled", "安装已取消"),
DownloadProgress => (
"{done}/{total} files",
"{done}/{total} 文件"
),
DownloadProgressUnknown => ("Waiting for the first file…", "等第一个文件…"),
// 速率与剩余时间是两个独立短语,各占一个 key(`DownloadRate` 只负责把它们排成一行)
DownloadRate => ("{speed} · {eta}", "{speed},{eta}"),
DownloadEtaSecs => ("{secs}s left", "剩 {secs}s"),
DownloadEtaMins => ("{mins}m{secs}s left", "剩 {mins}m{secs}s"),
DownloadFailedHint => (
"The Logs page has the full reason.",
"完整原因在日志页。"
),
DownloadOpenLogs => ("Open logs", "打开日志"),
DownloadDismiss => ("Dismiss", "关闭"),
// --- logs page (M95) ---
// 日志页的**界面文案**(标题 / 按钮 / 分组名 / 空态)。诊断结论的正文不在这里:
// 那是规则表 `rules.d/*.yaml` 的输出(数据),见 `pages/logs/diagnostic.rs` 的模块文档。
// `LogsStatus` 是**模板**:中英差别落在分隔符上(中文用全角竖线——`·` 是外来符号,
// 中文正文里偏挤;这也让「中文目录必须与英文不同」这条守卫成立,不是为过测试硬造差异)。
LogsSubtitleIdle => ("Nothing is running right now", "当前没有运行中的实例"),
LogsStatus => ("{instance} · {when} · {outcome}", "{instance}|{when}|{outcome}"),
LogsOpenFolder => ("Open folder", "打开目录"),
LogsClear => ("Clear", "清空"),
LogsOutputTitle => ("OUTPUT", "输出"),
LogsDiagnosisTitle => ("DIAGNOSIS", "诊断"),
LogsConfidenceHigh => ("HIGH CONFIDENCE", "高置信度"),
LogsConfidenceMedium => ("MEDIUM CONFIDENCE", "中置信度"),
LogsConfidenceLow => ("LOW CONFIDENCE", "低置信度"),
LogsSubtitleLive => ("Running now — logs stream as they arrive", "正在运行 — 日志实时滚动"),
LogsConfidence => ("Confidence {percent}%", "置信度 {percent}%"),
LogsSuggestedFix => ("Suggested fix", "建议动作"),
LogsSummaryLabel => ("One-line summary", "一行摘要"),
LogsCopySummary => ("Copy summary", "复制摘要"),
LogsEmptyTitle => ("No output yet", "还没有输出"),
LogsEmptyBody => (
"Logs stream here while the launcher or the game is running. Pick an instance and start it to see the first lines.",
"启动器或游戏运行时,日志会实时出现在这里。选择一个实例并启动,就能看到第一批日志。"
),
LogsEmptyAction => ("Choose an instance", "选择实例"),
}
/// 一套语言的目录(不可变;`Copy`,随手传值)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Catalog {
lang: Lang,
entries: &'static [(Key, &'static str)],
}
impl Catalog {
/// 内置目录。
#[must_use]
pub const fn of(lang: Lang) -> Catalog {
let entries: &'static [(Key, &'static str)] = match lang {
Lang::En => EN,
Lang::ZhCn => ZH_CN,
};
Catalog { lang, entries }
}
/// 用外部表构造目录(给后续 mission 的第三套语言 / 测试注入用)。
#[must_use]
pub const fn from_entries(lang: Lang, entries: &'static [(Key, &'static str)]) -> Catalog {
Catalog { lang, entries }
}
/// 这套目录是哪种语言。
#[must_use]
pub const fn lang(&self) -> Lang {
self.lang
}
/// 严格取词:缺译就是 `None`(**不回落**到别的语言)。
#[must_use]
pub fn get(&self, key: Key) -> Option<&'static str> {
self.entries
.iter()
.find(|(entry, _)| *entry == key)
.map(|(_, text)| *text)
}
/// 这套目录的全部条目(声明顺序)。给导出 / 校对工具与完整性守门测试用。
#[must_use]
pub const fn entries(&self) -> &'static [(Key, &'static str)] {
self.entries
}
/// 取词(UI 便利路径):缺译返回 [`MISSING`]。
#[must_use]
pub fn text(&self, key: Key) -> &'static str {
self.get(key).unwrap_or(MISSING)
}
/// 带变量的句子:把 `{name}` 换掉。
///
/// 参数没给全时**占位符原样留在文案里**(`Launch failed (exit {code})`),不 panic、不猜空串——
/// 缺参在截图上一眼可见,比静默变成 `exit ` 好查。
#[must_use]
pub fn render(&self, key: Key, args: &[(&str, &str)]) -> String {
let Some(template) = self.get(key) else {
return format!("⟪missing:{}⟫", key.name());
};
let mut out = template.to_owned();
for (name, value) in args {
out = out.replace(&format!("{{{name}}}"), value);
}
out
}
/// 缺译的 key(自带语言里从来是空;注入目录与回归守门才用得上)。
#[must_use]
pub fn missing(&self) -> Vec<Key> {
Key::ALL
.iter()
.copied()
.filter(|key| self.get(*key).is_none())
.collect()
}
/// 人话诊断(`None` = 没有缺译)。一条缺译一行,附带英文原文当参考。
#[must_use]
pub fn diagnose(&self) -> Option<String> {
let missing = self.missing();
if missing.is_empty() {
return None;
}
let mut out = format!("{} 缺 {} 条译文:", self.lang.tag(), missing.len());
for key in missing {
let reference = Catalog::of(Lang::En)
.get(key)
.map_or_else(|| "<en 也缺>".to_owned(), |text| format!("\"{text}\""));
out.push_str(&format!("\n - {} · en={reference}", key.name()));
}
Some(out)
}
}
/// 抽取文案里的 `{name}` 占位符(守门测试用:同一 key 的两套译文占位符必须一致)。
#[must_use]
pub fn placeholders(text: &str) -> Vec<&str> {
let mut found = Vec::new();
let mut rest = text;
while let Some(open) = rest.find('{') {
let after = &rest[open + 1..];
let Some(close) = after.find('}') else {
break;
};
found.push(&after[..close]);
rest = &after[close + 1..];
}
found
}
#[cfg(test)]
mod tests;