fix(m161-r1): 崩溃/静默失败不再冒充「启动进程失败」;补三处假绿的守卫
复审 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 的拆分冲突。
This commit is contained in:
1 parent
6804b0ad1d
commit
79b796fd58
21 files changed
+894
-168
No files matched your search
@@ -20,8 +20,8 @@
|
||||
|
||||
use unlucky_i18n::{Catalog, Key, Lang};
|
||||
use unlucky_presenter::dto::{
|
||||
InstanceRuntime, InstanceSummary, LaunchFailureStage, LaunchFinished, LaunchStage, LoaderKind,
|
||||
LogOutcome,
|
||||
InstanceRuntime, InstanceSummary, LaunchFailureCause, LaunchFailureStage, LaunchFinished,
|
||||
LaunchStage, LoaderKind, LogOutcome,
|
||||
};
|
||||
|
||||
use crate::pages::{Detail, LaunchFacts, Meta};
|
||||
@@ -74,19 +74,47 @@ pub(crate) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Opt
|
||||
InstanceRuntime::Running { pid } => {
|
||||
Some(catalog.render(Key::InstancesRunning, &[("pid", &pid.to_string())]))
|
||||
}
|
||||
// `detail` 是 core/io 给的原文(**数据**,不翻译);「哪一步」与前半句是界面文案。
|
||||
// M161 之前这里只有 `reason`,而 reason 里已经是「启动失败:…」,于是英文界面会出现
|
||||
// 「Launch failed · 启动失败:找不到 Java」——半句英文半句中文。
|
||||
// 失败有**两种形状**,各说各的话(复审 P1-1):
|
||||
// - `Stage`:进程**没起来** → 「哪一步 + 那一步的原文」(原文不翻译,是取证信息);
|
||||
// - `Crashed` / `SilentFailure`:**起来过** → 说结局(崩溃 / 疑似静默失败 + 退出码),
|
||||
// 绝不说成「启动游戏进程失败」。
|
||||
//
|
||||
// 这一份是**详情页**用的全文(详情列会自动换行);列表行那份是 [`runtime_status_row`]
|
||||
// (单行、不许折行,见 D-M79-5)。
|
||||
InstanceRuntime::Failed { stage, detail } => Some(catalog.render(
|
||||
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())])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,10 +124,7 @@ pub(crate) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Opt
|
||||
/// 路径),放进 60/76 高的单行 overline 会被裁成半截字。全文在**详情页**([`runtime_status`])。
|
||||
pub(crate) fn runtime_status_row(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
|
||||
match runtime {
|
||||
InstanceRuntime::Failed { stage, .. } => Some(catalog.render(
|
||||
Key::InstancesFailedStage,
|
||||
&[("stage", failure_stage_label(*stage, catalog))],
|
||||
)),
|
||||
InstanceRuntime::Failed { cause } => Some(failure_line(cause, catalog, false)),
|
||||
other => runtime_status(other, catalog),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ use unlucky_presenter::dto::{
|
||||
};
|
||||
use unlucky_presenter::mock_data::mock_instances;
|
||||
|
||||
#[path = "tests/launch.rs"]
|
||||
mod launch;
|
||||
|
||||
fn instance(runtime: InstanceRuntime) -> InstanceSummary {
|
||||
InstanceSummary {
|
||||
id: "survival".to_string(),
|
||||
@@ -123,28 +126,6 @@ fn a_busy_instance_offers_stop_instead_of_play() {
|
||||
assert!(detail.can_stop, "启动中要能 Stop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_instances_can_be_retried() {
|
||||
let failed = instance(InstanceRuntime::Failed {
|
||||
stage: unlucky_presenter::dto::LaunchFailureStage::Spawn,
|
||||
detail: "exit 1".to_string(),
|
||||
});
|
||||
// 列表行是**单行**:只放「哪一步」(原文太长会被 `Wrapping::None` 裁成半截字)。
|
||||
assert_eq!(
|
||||
super::instances::runtime_status_row(&failed.runtime, Lang::En.catalog()).as_deref(),
|
||||
Some("Launch failed · Starting the game process")
|
||||
);
|
||||
// 详情页给全文(阶段 + 原文)。
|
||||
assert_eq!(
|
||||
super::instances::runtime_status(&failed.runtime, Lang::En.catalog()).as_deref(),
|
||||
Some("Launch failed · Starting the game process: exit 1")
|
||||
);
|
||||
assert!(
|
||||
detail_of(&failed, Lang::En.catalog()).can_launch,
|
||||
"失败后要能重试"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_sliced_not_guessed() {
|
||||
let en = Lang::En.catalog();
|
||||
@@ -327,67 +308,3 @@ fn the_agent_route_has_no_page() {
|
||||
assert!(model.instances.detail.is_none());
|
||||
assert_eq!(model.active, None, "AI 页不高亮任何导航项");
|
||||
}
|
||||
|
||||
/// **启动收尾的提示语跟着界面语言走**(M161 收的债)。
|
||||
///
|
||||
/// 此前 `LaunchFinished.summary` 是 presenter 拼的中文,英文界面上弹的就是中文;
|
||||
/// 现在 presenter 只给结构(结局 + 退出码 + 失败阶段),文案在这里按 key 现求。
|
||||
///
|
||||
/// 抓得住:①谁把中文串再写回 presenter(英文那一行会露中文);②失败时丢掉 `detail`
|
||||
/// (原文是取证信息,不许翻也不许删);③「跑起来再崩」被说成「启动失败在某一步」。
|
||||
#[test]
|
||||
fn the_launch_toast_speaks_the_interface_language() {
|
||||
use unlucky_presenter::dto::{
|
||||
LaunchFailureStage, LaunchFailureView, LaunchFinished, LogOutcome,
|
||||
};
|
||||
|
||||
let finished = |outcome, failure| LaunchFinished {
|
||||
id: "survival".to_string(),
|
||||
session: "20261031T120000-abcd12".to_string(),
|
||||
outcome,
|
||||
exit_code: Some(1),
|
||||
uptime_ms: 12_500,
|
||||
failure,
|
||||
};
|
||||
|
||||
// 正常结束:只报结局 + 时长(`正常结束 · 12.5 秒` / `Finished normally · 12.5s`)。
|
||||
let ok = finished(LogOutcome::Ok, None);
|
||||
let en = super::instances::launch_toast(&ok, Lang::En);
|
||||
let zh = super::instances::launch_toast(&ok, Lang::ZhCn);
|
||||
assert_eq!(en, "Finished normally · 12.5s");
|
||||
assert_eq!(zh, "正常结束 · 12.5 秒");
|
||||
assert_ne!(en, zh, "切语言时提示语要变");
|
||||
|
||||
// 装配阶段失败:说清**哪一步**,并把原文原样带出来。
|
||||
let failed = finished(
|
||||
LogOutcome::Crashed,
|
||||
Some(LaunchFailureView::new(
|
||||
LaunchFailureStage::InstanceConfig,
|
||||
"permission denied (os error 5)",
|
||||
)),
|
||||
);
|
||||
let en = super::instances::launch_toast(&failed, Lang::En);
|
||||
let zh = super::instances::launch_toast(&failed, Lang::ZhCn);
|
||||
assert!(
|
||||
en.starts_with("Launch failed · Reading the instance settings"),
|
||||
"{en}"
|
||||
);
|
||||
assert!(zh.starts_with("启动失败 · 读实例设置"), "{zh}");
|
||||
for text in [&en, &zh] {
|
||||
assert!(
|
||||
text.contains("permission denied (os error 5)"),
|
||||
"原文(取证信息)不许丢:{text}"
|
||||
);
|
||||
}
|
||||
// 英文界面里不许出现中文(反之亦然)——这条是本次债的核心。
|
||||
assert!(!en.chars().any(is_cjk), "英文提示里混了中文:{en}");
|
||||
assert!(
|
||||
!zh.contains("Launch failed"),
|
||||
"中文提示里混了英文前缀:{zh}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 有没有中文字符(上面那条「不许混语言」的判据)。
|
||||
fn is_cjk(ch: char) -> bool {
|
||||
('\u{4e00}'..='\u{9fff}').contains(&ch)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! 启动失败/收尾的**文案**用例(从 `app/view/tests.rs` 拆出:那边 400 行到线)。
|
||||
//!
|
||||
//! 这里的断言都钉**具体句子**(不是「字符串非空」):磁贴与 Toast 必须说同一件事,
|
||||
//! 「没起来」与「起来又崩」不许共用一句(M161 复审 P1-1)。
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 失败后磁贴要留「重试」,且行/详情文案按「哪一步 + 原文」给。
|
||||
#[test]
|
||||
fn failed_instances_can_be_retried() {
|
||||
// 进程**没起来**:说「哪一步 + 原文」。
|
||||
let failed = instance(InstanceRuntime::Failed {
|
||||
cause: unlucky_presenter::dto::LaunchFailureCause::Stage {
|
||||
stage: unlucky_presenter::dto::LaunchFailureStage::Spawn,
|
||||
detail: "os error 5".to_string(),
|
||||
},
|
||||
});
|
||||
// 列表行是**单行**:只放「哪一步」(原文太长会被 `Wrapping::None` 裁成半截字)。
|
||||
assert_eq!(
|
||||
super::instances::runtime_status_row(&failed.runtime, Lang::En.catalog()).as_deref(),
|
||||
Some("Launch failed · Starting the game process")
|
||||
);
|
||||
// 详情页给全文(阶段 + 原文)。
|
||||
assert_eq!(
|
||||
super::instances::runtime_status(&failed.runtime, Lang::En.catalog()).as_deref(),
|
||||
Some("Launch failed · Starting the game process: os error 5")
|
||||
);
|
||||
assert!(
|
||||
detail_of(&failed, Lang::En.catalog()).can_launch,
|
||||
"失败后要能重试"
|
||||
);
|
||||
}
|
||||
|
||||
/// **「没起来」与「起来又崩」不许说成同一句**(复审 P1-1 的文案面)。
|
||||
///
|
||||
/// 抓得住:崩溃/静默失败被塞回 `LaunchFailureStage::Spawn`(磁贴会写「启动游戏进程失败」,
|
||||
/// 而进程明明起来了);退出码漏成 `Some(1)` 这种 Debug 文本;静默失败丢掉
|
||||
/// 「exit 0 却没跑起来」这条信息。断言的是**具体句子**,不是「字符串非空」。
|
||||
#[test]
|
||||
fn a_crash_after_start_is_not_worded_as_a_start_failure() {
|
||||
use unlucky_presenter::dto::{LaunchFailureCause, LaunchFailureStage};
|
||||
|
||||
let line = |cause: LaunchFailureCause, lang: Lang| {
|
||||
let runtime = InstanceRuntime::Failed { cause };
|
||||
let row = super::instances::runtime_status_row(&runtime, lang.catalog()).expect("行文案");
|
||||
let detail = super::instances::runtime_status(&runtime, lang.catalog()).expect("详情文案");
|
||||
(row, detail)
|
||||
};
|
||||
|
||||
// ① 没起来:说阶段。
|
||||
let (row, detail) = line(
|
||||
LaunchFailureCause::Stage {
|
||||
stage: LaunchFailureStage::Spawn,
|
||||
detail: "os error 5".to_string(),
|
||||
},
|
||||
Lang::En,
|
||||
);
|
||||
assert_eq!(row, "Launch failed · Starting the game process");
|
||||
assert_eq!(
|
||||
detail,
|
||||
"Launch failed · Starting the game process: os error 5"
|
||||
);
|
||||
|
||||
// ② 起来又崩:说结局 + 数字退出码(不许出现 `Some(1)`)。
|
||||
let (row, detail) = line(LaunchFailureCause::Crashed { exit_code: Some(1) }, Lang::En);
|
||||
assert_eq!(row, "Launch failed · the game crashed (exit 1)");
|
||||
assert_eq!(detail, row, "结局类没有「原文」可加,行与详情同一句");
|
||||
assert!(!detail.contains("Some("), "退出码不许漏 Debug:{detail}");
|
||||
assert!(
|
||||
!detail.contains("Starting the game process"),
|
||||
"进程起来了,不许说「启动游戏进程」:{detail}"
|
||||
);
|
||||
|
||||
// ③ 静默失败:exit 0 却没跑起来 —— 这条信息不能丢。
|
||||
let (row, zh) = line(
|
||||
LaunchFailureCause::SilentFailure { exit_code: Some(0) },
|
||||
Lang::ZhCn,
|
||||
);
|
||||
assert_eq!(row, "启动失败 · 退出码 0,没起来");
|
||||
assert_eq!(zh, row);
|
||||
assert!(row.contains("没起来"), "{row}");
|
||||
|
||||
// ④ 平台没给退出码:只说崩溃,不编一个 0 出来。
|
||||
let (row, _) = line(LaunchFailureCause::Crashed { exit_code: None }, Lang::En);
|
||||
assert_eq!(row, "Launch failed · The game crashed");
|
||||
}
|
||||
|
||||
///
|
||||
/// 此前 `LaunchFinished.summary` 是 presenter 拼的中文,英文界面上弹的就是中文;
|
||||
/// 现在 presenter 只给结构(结局 + 退出码 + 失败阶段),文案在这里按 key 现求。
|
||||
///
|
||||
/// 抓得住:①谁把中文串再写回 presenter(英文那一行会露中文);②失败时丢掉 `detail`
|
||||
/// (原文是取证信息,不许翻也不许删);③「跑起来再崩」被说成「启动失败在某一步」。
|
||||
#[test]
|
||||
fn the_launch_toast_speaks_the_interface_language() {
|
||||
use unlucky_presenter::dto::{
|
||||
LaunchFailureStage, LaunchFailureView, LaunchFinished, LogOutcome,
|
||||
};
|
||||
|
||||
let finished = |outcome, failure| LaunchFinished {
|
||||
id: "survival".to_string(),
|
||||
session: "20261031T120000-abcd12".to_string(),
|
||||
outcome,
|
||||
exit_code: Some(1),
|
||||
uptime_ms: 12_500,
|
||||
failure,
|
||||
};
|
||||
|
||||
// 正常结束:只报结局 + 时长(`正常结束 · 12.5 秒` / `Finished normally · 12.5s`)。
|
||||
let ok = finished(LogOutcome::Ok, None);
|
||||
let en = super::instances::launch_toast(&ok, Lang::En);
|
||||
let zh = super::instances::launch_toast(&ok, Lang::ZhCn);
|
||||
assert_eq!(en, "Finished normally · 12.5s");
|
||||
assert_eq!(zh, "正常结束 · 12.5 秒");
|
||||
assert_ne!(en, zh, "切语言时提示语要变");
|
||||
|
||||
// 装配阶段失败:说清**哪一步**,并把原文原样带出来。
|
||||
let failed = finished(
|
||||
LogOutcome::Crashed,
|
||||
Some(LaunchFailureView::new(
|
||||
LaunchFailureStage::InstanceConfig,
|
||||
"permission denied (os error 5)",
|
||||
)),
|
||||
);
|
||||
let en = super::instances::launch_toast(&failed, Lang::En);
|
||||
let zh = super::instances::launch_toast(&failed, Lang::ZhCn);
|
||||
assert!(
|
||||
en.starts_with("Launch failed · Reading the instance settings"),
|
||||
"{en}"
|
||||
);
|
||||
assert!(zh.starts_with("启动失败 · 读实例设置"), "{zh}");
|
||||
for text in [&en, &zh] {
|
||||
assert!(
|
||||
text.contains("permission denied (os error 5)"),
|
||||
"原文(取证信息)不许丢:{text}"
|
||||
);
|
||||
}
|
||||
// 英文界面里不许出现中文(反之亦然)——这条是本次债的核心。
|
||||
assert!(!en.chars().any(is_cjk), "英文提示里混了中文:{en}");
|
||||
assert!(
|
||||
!zh.contains("Launch failed"),
|
||||
"中文提示里混了英文前缀:{zh}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 有没有中文字符(上面那条「不许混语言」的判据)。
|
||||
fn is_cjk(ch: char) -> bool {
|
||||
('\u{4e00}'..='\u{9fff}').contains(&ch)
|
||||
}
|
||||
@@ -270,18 +270,70 @@ mod body_mode_tests {
|
||||
assert!(!BodyMode::of(300.0).list_scrolls());
|
||||
}
|
||||
|
||||
/// 窗口最小尺寸(880×560)落在**矮**档:这是本条债的真实场景,别让它悄悄变回 `Split`。
|
||||
/// 最小窗口(880×560)落在**矮**档,验收到 760 不换版面——**正文可用高按真实外壳算**。
|
||||
///
|
||||
/// 复审 P2-2:早先这里是 `560 − 56 − 88 − 32` 反推的(三个数全错:真实是 titlebar 48 /
|
||||
/// 两行页头 84 / Expanded 页边距 2×24 = chrome 180),结论碰巧对、但抓不住 metrics 漂移。
|
||||
/// 现在直接调 [`crate::pages::frame::body_height`],chrome 从 token 现取。
|
||||
#[test]
|
||||
fn the_minimum_window_size_uses_the_scrolling_body() {
|
||||
// 560 窗口 − 顶栏 56 − 页头 88(两行标题 + 留白)− 上下页边距 2×16 ≈ 384
|
||||
let body = 560.0 - 56.0 - 88.0 - 32.0;
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::typography::Platform;
|
||||
|
||||
use crate::pages::frame;
|
||||
use crate::pages::layout::{frame_metrics, PaneKit};
|
||||
use crate::theme::{Families, Ui};
|
||||
|
||||
let ui = Ui::with_platform(
|
||||
SchemeFlavor::Light,
|
||||
Platform::Windows,
|
||||
Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
},
|
||||
);
|
||||
// 880 宽 = 窗口最小尺寸(`app::MIN_SIZE.width`),属 Expanded。
|
||||
let metrics = frame_metrics(PaneKit::Single, 880.0);
|
||||
let chrome =
|
||||
ui.metrics().titlebar_height + ui.metrics().header_height(true) + 2.0 * metrics.gutter;
|
||||
|
||||
// 真值:48 + 84 + 48 = 180。这条会随 token 改而红——那时请重看一眼两档版面。
|
||||
assert_eq!(chrome, 180.0, "外壳 chrome 与 design token 不一致");
|
||||
|
||||
// 最小窗口 560 高:正文只剩 380 < 460 ⇒ 整列滚动(本条债的真实场景)。
|
||||
let short = frame::body_height(&ui, metrics, 560.0, true);
|
||||
assert_eq!(short, 560.0 - chrome);
|
||||
assert_eq!(
|
||||
BodyMode::of(body),
|
||||
BodyMode::of(short),
|
||||
BodyMode::ScrollWhole,
|
||||
"最小窗口(560 高)必须走整列滚动:正文只剩 {body}"
|
||||
"正文只剩 {short}"
|
||||
);
|
||||
|
||||
// 验收尺寸 760 高:580 ≥ 460 ⇒ 列表自己滚(不换版面)。
|
||||
let tall = frame::body_height(&ui, metrics, 760.0, true);
|
||||
assert_eq!(BodyMode::of(tall), BodyMode::Split);
|
||||
|
||||
// 把门槛和**真实 chrome** 绑死:窗口高 = chrome + MIN 时必须还是 Split,
|
||||
// 少 1px 就翻成整列滚动。chrome 涨到 100/页边距 40 这类漂移会让这条立刻红
|
||||
// (而不是像旧用例那样拿反推数字自说自话)。
|
||||
assert_eq!(
|
||||
BodyMode::of(frame::body_height(
|
||||
&ui,
|
||||
metrics,
|
||||
chrome + MIN_BODY_HEIGHT,
|
||||
true
|
||||
)),
|
||||
BodyMode::Split
|
||||
);
|
||||
assert_eq!(
|
||||
BodyMode::of(frame::body_height(
|
||||
&ui,
|
||||
metrics,
|
||||
chrome + MIN_BODY_HEIGHT - 1.0,
|
||||
true
|
||||
)),
|
||||
BodyMode::ScrollWhole
|
||||
);
|
||||
// 验收尺寸 760 高 → 约 584,仍走「列表自己滚」。
|
||||
let body = 760.0 - 56.0 - 88.0 - 32.0;
|
||||
assert_eq!(BodyMode::of(body), BodyMode::Split, "验收尺寸不该换版面");
|
||||
}
|
||||
}
|
||||
@@ -99,3 +99,49 @@ fn two_pane_body_is_narrower_than_the_available_width() {
|
||||
assert!(wide.column < wide.available);
|
||||
assert!(wide.secondary.is_some());
|
||||
}
|
||||
|
||||
/// **`body_height` 用真的 `Ui::metrics` + `FrameMetrics` 算**(M161 复审 P2-2:这条函数此前
|
||||
/// 一条测试都没有,而④的「最小窗口」用例是拿 56/88/32 反推的——三个数全错)。
|
||||
///
|
||||
/// 抓得住:①`titlebar_height` / `header_height` / `gutter` 任何一个被改(chrome 变了,
|
||||
/// 下面的等式立刻红);②把两行页头当单行算(`has_subtitle` 被忽略);③把页边距只减一遍。
|
||||
#[test]
|
||||
fn body_height_subtracts_the_real_shell_chrome() {
|
||||
let ui = ui();
|
||||
let metrics = frame_metrics(PaneKit::Single, 880.0);
|
||||
let metrics_wide = frame_metrics(PaneKit::Single, 1180.0);
|
||||
|
||||
let chrome = |two_line: bool| {
|
||||
ui.metrics().titlebar_height + ui.metrics().header_height(two_line) + 2.0 * metrics.gutter
|
||||
};
|
||||
|
||||
// 真值来自 token:自绘标题栏 48 + 两行页头 84 + 上下页边距 2×24 = 180。
|
||||
assert_eq!(
|
||||
chrome(true),
|
||||
48.0 + 84.0 + 2.0 * 24.0,
|
||||
"chrome 与 design token 不一致(titlebar_height / page_header_tall_height / Expanded gutter)"
|
||||
);
|
||||
assert_eq!(body_height(&ui, metrics, 560.0, true), 560.0 - chrome(true));
|
||||
// 单行页头少 28(84 − 56 = `page_header_tall_height` − `page_header_height`)。
|
||||
assert_eq!(
|
||||
body_height(&ui, metrics, 560.0, false),
|
||||
560.0 - chrome(false)
|
||||
);
|
||||
assert!(
|
||||
body_height(&ui, metrics, 560.0, true) < body_height(&ui, metrics, 560.0, false),
|
||||
"带副标题的页头更高,正文可用高必须更小"
|
||||
);
|
||||
// 窄窗(Compact < 600)页边距更小 ⇒ 同样窗口高下正文更高。
|
||||
let compact = frame_metrics(PaneKit::Single, 560.0);
|
||||
assert_eq!(compact.class, crate::pages::layout::WidthClass::Compact);
|
||||
assert!(
|
||||
compact.gutter < metrics_wide.gutter,
|
||||
"Compact 档的页边距应小于 Expanded(16 < 24)"
|
||||
);
|
||||
assert!(
|
||||
body_height(&ui, compact, 560.0, true) > body_height(&ui, metrics_wide, 560.0, true),
|
||||
"页边距变小 ⇒ 正文可用高变大"
|
||||
);
|
||||
// 窗口比外壳还矮时不返回负数(不许把版面算成 NaN/负值)。
|
||||
assert_eq!(body_height(&ui, metrics, 100.0, true), 0.0);
|
||||
}
|
||||
@@ -61,15 +61,18 @@ const PAGE_SOURCES: [(&str, &str); 6] = [
|
||||
/// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 |
|
||||
/// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) |
|
||||
/// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) |
|
||||
const ALLOWED_LITERALS: [&str; 22] = [
|
||||
const ALLOWED_LITERALS: [&str; 24] = [
|
||||
"{date} {time} UTC",
|
||||
"isolated",
|
||||
"shared",
|
||||
"pid",
|
||||
// M161:`InstancesFailed` 拆成「阶段 + 原文」之后,失败行改由 `LaunchStageFailed` 渲染,
|
||||
// 它的两个占位符名是 `{stage}` / `{detail}`(占位符名不是文案)。
|
||||
// M161:失败行由 `LaunchStageFailed` / `InstancesFailed` / `InstancesCrashed` 渲染,
|
||||
// 占位符名是 `{stage}` / `{detail}` / `{reason}` / `{code}`(占位符名不是文案);
|
||||
// `{outcome}` / `{seconds}` 是收尾 Toast 的。
|
||||
"stage",
|
||||
"detail",
|
||||
"reason",
|
||||
"code",
|
||||
"outcome",
|
||||
"seconds",
|
||||
"Instances",
|
||||
|
||||
@@ -172,6 +172,17 @@ fn the_buttons_are_disabled_exactly_while_something_is_running() {
|
||||
assert!(applying.busy());
|
||||
assert!(!applying.can_apply(), "装的时候安装键置灰,不是消失");
|
||||
assert!(!applying.can_check(), "装的时候也不许再来一条检查");
|
||||
// 状态行在这一档也必须说「正在安装…」(复审 P2 附注:此前只有截图兜着)。
|
||||
assert_eq!(
|
||||
super::super::update::status_text(catalog, &applying),
|
||||
(catalog.text(Key::SettingsUpdateApplying).to_string(), false),
|
||||
"applying 档的状态行必须是「正在安装…」且不是错误色"
|
||||
);
|
||||
// 同一档的两句不许撞(「正在安装」≠「正在检查」)。
|
||||
assert_ne!(
|
||||
catalog.text(Key::SettingsUpdateApplying),
|
||||
catalog.text(Key::SettingsUpdateChecking)
|
||||
);
|
||||
|
||||
// 结论回来(这里是最坏的结论:失败)⇒ 恢回可点。
|
||||
let failed = Update {
|
||||
|
||||
@@ -166,11 +166,7 @@ fn status_block<'a>(ui: &'a Ui, catalog: Catalog, update: &'a Update) -> Element
|
||||
|
||||
// 下载/校验/替换期间先说这一句:设置页的按钮在这一刻也点不动(见下面 `applying` 分支),
|
||||
// 用户得知道「它正在干活」而不是「点了没反应」。
|
||||
let (text, error) = if update.applying {
|
||||
(catalog.text(Key::SettingsUpdateApplying).to_string(), false)
|
||||
} else {
|
||||
status_line(catalog, update)
|
||||
};
|
||||
let (text, error) = status_text(catalog, update);
|
||||
let color = ui.color(if error {
|
||||
Role::Error
|
||||
} else {
|
||||
@@ -242,6 +238,18 @@ fn status_block<'a>(ui: &'a Ui, catalog: Catalog, update: &'a Update) -> Element
|
||||
block.into()
|
||||
}
|
||||
|
||||
/// 状态行此刻那句话(**唯一**产地:`applying` 与四态都在这里分派)。
|
||||
///
|
||||
/// 拆成函数是为了可断言:`applying` 这一档此前只有截图兜着(复审 P2 附注),
|
||||
/// `Element` 没有内省接口,而上屏文本只能从**取词的那一个入口**取。
|
||||
pub(super) fn status_text(catalog: Catalog, update: &Update) -> (String, bool) {
|
||||
if update.applying {
|
||||
// 「正在下载/校验/替换」不是错误色(它只是在干活)。
|
||||
return (catalog.text(Key::SettingsUpdateApplying).to_string(), false);
|
||||
}
|
||||
status_line(catalog, update)
|
||||
}
|
||||
|
||||
/// 一条状态行(文案 + 是不是「错」那一类)。
|
||||
///
|
||||
/// 四种结论各写各的句子,**不共用**「已是最新」那句:失败/缺产物时那句就是撒谎。
|
||||
|
||||
Reference in new issue
Block a user