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
@@ -62,10 +62,10 @@ pub use pack::{
|
||||
ConflictChoiceView, ConflictDecisionView, ConflictDecisionsView, ConflictFileView,
|
||||
ConflictReasonView, ConflictSourceView, ExportClassView, ExportDoneView, ExportFileView,
|
||||
ExportPreviewView, ExportReasonView, ExportRequestView, ExportState, FileActionView,
|
||||
ImportConflictView, ImportErrorView, ImportFailureStageView, ImportProgressView,
|
||||
ImportReportView, ImportRequestView, ImportScanView, ImportStageView, ImportState,
|
||||
ImportStatusView, ImportedFileView, MissingFileView, MissingReasonView, PackCommand, PackEvent,
|
||||
PackFormatView, PackState, ProbeView,
|
||||
ImportCancelView, ImportConflictView, ImportErrorView, ImportFailureStageView,
|
||||
ImportProgressView, ImportReportView, ImportRequestView, ImportScanView, ImportStageView,
|
||||
ImportState, ImportStatusView, ImportedFileView, MissingFileView, MissingReasonView,
|
||||
PackCommand, PackEvent, PackFormatView, PackState, ProbeView,
|
||||
};
|
||||
pub use settings::{SettingControl, SettingGroupView, SettingRowView};
|
||||
// M159: 自更新
|
||||
|
||||
@@ -57,7 +57,7 @@ pub use export::{
|
||||
};
|
||||
pub use format::PackFormatView;
|
||||
pub use import::{
|
||||
FileActionView, ImportErrorView, ImportFailureStageView, ImportProgressView, ImportReportView,
|
||||
ImportRequestView, ImportStageView, ImportState, ImportStatusView, ImportedFileView,
|
||||
MissingFileView, MissingReasonView, ProbeView,
|
||||
FileActionView, ImportCancelView, ImportErrorView, ImportFailureStageView, ImportProgressView,
|
||||
ImportReportView, ImportRequestView, ImportStageView, ImportState, ImportStatusView,
|
||||
ImportedFileView, MissingFileView, MissingReasonView, ProbeView,
|
||||
};
|
||||
@@ -64,8 +64,11 @@ pub enum PackEvent {
|
||||
Exported(Box<ExportDoneView>),
|
||||
/// 导出/预检在 host 侧失败(实例找不到、目录派生失败……一句人读原因)
|
||||
ExportFailed(String),
|
||||
/// 用户取消了导入:面板收回,但后台那次导入会跑完(core 没有取消令牌),
|
||||
/// 所以留一位给界面说清「还在跑、结果作废」。
|
||||
/// 用户取消了导入(面板立刻收回)。
|
||||
///
|
||||
/// 它只表示"取消后还没有结果":M195 起 core 有取消令牌,管线会在检查点停手并回滚,**真实
|
||||
/// 结局随后的 [`PackEvent::Report`] 才是准话**(`ImportReportView::cancel` 三档:已回滚 /
|
||||
/// 回滚没做完 / 取消太晚),所以 `Report` 一到就清掉这一位。
|
||||
Cancelled,
|
||||
/// 关掉所有面板
|
||||
Cleared,
|
||||
@@ -99,6 +102,9 @@ impl PackState {
|
||||
self.import.progress = None;
|
||||
self.import.probe = None;
|
||||
self.import.scan = None;
|
||||
// M195 r3:取消的**真实结局**在 `report.cancel` 里,`cancelled` 只作"取消后
|
||||
// 还没有结果"时的占位;结果一到就清掉,免得它跨到下一次导入上撒谎。
|
||||
self.import.cancelled = false;
|
||||
self.import.report = Some(*report);
|
||||
}
|
||||
PackEvent::Probe(probe) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! 导入侧 DTO:请求、进度、三态、逐项原因、探测诊断。
|
||||
//!
|
||||
//! 三态(`Success` / `Partial` / `Failed`)与 core 一一对应,**`Partial` 不是 `Success`**;
|
||||
//! 容忍域([`MissingFileView`])与致命域([`ImportErrorView`])分属不同类型,UI 不可能混成一个列表。
|
||||
//! 状态档(`Success` / `Partial` / `Failed` / `Cancelled`)与 core 一一对应,**`Partial` 不是
|
||||
//! `Success`**、**取消不是失败**;容忍域与致命域分属不同类型,UI 不可能混成一个列表。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -12,6 +12,12 @@ use crate::dto::InstanceId;
|
||||
use super::conflict::{ConflictDecisionsView, ConflictFileView, ImportScanView};
|
||||
use super::format::PackFormatView;
|
||||
|
||||
mod cancel;
|
||||
mod failure_stage;
|
||||
|
||||
pub use cancel::ImportCancelView;
|
||||
pub use failure_stage::ImportFailureStageView;
|
||||
|
||||
/// 导入阶段(与 core 的 `ImportStage` 一一对应)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum ImportStageView {
|
||||
@@ -62,43 +68,6 @@ impl ImportStageView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 致命失败的**阶段**(M161:`rolled_back` 一个人说不了两件事)。
|
||||
///
|
||||
/// core 的 `ImportReport::rolled_back` 语义是「目标目录已确认与导入前一致」——它同时覆盖
|
||||
/// 「回滚完整完成」和「**从未写过**目标目录」两种情形(见 `core::addon::import::pipeline` 的
|
||||
/// `record_rollback` / `abandoned`)。UI 拿一个 bool 判不出该说哪一句,于是预检失败也会被
|
||||
/// 说成「已回滚」,而实际上一个字节都没写。这个枚举把两种情形重新分开,并补上第三种
|
||||
/// (回滚有失败项,目标目录可能与导入前不同)。
|
||||
///
|
||||
/// 只在 `status == Failed` 时有值:成功/部分成功的报告没有「失败阶段」可言。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ImportFailureStageView {
|
||||
/// 预检/装配阶段就失败了:**目标目录一个字节都没写**(用户该去修包/修配置再试)。
|
||||
NotStarted,
|
||||
/// 已经写过目标目录,回滚**完整**完成(零残留)。
|
||||
RolledBack,
|
||||
/// 写过了,但回滚有失败项:目标目录可能与导入前不同(`warnings` 里逐条列了)。
|
||||
RollbackIncomplete,
|
||||
}
|
||||
|
||||
impl ImportFailureStageView {
|
||||
/// 稳定 slug(日志 / 测试用;**不要改名**)。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::NotStarted => "not_started",
|
||||
Self::RolledBack => "rolled_back",
|
||||
Self::RollbackIncomplete => "rollback_incomplete",
|
||||
}
|
||||
}
|
||||
|
||||
/// 本次失败到底动没动目标目录。
|
||||
#[must_use]
|
||||
pub const fn touched_target(self) -> bool {
|
||||
!matches!(self, Self::NotStarted)
|
||||
}
|
||||
}
|
||||
|
||||
/// 导入三态(`Partial` **不是** `Success`)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ImportStatusView {
|
||||
@@ -112,6 +81,10 @@ pub enum ImportStatusView {
|
||||
/// 也可能是写完又回滚干净,还可能是回滚没做完——三种由
|
||||
/// [`ImportReportView::failure_stage`] 分开说,界面按它选标题与图标。
|
||||
Failed,
|
||||
/// **用户取消,管线停手并回滚**(M195 r3):不是失败,也不是「部分成功」。
|
||||
/// 「回滚干净 / 没做完」由 [`ImportReportView::cancel`] 再分;取消**太晚**(管线已跑完)
|
||||
/// 不落这一档——那时实例真装上了,状态是 `Success`/`Partial`,只在 `cancel` 记 `TooLate`。
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl ImportStatusView {
|
||||
@@ -122,6 +95,7 @@ impl ImportStatusView {
|
||||
Self::Success => "success",
|
||||
Self::Partial => "partial",
|
||||
Self::Failed => "failed",
|
||||
Self::Cancelled => "cancelled",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,14 +106,15 @@ impl ImportStatusView {
|
||||
"success" => Some(Self::Success),
|
||||
"partial" => Some(Self::Partial),
|
||||
"failed" => Some(Self::Failed),
|
||||
"cancelled" => Some(Self::Cancelled),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 实例可用吗(`Success` / `Partial` 都是「装上了」)。
|
||||
/// 实例可用吗(`Success` / `Partial` 是「装上了」;取消与失败都没装上)。
|
||||
#[must_use]
|
||||
pub const fn installed(self) -> bool {
|
||||
!matches!(self, Self::Failed)
|
||||
matches!(self, Self::Success | Self::Partial)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,6 +315,11 @@ pub struct ImportReportView {
|
||||
pub rolled_back: bool,
|
||||
/// 失败发生在哪一步(`None` = 这次不是致命失败)。
|
||||
pub failure_stage: Option<ImportFailureStageView>,
|
||||
/// 这次运行与「取消」的关系(M195 r3:取消的**真实结局**,界面按它选文案)。
|
||||
///
|
||||
/// 只靠 `status` 表达不了三档:已回滚 / 回滚没做完都在 [`ImportStatusView::Cancelled`] 里,
|
||||
/// 而「取消太晚、已经跑完」的状态是 `Success`/`Partial`——三档见 [`ImportCancelView`]。
|
||||
pub cancel: ImportCancelView,
|
||||
}
|
||||
|
||||
impl ImportReportView {
|
||||
@@ -379,8 +359,11 @@ pub struct ProbeView {
|
||||
pub struct ImportState {
|
||||
/// 正在跑的进度(`None` = 没在跑)。
|
||||
pub progress: Option<ImportProgressView>,
|
||||
/// 用户点了取消(M113 r1):**面板收回但后台还在跑**,所以这一位要留在界面上——
|
||||
/// 否则用户会以为「没建成」,回头再导一次就撞「directory already exists」。
|
||||
/// 用户点了取消、**结果还没回来**(M195 r3 收窄了它的含义)。
|
||||
///
|
||||
/// 它只作"取消后还没有结果"的占位:真实结局随后的 `report.cancel` 才是准话(已回滚 /
|
||||
/// 回滚没做完 / 取消太晚)。所以 `PackEvent::Report` 一到就清掉这一位,免得它跨到下一次
|
||||
/// 导入上替结局下结论。
|
||||
pub cancelled: bool,
|
||||
/// 最近一次的预检(M160:冲突清单;`None` = 还没预检过或已被新结果顶掉)。
|
||||
pub scan: Option<ImportScanView>,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! 一次导入与「取消」的关系(**真实结局**,不是"用户点没点过取消")。
|
||||
//!
|
||||
//! 从父模块拆出来只为行数(父文件已到 400 硬限附近),判据与文案口径原样。
|
||||
//!
|
||||
//! 三档各有各的话要说,混成一句就是撒谎(M195 r3):
|
||||
//! - [`ImportCancelView::RolledBack`]:停手了,目标目录与导入前一致;
|
||||
//! - [`ImportCancelView::RollbackIncomplete`]:停手了,但回滚没做完,目标目录可能与导入前不同;
|
||||
//! - [`ImportCancelView::TooLate`]:**没来得及停**,管线已经跑完——实例会保留,同名文件可能已被覆盖。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 一次导入与「取消」的关系(**真实结局**)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum ImportCancelView {
|
||||
/// 这次运行没被取消过。
|
||||
#[default]
|
||||
NotRequested,
|
||||
/// 取消生效,管线停手并**完整**回滚(目标目录与导入前一致,实例没登记)。
|
||||
RolledBack,
|
||||
/// 取消生效,但回滚没做完(目标目录可能与导入前不同,逐条在 `warnings` 里)。
|
||||
RollbackIncomplete,
|
||||
/// 取消到得太晚:管线已经跑完,实例保留,同名文件可能已被覆盖。
|
||||
TooLate,
|
||||
}
|
||||
|
||||
impl ImportCancelView {
|
||||
/// 稳定 slug。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::NotRequested => "not_requested",
|
||||
Self::RolledBack => "rolled_back",
|
||||
Self::RollbackIncomplete => "rollback_incomplete",
|
||||
Self::TooLate => "too_late",
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户这次取消有没有**真的生效**(前两档是真停手了)。
|
||||
#[must_use]
|
||||
pub const fn stopped(self) -> bool {
|
||||
matches!(self, Self::RolledBack | Self::RollbackIncomplete)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! 致命失败发生在哪一步(M161:`rolled_back` 一个人说不了两件事)。
|
||||
//!
|
||||
//! 从父模块拆出来只为行数;判据与文案口径原样。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 致命失败的**阶段**(M161:`rolled_back` 一个人说不了两件事)。
|
||||
///
|
||||
/// core 的 `ImportReport::rolled_back` 语义是「目标目录已确认与导入前一致」——它同时覆盖
|
||||
/// 「回滚完整完成」和「**从未写过**目标目录」两种情形(见 `core::addon::import::pipeline` 的
|
||||
/// `record_rollback` / `abandoned`)。UI 拿一个 bool 判不出该说哪一句,于是预检失败也会被
|
||||
/// 说成「已回滚」,而实际上一个字节都没写。这个枚举把两种情形重新分开,并补上第三种
|
||||
/// (回滚有失败项,目标目录可能与导入前不同)。
|
||||
///
|
||||
/// 只在 `status == Failed` 时有值:成功/部分成功的报告没有「失败阶段」可言。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ImportFailureStageView {
|
||||
/// 预检/装配阶段就失败了:**目标目录一个字节都没写**(用户该去修包/修配置再试)。
|
||||
NotStarted,
|
||||
/// 已经写过目标目录,回滚**完整**完成(零残留)。
|
||||
RolledBack,
|
||||
/// 写过了,但回滚有失败项:目标目录可能与导入前不同(`warnings` 里逐条列了)。
|
||||
RollbackIncomplete,
|
||||
}
|
||||
|
||||
impl ImportFailureStageView {
|
||||
/// 稳定 slug(日志 / 测试用;**不要改名**)。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::NotStarted => "not_started",
|
||||
Self::RolledBack => "rolled_back",
|
||||
Self::RollbackIncomplete => "rollback_incomplete",
|
||||
}
|
||||
}
|
||||
|
||||
/// 本次失败到底动没动目标目录。
|
||||
#[must_use]
|
||||
pub const fn touched_target(self) -> bool {
|
||||
!matches!(self, Self::NotStarted)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user