复审 P1-1(用户可见假文案):`LaunchEnd` 在 `failure == None`(跑起来之后崩的)时
临时造了 `Stage::Spawn + format!("exit {:?}")`,于是磁贴写「启动失败 · 启动游戏进程」、
详情页漏出 `exit Some(1)`、静默失败丢掉「exit 0 却没起来」,而同一时刻 Toast 说「崩溃」。
- 新增 `LaunchFailureCause{Stage|Crashed|SilentFailure}`(dto/launch_failure.rs),
`LaunchFailureCause::of(outcome, failure, exit_code)` 是唯一组装点;`InstanceRuntime::Failed{cause}`。
`LaunchFailureStage` 保持「只装装配阶段」(文档那句「崩溃是结局不是阶段」现在成立)。
- 界面:`InstancesFailed`(框架)+ `InstancesCrashed` + `InstancesSilentFailed`;
删掉 `InstancesFailedStage`。退出码按数字出词,不再有 Debug 文本。
- 用例:presenter 断言**枚举变体**(三种形状逐条)、app 断言**具体句子**
(「不许出现 Some(」「进程起来了不许说启动游戏进程」「静默失败要点出没起来」)。
mock 夹具与 launch_state_tests 的期望值同步;launch-error 三张图重拍。
复审 P2-1(`convert::failure_stage` 零覆盖):新增 `convert/stage_tests.rs` 5 条(三档 +
「既没回滚也没宣称干净」+ 非致命无阶段 + `failed_report_with` ⇒ NotStarted)。
变异实测:改成恒 `RolledBack` ⇒ 3 条红。
复审 P2-2(矮窗口用例的算式三个数全错、`body_height` 零测试):用例改成直接调
`frame::body_height`,chrome 从 `Ui::metrics` + `WidthClass::gutter` 现取,并把门槛与真实
chrome 绑死(chrome+MIN ⇒ Split、-1px ⇒ ScrollWhole);新增 `frame` 自己的 `body_height` 用例。
变异实测:`2.0 * gutter` → `gutter` ⇒ 2 条红。
复审 P2-3(源指纹闸门 / `Refused` 零覆盖):新增两条 core 用例——闸门(原地留着 prefs.json
+ 预先写好的同源收据 ⇒ Nothing 且收据未被重写,反证:源变了允许重放)、`Refused`
(`$schema` 1.7.0 ⇒ ReadOnly ⇒ 不动旧文件、不写收据、理由含「不可写」)。
变异实测:删闸门 ⇒ 1 条红;`can_save` 守卫改 `if false` ⇒ 1 条红。
顺手收:
- CLI `pack import` 那句「已整包回滚」也按三档出词(`RollbackStage` + `rollback_line`),
加 2 条单测。CLI 不依赖 presenter,判定暂与 presenter 各一份 → 已 file finding 建议下沉 core。
- 设置页 `status_text()` 抽出 applying 档,用例断言「正在安装…」且非错误色。
金表:新 key 加在 `golden.rs`(81 行),未动 `golden/tail.rs`(211 行),避免与 M162 的拆分冲突。
267 lines
13 KiB
Rust
267 lines
13 KiB
Rust
//! 实例页的**展示转换**(M120 从 `app/view.rs` 原样搬出:那边给 i18n 让出行数)。
|
||
//!
|
||
//! | 函数 | 产物 | 谁读 |
|
||
//! |---|---|---|
|
||
//! | [`supporting_line`] | 列表行 / 详情副标题 `1.20.4 · Fabric 0.16.5` | [`crate::pages::Row`] / [`crate::pages::Detail`] |
|
||
//! | [`runtime_status`] | 运行状态行(列表 overline + 详情状态行共用一份) | 同上 |
|
||
//! | [`detail_of`] | 详情列的全部字段 | [`crate::pages::Detail`] |
|
||
//! | [`last_played`] | `2026-10-29 19:22 UTC` / `Never` | 元信息卡 `LAST PLAYED` |
|
||
//!
|
||
//! 这里只做「快照字段 → 要画什么」,不碰控件树。**文案的语言由调用方的 [`Catalog`] 决定**
|
||
//! (`view::model` 把 `App::lang` 的目录传进来),不再是 M90 时期的英文基线字面量。
|
||
//!
|
||
//! ## 两条接法上的取舍
|
||
//!
|
||
//! 1. **原版徽标走词表**:`LoaderInfo::badge()` 对原版返回**中文**「原版」(那是 presenter 的
|
||
//! 面向中文日志的 `label()`),英文界面里直接画它就是「中文漏进英文界面」。所以原版这一支
|
||
//! 改走 [`Key::LoaderVanilla`],装加载器时仍是 `Fabric 0.16.5`(品牌 + 版本号,不翻译);
|
||
//! 2. **运行状态只翻标签本体**:`Running · pid 1234` → `运行中 · pid 1234`,`pid` 与数字、
|
||
//! `·` 分隔符一个字都不动([`Key::InstancesRunning`] 的占位符只有 `{pid}`)。
|
||
|
||
use unlucky_i18n::{Catalog, Key, Lang};
|
||
use unlucky_presenter::dto::{
|
||
InstanceRuntime, InstanceSummary, LaunchFailureCause, LaunchFailureStage, LaunchFinished,
|
||
LaunchStage, LoaderKind, LogOutcome,
|
||
};
|
||
|
||
use crate::pages::{Detail, LaunchFacts, Meta};
|
||
|
||
/// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版 / Vanilla」,见 [`loader_badge`])。
|
||
pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> String {
|
||
format!(
|
||
"{} · {}",
|
||
instance.mc_version,
|
||
loader_badge(instance, catalog)
|
||
)
|
||
}
|
||
|
||
/// 加载器徽标:原版走词表,其余交给 `LoaderInfo::badge()`(品牌 + 版本号,原样)。
|
||
///
|
||
/// **判据只能是「加载器类型本身是原版」**。曾经写成「原版 **或** 版本号为空」⇒
|
||
/// `1.20.1-forge` / `1.20.1-neoforge` / `1.20.1-OptiFine` 这类**取不到版本段**的 id(`badge()`
|
||
/// 对它们给的就是品牌名 `Forge` / `NeoForge` / `OptiFine`)会被显示成「原版 / Vanilla」——
|
||
/// 那是 M120 r1 的 p2-1 回归,`the_loader_badge_keeps_the_brand_when_the_version_is_missing` 守着。
|
||
fn loader_badge(instance: &InstanceSummary, catalog: Catalog) -> String {
|
||
match instance.loader.kind {
|
||
// 唯一需要过词表的一支:`LoaderKind::label()` 对原版返回的是**中文**「原版」
|
||
LoaderKind::Vanilla => catalog.text(Key::LoaderVanilla).to_string(),
|
||
// 其余品牌名不翻译;版本号为空时 `badge()` 自己就只给品牌名
|
||
_ => instance.loader.badge(),
|
||
}
|
||
}
|
||
|
||
/// 隔离档位:`isolated` / `shared` 是两个稳定 id(`core::game::IsolationMode::as_str`),
|
||
/// 直接上屏就是英文界面漏中文、中文界面漏英文。只认这两档,**其余原样**(`inherit` / `custom` /
|
||
/// 未来新增的值不许在这里被猜成一个意思)。
|
||
fn isolation(raw: &str, catalog: Catalog) -> String {
|
||
match raw {
|
||
"isolated" => catalog.text(Key::InstancesIsolationIsolated).to_string(),
|
||
"shared" => catalog.text(Key::InstancesIsolationShared).to_string(),
|
||
other => other.to_string(),
|
||
}
|
||
}
|
||
|
||
/// 运行状态(列表行的 overline 与详情页的状态行共用一份文案)。
|
||
pub(crate) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
|
||
match runtime {
|
||
InstanceRuntime::Idle => None,
|
||
// 「阶段 · 百分比」:百分比是数字,不过词表;只有阶段名换语言
|
||
InstanceRuntime::Busy { stage, progress } => Some(format!(
|
||
"{} · {:.0}%",
|
||
stage_label(*stage, catalog),
|
||
progress.ratio() * 100.0
|
||
)),
|
||
InstanceRuntime::Running { pid } => {
|
||
Some(catalog.render(Key::InstancesRunning, &[("pid", &pid.to_string())]))
|
||
}
|
||
// 失败有**两种形状**,各说各的话(复审 P1-1):
|
||
// - `Stage`:进程**没起来** → 「哪一步 + 那一步的原文」(原文不翻译,是取证信息);
|
||
// - `Crashed` / `SilentFailure`:**起来过** → 说结局(崩溃 / 疑似静默失败 + 退出码),
|
||
// 绝不说成「启动游戏进程失败」。
|
||
//
|
||
// 这一份是**详情页**用的全文(详情列会自动换行);列表行那份是 [`runtime_status_row`]
|
||
// (单行、不许折行,见 D-M79-5)。
|
||
InstanceRuntime::Failed { cause } => Some(failure_line(cause, catalog, true)),
|
||
}
|
||
}
|
||
|
||
/// 一条失败状态行。
|
||
///
|
||
/// `with_detail = false` 是**列表行**那一档:`Stage` 只留「哪一步」,不带原文
|
||
/// (原文可能是一长串路径,单行 overline 会把它裁成半截字);结局类的两种形状本来就没有原文。
|
||
fn failure_line(cause: &LaunchFailureCause, catalog: Catalog, with_detail: bool) -> String {
|
||
match cause {
|
||
LaunchFailureCause::Stage { stage, detail } if with_detail => catalog.render(
|
||
Key::LaunchStageFailed,
|
||
&[
|
||
("stage", failure_stage_label(*stage, catalog)),
|
||
("detail", detail.as_str()),
|
||
],
|
||
),
|
||
LaunchFailureCause::Stage { stage, .. } => catalog.render(
|
||
Key::InstancesFailed,
|
||
&[("reason", failure_stage_label(*stage, catalog))],
|
||
),
|
||
LaunchFailureCause::Crashed { exit_code } => {
|
||
let reason = match exit_code {
|
||
Some(code) => catalog.render(Key::InstancesCrashed, &[("code", &code.to_string())]),
|
||
// 平台没给退出码:只说「崩溃」,不编一个 0 出来。
|
||
None => catalog.text(Key::LaunchOutcomeCrash).to_string(),
|
||
};
|
||
catalog.render(Key::InstancesFailed, &[("reason", reason.as_str())])
|
||
}
|
||
LaunchFailureCause::SilentFailure { .. } => {
|
||
// 静默失败的**关键信息是「exit 0 却没起来」**,不是退出码本身(复审 P1-1 第 ③ 条)。
|
||
let reason = catalog.text(Key::InstancesSilentFailed).to_string();
|
||
catalog.render(Key::InstancesFailed, &[("reason", reason.as_str())])
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 列表行用的运行状态:**单行**(`ListItem` 的 overline 不折行,D-M79-5)。
|
||
///
|
||
/// 与 [`runtime_status`] 只差一处:失败时不带 `detail`——那一段是原始错误文本(可能是一长串
|
||
/// 路径),放进 60/76 高的单行 overline 会被裁成半截字。全文在**详情页**([`runtime_status`])。
|
||
pub(crate) fn runtime_status_row(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
|
||
match runtime {
|
||
InstanceRuntime::Failed { cause } => Some(failure_line(cause, catalog, false)),
|
||
other => runtime_status(other, catalog),
|
||
}
|
||
}
|
||
|
||
/// 失败阶段文案(presenter 给 slug,界面在这里选词)。
|
||
///
|
||
/// 与 [`stage_label`] 同一套做法:**不**复用 presenter 侧的任何中文常量。
|
||
pub(crate) fn failure_stage_label(stage: LaunchFailureStage, catalog: Catalog) -> &'static str {
|
||
catalog.text(match stage {
|
||
LaunchFailureStage::VersionManifest => Key::LaunchStageVersionManifest,
|
||
LaunchFailureStage::Inheritance => Key::LaunchStageInheritance,
|
||
LaunchFailureStage::InstanceConfig => Key::LaunchStageInstanceConfig,
|
||
LaunchFailureStage::Isolation => Key::LaunchStageIsolation,
|
||
LaunchFailureStage::Identity => Key::LaunchStageIdentity,
|
||
LaunchFailureStage::Spawn => Key::LaunchStageSpawn,
|
||
})
|
||
}
|
||
|
||
/// 一次启动收尾的 Toast 文案(**唯一的产地**:presenter 只发结构,不拼句子)。
|
||
///
|
||
/// 三种结局各有各的说法:正常结束报时长,崩溃/静默失败报「结局 + 阶段 + 原文」,
|
||
/// 组装阶段就失败(`failure` 有值)报「哪一步 + 原文」。
|
||
#[must_use]
|
||
pub(crate) fn launch_toast(finished: &LaunchFinished, lang: Lang) -> String {
|
||
let catalog = lang.catalog();
|
||
if let Some(failure) = &finished.failure {
|
||
return catalog.render(
|
||
Key::LaunchStageFailed,
|
||
&[
|
||
("stage", failure_stage_label(failure.stage, catalog)),
|
||
("detail", failure.detail.as_str()),
|
||
],
|
||
);
|
||
}
|
||
let outcome = catalog.text(match finished.outcome {
|
||
LogOutcome::Ok => Key::LaunchOutcomeNormal,
|
||
LogOutcome::Crashed => Key::LaunchOutcomeCrash,
|
||
LogOutcome::SilentFail => Key::LaunchOutcomeSilent,
|
||
});
|
||
let seconds = format!("{:.1}", finished.uptime_ms as f64 / 1000.0);
|
||
catalog.render(
|
||
Key::LaunchToastEnded,
|
||
&[("outcome", outcome), ("seconds", seconds.as_str())],
|
||
)
|
||
}
|
||
|
||
/// 阶段文案:**不**复用 `LaunchStage::label()`(那是核心层的中文诊断串,英文界面会漏中文)。
|
||
fn stage_label(stage: LaunchStage, catalog: Catalog) -> &'static str {
|
||
catalog.text(match stage {
|
||
LaunchStage::Download => Key::InstancesStageDownload,
|
||
LaunchStage::Verify => Key::InstancesStageVerify,
|
||
LaunchStage::Launch => Key::InstancesStageLaunch,
|
||
LaunchStage::Window => Key::InstancesStageWindow,
|
||
})
|
||
}
|
||
|
||
/// 详情列的内容(字段全部来自 [`InstanceSummary`])。
|
||
pub(super) fn detail_of(instance: &InstanceSummary, catalog: Catalog) -> Detail {
|
||
Detail {
|
||
id: instance.id.clone(),
|
||
name: instance.name.clone(),
|
||
subtitle: supporting_line(instance, catalog),
|
||
meta: vec![
|
||
meta(
|
||
catalog.text(Key::InstancesMetaVersion),
|
||
&instance.mc_version,
|
||
),
|
||
meta(
|
||
catalog.text(Key::InstancesMetaLoader),
|
||
&loader_badge(instance, catalog),
|
||
),
|
||
meta(
|
||
catalog.text(Key::InstancesMetaIsolation),
|
||
&isolation(&instance.isolation, catalog),
|
||
),
|
||
meta(
|
||
catalog.text(Key::InstancesMetaVersionId),
|
||
&instance.version_id,
|
||
),
|
||
meta(
|
||
catalog.text(Key::InstancesMetaFiles),
|
||
catalog.text(if instance.version_present {
|
||
Key::InstancesPresentOnDisk
|
||
} else {
|
||
Key::InstancesMissing
|
||
}),
|
||
),
|
||
meta(
|
||
catalog.text(Key::InstancesMetaLastPlayed),
|
||
&last_played(instance.last_played.as_deref(), catalog),
|
||
),
|
||
],
|
||
// 真快照没有「正文描述」这种字段(偏离 #9):演示数据才有正文段
|
||
note: None,
|
||
status: runtime_status(&instance.runtime, catalog),
|
||
can_launch: matches!(
|
||
instance.runtime,
|
||
InstanceRuntime::Idle | InstanceRuntime::Failed { .. }
|
||
),
|
||
can_stop: matches!(
|
||
instance.runtime,
|
||
InstanceRuntime::Busy { .. } | InstanceRuntime::Running { .. }
|
||
),
|
||
// M157:启动参数区的**只读事实**(门槛 / 生效值 / 三态词表全部来自 presenter 快照,
|
||
// 页面不复制任何一条判据;表单值在 `App::launch_form`)。
|
||
launch: LaunchFacts {
|
||
allowed: instance.launch.graphics_backend_allowed,
|
||
mc_version: instance.launch.mc_version.clone(),
|
||
min_version: instance.launch.graphics_backend_min_version.clone(),
|
||
memory_effective_mb: instance.launch.memory_effective_mb,
|
||
java_effective: instance.launch.java_effective.clone(),
|
||
backend_effective: instance.launch.graphics_backend_effective.clone(),
|
||
options: instance.launch.graphics_backend_options.clone(),
|
||
},
|
||
}
|
||
}
|
||
|
||
fn meta(label: &str, value: &str) -> Meta {
|
||
Meta {
|
||
label: label.to_string(),
|
||
value: value.to_string(),
|
||
}
|
||
}
|
||
|
||
/// ISO-8601(`2026-10-29T19:22:00Z`)→ `2026-10-29 19:22 UTC`;`None` = 从未启动。
|
||
///
|
||
/// 只做切片不做时区换算:core 给的是 UTC,`Z` 就是它的时区;本地时区换算等时间格式 mission。
|
||
/// 非 ASCII 或长度不足时原样返回(不 panic、不猜)。
|
||
pub(super) fn last_played(raw: Option<&str>, catalog: Catalog) -> String {
|
||
let Some(text) = raw else {
|
||
return catalog.text(Key::InstancesNeverPlayed).to_string();
|
||
};
|
||
match (text.get(0..10), text.get(11..16)) {
|
||
(Some(date), Some(time)) if date.starts_with(|c: char| c.is_ascii_digit()) => {
|
||
format!("{date} {time} UTC")
|
||
}
|
||
_ => text.to_string(),
|
||
}
|
||
}
|