fix(m195): 复审 r1 三项 —— 解压检查点真守卫、presenter 接线测试、取消文案改回真话

P2-1(解压循环检查点没有真守卫):那条用例原来的断言(status/target/files/bytes/无残留暂存)
在删掉 `extract_overrides` 的检查点后**依然成立**——提交前检查点⑤会替它停一次,于是几 GB 的包
会一直解到结束而测试全绿。改成数 `ImportStage::Extract` 事件:有检查点 = 1 条、删掉 = 3 条
(两面实测)。模块文档同时如实登记"还没有真守卫"的检查点:②③④⑤、全新导入 commit 的取消
分支、phase.rs 远端下载逐条、complete.rs 补全循环的 break。

P2-2(presenter 的真令牌接线零测试):新增 `pack/tests/cancel.rs`,直接调 `import::run` 并把
传入的 `Arc<AtomicBool>` **预先置 true**,判据只能是盘上事实(目标目录不存在 + profiles.json
零实例)——"没收到 Report"那种判据两条路都成立,`run` 早退条件里的 `cancel.load()` 会把
"没接线"完全遮住。

P2-3(用户可见文案说反了话):`PackCancelled` 中英双语从「导入会在后台继续跑完 / Import keeps
running in the background…已经写下的文件会留下」改为「导入已取消 —— 后台已停手并回滚:没有留下
半包,也没有覆盖已有文件」;golden 金表同步;`model/tests.rs` 那条把旧文案钉死的用例改成钉新
语义(必须说清停手+回滚,且**禁止**出现 background);`model.rs` 的 `cancelled` 字段文档与
`dispatch.rs` 的取消注释同族修正。

验证:`cargo test -p unlucky-core -p unlucky-presenter` 1480 passed / 0 failed;
i18n 31 / 0;app-iced packui 28 / 0;三 crate clippy 零 warning。
This commit is contained in:
AskaEth committed 2026-11-09 05:06:53 +08:00
1 parent 1441a240fe
commit db5b08b9ab
9 files changed
+133 -29

No files matched your search

+46 -11
View File
@@ -6,16 +6,26 @@
//! | 用例 | 删掉哪一处就红 |
//! |------|----------------|
//! | `a_cancel_before_the_first_write_touches_nothing` | 入口检查点 |
//! | `a_cancel_inside_the_extract_loop_leaves_no_half_unpacked_pack` | 解压循环里的检查点 |
//! | `a_cancel_inside_the_extract_loop_leaves_no_half_unpacked_pack` | 解压循环检查点(判据 = 解压进度条数:有 = 1、删掉 = 3) |
//! | `a_cancel_in_the_middle_of_the_merge_restores_the_users_file` | 取消时的 `rollback_merge` |
//! | `a_cancel_right_before_registering_rolls_the_whole_instance_back` | 登记前的检查点 |
//! | `cancel_and_failure_are_two_different_result_types` | 把取消并进 `fail()` 那条路 |
//!
//! 取消的触发点全部走**进度回调**(回调里置位 = 用户此刻点了取消,没有 sleep、没有竞态)。
//! 全部离线:手造 mrpack(`files` 为空,内容全在 `overrides/`)。
//! # 解压那条必须数事件,其余没有真守卫的检查点
//!
//! 解压只写**暂存目录**,所以"status == Cancelled / 目标目录不存在 / `files` 为空 / 无残留暂存"
//! 在删掉循环检查点后**依然成立**(提交前检查点会替它停一次)——只有"解了几条"能区分两者。
//!
//! 有变异证明的只有上表五处。**没有**:检查点②解析后 / ③补全后 / ④开暂存目录前 / ⑤提交前(删掉
//! 任一处,别的检查点会接住同一次取消,`pipeline.rs` 的断言仍绿)、全新导入 `commit` 的取消分支、
//! [`super::phase`] 远端下载逐条检查点、[`super::complete`] 补全循环的 `break`——补它们要两段式
//! 夹具或更多计数探针,本轮没做。
//!
//! 触发点全部走**进度回调**(置位 = 用户此刻点了取消,无 sleep、无竞态);全部离线。
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use super::cancel::ImportCancel;
@@ -35,7 +45,7 @@ const INDEX: &str = r#"{
}
"#;
/// 手造一个 mrpack(`entries` 是 `overrides/` 下的实例内路径与字节)。
/// 手造 mrpack(`entries` 是 `overrides/` 下的实例内路径与字节)。
fn mrpack(tag: &str, entries: &[(&str, &[u8])]) -> (TempRoot, PathBuf) {
let root = TempRoot::new(tag);
let source = root.join("src");
@@ -105,11 +115,28 @@ fn cancel_on(token: &ImportCancel, stage: ImportStage) -> ImportProgressSink {
})
}
/// 在**合并态第二条逐文件进度**上置位取消。
///
/// 合并是**排序后**逐文件搬:第 0 条是基线(`.ull/pack-baseline.json`,`.` 排在最前),第 1 条
/// 才是 `mods/a.jar`。所以 `done == 1` 正好是"用户那个被覆盖的文件已经搬进去、后面还有"的
/// 窗口——journal 回滚要接住的正是这一刻(`total > 0` 顺带把阶段开始那条 0/0 排除掉)。
/// 数**解压阶段**的进度条数,并在第一条上置位取消(这是解压循环检查点唯一的可观测判据:
/// 有它 ⇒ 第一条之后立刻停 = 1;删掉 ⇒ 一路解完 = 文件条数)。
fn cancel_on_first_extract_counting(
token: &ImportCancel,
) -> (ImportProgressSink, Arc<AtomicUsize>) {
let token = token.clone();
let count = Arc::new(AtomicUsize::new(0));
let seen = Arc::clone(&count);
let sink: ImportProgressSink = Arc::new(move |progress: &ImportProgress| {
if progress.stage != ImportStage::Extract {
return;
}
if seen.fetch_add(1, Ordering::SeqCst) == 0 {
token.cancel();
}
});
(sink, count)
}
/// 在**合并态第二条逐文件进度**上置位取消。合并是排序后逐文件搬:第 0 条是基线
/// (`.ull/pack-baseline.json`,`.` 排最前),第 1 条才是 `mods/a.jar`——`done == 1` 正好是
/// "用户那个被覆盖的文件已经搬进去、后面还有"的窗口,journal 回滚要接住的正是这一刻。
fn cancel_on_second_merged_file(token: &ImportCancel) -> ImportProgressSink {
let token = token.clone();
Arc::new(move |progress: &ImportProgress| {
@@ -119,7 +146,7 @@ fn cancel_on_second_merged_file(token: &ImportCancel) -> ImportProgressSink {
})
}
/// 目标目录里**全部文件**的实例内相对路径(排序后),用来钉"取消后与导入前逐文件相同"。
/// 目标目录里全部文件的实例内相对路径(排序后):钉"取消后与导入前逐文件相同"。
fn files_under(root: &Path) -> Vec<String> {
let mut out = Vec::new();
let mut pending = vec![root.to_path_buf()];
@@ -193,14 +220,22 @@ fn a_cancel_inside_the_extract_loop_leaves_no_half_unpacked_pack() {
let (root, pack) = three_files("cancel-extract");
let target = root.join("instance");
let token = ImportCancel::new();
let (sink, extract_events) = cancel_on_first_extract_counting(&token);
let report = block_on(import_pack(
&ImportRequest::new(&pack, &target)
.with_cancel(&token)
.with_progress(cancel_on(&token, ImportStage::Extract)),
.with_progress(sink),
));
assert_eq!(report.status, ImportStatus::Cancelled, "{:?}", report.error);
// **这一条才是解压循环检查点的判据**:有它 ⇒ 第一条之后立刻停(3 个文件的包里只报了 1 条
// 解压进度);删掉它 ⇒ 一路解完(3 条)。其余断言在删掉检查点后仍然成立,挡不住回归。
assert_eq!(
extract_events.load(Ordering::SeqCst),
1,
"取消必须在第一条解压之后立刻生效(包里有 3 个文件,解完再停 = 解压段没有检查点)"
);
assert!(report.error.is_none(), "取消不许带错误");
assert!(!target.exists(), "不许留下半包:{}", target.display());
assert!(