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
@@ -19,9 +19,10 @@ use std::path::PathBuf;
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::dto::{
|
||||
ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportFileView,
|
||||
ExportPreviewView, ExportReasonView, ExportState, FileActionView, ImportErrorView,
|
||||
ImportFailureStageView, ImportProgressView, ImportReportView, ImportStageView, ImportState,
|
||||
ImportStatusView, InstanceId, LoaderKind, MissingFileView, MissingReasonView, PackFormatView,
|
||||
ExportPreviewView, ExportReasonView, ExportState, FileActionView, ImportCancelView,
|
||||
ImportErrorView, ImportFailureStageView, ImportProgressView, ImportReportView, ImportStageView,
|
||||
ImportState, ImportStatusView, InstanceId, LoaderKind, MissingFileView, MissingReasonView,
|
||||
PackFormatView,
|
||||
};
|
||||
|
||||
use crate::pages::content::Target;
|
||||
@@ -212,6 +213,7 @@ fn report(status: ImportStatusView, conflicts: bool) -> ImportReportView {
|
||||
bytes_written: 3_400_000,
|
||||
rolled_back: true,
|
||||
failure_stage: None,
|
||||
cancel: ImportCancelView::NotRequested,
|
||||
};
|
||||
if conflicts {
|
||||
report.conflicts = vec![
|
||||
|
||||
@@ -196,9 +196,8 @@ pub struct Pack {
|
||||
pub running: Option<Running>,
|
||||
/// 探测失败诊断
|
||||
pub probe: Option<Probe>,
|
||||
/// 用户取消过这次导入:面板收回,**后台也真的停手并回滚了**(M195 起 core 有取消令牌:
|
||||
/// 暂存目录删掉、落盘过的撤回、实例不登记)。这句话仍要留在界面上——取消不是"什么都没
|
||||
/// 发生",而是"按你的意思停下来了",别让用户以为点空了。
|
||||
/// 用户点过取消、**结果还没回来**(面板已收回)。它只作占位:真实结局在
|
||||
/// `report.cancel` 里(已回滚 / 回滚没做完 / 取消太晚),所以文案不许在这里替结局下结论。
|
||||
pub cancelled: bool,
|
||||
/// 取消提示的正文
|
||||
pub cancelled_note: &'static str,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{
|
||||
ConflictChoiceView, ConflictFileView, ConflictReasonView, ImportErrorView,
|
||||
ConflictChoiceView, ConflictFileView, ConflictReasonView, ImportCancelView, ImportErrorView,
|
||||
ImportFailureStageView, ImportReportView, ImportStatusView, MissingFileView, MissingReasonView,
|
||||
};
|
||||
|
||||
@@ -124,6 +124,11 @@ pub struct Report {
|
||||
pub can_overwrite: bool,
|
||||
/// 「两者都留改名」为何不支持(有冲突时如实写)
|
||||
pub overwrite_note: Option<String>,
|
||||
/// 这次运行与取消的关系(M195 r3:标题与图标按它分档,不许一句打天下)
|
||||
pub cancel: ImportCancelView,
|
||||
/// 取消的**补充说明**:只有「取消太晚」有——那种情况标题照常是"已导入/部分成功",
|
||||
/// 必须另起一句说清"你的取消没赶上,实例会保留、同名文件可能已被覆盖"。
|
||||
pub cancel_note: Option<String>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
@@ -202,6 +207,9 @@ pub(super) fn report_of(
|
||||
&& page.instance.is_some(),
|
||||
overwrite_note: (!report.conflicts.is_empty())
|
||||
.then(|| catalog.text(Key::PackOverwriteNote).to_owned()),
|
||||
cancel: report.cancel,
|
||||
cancel_note: (report.cancel == ImportCancelView::TooLate)
|
||||
.then(|| catalog.text(Key::PackCancelTooLate).to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +223,12 @@ fn status_title(report: &ImportReportView, catalog: Catalog) -> &'static str {
|
||||
match report.status {
|
||||
ImportStatusView::Success => catalog.text(Key::PackStatusSuccess),
|
||||
ImportStatusView::Partial => catalog.text(Key::PackStatusPartial),
|
||||
// 三档取消各有各的话:已回滚(什么都没留下)/ 回滚没做完(要对一眼目录)/
|
||||
// 取消太晚(标题是真实状态,另有一句 cancel_note 说明"没赶上")。
|
||||
ImportStatusView::Cancelled => match report.cancel {
|
||||
ImportCancelView::RollbackIncomplete => catalog.text(Key::PackCancelIncomplete),
|
||||
_ => catalog.text(Key::PackStatusCancelled),
|
||||
},
|
||||
ImportStatusView::Failed => match report.failure_stage {
|
||||
Some(ImportFailureStageView::NotStarted) => {
|
||||
catalog.text(Key::PackStatusFailedNotStarted)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! 这里**不测画法**(画法靠出图),测的是「决策」:哪些数字进了哪一行、哪些字段被截短、
|
||||
//! 哪一态该出现哪一块。所有断言都能在没有图形环境的情况下跑。
|
||||
|
||||
mod cancel;
|
||||
mod pick;
|
||||
mod stage;
|
||||
|
||||
@@ -11,8 +12,9 @@ use std::path::PathBuf;
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::dto::{
|
||||
ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportFileView,
|
||||
ExportPreviewView, ImportErrorView, ImportProgressView, ImportReportView, ImportStageView,
|
||||
ImportStatusView, InstallStage, MissingFileView, MissingReasonView, PackFormatView,
|
||||
ExportPreviewView, ImportCancelView, ImportErrorView, ImportProgressView, ImportReportView,
|
||||
ImportStageView, ImportStatusView, InstallStage, MissingFileView, MissingReasonView,
|
||||
PackFormatView,
|
||||
};
|
||||
|
||||
use super::progress::{panel_progress, panel_stage, stage_line};
|
||||
@@ -228,6 +230,7 @@ fn report_keeps_tolerable_and_fatal_apart() {
|
||||
bytes_written: 0,
|
||||
rolled_back: true,
|
||||
failure_stage: None,
|
||||
cancel: ImportCancelView::NotRequested,
|
||||
};
|
||||
let block = report_of(&report, &PackPageState::default(), catalog());
|
||||
assert!(block.fatal.is_some(), "致命块必须单独存在");
|
||||
@@ -265,6 +268,7 @@ fn conflicts_default_to_keeping_the_user_copy() {
|
||||
bytes_written: 0,
|
||||
rolled_back: true,
|
||||
failure_stage: None,
|
||||
cancel: ImportCancelView::NotRequested,
|
||||
};
|
||||
// 「以包为准」只在导入到**已有实例**时给(复审 r1 P2-2),所以这里选一个实例
|
||||
let page = PackPageState {
|
||||
@@ -363,6 +367,7 @@ fn overwrite_is_offered_only_when_importing_into_an_existing_instance() {
|
||||
bytes_written: 0,
|
||||
rolled_back: true,
|
||||
failure_stage: None,
|
||||
cancel: ImportCancelView::NotRequested,
|
||||
};
|
||||
|
||||
let new_instance = PackPageState::default();
|
||||
@@ -380,19 +385,3 @@ fn overwrite_is_offered_only_when_importing_into_an_existing_instance() {
|
||||
"导入到已有实例时才给翻档动作"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_state_is_visible_and_says_the_import_stopped() {
|
||||
// M195:取消是管线级的(core 停手并回滚),旧文案「后台还在跑」已是假话。
|
||||
let import = unlucky_presenter::dto::ImportState {
|
||||
cancelled: true,
|
||||
..unlucky_presenter::dto::ImportState::default()
|
||||
};
|
||||
let export = unlucky_presenter::dto::ExportState::default();
|
||||
let model = Pack::of(&import, &export, &PackPageState::default(), &[], catalog());
|
||||
let note = model.cancelled_note.to_lowercase();
|
||||
let stopped = note.contains("stopped") && note.contains("rolled back");
|
||||
assert!(model.cancelled && !model.is_idle(), "取消态不能当成空闲");
|
||||
assert!(stopped, "文案要说清停手并回滚");
|
||||
assert!(!note.contains("background"), "文案不许说后台跑");
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
//! 从 `tests.rs` 拆出(那边贴着 400 行硬线):本文件只守「预检没过(一个字节都没写)」与
|
||||
//! 「已回滚」的措辞/图标分野。夹具在父模块(`use super::*`)。
|
||||
|
||||
use unlucky_presenter::dto::ImportFailureStageView;
|
||||
use unlucky_presenter::dto::{ImportCancelView, ImportFailureStageView};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -39,6 +39,7 @@ fn the_two_failure_stages_never_share_one_sentence() {
|
||||
bytes_written: 0,
|
||||
rolled_back: true,
|
||||
failure_stage: stage,
|
||||
cancel: ImportCancelView::NotRequested,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +98,8 @@ pub(crate) fn section<'a>(
|
||||
} else if let Some(report) = &model.report {
|
||||
block = block.push(report_block(ui, catalog, report));
|
||||
} else if model.cancelled {
|
||||
// 取消过、还没有新结果:把「后台还在跑」这句话留在屏上(复审 r1 P1-2)
|
||||
// 取消过、**结果还没回来**:先留一句"正在停手"的占位(真实结局随后的报告给出,
|
||||
// 已回滚 / 回滚没做完 / 取消太晚三档见 `model::report`)
|
||||
block = block.push(
|
||||
column![
|
||||
frame::text_role(
|
||||
|
||||
@@ -57,6 +57,14 @@ pub(super) fn report_block<'a>(
|
||||
report.written_line.as_str(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
// 「取消太晚」的那句补充说明(标题仍是真实的 `已导入 / 部分成功`)。
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
report.cancel_note.as_deref().unwrap_or(""),
|
||||
ui.color(Role::Tertiary),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill);
|
||||
@@ -292,17 +300,31 @@ pub(super) fn conflict_row<'a>(
|
||||
}
|
||||
|
||||
pub(super) fn status_icon(report: &Report) -> IconKind {
|
||||
use unlucky_presenter::dto::{ImportCancelView, ImportStatusView};
|
||||
match report.status {
|
||||
unlucky_presenter::dto::ImportStatusView::Success => IconKind::Check,
|
||||
unlucky_presenter::dto::ImportStatusView::Partial => IconKind::Warn,
|
||||
unlucky_presenter::dto::ImportStatusView::Failed => IconKind::Error,
|
||||
ImportStatusView::Success => IconKind::Check,
|
||||
ImportStatusView::Partial => IconKind::Warn,
|
||||
ImportStatusView::Failed => IconKind::Error,
|
||||
// 取消**不是错误**(M195):停手用「关闭(叉)」这个描边字形(页面骨架只允许描边,
|
||||
// 令牌层的 Play/Stop/More 是填充字形,见 `tests/no_hardcoded_tokens.rs` 的守门),
|
||||
// 回滚没做完才升级成 Warn,都不上红。
|
||||
ImportStatusView::Cancelled => match report.cancel {
|
||||
ImportCancelView::RollbackIncomplete => IconKind::Warn,
|
||||
_ => IconKind::Close,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn status_role(report: &Report) -> Role {
|
||||
use unlucky_presenter::dto::{ImportCancelView, ImportStatusView};
|
||||
match report.status {
|
||||
unlucky_presenter::dto::ImportStatusView::Success => Role::Primary,
|
||||
unlucky_presenter::dto::ImportStatusView::Partial => Role::Tertiary,
|
||||
unlucky_presenter::dto::ImportStatusView::Failed => Role::Error,
|
||||
ImportStatusView::Success => Role::Primary,
|
||||
ImportStatusView::Partial => Role::Tertiary,
|
||||
ImportStatusView::Failed => Role::Error,
|
||||
// 取消一档也不做成 `Role::Error`:用户自己点的,不该看起来像装坏了。
|
||||
ImportStatusView::Cancelled => match report.cancel {
|
||||
ImportCancelView::RollbackIncomplete => Role::Tertiary,
|
||||
_ => Role::OnSurfaceVariant,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user