代码复审 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。
311 lines
14 KiB
Rust
311 lines
14 KiB
Rust
//! 消息目录:`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;
|