fix(m195): 复审 r2 —— 取消的真实结局必须到达界面(三档 + 终态穿透代次)

复审 r2 P2-1:界面无条件断言「已停手并回滚:没有留下半包,也没有覆盖已有文件」,而两条路径下
这句话是假的——①取消来得太晚(落在最后一个检查点之后)时管线已经跑完,用户的同名文件被覆盖、
实例已登记;②回滚本身没做完(`rolled_back=false`)。core 一直是诚实的,是 presenter 把报告丢了:
`cancel.load()` 早退 + `cancel_pack` bump 代次后 `handle` 按代次丢弃。

按"接通真相"做(不是改软话):

- **终态一定发出**:`import::run` 去掉 `cancel` 的无条件早退,收尾抽成 `finish`(清自建空目录 +
  无条件 `send(PackEvent::Report)`);取消标记在管线返回后**再读一次**,作为"太晚"的唯一证据。
- **终态穿透代次**:`HostPackState::terminal_epochs` 记下 `cancel_pack` 作废的那个代次,
  `handle` 对它**只放行一条 `PackEvent::Report`**(进度事件照旧按代次丢,`fresh_cancel` 起新
  一轮时清空)。这是路径 A 的成因与修法。
- **DTO 三档**(tower 批准扩 scope):`ImportStatusView` 加 `Cancelled`;新增
  `ImportCancelView { NotRequested, RolledBack, RollbackIncomplete, TooLate }` 挂在
  `ImportReportView::cancel` 上——已回滚 / 回滚没做完 / 取消太晚,三档各自可判。
  `PackEvent::Report` 到达时清掉 `ImportState.cancelled`(它现在只是"取消后还没有结果"的占位)。
- **界面按结局分支**:标题 `PackStatusCancelled`(已取消 — 已回滚)/ `PackCancelIncomplete`
  (已取消 — 回滚没做完)/ 取消太晚时保留真实状态标题并另起一句 `PackCancelTooLate`
  (实例会保留、同名文件可能已被覆盖);占位的 `PackCancelled` 改成"正在停手,真实结果随后给出"。
  图标:`Close`(描边,页面对填充字形有守门)/ 回滚不完整用 `Warn`,一档都不用 `Role::Error`。

守门与变异:
- 新增路径 A 用例 `a_late_cancel_still_delivers_the_real_outcome_to_the_ui`:真管线把取消置在
  **最后一条 Commit 进度**上(之后没有检查点)⇒ core 报 `Success`/未回滚、用户的 `mods/a.jar`
  确实被覆盖;再 `cancel_pack`(代次 +1 并记终态)→ `finish` → `handle`,断言界面收到
  `status == Success` + `rolled_back == false` + `cancel == TooLate`,且同代次的进度事件仍被丢。
- 变异①:`handle` 恢复"代次不符一律丢" ⇒ 该用例红("取消太晚时,真实结果必须到达界面")。
- 变异②:`finish` 恢复"取消就早退" ⇒ 该用例红(另带 P2-2 那条也红)。
- app-iced 新增 `model/tests/cancel.rs`:三档标题互不相同 + 「太晚」必须有那句补充说明 +
  已回滚不许显示成「部分成功」。

验证(CARGO_TARGET_DIR=C:/Temp/ull-target/wt-194):
`cargo test -p unlucky-core -p unlucky-presenter -p unlucky-app-iced` = 2185 passed / 0 failed;
`-p unlucky-i18n` = 31 / 0;clippy 四 crate = 0 warning / 0 error。
This commit is contained in:
AskaEth committed 2026-11-09 05:06:55 +08:00
1 parent db5b08b9ab
commit b1524635ba
23 files changed
+627 -120

No files matched your search

@@ -0,0 +1,140 @@
//! 取消的**三档真实结局**在界面上的分支(M195 r3)。
//!
//! 口径:一句「已停手并回滚」打天下是假话——取消可能停在提交前(已回滚)、可能停在回滚没收尾
//! (目标目录可能变了)、也可能**太晚**(管线已经跑完,实例保留、同名文件可能已被覆盖)。
//! 这一族用例钉的就是"三档各说各的话",以及"占位文案不再替结局下结论"。
use std::path::PathBuf;
use unlucky_i18n::{Catalog, Key, Lang};
use unlucky_presenter::dto::{
ImportCancelView, ImportReportView, ImportStatusView, PackFormatView,
};
use super::super::report::report_of;
use super::super::PackPageState;
fn catalog() -> Catalog {
Lang::En.catalog()
}
fn report(
status: ImportStatusView,
cancel: ImportCancelView,
rolled_back: bool,
) -> ImportReportView {
ImportReportView {
status,
format: Some(PackFormatView::Multimc),
pack_name: Some("Better MC".to_string()),
pack_version: None,
game_version: Some("1.20.1".to_string()),
loader: None,
version_id: Some("1.20.1".to_string()),
target: PathBuf::from("C:/tmp/instance"),
files: Vec::new(),
conflicts: Vec::new(),
missing: Vec::new(),
rejected: Vec::new(),
warnings: Vec::new(),
error: None,
registered: None,
bytes_written: 0,
rolled_back,
failure_stage: None,
cancel,
}
}
#[test]
fn a_rolled_back_cancel_says_it_is_cancelled_not_partially_installed() {
// 取消停在提交前:已回滚 ⇒ 标题是「已取消 —— 已回滚」,**不是**「部分成功」(那等于说装了一半),
// 也不能出现「取消太晚」那句补充说明。
let view = report(
ImportStatusView::Cancelled,
ImportCancelView::RolledBack,
true,
);
let block = report_of(&view, &PackPageState::default(), catalog());
assert_eq!(block.title, catalog().text(Key::PackStatusCancelled));
assert_ne!(
block.title,
catalog().text(Key::PackStatusPartial),
"已回滚的取消不许显示成「部分成功」"
);
assert!(!block.installed(), "取消不是「装上了」");
assert!(block.cancel_note.is_none(), "已回滚不需要「太晚」那句");
}
#[test]
fn an_incomplete_cancel_rollback_says_the_directory_may_differ() {
let view = report(
ImportStatusView::Cancelled,
ImportCancelView::RollbackIncomplete,
false,
);
let block = report_of(&view, &PackPageState::default(), catalog());
assert_eq!(block.title, catalog().text(Key::PackCancelIncomplete));
assert_ne!(
block.title,
catalog().text(Key::PackStatusCancelled),
"回滚没做完 ≠ 已回滚(这两句话不能混)"
);
}
#[test]
fn a_too_late_cancel_says_the_import_already_finished() {
// 取消来得太晚:状态是真实的 `Success`,另加一句「实例会保留、同名文件可能已被覆盖」。
let view = report(ImportStatusView::Success, ImportCancelView::TooLate, false);
let block = report_of(&view, &PackPageState::default(), catalog());
assert_eq!(block.title, catalog().text(Key::PackStatusSuccess));
assert_eq!(block.status, ImportStatusView::Success);
assert_ne!(
block.status,
ImportStatusView::Cancelled,
"太晚 ≠ 已回滚(状态是真的 Success/Partial)"
);
assert_eq!(
block.cancel_note,
Some(catalog().text(Key::PackCancelTooLate).to_owned()),
"必须另外说清「取消没赶上」"
);
}
/// 三档的标题**互不相同**(把任意两档并成一句,这里就红)。
#[test]
fn the_three_cancel_outcomes_have_three_different_titles() {
let titles = [
report_of(
&report(
ImportStatusView::Cancelled,
ImportCancelView::RolledBack,
true,
),
&PackPageState::default(),
catalog(),
)
.title,
report_of(
&report(
ImportStatusView::Cancelled,
ImportCancelView::RollbackIncomplete,
false,
),
&PackPageState::default(),
catalog(),
)
.title,
report_of(
&report(ImportStatusView::Success, ImportCancelView::TooLate, false),
&PackPageState::default(),
catalog(),
)
.title,
];
let unique: std::collections::BTreeSet<&String> = titles.iter().collect();
assert_eq!(unique.len(), 3, "三档必须各说各的话:{titles:?}");
}