From b1524635ba7c8ada8a5699e22d3875111f10cc40 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 17:17:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(m195):=20=E5=A4=8D=E5=AE=A1=20r2=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E5=8F=96=E6=B6=88=E7=9A=84=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E7=BB=93=E5=B1=80=E5=BF=85=E9=A1=BB=E5=88=B0=E8=BE=BE?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=EF=BC=88=E4=B8=89=E6=A1=A3=20+=20=E7=BB=88?= =?UTF-8?q?=E6=80=81=E7=A9=BF=E9=80=8F=E4=BB=A3=E6=AC=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 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。 --- .../app-iced/src/pages/content/packui/demo.rs | 8 +- .../src/pages/content/packui/model.rs | 5 +- .../src/pages/content/packui/model/report.rs | 16 +- .../src/pages/content/packui/model/tests.rs | 25 +-- .../content/packui/model/tests/cancel.rs | 140 +++++++++++++++++ .../pages/content/packui/model/tests/stage.rs | 3 +- .../app-iced/src/pages/content/packui/view.rs | 3 +- .../src/pages/content/packui/view/report.rs | 34 +++- crates/i18n/src/catalog/content.rs | 13 +- crates/i18n/src/catalog/golden/tail2.rs | 5 +- crates/presenter/src/dto/mod.rs | 8 +- crates/presenter/src/dto/pack.rs | 6 +- crates/presenter/src/dto/pack/event.rs | 10 +- crates/presenter/src/dto/pack/import.rs | 69 +++----- .../presenter/src/dto/pack/import/cancel.rs | 43 +++++ .../src/dto/pack/import/failure_stage.rs | 42 +++++ crates/presenter/src/host/addon.rs | 2 +- crates/presenter/src/host/addon/pack.rs | 48 +++++- .../presenter/src/host/addon/pack/convert.rs | 28 ++-- .../src/host/addon/pack/convert/cancel.rs | 27 ++++ .../presenter/src/host/addon/pack/import.rs | 54 +++++-- .../src/host/addon/pack/tests/cancel.rs | 148 ++++++++++++++++++ crates/presenter/src/host/handle.rs | 10 +- 23 files changed, 627 insertions(+), 120 deletions(-) create mode 100644 crates/app-iced/src/pages/content/packui/model/tests/cancel.rs create mode 100644 crates/presenter/src/dto/pack/import/cancel.rs create mode 100644 crates/presenter/src/dto/pack/import/failure_stage.rs create mode 100644 crates/presenter/src/host/addon/pack/convert/cancel.rs diff --git a/crates/app-iced/src/pages/content/packui/demo.rs b/crates/app-iced/src/pages/content/packui/demo.rs index 33fd07f..2a150be 100644 --- a/crates/app-iced/src/pages/content/packui/demo.rs +++ b/crates/app-iced/src/pages/content/packui/demo.rs @@ -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![ diff --git a/crates/app-iced/src/pages/content/packui/model.rs b/crates/app-iced/src/pages/content/packui/model.rs index ccb0882..ac015ba 100644 --- a/crates/app-iced/src/pages/content/packui/model.rs +++ b/crates/app-iced/src/pages/content/packui/model.rs @@ -196,9 +196,8 @@ pub struct Pack { pub running: Option, /// 探测失败诊断 pub probe: Option, - /// 用户取消过这次导入:面板收回,**后台也真的停手并回滚了**(M195 起 core 有取消令牌: - /// 暂存目录删掉、落盘过的撤回、实例不登记)。这句话仍要留在界面上——取消不是"什么都没 - /// 发生",而是"按你的意思停下来了",别让用户以为点空了。 + /// 用户点过取消、**结果还没回来**(面板已收回)。它只作占位:真实结局在 + /// `report.cancel` 里(已回滚 / 回滚没做完 / 取消太晚),所以文案不许在这里替结局下结论。 pub cancelled: bool, /// 取消提示的正文 pub cancelled_note: &'static str, diff --git a/crates/app-iced/src/pages/content/packui/model/report.rs b/crates/app-iced/src/pages/content/packui/model/report.rs index b2f2016..ec78d4c 100644 --- a/crates/app-iced/src/pages/content/packui/model/report.rs +++ b/crates/app-iced/src/pages/content/packui/model/report.rs @@ -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, + /// 这次运行与取消的关系(M195 r3:标题与图标按它分档,不许一句打天下) + pub cancel: ImportCancelView, + /// 取消的**补充说明**:只有「取消太晚」有——那种情况标题照常是"已导入/部分成功", + /// 必须另起一句说清"你的取消没赶上,实例会保留、同名文件可能已被覆盖"。 + pub cancel_note: Option, } 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) diff --git a/crates/app-iced/src/pages/content/packui/model/tests.rs b/crates/app-iced/src/pages/content/packui/model/tests.rs index 8a8e876..fc8a39c 100644 --- a/crates/app-iced/src/pages/content/packui/model/tests.rs +++ b/crates/app-iced/src/pages/content/packui/model/tests.rs @@ -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"), "文案不许说后台跑"); -} diff --git a/crates/app-iced/src/pages/content/packui/model/tests/cancel.rs b/crates/app-iced/src/pages/content/packui/model/tests/cancel.rs new file mode 100644 index 0000000..d051f0f --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/model/tests/cancel.rs @@ -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:?}"); +} diff --git a/crates/app-iced/src/pages/content/packui/model/tests/stage.rs b/crates/app-iced/src/pages/content/packui/model/tests/stage.rs index d30ed6e..e8582d2 100644 --- a/crates/app-iced/src/pages/content/packui/model/tests/stage.rs +++ b/crates/app-iced/src/pages/content/packui/model/tests/stage.rs @@ -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, } } diff --git a/crates/app-iced/src/pages/content/packui/view.rs b/crates/app-iced/src/pages/content/packui/view.rs index f282664..b98126d 100644 --- a/crates/app-iced/src/pages/content/packui/view.rs +++ b/crates/app-iced/src/pages/content/packui/view.rs @@ -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( diff --git a/crates/app-iced/src/pages/content/packui/view/report.rs b/crates/app-iced/src/pages/content/packui/view/report.rs index 29789e6..5266371 100644 --- a/crates/app-iced/src/pages/content/packui/view/report.rs +++ b/crates/app-iced/src/pages/content/packui/view/report.rs @@ -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, + }, } } diff --git a/crates/i18n/src/catalog/content.rs b/crates/i18n/src/catalog/content.rs index 1716c4f..60b71e1 100644 --- a/crates/i18n/src/catalog/content.rs +++ b/crates/i18n/src/catalog/content.rs @@ -179,8 +179,17 @@ macro_rules! content_entries { ), PackReportRejectedNote => ("not supported by the client", "客户端不支持"), PackCancelled => ( - "Import cancelled — it stopped and rolled back: nothing was left half-done, no existing files were overwritten.", - "导入已取消 —— 后台已停手并回滚:没有留下半包,也没有覆盖已有文件。" + "Cancel requested — the pipeline is stopping now; the real outcome follows.", + "已请求取消 —— 后台正在停手,真实结果随后给出。" + ), + PackStatusCancelled => ("Cancelled — rolled back", "已取消 —— 已回滚"), + PackCancelIncomplete => ( + "Cancelled — rollback incomplete: the target directory may differ from before the import.", + "已取消 —— 回滚没做完:目标目录可能与导入前不同。" + ), + PackCancelTooLate => ( + "The cancel arrived too late: this import already finished. The instance is kept; same-name files may already be overwritten.", + "取消来得太晚:这次导入已经跑完,实例会保留,同名文件可能已被覆盖。" ), PackOverwriteAction => ("Re-import, the pack wins", "以包为准重新导入"), PackOverwriteNote => ( diff --git a/crates/i18n/src/catalog/golden/tail2.rs b/crates/i18n/src/catalog/golden/tail2.rs index 65310fc..2daa388 100644 --- a/crates/i18n/src/catalog/golden/tail2.rs +++ b/crates/i18n/src/catalog/golden/tail2.rs @@ -29,7 +29,10 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("PackReportConflicts", "Conflicts: {count}", "冲突:{count} 个"), ("PackReportRejected", "The client does not support these: {count}", "这些组件客户端不支持:{count} 个"), ("PackReportRejectedNote", "not supported by the client", "客户端不支持"), - ("PackCancelled", "Import cancelled — it stopped and rolled back: nothing was left half-done, no existing files were overwritten.", "导入已取消 —— 后台已停手并回滚:没有留下半包,也没有覆盖已有文件。"), + ("PackCancelled", "Cancel requested — the pipeline is stopping now; the real outcome follows.", "已请求取消 —— 后台正在停手,真实结果随后给出。"), + ("PackStatusCancelled", "Cancelled — rolled back", "已取消 —— 已回滚"), + ("PackCancelIncomplete", "Cancelled — rollback incomplete: the target directory may differ from before the import.", "已取消 —— 回滚没做完:目标目录可能与导入前不同。"), + ("PackCancelTooLate", "The cancel arrived too late: this import already finished. The instance is kept; same-name files may already be overwritten.", "取消来得太晚:这次导入已经跑完,实例会保留,同名文件可能已被覆盖。"), ("PackOverwriteAction", "Re-import, the pack wins", "以包为准重新导入"), ("PackOverwriteNote", "Keeping both (renamed) is not supported yet: for each existing file you pick either “keep” or “overwrite”.", "「两者都留(改名)」暂不支持:每个已存在的文件只能选「保留」或「覆盖」。"), ("PackMissingOffline", "offline import", "离线导入"), diff --git a/crates/presenter/src/dto/mod.rs b/crates/presenter/src/dto/mod.rs index 16f5ec6..961dafb 100644 --- a/crates/presenter/src/dto/mod.rs +++ b/crates/presenter/src/dto/mod.rs @@ -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: 自更新 diff --git a/crates/presenter/src/dto/pack.rs b/crates/presenter/src/dto/pack.rs index af82210..a6bf806 100644 --- a/crates/presenter/src/dto/pack.rs +++ b/crates/presenter/src/dto/pack.rs @@ -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, }; diff --git a/crates/presenter/src/dto/pack/event.rs b/crates/presenter/src/dto/pack/event.rs index b32ae9c..d7a4051 100644 --- a/crates/presenter/src/dto/pack/event.rs +++ b/crates/presenter/src/dto/pack/event.rs @@ -64,8 +64,11 @@ pub enum PackEvent { Exported(Box), /// 导出/预检在 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) => { diff --git a/crates/presenter/src/dto/pack/import.rs b/crates/presenter/src/dto/pack/import.rs index f1c8c4f..0be448e 100644 --- a/crates/presenter/src/dto/pack/import.rs +++ b/crates/presenter/src/dto/pack/import.rs @@ -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, + /// 这次运行与「取消」的关系(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, - /// 用户点了取消(M113 r1):**面板收回但后台还在跑**,所以这一位要留在界面上—— - /// 否则用户会以为「没建成」,回头再导一次就撞「directory already exists」。 + /// 用户点了取消、**结果还没回来**(M195 r3 收窄了它的含义)。 + /// + /// 它只作"取消后还没有结果"的占位:真实结局随后的 `report.cancel` 才是准话(已回滚 / + /// 回滚没做完 / 取消太晚)。所以 `PackEvent::Report` 一到就清掉这一位,免得它跨到下一次 + /// 导入上替结局下结论。 pub cancelled: bool, /// 最近一次的预检(M160:冲突清单;`None` = 还没预检过或已被新结果顶掉)。 pub scan: Option, diff --git a/crates/presenter/src/dto/pack/import/cancel.rs b/crates/presenter/src/dto/pack/import/cancel.rs new file mode 100644 index 0000000..337a6cf --- /dev/null +++ b/crates/presenter/src/dto/pack/import/cancel.rs @@ -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) + } +} diff --git a/crates/presenter/src/dto/pack/import/failure_stage.rs b/crates/presenter/src/dto/pack/import/failure_stage.rs new file mode 100644 index 0000000..8a36ef4 --- /dev/null +++ b/crates/presenter/src/dto/pack/import/failure_stage.rs @@ -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) + } +} diff --git a/crates/presenter/src/host/addon.rs b/crates/presenter/src/host/addon.rs index 92e6073..ff2eb56 100644 --- a/crates/presenter/src/host/addon.rs +++ b/crates/presenter/src/host/addon.rs @@ -42,7 +42,7 @@ mod plan; mod pack; mod sources; -pub(super) use pack::{cancel_pack, pack_epoch}; +pub(super) use pack::{cancel_pack, pack_epoch, take_terminal_epoch}; #[cfg(test)] mod tests; diff --git a/crates/presenter/src/host/addon/pack.rs b/crates/presenter/src/host/addon/pack.rs index d453ab5..79c304b 100644 --- a/crates/presenter/src/host/addon/pack.rs +++ b/crates/presenter/src/host/addon/pack.rs @@ -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, } static PACK_STATE: Mutex> = 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 { + 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 diff --git a/crates/presenter/src/host/addon/pack/convert.rs b/crates/presenter/src/host/addon/pack/convert.rs index 672dba6..640f89f 100644 --- a/crates/presenter/src/host/addon/pack/convert.rs +++ b/crates/presenter/src/host/addon/pack/convert.rs @@ -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, } } diff --git a/crates/presenter/src/host/addon/pack/convert/cancel.rs b/crates/presenter/src/host/addon/pack/convert/cancel.rs new file mode 100644 index 0000000..f4f1a8d --- /dev/null +++ b/crates/presenter/src/host/addon/pack/convert/cancel.rs @@ -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, + } +} diff --git a/crates/presenter/src/host/addon/pack/import.rs b/crates/presenter/src/host/addon/pack/import.rs index 49f80b3..eac39ec 100644 --- a/crates/presenter/src/host/addon/pack/import.rs +++ b/crates/presenter/src/host/addon/pack/import.rs @@ -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, + ))), ); } diff --git a/crates/presenter/src/host/addon/pack/tests/cancel.rs b/crates/presenter/src/host/addon/pack/tests/cancel.rs index 0c7728f..92e44f1 100644 --- a/crates/presenter/src/host/addon/pack/tests/cancel.rs +++ b/crates/presenter/src/host/addon/pack/tests/cancel.rs @@ -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); +} diff --git a/crates/presenter/src/host/handle.rs b/crates/presenter/src/host/handle.rs index 1b81354..493dd92 100644 --- a/crates/presenter/src/host/handle.rs +++ b/crates/presenter/src/host/handle.rs @@ -49,7 +49,15 @@ impl CoreHost { // pack 用**自己**的代次(复审 r1 P2-7):共用安装的计数器会让 // 「关一次导入面板」把下载页正在跑的安装静默丢掉。 if epoch != addon::pack_epoch(&self.env) { - return; + // 唯一例外(M195 r3):**刚被取消的那一次**交上来的终态报告。取消会把代次 + // +1,若连报告一起丢,界面就永远学不到"取消太晚、其实已经装完/回滚没做完" + // 这个真相——只剩一句无条件的"已停手并回滚"。 + // 只放行 `Report`、且同一代次只放行一条;进度事件照旧按代次丢。 + if !(matches!(&*event, PackEvent::Report(_)) + && addon::take_terminal_epoch(&self.env, epoch)) + { + return; + } } // 导入成功可能登记了新实例:与 `InstallDone` 同一条刷新(复审 r1 P2-1), // 否则新实例要等下一次无关的 `RefreshAll` 才出现在实例页与目标 chips 里。