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

+144 -14
View File
@@ -194,26 +194,73 @@ fn outcome(
if let Some(hint) = error.hint() {
human.push(format!("提示:{hint}"));
}
if report.rolled_back {
human.push("已整包回滚:目标目录与导入前一致".to_string());
} else if let Some(rollback) = &report.rollback {
human.push(format!(
"回滚未完整:{} 项没回去(详见 --json 的 rollback.failed)",
rollback.failure_count()
));
}
human.push(rollback_line(report));
}
Outcome::new(Report { data, human }).with_exit_code(report.status.exit_code())
}
/// 状态的人读名。**失败要按回滚是否干净分叉**:不完整时不能说"已回滚"。
/// 状态的人读名。**失败要按「动没动过目标目录」分叉**(见 [`rollback_line`])。
fn status_label(report: &ImportReport) -> &'static str {
match report.status {
ImportStatus::Success => "成功",
ImportStatus::Partial => "部分成功",
ImportStatus::Failed if report.rolled_back => "失败(已回滚)",
ImportStatus::Failed => "失败(回滚不完整)",
match rollback_stage(report) {
Some(RollbackStage::NotStarted) => "失败(未写入:目标目录没被动过)",
Some(RollbackStage::RolledBack) => "失败(已回滚)",
Some(RollbackStage::Incomplete) => "失败(回滚不完整)",
None => match report.status {
ImportStatus::Success => "成功",
_ => "部分成功",
},
}
}
/// 「这次失败到底动没动过目标目录」(三档)。
///
/// 与 `presenter` 的 `host::addon::pack::convert::failure_stage` **同义**——CLI 不依赖 presenter
/// 层(`unlucky-cli` 只依赖 core + agent),所以这套判据在这里各写一份。已登记 finding:
/// 更干净的做法是把三档挪进 `core::addon::import::ImportReport`(那儿有 `rollback` /
/// `rolled_back` 两个机器位),两处都改成读它。
///
/// 为什么要三档而不是一个 `rolled_back` 布尔:core 的 `rolled_back` 在「回滚完整完成」与
/// **「从未写过目标目录」**两种情形下都是 `true`。只按它出词,预检失败会被说成「已整包回滚:目标
/// 目录与导入前一致」——那句话在**一个字节都没写**时是假的(M161 复审的 P1 同族问题)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RollbackStage {
/// 预检/装配阶段就失败:目标目录一个字节都没写
NotStarted,
/// 写过,回滚**完整**完成
RolledBack,
/// 写过,回滚有失败项(目标目录可能与导入前不同)
Incomplete,
}
/// 三档判据(与 presenter 那份逐条对应)。
fn rollback_stage(report: &ImportReport) -> Option<RollbackStage> {
if report.status != ImportStatus::Failed {
return None;
}
Some(match &report.rollback {
Some(outcome) if outcome.is_complete() => RollbackStage::RolledBack,
Some(_) => RollbackStage::Incomplete,
None if report.rolled_back => RollbackStage::NotStarted,
None => RollbackStage::Incomplete,
})
}
/// 失败收尾那一段人读文案(**三档三句**,不许共用)。
fn rollback_line(report: &ImportReport) -> String {
match rollback_stage(report) {
Some(RollbackStage::NotStarted) => {
"预检未通过:目标目录一个字节都没写(不需要回滚)".to_string()
}
Some(RollbackStage::RolledBack) => "已整包回滚:目标目录与导入前一致".to_string(),
Some(RollbackStage::Incomplete) => match &report.rollback {
Some(rollback) => format!(
"回滚未完整:{} 项没回去(详见 --json 的 rollback.failed)",
rollback.failure_count()
),
None => "回滚状态不明:目标目录可能与导入前不同(详见 --json)".to_string(),
},
None => String::new(),
}
}
@@ -244,3 +291,86 @@ fn build_engine(ctx: &Ctx) -> DownloadEngine {
Arc::new(ContentCache::open(&cache_root)),
)
}
#[cfg(test)]
mod tests {
//! `pack import` 失败收尾的三档文案(M161 复审「不计入但该记」第一条:CLI 也曾把
//! 预检失败说成「已整包回滚」)。
//!
//! 抓得住:三档塌成两句(预检 → 已回滚);`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
}
/// 三档三句,**互不相同**(同族的谎就是这个 mission 要消灭的那类)。
#[test]
fn the_three_rollback_stages_get_three_different_sentences() {
// ① 预检没过:一个字节都没写。
let mut preflight = failed();
preflight.rolled_back = true;
assert_eq!(rollback_stage(&preflight), Some(RollbackStage::NotStarted));
let not_started = rollback_line(&preflight);
assert!(not_started.contains("没写"), "{not_started}");
assert!(
!not_started.contains("回滚:目标目录"),
"预检失败不许说「已回滚」:{not_started}"
);
assert_eq!(status_label(&preflight), "失败(未写入:目标目录没被动过)");
// ② 写过又完整回滚。
let mut rolled_back = failed();
rolled_back.rolled_back = true;
rolled_back.rollback = Some(RollbackOutcome {
attempted: 2,
failed: Vec::new(),
});
assert_eq!(
rollback_stage(&rolled_back),
Some(RollbackStage::RolledBack)
);
assert!(rollback_line(&rolled_back).contains("已整包回滚"));
assert_eq!(status_label(&rolled_back), "失败(已回滚)");
// ③ 回滚没做完。
let mut incomplete = failed();
incomplete.rolled_back = false;
incomplete.rollback = Some(RollbackOutcome {
attempted: 2,
failed: vec!["mods/a.jar".to_string()],
});
assert_eq!(rollback_stage(&incomplete), Some(RollbackStage::Incomplete));
assert!(rollback_line(&incomplete).contains("1 项没回去"));
assert_eq!(status_label(&incomplete), "失败(回滚不完整)");
// 三句必须互不相同(把任何两档并成一句,这里就红)。
let lines = [
rollback_line(&preflight),
rollback_line(&rolled_back),
rollback_line(&incomplete),
];
assert_eq!(
lines.iter().collect::<std::collections::HashSet<_>>().len(),
3,
"{lines:?}"
);
}
/// 不是致命失败 ⇒ 没有回滚那一段话(成功/部分成功不该出现「已回滚」)。
#[test]
fn a_non_fatal_report_says_nothing_about_rollback() {
let mut report = ImportReport::empty(PathBuf::from("C:/tmp"));
report.status = ImportStatus::Partial;
assert_eq!(rollback_stage(&report), None);
assert_eq!(rollback_line(&report), "");
assert_eq!(status_label(&report), "部分成功");
}
}