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

@@ -375,3 +375,7 @@ pub(super) fn slug(text: &str) -> String {
trimmed
}
}
#[cfg(test)]
#[path = "convert/stage_tests.rs"]
mod stage_tests;
@@ -0,0 +1,108 @@
//! M161 复审 P2-1:`failure_stage` 是⑥三档的**唯一**判定点,此前零覆盖(把它改成恒
//! `RolledBack`,presenter 254 条全绿)。这里直接喂 core 的机器位,断言的是**判定**,
//! 不是渲染(渲染在 app-iced 的 `packui/model/tests/stage.rs`)。
//!
//! 抓得住:`None if report.rolled_back` 被写反(预检失败 → 「已回滚」,正是⑥要消灭的谎);
//! `is_complete()` 被丢掉(回滚没做完也说成零残留);`status != Failed` 也发阶段。
use std::path::PathBuf;
use unlucky_core::addon::import::{ImportError, ImportReport, ImportStatus, RollbackOutcome};
use super::*;
/// 一份「失败」的空报告(还没决定回滚结果)。
fn failed() -> ImportReport {
let mut report = ImportReport::empty(PathBuf::from("C:/tmp/.minecraft"));
report.fail(ImportError::integrity("sha1 mismatch"));
report
}
/// 预检就失败(**从未写过目标目录**)⇒ `NotStarted`。
///
/// core 的 `abandoned()` 就是这一形:`rollback == None` + `rolled_back == true`。
#[test]
fn preflight_failure_is_not_started() {
let mut report = failed();
report.rolled_back = true;
assert_eq!(
failure_stage(&report),
Some(ImportFailureStageView::NotStarted),
"没写过目标目录就是「根本没开始」,不许说成「已回滚」"
);
assert_eq!(failure_stage(&report).unwrap().slug(), "not_started");
// 渲染层同一份判据(`failed_report_with` 走的是同一条)。
assert_eq!(
failed_report_with(
PathBuf::from("C:/tmp"),
std::path::Path::new("Better MC.mrpack"),
ImportErrorView {
code: "pack_traversal".to_string(),
label: "Path traversal".to_string(),
message: "../etc/passwd".to_string(),
hint: None,
},
)
.failure_stage,
Some(ImportFailureStageView::NotStarted),
"宿主侧失败(目标实例不可用)也是「没开始写」,不是「已回滚」"
);
}
/// 跑过回滚且完整 ⇒ `RolledBack`。
#[test]
fn a_complete_rollback_is_rolled_back() {
let mut report = failed();
report.rolled_back = true;
report.rollback = Some(RollbackOutcome {
attempted: 3,
failed: Vec::new(),
});
assert_eq!(
failure_stage(&report),
Some(ImportFailureStageView::RolledBack)
);
assert_eq!(failure_stage(&report).unwrap().slug(), "rolled_back");
}
/// 跑过回滚但有失败项 ⇒ `RollbackIncomplete`(目标目录可能与导入前不同)。
#[test]
fn a_partial_rollback_is_incomplete() {
let mut report = failed();
report.rolled_back = false;
report.rollback = Some(RollbackOutcome {
attempted: 3,
failed: vec!["mods/a.jar: 占用中".to_string()],
});
assert_eq!(
failure_stage(&report),
Some(ImportFailureStageView::RollbackIncomplete)
);
assert_eq!(
failure_stage(&report).unwrap().slug(),
"rollback_incomplete"
);
}
/// 既没回滚也没宣称干净(core 的 `failed()` 单独一支)⇒ 只能如实说「可能不一致」。
#[test]
fn a_failure_without_any_claim_is_incomplete_not_not_started() {
let mut report = failed();
report.rolled_back = false;
report.rollback = None;
assert_eq!(
failure_stage(&report),
Some(ImportFailureStageView::RollbackIncomplete),
"没跑回滚也不宣称干净:不许说「没开始写」"
);
}
/// 不是致命失败 ⇒ 没有「失败阶段」可言(成功/部分成功都不该发这一段)。
#[test]
fn a_non_fatal_report_has_no_stage() {
let mut report = ImportReport::empty(PathBuf::from("C:/tmp"));
report.rolled_back = false;
assert_eq!(failure_stage(&report), None, "Success 不该有失败阶段");
report.status = ImportStatus::Partial;
assert_eq!(failure_stage(&report), None, "Partial 不该有失败阶段");
}