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:
1 parent
db5b08b9ab
commit
b1524635ba
23 files changed
+627
-120
No files matched your search
@@ -34,6 +34,13 @@
|
||||
//! 3. **后台那次导入真的停手**:core 在阶段边界与长循环内检查令牌,停下就回滚(暂存目录删掉、
|
||||
//! 落盘过的撤回、实例不登记),结果是 `ImportStatus::Cancelled` 而**不是**失败。响应延迟的
|
||||
//! 底线是「当前这一条文件读完/下完」,见 `core::addon::import::cancel` 的逐段表;
|
||||
//!
|
||||
//! **收尾永远把真实结局送到界面**(M195 r3):取消可能落在最后一个检查点之后 —— 那时管线
|
||||
//! 已经跑完(用户的同名文件被覆盖、实例已登记),也可能回滚没做完。这两种结果以前被
|
||||
//! `cancel` 的早退 + 代次过滤一起丢掉,界面只剩一句无条件的"已停手并回滚"。现在
|
||||
//! `import::finish` 无条件发 `PackEvent::Report`,`HostPackState::terminal_epochs` 给
|
||||
//! "刚被取消的那一次"留一条**只放行 Report** 的一次性通道([`take_terminal_epoch`]),
|
||||
//! DTO 的 `ImportReportView::cancel` 三档把区别带到界面;
|
||||
//! 4. **提示语不能借用安装那条**(复审 r1 P1-2):`cancel_pack()` 回一个「刚才有没有导入在跑」,
|
||||
//! `dispatch` 据此改发 [`crate::dto::PackEvent::Cancelled`]。取消**通常**意味着实例没建
|
||||
//! (管线回滚了),但取消也可能恰好在两个检查点之间落下(那时已经装完并登记)——所以文案
|
||||
@@ -78,10 +85,17 @@ use super::super::job::{HostTask, JobSender};
|
||||
///
|
||||
/// 为什么不挪进 `CoreHost`:那要给 `host/mod.rs` 加字段(不在本 mission 的 scope)。这张表等价,
|
||||
/// 只是把「每 host 的字段」放在模块里;`host/mod.rs` 一旦进 scope,搬过去只是把这张表删掉。
|
||||
#[derive(Default, Clone, Copy)]
|
||||
#[derive(Default)]
|
||||
struct HostPackState {
|
||||
epoch: u64,
|
||||
in_flight: usize,
|
||||
/// 「刚被取消、还欠界面一份终态报告」的那几个代次(M195 r3)。
|
||||
///
|
||||
/// `cancel_pack` 会把代次 +1,于是被取消那次运行交上来的 `PackEvent::Report` 会被 `handle`
|
||||
/// 按代次丢掉——可那份报告才是**真实结局**(停手并回滚 / 回滚没做完 / 取消太晚其实已装完)。
|
||||
/// 记在这里的代次允许**一条** `Report` 穿透代次过滤,取走即销;新一轮导入(`fresh_cancel`)
|
||||
/// 清空整表,旧运行的报告不会污染新面板。
|
||||
terminal_epochs: Vec<u64>,
|
||||
}
|
||||
|
||||
static PACK_STATE: Mutex<Vec<(PathBuf, HostPackState)>> = Mutex::new(Vec::new());
|
||||
@@ -163,12 +177,42 @@ pub(in crate::host) fn cancel_pack(env: &HostEnv) -> bool {
|
||||
cancel.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
let running = in_flight(env) > 0;
|
||||
if running {
|
||||
// 记下**马上要被作废的那个代次**:那次运行随后交上来的报告是它的真实结局,必须能穿透
|
||||
// 代次过滤(否则"取消太晚、其实已经装完"在界面上永远不成立)。重复取消只记一次。
|
||||
let stale = pack_epoch(env);
|
||||
with_state(env, |state| {
|
||||
if !state.terminal_epochs.contains(&stale) {
|
||||
state.terminal_epochs.push(stale);
|
||||
}
|
||||
});
|
||||
}
|
||||
bump_pack_epoch(env);
|
||||
in_flight(env) > 0
|
||||
running
|
||||
}
|
||||
|
||||
/// 这个代次是不是「刚被取消、还欠一份终态报告」的那一次(**取走即销**:只放行一条)。
|
||||
///
|
||||
/// 只给 `PackEvent::Report` 用:取消之后的**进度**事件仍旧一律按代次丢掉(面板早就收回了,
|
||||
/// 再点亮它就是「点了取消没反应」的旧病)。
|
||||
pub(in crate::host) fn take_terminal_epoch(env: &HostEnv, epoch: u64) -> bool {
|
||||
with_state(env, |state| {
|
||||
match state.terminal_epochs.iter().position(|seen| *seen == epoch) {
|
||||
Some(index) => {
|
||||
state.terminal_epochs.remove(index);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 这个 host 新一次导入的取消标记(挂进表里供 [`cancel_pack`] 用)。
|
||||
///
|
||||
/// 顺手清掉「还欠终态报告」的代次:新一次运行占住面板,上一次的结局不该再冒出来。
|
||||
fn fresh_cancel(env: &HostEnv) -> Arc<AtomicBool> {
|
||||
with_state(env, |state| state.terminal_epochs.clear());
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let key = host_key(env);
|
||||
let mut table = PACK_CANCEL
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
//! 于是「翻译表」本身就是一份可审计的清单:core 加了新变体,`from_slug` 返回 `None`
|
||||
//! 时这里会落到一个有名字的兜底,而不是悄悄画成别的意思。
|
||||
|
||||
mod cancel;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use unlucky_core::addon::export::{ExportReport, FileClass, FileReason};
|
||||
@@ -101,20 +103,18 @@ const fn reason_of(reason: ConflictReason) -> ConflictReasonView {
|
||||
|
||||
/// 一次导入结果 → DTO。
|
||||
///
|
||||
/// # `Cancelled` 为什么在这里没有对应档(M195)
|
||||
///
|
||||
/// DTO 的 `ImportStatusView` 只有三档,取消走的是 [`crate::dto::PackEvent::Cancelled`]
|
||||
/// (`import::run` 拿到 `ImportStatus::Cancelled` 就不发报告事件了)。所以本函数只会在
|
||||
/// "取消恰好落在两个检查点之间、报告仍然是成功/部分成功"时被调用。真收到 `Cancelled`
|
||||
/// 时按 `Partial` 落地:**绝不是** `Failed`——取消不是失败,不许画成红的。
|
||||
pub(super) fn report_view(report: &ImportReport, target: &Path) -> ImportReportView {
|
||||
/// `cancel_requested` = 这次运行**被点过取消**(presenter 手里的那个开关)。它只用来区分
|
||||
/// "取消太晚"那一档:管线已经跑完时报告是 `Success`/`Partial`,但那不是一次正常导入——
|
||||
/// 用户的取消没赶上,实例会保留、同名文件可能已被覆盖(M195 r3)。
|
||||
pub(super) fn report_view(
|
||||
report: &ImportReport,
|
||||
target: &Path,
|
||||
cancel_requested: bool,
|
||||
) -> ImportReportView {
|
||||
ImportReportView {
|
||||
status: match report.status {
|
||||
ImportStatus::Cancelled => ImportStatusView::Partial,
|
||||
other => {
|
||||
ImportStatusView::from_slug(other.as_str()).unwrap_or(ImportStatusView::Failed)
|
||||
}
|
||||
},
|
||||
status: ImportStatusView::from_slug(report.status.as_str())
|
||||
.unwrap_or(ImportStatusView::Failed),
|
||||
cancel: cancel::cancel_view(report, cancel_requested),
|
||||
format: report.format.as_deref().and_then(PackFormatView::from_slug),
|
||||
pack_name: report.pack_name.clone(),
|
||||
pack_version: report.pack_version.clone(),
|
||||
@@ -330,6 +330,8 @@ fn failed_report_with(target: PathBuf, pack: &Path, error: ImportErrorView) -> I
|
||||
rolled_back: true,
|
||||
// 预检/装配阶段就失败了:目标目录一个字节都没写(不是「写完又回滚」)。
|
||||
failure_stage: Some(ImportFailureStageView::NotStarted),
|
||||
// 这一路根本没进管线,谈不上取消。
|
||||
cancel: crate::dto::ImportCancelView::NotRequested,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//! 「取消的**真实结局**」→ DTO 三档(M195 r3)。
|
||||
//!
|
||||
//! 从父模块拆出来只为行数(父文件已到 400 硬限附近),判据与文案口径原样。
|
||||
|
||||
use unlucky_core::addon::import::report::ImportStatus;
|
||||
use unlucky_core::addon::import::ImportReport;
|
||||
|
||||
use crate::dto::ImportCancelView;
|
||||
|
||||
/// core 的「取消真实结局」→ DTO 的三档(M195 r3:**不许**把三档混成一句)。
|
||||
///
|
||||
/// - core 说 `Cancelled` ⇒ 管线真的停手了;回滚完整与否看 `rolled_back`(两档分开说);
|
||||
/// - core 说别的(`Success`/`Partial`/`Failed`)但这次被点过取消 ⇒ **取消太晚**:报告是真结局,
|
||||
/// 但它不是"停下来了",界面必须照实说「实例会保留、文件可能已被覆盖」。
|
||||
pub(super) fn cancel_view(report: &ImportReport, cancel_requested: bool) -> ImportCancelView {
|
||||
match report.status {
|
||||
ImportStatus::Cancelled => {
|
||||
if report.rolled_back {
|
||||
ImportCancelView::RolledBack
|
||||
} else {
|
||||
ImportCancelView::RollbackIncomplete
|
||||
}
|
||||
}
|
||||
_ if cancel_requested => ImportCancelView::TooLate,
|
||||
_ => ImportCancelView::NotRequested,
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,9 @@
|
||||
//! 3. **缺省档永远 `KeepAll`**:决策从 DTO 逐字段搬([`convert::decisions_of`]),页面没点过的
|
||||
//! 路径在 core 侧落到"保留";
|
||||
//! 4. **取消是真取消**(M195):`cancel` 标记既挡界面事件、又**交给 core 当取消令牌**——管线
|
||||
//! 在检查点停手并回滚,不再"后台跑完并登记"(逐段检查点见
|
||||
//! `core::addon::import::cancel`)。取消后不再打扰界面:进度回调与最终报告都先看取消标记。
|
||||
//! 在检查点停手并回滚(逐段检查点见 `core::addon::import::cancel`)。取消之后的**进度**不再
|
||||
//! 打扰界面(回调与代次都挡),但**终态报告一定发出**:取消可能太晚(管线已跑完)或回滚
|
||||
//! 没做完,那两种结局必须让用户看见(见 [`finish`])。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -196,26 +197,49 @@ pub(super) fn run(
|
||||
}
|
||||
|
||||
let report = block_on(import_pack(&core_request));
|
||||
if (report.status == ImportStatus::Failed || report.status == ImportStatus::Cancelled)
|
||||
&& register
|
||||
// 取消标记要**在管线返回之后**再读一次:取消可能来得太晚(管线已经跑完),这一读就是
|
||||
// "太晚"的唯一证据(`finish` 用它把报告标成 `TooLate`)。
|
||||
let cancel_requested = cancel.load(Ordering::SeqCst);
|
||||
finish(tx, &target, register, epoch, cancel_requested, &report);
|
||||
}
|
||||
|
||||
/// 收尾:清掉我们自建的空目录 + 把 core 的报告变成事件。
|
||||
///
|
||||
/// # 取消也照发(M195 r3)
|
||||
///
|
||||
/// 以前这里 `cancel.load()` 是对**所有**事件的早退,于是"取消太晚、后台其实已经装完并登记"
|
||||
/// 这件事永远到不了界面,界面只能照着旧文案说「已停手并回滚」。现在报告无条件发出:它就是
|
||||
/// 真实结局——core 说 `Cancelled`(停手并回滚 / 回滚没做完),或者说 `Success`/`Partial`
|
||||
/// (取消没赶上),由 DTO 的 `cancel` 三档把区别带到界面。
|
||||
///
|
||||
/// 代次仍用**出发时**那个(`epoch`):`handle` 给"刚被取消的那一次"留了一次性终态通道
|
||||
/// ([`super::super::addon::pack::take_terminal_epoch`]),进度事件照旧按代次丢。
|
||||
pub(super) fn finish(
|
||||
tx: &JobSender,
|
||||
target: &std::path::Path,
|
||||
register: bool,
|
||||
epoch: u64,
|
||||
cancel_requested: bool,
|
||||
report: &unlucky_core::addon::import::ImportReport,
|
||||
) {
|
||||
if register
|
||||
&& matches!(
|
||||
report.status,
|
||||
ImportStatus::Failed | ImportStatus::Cancelled
|
||||
)
|
||||
{
|
||||
// 失败 / 取消都已回滚,但**目录是我们建的**(`prepare_target`)、core 只清文件不清
|
||||
// 目录 ⇒ 不清掉的话下一次重试会撞「directory already exists」(复审 r1 P2-9)。
|
||||
remove_if_empty(&target);
|
||||
}
|
||||
if report.status == ImportStatus::Cancelled || cancel.load(Ordering::SeqCst) {
|
||||
// 取消之后不再打扰界面(面板已经收回,代次也已经作废)。
|
||||
//
|
||||
// M195 起这条是**真的停下来了**:core 在检查点回滚(暂存目录已删,落盘过的已撤回,
|
||||
// 实例没有登记)。所以提示语可以照实说「已取消:目标目录未改动」;
|
||||
// `cancel.load(..)` 单独留一条是因为取消也可能**恰好落在两个检查点之间**(管线已经
|
||||
// 跑完并登记),那时同样不再打扰界面,但别对用户说"没装"。
|
||||
return;
|
||||
remove_if_empty(target);
|
||||
}
|
||||
send(
|
||||
tx,
|
||||
epoch,
|
||||
PackEvent::Report(Box::new(convert::report_view(&report, &target))),
|
||||
PackEvent::Report(Box::new(convert::report_view(
|
||||
report,
|
||||
target,
|
||||
cancel_requested,
|
||||
))),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -65,3 +65,151 @@ fn a_pre_cancelled_flag_really_stops_the_core_pipeline() {
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// **路径 A:取消来得太晚**(M195 r3 的核心用例)。
|
||||
///
|
||||
/// 三段拼起来,全部确定性、没有 sleep:
|
||||
/// 1. **core 真管线**跑一次合并态导入,把取消置在**最后一条** `ImportStage::Commit` 进度上——
|
||||
/// 之后没有任何检查点,于是管线一路跑完(用户的 `mods/a.jar` 被覆盖),报告是
|
||||
/// `Success` + `rolled_back == false`(core 一直是诚实的);
|
||||
/// 2. `cancel_pack` 把代次 +1(这就是"取消"),并记下被作废的那个代次;
|
||||
/// 3. `finish` 照发 `PackEvent::Report`,`handle` 让它**穿透代次**——界面因此拿到「已完成 /
|
||||
/// 未回滚 / 取消太晚」,而不是旧行为里那句无条件的「已停手并回滚」。
|
||||
///
|
||||
/// 变异:把终态通道去掉(`handle` 恢复"代次不符一律丢")⇒ 本用例立刻红(报告到不了界面)。
|
||||
#[test]
|
||||
fn a_late_cancel_still_delivers_the_real_outcome_to_the_ui() {
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
use unlucky_core::addon::import::{
|
||||
import_pack, ConflictPolicy, ImportCancel, ImportProgress, ImportProgressSink,
|
||||
ImportRequest, ImportStage, ImportStatus,
|
||||
};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
|
||||
use crate::dto::{AppEvent, Backend, CoreEvent, PackEvent};
|
||||
|
||||
let dir = temp_dir("cancel-late");
|
||||
// 包里有 `mods/a.jar`,目标目录里也有(内容不同)⇒ 合并态"被覆盖"是真的会发生。
|
||||
let source = dir.join("Path A Pack");
|
||||
write(
|
||||
&source.join("mmc-pack.json"),
|
||||
br#"{"formatVersion": 1, "components": [
|
||||
{"uid": "net.minecraft", "version": "1.20.1"}]}"#,
|
||||
);
|
||||
write(&source.join(".minecraft/mods/a.jar"), b"pack a");
|
||||
let target = dir.join("instance");
|
||||
write(&target.join("mods/a.jar"), b"user a");
|
||||
|
||||
let data = dir.join("data");
|
||||
std::fs::create_dir_all(&data).expect("data root");
|
||||
let env = crate::host::HostEnv::at_root(&data);
|
||||
|
||||
// ① core:取消置在最后一条逐文件进度上 ⇒ 之后没有检查点 ⇒ 管线跑完。
|
||||
let token = ImportCancel::new();
|
||||
let sink_token = token.clone();
|
||||
let seen = Arc::new(AtomicUsize::new(0));
|
||||
let seen_by_sink = Arc::clone(&seen);
|
||||
let sink: ImportProgressSink = Arc::new(move |progress: &ImportProgress| {
|
||||
if progress.stage != ImportStage::Commit || progress.total == 0 {
|
||||
return;
|
||||
}
|
||||
seen_by_sink.fetch_add(1, Ordering::SeqCst);
|
||||
if progress.done + 1 == progress.total {
|
||||
sink_token.cancel();
|
||||
}
|
||||
});
|
||||
let report = block_on(import_pack(
|
||||
&ImportRequest::new(&source, &target)
|
||||
.with_cancel(&token)
|
||||
// 覆盖档:让用户的 `mods/a.jar` 真的被覆盖(缺省是"保留用户那一侧")。
|
||||
.with_conflict_policy(ConflictPolicy::Overwrite)
|
||||
.with_progress(sink),
|
||||
));
|
||||
assert!(token.is_cancelled(), "用例前提:取消确实置位了");
|
||||
assert!(
|
||||
seen.load(Ordering::SeqCst) > 1,
|
||||
"确实逐文件走到了底(合并态)"
|
||||
);
|
||||
assert_eq!(
|
||||
report.status,
|
||||
ImportStatus::Success,
|
||||
"取消置在最后一条之后 ⇒ 管线已经跑完:{:?}",
|
||||
report.error
|
||||
);
|
||||
assert!(
|
||||
!report.rolled_back,
|
||||
"没有回滚就别说回滚过(core 的机器位是诚实的)"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(target.join("mods/a.jar")).expect("读被覆盖的文件"),
|
||||
b"pack a",
|
||||
"路径 A 的前提:用户的同名文件**已经被覆盖**"
|
||||
);
|
||||
|
||||
// ② 用户点取消:代次 +1,被作废的那个代次进了终态通道。
|
||||
let epoch = super::super::pack_epoch(&env);
|
||||
let _in_flight = super::super::InFlight::begin(&env);
|
||||
assert!(
|
||||
super::super::cancel_pack(&env),
|
||||
"取消应当看到一条在跑的导入"
|
||||
);
|
||||
assert_ne!(
|
||||
super::super::pack_epoch(&env),
|
||||
epoch,
|
||||
"取消会作废本次代次(这正是旧实现丢报告的成因)"
|
||||
);
|
||||
|
||||
// ③ 收尾照发报告;同代次上的**进度**事件仍按代次丢掉。
|
||||
let mut host = crate::host::CoreHost::headless(env.clone());
|
||||
let (tx, rx) = crate::host::job::channel();
|
||||
super::super::import::finish(&tx, &target, false, epoch, true, &report);
|
||||
tx.send(crate::host::job::HostTask::Pack {
|
||||
event: Box::new(PackEvent::Progress(None)),
|
||||
epoch,
|
||||
})
|
||||
.expect("送一条同代次的进度事件");
|
||||
drop(tx);
|
||||
for task in rx.iter() {
|
||||
host.handle(task);
|
||||
}
|
||||
let events = host.drain_events();
|
||||
|
||||
let delivered: Vec<&crate::dto::ImportReportView> = events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
AppEvent::Core(CoreEvent::Pack(inner)) => match inner.as_ref() {
|
||||
PackEvent::Report(view) => Some(view.as_ref()),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let view = delivered
|
||||
.first()
|
||||
.expect("取消太晚时,真实结果必须到达界面(丢掉就是路径 A 的假话)");
|
||||
assert_eq!(
|
||||
view.status,
|
||||
crate::dto::ImportStatusView::Success,
|
||||
"界面拿到的结局是「已完成」"
|
||||
);
|
||||
assert!(
|
||||
!view.rolled_back,
|
||||
"界面拿到的结局是「未回滚」,不是「已回滚」"
|
||||
);
|
||||
assert_eq!(
|
||||
view.cancel,
|
||||
crate::dto::ImportCancelView::TooLate,
|
||||
"必须标成「取消太晚」,不能标成「已回滚」"
|
||||
);
|
||||
assert!(
|
||||
!events.iter().any(|event| match event {
|
||||
AppEvent::Core(CoreEvent::Pack(inner)) =>
|
||||
matches!(inner.as_ref(), PackEvent::Progress(_)),
|
||||
_ => false,
|
||||
}),
|
||||
"终态通道只放行报告:取消后的进度事件仍按代次丢掉"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
Reference in new issue
Block a user