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:
AskaEth committed 2026-11-07 17:01:13 +08:00
1 parent 6804b0ad1d
commit 79b796fd58
21 files changed
+894 -168

No files matched your search

@@ -213,13 +213,15 @@ fn refresh_keeps_pending_as_busy_and_failed_as_failed() {
.into_iter()
.find(|instance| instance.id == id)
.expect("实例存在");
// 跑起来又崩:原因是**结局**,不是「进程起不来」(复审 P1-1)。
// 抓得住:把崩溃塞回 `LaunchFailureStage::Spawn`(磁贴会说「启动游戏进程失败」,假话),
// 或者把退出码漏成 `Some(1)` 这种 Debug 文本(这里只认数字)。
assert_eq!(
failed.runtime,
InstanceRuntime::Failed {
stage: crate::dto::LaunchFailureStage::Spawn,
detail: "exit Some(1)".to_string()
cause: crate::dto::LaunchFailureCause::Crashed { exit_code: Some(1) }
},
"失败原因要经得住一次刷新(否则磁贴的「重试」外观会被 Idle 盖掉)"
"崩溃要如实报成「起来了又崩」,且经得住一次刷新"
);
}
@@ -258,13 +260,101 @@ fn launch_end_before_launch_started_still_releases_the_instance() {
assert_eq!(
failed.runtime,
InstanceRuntime::Failed {
stage: crate::dto::LaunchFailureStage::Identity,
detail: "找不到账户 `steve`".to_string()
cause: crate::dto::LaunchFailureCause::Stage {
stage: crate::dto::LaunchFailureStage::Identity,
detail: "找不到账户 `steve`".to_string()
}
},
"早失败也要在磁贴上留下「失败/重试」,不能被 Idle 盖掉"
"早失败(进程没起来)要留在阶段那一支,不能被 Idle 盖掉"
);
}
/// **三种失败形状各归各的**(复审 P1-1 的核心判据)。
///
/// | 收尾事件 | 期望原因 |
/// |---|---|
/// | `failure: Some(..)` | `Stage{..}`(进程**没起来**)|
/// | `failure: None` + `Crashed` | `Crashed{exit_code}`(起来又崩)|
/// | `failure: None` + `SilentFail` | `SilentFailure{exit_code}` |
///
/// 抓得住:把「起来了又崩」塞进 `LaunchFailureStage::Spawn`(磁贴会说「启动游戏进程失败」,
/// 而进程明明起来了);或者把静默失败也塌成崩溃(丢掉「退出码 0 却没跑起来」这条信息)。
/// 断言的是**枚举变体**,不是字符串里有没有某个词。
#[test]
fn a_launch_end_without_a_stage_reports_the_outcome_not_a_start_failure() {
use crate::dto::{LaunchFailureCause, LaunchFailureStage, LaunchFailureView};
/// 一行用例:`(标签, 结局, 阶段失败, 退出码, 期望原因)`。
type Case = (
&'static str,
LogOutcome,
Option<LaunchFailureView>,
Option<i32>,
LaunchFailureCause,
);
let cases: Vec<Case> = vec![
(
"spawn-failed",
LogOutcome::Crashed,
Some(LaunchFailureView::new(
LaunchFailureStage::Spawn,
"os error 5",
)),
None,
LaunchFailureCause::Stage {
stage: LaunchFailureStage::Spawn,
detail: "os error 5".to_string(),
},
),
(
"crashed-after-start",
LogOutcome::Crashed,
None,
Some(1),
LaunchFailureCause::Crashed { exit_code: Some(1) },
),
(
"silent-after-start",
LogOutcome::SilentFail,
None,
Some(0),
LaunchFailureCause::SilentFailure { exit_code: Some(0) },
),
];
for (label, outcome, failure, exit_code, expected) in cases {
let (mut host, id, _root) = host_with_instance(label);
host.mark_pending(&id);
host.handle(HostTask::LaunchEnd(Box::new(LaunchFinished {
id: id.clone(),
session: format!("20261031T120000-{label}"),
outcome,
exit_code,
uptime_ms: 1_000,
failure,
})));
let failed = instances_from_events({
host.refresh_instances();
host.drain_events()
})
.into_iter()
.find(|instance| instance.id == id)
.expect("实例存在");
match (&failed.runtime, &expected) {
(InstanceRuntime::Failed { cause }, want) => {
assert_eq!(cause, want, "{label}:失败原因说错了");
assert_eq!(
cause.slug(),
want.slug(),
"{label}:slug 与变体不一致(日志/事件流按 slug 判)"
);
}
(other, want) => panic!("{label}:期望 Failed{{cause:{want:?}}},实际 {other:?}"),
}
}
}
/// 从事件队列里取最后一次 `InstancesChanged`。
fn instances_from_events(events: Vec<AppEvent>) -> Vec<crate::dto::InstanceSummary> {
events