Files
Unlucky-Launcher/crates/app-iced/src/pages/content/packui/model.rs
T
AskaEth b1524635ba 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。
2026-11-09 05:06:55 +08:00

311 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 导入/导出区的视图模型:**presenter 快照 + 页面选择 → 要画的东西**。
//!
//! ## 分层(与内容库页、下载页同一条)
//!
//! | 数据源 | 谁转 | 用途 |
//! |---|---|---|
//! | `AppState::import` / `AppState::export` | [`Pack::of`] | 产品运行时的真页面 |
//! | [`super::demo`] 的固定夹具 | 同上 | `pages_demo` 出图与单测 |
//!
//! 页面自己**不留**业务状态:进度、报告、预检都在 presenter(`ImportState` / `ExportState`)。
//! [`PackPageState`] 里只有「用户在这一屏上点了什么」——路径草稿、目标实例、格式、包名、
//! 展开的那条冲突。
//!
//! ## 文件分工(守 400 行)
//!
//! | 文件 | 职责 |
//! |---|---|
//! | 本文件 | 整块 [`Pack`] + 页内选择 [`PackPageState`] + 探测诊断 [`Probe`] |
//! | [`progress`] | 阶段 / 进度 → 下载页面板的入参(**唯一**的进度形状来源) |
//! | [`report`] | 导入结果:三态 + 容忍域逐条 + 致命块 |
//! | [`export`] | 导出:格式选择 + 预检(不写盘) |
//!
//! ## 容忍 / 致命在模型层就分开
//!
//! [`report::Report::missing`] 是「搞不到某个文件」(实例可用),[`report::Report::fatal`]
//! 是致命失败。两者字段类型不同、画的时候颜色与图标也不同,不可能混成一个列表。
//!
//! 致命那一类内部还要再分三档(M161):预检没过(一个字节都没写)/ 已回滚 / 回滚没做完,
//! 见 [`report::FatalKind`]——措辞、图标、底色三处都分开。
mod export;
mod pick;
mod progress;
mod report;
pub use export::{format_label, request_of as request_export, Export, Preview};
pub use pick::{Pick, PickRow};
pub use progress::Running;
pub use report::{ConflictRow, FatalKind, Report, Row};
use std::collections::BTreeMap;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{
ConflictChoiceView, ConflictDecisionsView, ExportState, ImportState, InstanceId,
PackFormatView, ProbeView,
};
use crate::pages::content::Target;
/// 页面自留的选择(所有者在 `App::pack`,重建快照不该把它们冲掉)。
#[derive(Debug, Clone, PartialEq)]
pub struct PackPageState {
/// 路径草稿(拖入或手输;提交后才进请求)
pub draft: String,
/// 有文件悬在窗口上方(拖拽高亮)
pub hover: bool,
/// 目标实例(`None` = 按包名新建)
pub instance: Option<InstanceId>,
/// 新实例名
pub instance_name: String,
/// 导出格式
pub format: PackFormatView,
/// 导出包名
pub export_name: String,
/// 导出包版本
pub export_version: String,
/// 落盘路径草稿(空 = 让 host 给缺省)
pub output: String,
/// 导出面板开着吗
pub export_open: bool,
/// 展开的那条冲突路径
pub expanded: Option<String>,
/// 冲突清单翻到第几页(0 起;M160)
pub pick_page: usize,
/// 逐项决策(**只存"覆盖"的路径**:保留 = 表里没有这条,于是"没点过"永远是保留)
pub pick_choices: BTreeMap<String, ConflictChoiceView>,
/// 「全部覆盖」这一档是否被点过(整批档;逐项决策仍然优先于它)
pub pick_overwrite_all: bool,
}
impl Default for PackPageState {
fn default() -> Self {
Self {
draft: String::new(),
hover: false,
instance: None,
instance_name: String::new(),
// 导出的首选是 Modrinth:CF 的 `files[]` 只能写能证明 `projectID`/`fileID` 的项,
// 而离线目录证明不了任何一条 ⇒ 全内联 + 一堆「表达不了」的注记。mrpack 才是主路。
format: PackFormatView::Modrinth,
export_name: String::new(),
export_version: "1.0.0".to_string(),
output: String::new(),
export_open: false,
expanded: None,
pick_page: 0,
pick_choices: BTreeMap::new(),
pick_overwrite_all: false,
}
}
}
impl PackPageState {
/// 用户在冲突清单上点的处置(逐项优先,其次整批档,缺省保留)。
#[must_use]
pub fn pick_choice(&self, path: &str) -> ConflictChoiceView {
if let Some(choice) = self.pick_choices.get(path) {
return *choice;
}
if self.pick_overwrite_all {
ConflictChoiceView::Overwrite
} else {
ConflictChoiceView::KeepUser
}
}
/// 把这一屏的选择变成发给宿主的那份决策。
///
/// 一个都没点 ⇒ `KeepAll`(与 core 的"全部保留"同一个语义);点过 ⇒ `PerItem { default, items }`,
/// 其中 `default` 就是整批档。于是"全部覆盖之后又单独放回两条"也能表达(不需要把剩下几百条
/// 全点一遍),而"没点过"永远落在保留上。
#[must_use]
pub fn decisions(
&self,
scan: Option<&unlucky_presenter::dto::ImportScanView>,
) -> ConflictDecisionsView {
use unlucky_presenter::dto::{ConflictDecisionView, ConflictDecisionsView};
let default = if self.pick_overwrite_all {
ConflictChoiceView::Overwrite
} else {
ConflictChoiceView::KeepUser
};
// 只发"与缺省档不同"的项:其余在 core 侧自然落到缺省档。
let items: Vec<ConflictDecisionView> = self
.pick_choices
.iter()
.filter(|(_, choice)| **choice != default)
.map(|(path, choice)| ConflictDecisionView {
path: path.clone(),
choice: *choice,
})
.collect();
// 冲突清单为空时,`PerItem` 没有对象,直接给整批档(语义等价、日志更短)。
let empty = scan.is_none_or(|scan| scan.conflicts.is_empty());
if items.is_empty() || empty {
return match default {
ConflictChoiceView::KeepUser => ConflictDecisionsView::KeepAll,
ConflictChoiceView::Overwrite => ConflictDecisionsView::OverwriteAll,
};
}
ConflictDecisionsView::per_item(default, items)
}
}
/// 一个实例候选(导入目标与导出源共用)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackTarget {
/// 实例 id
pub id: InstanceId,
/// 一行显示名(`Survival · 1.20.1 · Fabric`)
pub label: String,
}
/// 探测失败时的具名诊断块。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Probe {
/// 最像哪个格式(`None` = 三个都不像)
pub looks_like: Option<PackFormatView>,
/// 「看起来像 X,但缺 Y」(或「没有任何已知格式认领」)
pub lead: String,
/// 包里实际看到的标记(host 已截断,只给代表性几条)
pub saw: Vec<String>,
/// 兜底说明(core 原文:试过哪些格式)
pub detail: String,
}
/// 整块导入/导出区要画的东西。
#[derive(Debug, Clone, PartialEq)]
pub struct Pack {
/// 拖拽高亮
pub hover: bool,
/// 路径草稿
pub draft: String,
/// 入口说明(拖什么)
pub drop_hint: &'static str,
/// 没有系统文件框的原因(如实说,不许装作有)
pub no_dialog_note: &'static str,
/// 目标实例候选
pub targets: Vec<PackTarget>,
/// 选中的目标实例
pub instance: Option<InstanceId>,
/// 新实例名
pub instance_name: String,
/// 正在导入(有它就画那块面板)
pub running: Option<Running>,
/// 探测失败诊断
pub probe: Option<Probe>,
/// 用户点过取消、**结果还没回来**(面板已收回)。它只作占位:真实结局在
/// `report.cancel` 里(已回滚 / 回滚没做完 / 取消太晚),所以文案不许在这里替结局下结论。
pub cancelled: bool,
/// 取消提示的正文
pub cancelled_note: &'static str,
/// 导入结果
pub report: Option<Report>,
/// **导入前的冲突清单**(M160:`Some` 就画那一屏,别的东西都让位)
pub pick: Option<Pick>,
/// 导出块
pub export: Export,
}
impl Pack {
/// presenter 快照 + 页面选择 → 视图模型(**唯一的真数据入口**)。
pub fn of(
import: &ImportState,
export: &ExportState,
page: &PackPageState,
targets: &[Target],
catalog: Catalog,
) -> Self {
let candidates: Vec<PackTarget> = targets
.iter()
.map(|target| PackTarget {
id: target.id.clone(),
label: format!(
"{} · {} · {}",
target.name,
target.mc_version,
crate::pages::content::loader_name(target.loader)
),
})
.collect();
let instance = page
.instance
.clone()
.or_else(|| candidates.first().map(|target| target.id.clone()));
Self {
hover: page.hover,
draft: page.draft.clone(),
drop_hint: catalog.text(Key::PackDropHint),
no_dialog_note: catalog.text(Key::PackNoDialog),
targets: candidates.clone(),
instance: instance.clone(),
instance_name: page.instance_name.clone(),
running: import
.progress
.as_ref()
.map(|progress| progress::running_of(progress, catalog)),
probe: import.probe.as_ref().map(|probe| probe_of(probe, catalog)),
cancelled: import.cancelled,
cancelled_note: catalog.text(Key::PackCancelled),
report: import
.report
.as_ref()
.map(|report| report::report_of(report, page, catalog)),
pick: import.scan.as_ref().and_then(|scan| {
pick::pick_of(
scan,
page.pick_page,
&page.pick_choices,
page.pick_overwrite_all,
catalog,
)
}),
export: export::export_of(export, page, candidates, catalog),
}
}
/// 有没有东西可画(没有就整块收起来,不占版面)。
pub fn is_idle(&self) -> bool {
self.running.is_none()
&& self.probe.is_none()
&& self.report.is_none()
&& self.pick.is_none()
&& !self.cancelled
&& !self.export.open
&& self.draft.is_empty()
}
}
/// 探测诊断:把「缺哪个标记」摊开。
///
/// `saw` 为空时补一句「没有可识别的东西」——空列表直接画出来是一行空白,用户会以为是渲染坏了。
fn probe_of(probe: &ProbeView, catalog: Catalog) -> Probe {
let lead = match probe.looks_like {
Some(format) => catalog.render(
Key::PackProbeLooksLike,
&[
("format", export::format_label(format, catalog)),
("missing", &probe.missing.join(" · ")),
],
),
None => catalog.text(Key::PackProbeUnknown).to_owned(),
};
let saw = if probe.saw.is_empty() {
vec![catalog.text(Key::PackProbeNothing).to_owned()]
} else {
probe.saw.clone()
};
Probe {
looks_like: probe.looks_like,
lead,
saw,
detail: probe.detail.clone(),
}
}
#[cfg(test)]
mod tests;