feat(m160): 整合包逐文件覆盖/保留决策 + 导入前冲突清单 + 限额口径统一
主任务(逐文件覆盖/保留)
- core: 新增 import/scan.rs —— 只读预检产出「冲突清单」(目标已存在且 SHA-1 不同才进),
逐条给路径/来源/两侧体积与哈希;用户删过的、内容相同的、被叠层遮蔽的一律不打扰。
- core: 新增 import/decision.rs —— ConflictDecisions(整批档 + 逐项表,逐项优先),
缺省档恒为 KeepUser;ImportRequest.conflict_policy 改由它承载,CLI 调用点不变。
- core: phase.rs 的 settle 按 path 取决策;非冲突路径不受决策影响。
- presenter: ImportScanView / ImportConflictView / ConflictDecisionsView(全部保留 /
全部覆盖 / 逐项{default,items})+ PackCommand::Scan / PackEvent::Scan;host 新增只读
spawn_scan,且新实例路径不再提前建目录(target_of 只算路径,prepare_target 才建)。
- app-iced: 导入前冲突清单页(分页 40 条/页、三入口、计数摘要、确认/取消、两行式行布局);
预检干净时直接往下导,不打扰用户。出图见 C:/Temp/m160-shots/{pick-light,pick-dark}.png。
- i18n: 新增 18 条 en/zh 文案 + 更新「两者都留」那条口径说明。
限额口径统一
- pack/archive/limits.rs 成为唯一来源:PACK_MAX_ENTRIES/UNCOMPRESSED_BYTES/ENTRY_BYTES
(20k / 512 MiB / 64 MiB);读侧 PackLimits::default 与写侧 WriteLimits::production 同源。
- 写侧不再直接用 zip::ZIP64_BYTES_THR;新增单条目闸,于是「能导出的包一定能导入」。
不等式在 pack/tests/limits.rs 里用 const 断言钉死。
测试临时物泄漏(塔台追加项)
- addon/temp.rs:TempRoot —— Drop 即删(panic 展开同样删),Deref/AsRef 到落点路径,
under()/at() 支持"子树落点、删父根"。形状照 core::update::tests::support::TempDir。
- addon::pack 与 addon::tests 两族全部改用 TempRoot(含 fixtures::Fixture 持守卫、
multimc/fabric_instance 等返回守卫);4 GiB 稀疏文件的语义一字未改,只改"谁负责删"。
- 自证:pack/tests/mod.rs 三条用例(drop 删 / panic 也删 / 子树删父根)。
测试
- import_decisions.rs(11 例):预检清单、不打扰、逐项决策后的最终文件集、基线记包内哈希。
- roundtrip_three.rs(2 例):叠层 + 冲突夹具 import→export→import 三轮文件集逐字节一致,
以及「选保留就按设计分叉」的反向证据。
- pack/tests/limits.rs(5 例):单条/总量边界、读侧用同一批常量、写出即可读回。
证据:cargo fmt --check 干净;cargo clippy --workspace --all-targets -D warnings 干净;
cargo test --workspace --all-targets -j 4 → 2507 passed / 0 failed(51 个 test binary)。
This commit is contained in:
1 parent
a4f096eea3
commit
4defc41e8d
64 files changed
+3819
-442
No files matched your search
@@ -54,11 +54,13 @@ pub use logs::{Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSes
|
||||
pub use notices::{NoticeLevel, NoticeView};
|
||||
// M113: import/export
|
||||
pub use pack::{
|
||||
ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportDoneView,
|
||||
ExportFileView, ExportPreviewView, ExportReasonView, ExportRequestView, ExportState,
|
||||
FileActionView, ImportErrorView, ImportProgressView, ImportReportView, ImportRequestView,
|
||||
ImportStageView, ImportState, ImportStatusView, ImportedFileView, MissingFileView,
|
||||
MissingReasonView, PackCommand, PackEvent, PackFormatView, PackState, ProbeView,
|
||||
ConflictChoiceView, ConflictDecisionView, ConflictDecisionsView, ConflictFileView,
|
||||
ConflictReasonView, ConflictSourceView, ExportClassView, ExportDoneView, ExportFileView,
|
||||
ExportPreviewView, ExportReasonView, ExportRequestView, ExportState, FileActionView,
|
||||
ImportConflictView, ImportErrorView, ImportProgressView, ImportReportView, ImportRequestView,
|
||||
ImportScanView, ImportStageView, ImportState, ImportStatusView, ImportedFileView,
|
||||
MissingFileView, MissingReasonView, PackCommand, PackEvent, PackFormatView, PackState,
|
||||
ProbeView,
|
||||
};
|
||||
pub use settings::{SettingControl, SettingGroupView, SettingRowView};
|
||||
// M159: 自更新
|
||||
|
||||
@@ -26,10 +26,18 @@
|
||||
//! | [`conflict`] | 冲突成因、处置、三方哈希 |
|
||||
//! | [`export`] | 三分类 / 逐条原因 / 预检 |
|
||||
//!
|
||||
//! ## 冲突:默认一侧永远是用户那一侧
|
||||
//! ## 冲突:预检清单 → 用户点头 → 事后账(M160)
|
||||
//!
|
||||
//! [`ImportRequestView::overwrite`] 缺省 `false` = `ConflictPolicy::KeepUser`。这是 M113 的硬线:
|
||||
//! **绝不默认覆盖用户改过的文件**。
|
||||
//! 三样东西在三处,别混:
|
||||
//!
|
||||
//! | 类型 | 时机 | 语义 |
|
||||
//! |---|---|---|
|
||||
//! | [`ImportScanView`] / [`ImportConflictView`] | 导入**前**([`PackEvent::Scan`]) | 待用户点头的冲突清单 |
|
||||
//! | [`ConflictDecisionsView`] | 页面 → 宿主([`ImportRequestView::decisions`]) | 全部保留 / 全部覆盖 / 逐项 |
|
||||
//! | [`ConflictFileView`] | 导入**后**([`PackEvent::Report`]) | 每条实际怎么处置的 |
|
||||
//!
|
||||
//! 两处缺省都是**保留**([`ConflictDecisionsView::default`] = `KeepAll`,core 的
|
||||
//! `ConflictChoice::default()` = `KeepUser`):**绝不默认覆盖用户已有文件**。
|
||||
|
||||
mod conflict;
|
||||
mod event;
|
||||
@@ -37,7 +45,10 @@ mod export;
|
||||
mod format;
|
||||
mod import;
|
||||
|
||||
pub use conflict::{ConflictChoiceView, ConflictFileView, ConflictReasonView};
|
||||
pub use conflict::{
|
||||
ConflictChoiceView, ConflictDecisionView, ConflictDecisionsView, ConflictFileView,
|
||||
ConflictReasonView, ConflictSourceView, ImportConflictView, ImportScanView,
|
||||
};
|
||||
pub use event::{PackCommand, PackEvent, PackState};
|
||||
pub use export::{
|
||||
ExportClassView, ExportDoneView, ExportFileView, ExportPreviewView, ExportReasonView,
|
||||
|
||||
@@ -1,11 +1,238 @@
|
||||
//! 冲突的 DTO:成因、本次处置、以及三方哈希的那一条账。
|
||||
//! 冲突的 DTO:成因、本次处置、**预检的待决清单**、以及三方哈希的那一条账。
|
||||
//!
|
||||
//! 三条判据与 core 的 `ConflictReason` 一一对应;`choice` 是**事后记录**(本次实际怎么处置的),
|
||||
//! 不是「逐文件决策通道」——core 今天没有那种通道(`ConflictPolicy` 是整次导入一个全局档)。
|
||||
//! 「默认保留用户那一侧」这条硬线在 `ImportRequestView::overwrite` 缺省 `false` 上。
|
||||
//! 三种东西分得清清楚楚(混起来就会把"要不要覆盖"变成"已经覆盖了"):
|
||||
//!
|
||||
//! | 类型 | 什么时候出现 | 语义 |
|
||||
//! |---|---|---|
|
||||
//! | [`ImportConflictView`] | **导入前**(`PackEvent::Scan`) | 待用户点头的一条:路径、来源、目标现有体积与哈希 |
|
||||
//! | [`ConflictFileView`] | **导入后**(`PackEvent::Report`) | 事后账:这条实际怎么处置的(`choice`) |
|
||||
//! | [`ConflictDecisionsView`] | 从页面发回去(`ImportRequestView::decisions`) | 用户点的「全部保留 / 全部覆盖 / 逐项」 |
|
||||
//!
|
||||
//! 「默认保留用户那一侧」这条硬线在 [`ConflictDecisionsView::default`] 上:缺省 = 全部保留,
|
||||
//! 而 core 侧 [`ConflictChoiceView::KeepUser`] 也是缺省档——两侧都不许"没收到决策就覆盖"。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 一条冲突**来源**(预检清单里那一列)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ConflictSourceView {
|
||||
/// 包内 `overrides/`(CF 清单点名的那个目录 / MultiMC 的 `.minecraft/` 也走这条)。
|
||||
Overrides,
|
||||
/// 包内 `client-overrides/`(叠层里胜出的那一侧)。
|
||||
ClientOverrides,
|
||||
/// CurseForge `files[]` 条目。
|
||||
CurseForge,
|
||||
/// Modrinth `files[]` 条目。
|
||||
Modrinth,
|
||||
}
|
||||
|
||||
impl ConflictSourceView {
|
||||
/// 稳定 slug。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::Overrides => "overrides",
|
||||
Self::ClientOverrides => "client_overrides",
|
||||
Self::CurseForge => "curseforge",
|
||||
Self::Modrinth => "modrinth",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 core 的 id 反解。
|
||||
#[must_use]
|
||||
pub fn from_slug(slug: &str) -> Option<Self> {
|
||||
match slug {
|
||||
"overrides" => Some(Self::Overrides),
|
||||
"client_overrides" => Some(Self::ClientOverrides),
|
||||
"curseforge" => Some(Self::CurseForge),
|
||||
"modrinth" => Some(Self::Modrinth),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 是不是"包内的覆盖目录"(相对于清单里的远端条目)。
|
||||
#[must_use]
|
||||
pub const fn is_override_dir(self) -> bool {
|
||||
matches!(self, Self::Overrides | Self::ClientOverrides)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条**待用户点头**的冲突(导入前那张清单里的一行)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImportConflictView {
|
||||
/// 实例内相对路径。
|
||||
pub path: String,
|
||||
/// 来源(包内覆盖目录 / 清单远端条目)。
|
||||
pub source: ConflictSourceView,
|
||||
/// 成因。
|
||||
pub reason: ConflictReasonView,
|
||||
/// 包内那条是不是禁用态(`.disabled`)。
|
||||
pub disabled: bool,
|
||||
/// 包内声明的 SHA-1。
|
||||
pub pack_sha1: String,
|
||||
/// 包内声明的字节数。
|
||||
pub pack_size: u64,
|
||||
/// 目标现有文件的 SHA-1。
|
||||
pub disk_sha1: String,
|
||||
/// 目标现有文件的字节数(覆盖会丢掉的量)。
|
||||
pub disk_size: u64,
|
||||
/// 上次导入写下的 SHA-1(没有基线时 `None`)。
|
||||
pub base_sha1: Option<String>,
|
||||
}
|
||||
|
||||
/// 用户的冲突决策(三条入口共用一个类型)。
|
||||
///
|
||||
/// 三条入口与 core 的 `ConflictDecisions`(`default` + `entries`)是同一件事的两种说法:
|
||||
///
|
||||
/// | 变体 | core 形状 |
|
||||
/// |---|---|
|
||||
/// | [`ConflictDecisionsView::KeepAll`] | `default = KeepUser`, 空表 |
|
||||
/// | [`ConflictDecisionsView::OverwriteAll`] | `default = Overwrite`, 空表 |
|
||||
/// | `PerItem { default, items }` | `default` + 逐项表 |
|
||||
///
|
||||
/// `PerItem` 带一个 `default` 是为了让"全部覆盖之后我又单独放回两条"这种真实操作可表达:
|
||||
/// 若只有"逐项覆盖 + 缺省保留",用户就得把剩下的几百条全点一遍。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ConflictDecisionsView {
|
||||
/// **全部保留**(缺省):用户已有文件一个都不动。
|
||||
KeepAll,
|
||||
/// **全部覆盖**:整合包说了算。
|
||||
OverwriteAll,
|
||||
/// **逐项**:点过的路径按 `items`,其余按 `default`。
|
||||
PerItem {
|
||||
/// 没点过的路径按它处置。
|
||||
default: ConflictChoiceView,
|
||||
/// 点过的路径。
|
||||
items: Vec<ConflictDecisionView>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for ConflictDecisionsView {
|
||||
/// **缺省 = 全部保留**(绝不默认覆盖用户已有文件)。
|
||||
fn default() -> Self {
|
||||
Self::KeepAll
|
||||
}
|
||||
}
|
||||
|
||||
impl ConflictDecisionsView {
|
||||
/// 逐项构造(缺省档 + 点过的那些)。
|
||||
#[must_use]
|
||||
pub fn per_item(default: ConflictChoiceView, items: Vec<ConflictDecisionView>) -> Self {
|
||||
if items.is_empty() {
|
||||
return match default {
|
||||
ConflictChoiceView::KeepUser => Self::KeepAll,
|
||||
ConflictChoiceView::Overwrite => Self::OverwriteAll,
|
||||
};
|
||||
}
|
||||
Self::PerItem { default, items }
|
||||
}
|
||||
|
||||
/// 逐项表里的缺省档(`KeepAll` / `OverwriteAll` 也答得出)。
|
||||
#[must_use]
|
||||
pub const fn default_choice(&self) -> ConflictChoiceView {
|
||||
match self {
|
||||
Self::KeepAll => ConflictChoiceView::KeepUser,
|
||||
Self::OverwriteAll => ConflictChoiceView::Overwrite,
|
||||
Self::PerItem { default, .. } => *default,
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户显式要求"以包为准"吗(整批覆盖)。
|
||||
#[must_use]
|
||||
pub fn overwrites_everything(&self) -> bool {
|
||||
matches!(self, Self::OverwriteAll)
|
||||
}
|
||||
|
||||
/// 逐项表里"覆盖"的条数(不含缺省档)。
|
||||
#[must_use]
|
||||
pub fn overwrite_count(&self) -> usize {
|
||||
self.items()
|
||||
.iter()
|
||||
.filter(|item| item.choice == ConflictChoiceView::Overwrite)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// 逐项表里"保留"的条数。
|
||||
#[must_use]
|
||||
pub fn keep_count(&self) -> usize {
|
||||
self.items()
|
||||
.iter()
|
||||
.filter(|item| item.choice == ConflictChoiceView::KeepUser)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// 逐项表(`KeepAll` / `OverwriteAll` 是空表)。
|
||||
#[must_use]
|
||||
pub fn items(&self) -> &[ConflictDecisionView] {
|
||||
match self {
|
||||
Self::PerItem { items, .. } => items,
|
||||
Self::KeepAll | Self::OverwriteAll => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// 逐项表的条数。
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.items().len()
|
||||
}
|
||||
|
||||
/// 有没有逐项决策。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items().is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 逐项决策的一条(路径 → 处置)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConflictDecisionView {
|
||||
/// 实例内相对路径。
|
||||
pub path: String,
|
||||
/// 处置。
|
||||
pub choice: ConflictChoiceView,
|
||||
}
|
||||
|
||||
/// 一次导入预检的结果(`PackEvent::Scan` 的载荷)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImportScanView {
|
||||
/// 目标实例目录(人要看到"装到哪")。
|
||||
pub target: std::path::PathBuf,
|
||||
/// 命中的格式。
|
||||
pub format: Option<crate::dto::PackFormatView>,
|
||||
/// 包名。
|
||||
pub pack_name: Option<String>,
|
||||
/// 包版本。
|
||||
pub pack_version: Option<String>,
|
||||
/// 游戏版本。
|
||||
pub game_version: Option<String>,
|
||||
/// 待用户点头的冲突(按路径排序)。
|
||||
pub conflicts: Vec<ImportConflictView>,
|
||||
/// 补全前没有路径、因而判不了的远端条目数。
|
||||
pub unknown_remote: usize,
|
||||
/// 覆盖会丢掉的字节总量。
|
||||
pub lost_bytes: u64,
|
||||
/// 包内那些条目声明了多大。
|
||||
pub incoming_bytes: u64,
|
||||
/// 注记(与导入报告同源的人读串)。
|
||||
pub notes: Vec<String>,
|
||||
/// 预检本身失败(探测不过 / 开包失败)时的原因。
|
||||
pub error: Option<crate::dto::ImportErrorView>,
|
||||
}
|
||||
|
||||
impl ImportScanView {
|
||||
/// 需要用户点头吗(`false` = 可以直接导入,别打扰)。
|
||||
#[must_use]
|
||||
pub fn needs_decision(&self) -> bool {
|
||||
!self.conflicts.is_empty()
|
||||
}
|
||||
|
||||
/// 一条冲突都没有、预检也成功(可以直接导)。
|
||||
#[must_use]
|
||||
pub fn is_clean(&self) -> bool {
|
||||
self.error.is_none() && self.conflicts.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 冲突成因(core 的三条判据)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ConflictReasonView {
|
||||
|
||||
@@ -26,12 +26,17 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dto::pack::{
|
||||
ExportDoneView, ExportPreviewView, ExportRequestView, ExportState, ImportProgressView,
|
||||
ImportReportView, ImportRequestView, ImportState, ProbeView,
|
||||
ImportReportView, ImportRequestView, ImportScanView, ImportState, ProbeView,
|
||||
};
|
||||
|
||||
/// app → host 的导入/导出意图。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PackCommand {
|
||||
/// **预检**导入(只读:算出冲突清单,**不写一个字节**)
|
||||
///
|
||||
/// 页面先发它,拿到 [`PackEvent::Scan`] 之后:没有冲突就直接发 [`PackCommand::Import`]
|
||||
/// (不打扰用户);有冲突就把清单画出来,用户点完再带着决策发 `Import`。
|
||||
Scan(Box<ImportRequestView>),
|
||||
/// 开始导入(目标实例已选好;`instance` 为 `None` = 按 `instance_name` 新建目录)
|
||||
Import(Box<ImportRequestView>),
|
||||
/// 跑**预检**(只扫描分类,**不写盘**)
|
||||
@@ -47,6 +52,8 @@ pub enum PackCommand {
|
||||
pub enum PackEvent {
|
||||
/// 导入进度(`None` = 收回面板:取消,或流程已经给出结果)
|
||||
Progress(Option<Box<ImportProgressView>>),
|
||||
/// 导入**预检**结果:冲突清单(M160)。空清单 = 没有文件需要用户点头。
|
||||
Scan(Box<ImportScanView>),
|
||||
/// 导入结果(三态 + 逐项原因;致命失败也走它,`report.error` 里有原因)
|
||||
Report(Box<ImportReportView>),
|
||||
/// 探测失败:这个包看起来像什么、缺哪个标记文件
|
||||
@@ -80,15 +87,24 @@ impl PackState {
|
||||
PackEvent::Progress(progress) => {
|
||||
self.import.progress = progress.map(|progress| *progress);
|
||||
}
|
||||
PackEvent::Report(report) => {
|
||||
// 结果到了:进度面板让位,探测诊断作废(这一轮已经有结论了)
|
||||
PackEvent::Scan(scan) => {
|
||||
// 预检到了:进度让位,上一轮的结果/诊断作废(这一轮还没开始导)。
|
||||
self.import.progress = None;
|
||||
self.import.probe = None;
|
||||
self.import.report = None;
|
||||
self.import.scan = Some(*scan);
|
||||
}
|
||||
PackEvent::Report(report) => {
|
||||
// 结果到了:进度面板让位,探测诊断与预检清单都作废(这一轮已经有结论了)
|
||||
self.import.progress = None;
|
||||
self.import.probe = None;
|
||||
self.import.scan = None;
|
||||
self.import.report = Some(*report);
|
||||
}
|
||||
PackEvent::Probe(probe) => {
|
||||
self.import.progress = None;
|
||||
self.import.report = None;
|
||||
self.import.scan = None;
|
||||
self.import.probe = Some(*probe);
|
||||
}
|
||||
PackEvent::Preview(preview) => {
|
||||
|
||||
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dto::InstanceId;
|
||||
|
||||
use super::conflict::ConflictFileView;
|
||||
use super::conflict::{ConflictDecisionsView, ConflictFileView, ImportScanView};
|
||||
use super::format::PackFormatView;
|
||||
|
||||
/// 导入阶段(与 core 的 `ImportStage` 一一对应)。
|
||||
@@ -144,10 +144,11 @@ pub struct ImportRequestView {
|
||||
pub instance: Option<InstanceId>,
|
||||
/// 新实例的展示名(`None` = 用包名)。
|
||||
pub instance_name: Option<String>,
|
||||
/// 冲突翻档:`false`(**默认**)= 保留用户那一侧;`true` = 以包为准覆盖。
|
||||
/// 冲突决策(**缺省 [`ConflictDecisionsView::KeepAll`]** = 用户已有文件一个都不动)。
|
||||
///
|
||||
/// 页面上把 `true` 那条写成「以包为准重新导入」,并且**只在用户显式点过之后**才发。
|
||||
pub overwrite: bool,
|
||||
/// 页面上:`KeepAll` 是缺省与「全部保留」,`OverwriteAll` 是「全部覆盖」,
|
||||
/// `PerItem` 是逐项勾选的结果。只有用户**显式点过**之后才会出现后两者。
|
||||
pub decisions: ConflictDecisionsView,
|
||||
}
|
||||
|
||||
/// 一条导入进度。
|
||||
@@ -334,6 +335,8 @@ pub struct ImportState {
|
||||
/// 用户点了取消(M113 r1):**面板收回但后台还在跑**,所以这一位要留在界面上——
|
||||
/// 否则用户会以为「没建成」,回头再导一次就撞「directory already exists」。
|
||||
pub cancelled: bool,
|
||||
/// 最近一次的预检(M160:冲突清单;`None` = 还没预检过或已被新结果顶掉)。
|
||||
pub scan: Option<ImportScanView>,
|
||||
/// 最近一次的结果(`None` = 还没导过)。
|
||||
pub report: Option<ImportReportView>,
|
||||
/// 探测失败的具名诊断(有它就让用户看清「像 X 缺 Y」,而不是一句「无法识别」)。
|
||||
|
||||
@@ -185,6 +185,11 @@ impl super::super::CoreHost {
|
||||
pub(crate) fn pack(&mut self, command: PackCommand) {
|
||||
let epoch = pack_epoch(&self.env);
|
||||
match command {
|
||||
PackCommand::Scan(request) => {
|
||||
// 预检只读:不点亮导入面板(那会让用户以为已经在装了),也不计"在跑导入"。
|
||||
let cancel = fresh_cancel(&self.env);
|
||||
import::spawn_scan(self.env.clone(), self.tx.clone(), *request, epoch, cancel);
|
||||
}
|
||||
PackCommand::Import(request) => {
|
||||
let cancel = fresh_cancel(&self.env);
|
||||
// 句柄在**派发点**就计上,随线程一起结束(含早退与 panic)
|
||||
|
||||
@@ -12,17 +12,92 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use unlucky_core::addon::export::{ExportReport, FileClass, FileReason};
|
||||
use unlucky_core::addon::import::{
|
||||
ConflictChoice, ConflictFile, ConflictReason, FileAction, ImportError, ImportProgress,
|
||||
ImportReport, LoaderTarget, MissingReason,
|
||||
ConflictChoice, ConflictDecisions, ConflictFile, ConflictReason, FileAction, FileSource,
|
||||
ImportError, ImportProgress, ImportReport, ImportScan, LoaderTarget, MissingReason,
|
||||
};
|
||||
|
||||
use crate::dto::{
|
||||
ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportDoneView,
|
||||
ExportReasonView, FileActionView, ImportErrorView, ImportProgressView, ImportReportView,
|
||||
ConflictChoiceView, ConflictDecisionsView, ConflictFileView, ConflictReasonView,
|
||||
ConflictSourceView, ExportClassView, ExportDoneView, ExportReasonView, FileActionView,
|
||||
ImportConflictView, ImportErrorView, ImportProgressView, ImportReportView, ImportScanView,
|
||||
ImportStageView, ImportStatusView, ImportedFileView, MissingFileView, MissingReasonView,
|
||||
PackFormatView,
|
||||
};
|
||||
|
||||
/// 一次导入**预检** → DTO(M160:冲突清单)。
|
||||
pub(super) fn scan_view(scan: &ImportScan) -> ImportScanView {
|
||||
ImportScanView {
|
||||
target: scan.target.clone(),
|
||||
format: scan.format.as_deref().and_then(PackFormatView::from_slug),
|
||||
pack_name: scan.pack_name.clone(),
|
||||
pack_version: scan.pack_version.clone(),
|
||||
game_version: scan.game_version.clone(),
|
||||
conflicts: scan
|
||||
.conflicts
|
||||
.iter()
|
||||
.map(|conflict| ImportConflictView {
|
||||
path: conflict.path.clone(),
|
||||
source: source_view(conflict.source),
|
||||
reason: reason_of(conflict.reason),
|
||||
disabled: conflict.disabled,
|
||||
pack_sha1: conflict.pack_sha1.clone(),
|
||||
pack_size: conflict.pack_size,
|
||||
disk_sha1: conflict.disk_sha1.clone(),
|
||||
disk_size: conflict.disk_size,
|
||||
base_sha1: conflict.base_sha1.clone(),
|
||||
})
|
||||
.collect(),
|
||||
unknown_remote: scan.unknown_remote,
|
||||
lost_bytes: scan.lost_bytes(),
|
||||
incoming_bytes: scan.incoming_bytes(),
|
||||
notes: scan
|
||||
.notes
|
||||
.iter()
|
||||
.map(|issue| issue.message.clone())
|
||||
.collect(),
|
||||
error: scan.error.as_ref().map(error_view),
|
||||
}
|
||||
}
|
||||
|
||||
/// DTO 的决策 → core 的决策表(**形状一换,语义不换**)。
|
||||
///
|
||||
/// 三条入口一一对应;没点名的路径在 core 侧落到"保留"(`ConflictDecisions` 的缺省档),
|
||||
/// 所以"页面少传一条"永远不会变成"覆盖"。
|
||||
pub(super) fn decisions_of(view: &ConflictDecisionsView) -> ConflictDecisions {
|
||||
let mut decisions = match view.default_choice() {
|
||||
ConflictChoiceView::KeepUser => ConflictDecisions::keep_all(),
|
||||
ConflictChoiceView::Overwrite => ConflictDecisions::overwrite_all(),
|
||||
};
|
||||
for item in view.items() {
|
||||
decisions.set(item.path.clone(), choice_of(item.choice));
|
||||
}
|
||||
decisions
|
||||
}
|
||||
|
||||
const fn choice_of(choice: ConflictChoiceView) -> ConflictChoice {
|
||||
match choice {
|
||||
ConflictChoiceView::KeepUser => ConflictChoice::KeepUser,
|
||||
ConflictChoiceView::Overwrite => ConflictChoice::Overwrite,
|
||||
}
|
||||
}
|
||||
|
||||
const fn source_view(source: FileSource) -> ConflictSourceView {
|
||||
match source {
|
||||
FileSource::Overrides => ConflictSourceView::Overrides,
|
||||
FileSource::ClientOverrides => ConflictSourceView::ClientOverrides,
|
||||
FileSource::CurseForge => ConflictSourceView::CurseForge,
|
||||
FileSource::Modrinth => ConflictSourceView::Modrinth,
|
||||
}
|
||||
}
|
||||
|
||||
const fn reason_of(reason: ConflictReason) -> ConflictReasonView {
|
||||
match reason {
|
||||
ConflictReason::UserModified => ConflictReasonView::UserModified,
|
||||
ConflictReason::UserDeleted => ConflictReasonView::UserDeleted,
|
||||
ConflictReason::PreExisting => ConflictReasonView::PreExisting,
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次导入结果 → DTO。
|
||||
pub(super) fn report_view(report: &ImportReport, target: &Path) -> ImportReportView {
|
||||
ImportReportView {
|
||||
@@ -114,15 +189,8 @@ fn error_view(error: &ImportError) -> ImportErrorView {
|
||||
fn conflict_view(file: &ConflictFile) -> ConflictFileView {
|
||||
ConflictFileView {
|
||||
path: file.path.clone(),
|
||||
reason: match file.reason {
|
||||
ConflictReason::UserModified => ConflictReasonView::UserModified,
|
||||
ConflictReason::UserDeleted => ConflictReasonView::UserDeleted,
|
||||
ConflictReason::PreExisting => ConflictReasonView::PreExisting,
|
||||
},
|
||||
choice: match file.choice {
|
||||
ConflictChoice::KeepUser => ConflictChoiceView::KeepUser,
|
||||
ConflictChoice::Overwrite => ConflictChoiceView::Overwrite,
|
||||
},
|
||||
reason: reason_of(file.reason),
|
||||
choice: choice_view(file.choice),
|
||||
pack_sha1: file.pack_sha1.clone(),
|
||||
base_sha1: file.base_sha1.clone(),
|
||||
disk_sha1: file.disk_sha1.clone(),
|
||||
@@ -130,6 +198,14 @@ fn conflict_view(file: &ConflictFile) -> ConflictFileView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条 `ConflictChoice` → DTO(预检与事后账共用一张翻译表)。
|
||||
const fn choice_view(choice: ConflictChoice) -> ConflictChoiceView {
|
||||
match choice {
|
||||
ConflictChoice::KeepUser => ConflictChoiceView::KeepUser,
|
||||
ConflictChoice::Overwrite => ConflictChoiceView::Overwrite,
|
||||
}
|
||||
}
|
||||
|
||||
const fn action_view(action: FileAction) -> FileActionView {
|
||||
match action {
|
||||
FileAction::Installed => FileActionView::Installed,
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
//! 后台导入(M113):探测 → 目标目录 → core 的 `import_pack` → 报告。
|
||||
//! 后台导入(M113/M160):探测 → **预检(冲突清单)** → 目标目录 → core 的 `import_pack` → 报告。
|
||||
//!
|
||||
//! 三条纪律:
|
||||
//! 四条纪律:
|
||||
//!
|
||||
//! 1. **探测失败不空跑管线**:先做具名诊断([`super::diagnose`]),失败就只回 `Probe` 事件;
|
||||
//! 2. **默认档永远是 `KeepUser`**:只有 `request.overwrite == true` 才换成 `Overwrite`;
|
||||
//! 3. **取消后不再打扰界面**:进度回调与最终报告都先看取消标记。
|
||||
//! 2. **先预检再导入**(M160):预检只读,把"会盖掉用户哪几个文件"算清楚。没有冲突就直接
|
||||
//! 往下导(不打扰用户);有冲突就回 [`PackEvent::Scan`] 让页面画清单,等用户点完再导;
|
||||
//! 3. **缺省档永远 `KeepAll`**:决策从 DTO 逐字段搬([`convert::decisions_of`]),页面没点过的
|
||||
//! 路径在 core 侧落到"保留";
|
||||
//! 4. **取消后不再打扰界面**:进度回调与最终报告都先看取消标记。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use unlucky_core::addon::import::{
|
||||
import_pack, ConflictPolicy, DownloadEngineFetcher, ImportProgress, ImportProgressSink,
|
||||
import_pack, scan_pack, DownloadEngineFetcher, ImportProgress, ImportProgressSink,
|
||||
ImportRequest,
|
||||
};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
@@ -41,6 +44,52 @@ pub(super) fn spawn_import(
|
||||
});
|
||||
}
|
||||
|
||||
/// 起一个后台**预检**线程(M160:只读,不写盘)。
|
||||
pub(super) fn spawn_scan(
|
||||
env: HostEnv,
|
||||
tx: JobSender,
|
||||
request: ImportRequestView,
|
||||
epoch: u64,
|
||||
cancel: Arc<AtomicBool>,
|
||||
) {
|
||||
spawn("pack-scan", move || {
|
||||
// 探测先过一遍:失败就只给具名诊断,不空跑一遍预检。
|
||||
if let Some(probe) = super::diagnose::of(&request.pack) {
|
||||
send(&tx, epoch, PackEvent::Probe(Box::new(probe)));
|
||||
return;
|
||||
}
|
||||
let target = match target_of(&env, &request) {
|
||||
Ok((target, _)) => target,
|
||||
Err(reason) => {
|
||||
// 目标实例不可用:与导入路径同一形状的失败报告(界面不用学第二套)。
|
||||
send(
|
||||
&tx,
|
||||
epoch,
|
||||
PackEvent::Report(Box::new(convert::failed_report(
|
||||
PathBuf::new(),
|
||||
&request.pack,
|
||||
"import_host",
|
||||
"Target instance is not usable",
|
||||
&reason,
|
||||
))),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let core_request = ImportRequest::new(&request.pack, &target);
|
||||
// 预检是纯读的同步函数(不写盘、不联网),所以直接调用,不必占一个 async 任务。
|
||||
let scan = scan_pack(&core_request);
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
send(
|
||||
&tx,
|
||||
epoch,
|
||||
PackEvent::Scan(Box::new(convert::scan_view(&scan))),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn run(
|
||||
env: &HostEnv,
|
||||
tx: &JobSender,
|
||||
@@ -71,6 +120,22 @@ pub(super) fn run(
|
||||
return;
|
||||
}
|
||||
};
|
||||
if register {
|
||||
if let Err(reason) = prepare_target(&target) {
|
||||
send(
|
||||
tx,
|
||||
epoch,
|
||||
PackEvent::Report(Box::new(convert::failed_report(
|
||||
target,
|
||||
&request.pack,
|
||||
"import_host",
|
||||
"Target instance is not usable",
|
||||
&reason,
|
||||
))),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let pack_name = request
|
||||
.instance_name
|
||||
@@ -97,10 +162,9 @@ pub(super) fn run(
|
||||
let fetcher = DownloadEngineFetcher::new(&engine);
|
||||
let mut core_request = ImportRequest::new(&request.pack, &target)
|
||||
.with_fetcher(&fetcher)
|
||||
.with_progress(sink);
|
||||
if request.overwrite {
|
||||
core_request = core_request.with_conflict_policy(ConflictPolicy::Overwrite);
|
||||
}
|
||||
.with_progress(sink)
|
||||
// 决策逐字段搬过来:`KeepAll`(缺省)/ `OverwriteAll` / 逐项,语义在 core 那一侧落地。
|
||||
.with_decisions(convert::decisions_of(&request.decisions));
|
||||
if register {
|
||||
core_request = core_request.with_registration(&env.layout, Some(&pack_name));
|
||||
}
|
||||
@@ -134,7 +198,7 @@ fn remove_if_empty(dir: &std::path::Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 导入目标:`(目录, 是否登记新实例)`。
|
||||
/// 导入目标:`(目录, 是否登记新实例)` —— **只算路径,不建目录**(预检要能只读地用它)。
|
||||
///
|
||||
/// - 选了实例 ⇒ 它的运行目录,**不登记**(已经是一条实例记录);
|
||||
/// - 没选实例 ⇒ `<data>/instances/<dir_name>` 新目录 + 登记(实例名 = 用户给的名字或包名)。
|
||||
@@ -166,11 +230,13 @@ pub(super) fn target_of(
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
} else {
|
||||
std::fs::create_dir_all(&target)
|
||||
.map_err(|error| format!("create dir failed: {error}"))?;
|
||||
}
|
||||
Ok((target, true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 真要写盘之前建目录(**预检不调它**:预检是只读的,不建任何东西)。
|
||||
pub(super) fn prepare_target(target: &std::path::Path) -> Result<(), String> {
|
||||
std::fs::create_dir_all(target).map_err(|error| format!("create dir failed: {error}"))
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
//! 夹具(临时目录、写文件、泵 host)在 [`harness`]。
|
||||
|
||||
mod harness;
|
||||
mod scan;
|
||||
|
||||
use harness::{pack_events, pump, pump_until, temp_dir, write};
|
||||
|
||||
@@ -21,7 +22,7 @@ use unlucky_core::addon::export::{
|
||||
DEFAULT_CONTENT_ROOTS,
|
||||
};
|
||||
|
||||
use crate::dto::{ExportClassView, ExportRequestView, PackFormatView};
|
||||
use crate::dto::{ConflictDecisionsView, ExportClassView, ExportRequestView, PackFormatView};
|
||||
|
||||
#[test]
|
||||
fn bad_packs_get_a_named_diagnosis() {
|
||||
@@ -179,7 +180,7 @@ fn a_real_pack_runs_through_the_pipeline_end_to_end() {
|
||||
pack: source,
|
||||
instance: None,
|
||||
instance_name: Some("Imported Pack".to_string()),
|
||||
overwrite: false,
|
||||
decisions: ConflictDecisionsView::default(),
|
||||
};
|
||||
super::import::run(
|
||||
&env,
|
||||
@@ -279,12 +280,17 @@ fn a_failed_new_instance_directory_can_be_reused_for_retry() {
|
||||
pack: dir.join("whatever.mrpack"),
|
||||
instance: None,
|
||||
instance_name: Some("Retry Me".to_string()),
|
||||
overwrite: false,
|
||||
decisions: ConflictDecisionsView::default(),
|
||||
};
|
||||
|
||||
// 第一次:目录不存在 ⇒ 建出来
|
||||
let (first, register) = super::import::target_of(&env, &request).expect("首次应当可以建目录");
|
||||
// 第一次:`target_of` 只算路径(预检要能只读地用它),真要写盘前才 `prepare_target`。
|
||||
let (first, register) = super::import::target_of(&env, &request).expect("首次应当算得出目录");
|
||||
assert!(register);
|
||||
assert!(
|
||||
!first.exists(),
|
||||
"`target_of` 不许建目录(M160:预检是只读的)"
|
||||
);
|
||||
super::import::prepare_target(&first).expect("写盘前建目录");
|
||||
assert!(first.is_dir());
|
||||
|
||||
// 第二次(模拟上一次失败留下的空目录)⇒ 复用,而不是报「已存在」
|
||||
@@ -349,7 +355,7 @@ fn a_failed_import_releases_the_in_flight_flag_and_keeps_install_cancel_intact()
|
||||
pack: dir.join("missing.mrpack"),
|
||||
instance: None,
|
||||
instance_name: Some("Guess".to_string()),
|
||||
overwrite: false,
|
||||
decisions: ConflictDecisionsView::default(),
|
||||
},
|
||||
)))));
|
||||
let events = pump_until(&mut host, |all| {
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
//! 预检与决策通道(M160):DTO ↔ core 的翻译、以及"缺省不覆盖"在 presenter 这一侧的表现。
|
||||
//!
|
||||
//! 这里**不重算**任何判定(判定在 core):只钉两件事——
|
||||
//!
|
||||
//! 1. 三条入口(全部保留 / 全部覆盖 / 逐项)翻成 core 的 `ConflictDecisions` 之后语义不变,
|
||||
//! 尤其"没点名的路径 = 保留";
|
||||
//! 2. 预检清单逐字段搬进 DTO(来源、成因、两侧体积与哈希、计数摘要)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::harness::{temp_dir, write};
|
||||
use crate::dto::{
|
||||
ConflictChoiceView, ConflictDecisionView, ConflictDecisionsView, ConflictReasonView,
|
||||
ConflictSourceView,
|
||||
};
|
||||
use unlucky_core::addon::import::{
|
||||
ConflictChoice, ConflictReason, FileSource, ImportConflict, ImportScan,
|
||||
};
|
||||
|
||||
/// 造一份 core 侧的预检结果(只填这条用例要看的字段)。
|
||||
fn scan_fixture() -> ImportScan {
|
||||
ImportScan {
|
||||
target: PathBuf::from("instances/survival/.minecraft"),
|
||||
format: Some("modrinth".to_string()),
|
||||
pack_name: Some("Better MC".to_string()),
|
||||
pack_version: Some("1.2.0".to_string()),
|
||||
game_version: Some("1.20.1".to_string()),
|
||||
loader: None,
|
||||
conflicts: vec![ImportConflict {
|
||||
path: "config/sodium-options.json".to_string(),
|
||||
source: FileSource::ClientOverrides,
|
||||
reason: ConflictReason::UserModified,
|
||||
disabled: false,
|
||||
pack_sha1: "aa".repeat(20),
|
||||
pack_size: 4_200,
|
||||
disk_sha1: "bb".repeat(20),
|
||||
disk_size: 5_100,
|
||||
base_sha1: Some("cc".repeat(20)),
|
||||
}],
|
||||
unknown_remote: 2,
|
||||
notes: Vec::new(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_view_carries_every_field_and_the_counts() {
|
||||
let scan = scan_fixture();
|
||||
let view = super::super::convert::scan_view(&scan);
|
||||
|
||||
assert_eq!(view.format, Some(crate::dto::PackFormatView::Modrinth));
|
||||
assert_eq!(view.pack_name.as_deref(), Some("Better MC"));
|
||||
assert_eq!(view.unknown_remote, 2, "判不了的远端条目要如实计数");
|
||||
assert!(view.needs_decision(), "有一条冲突就要问用户");
|
||||
let conflict = &view.conflicts[0];
|
||||
assert_eq!(conflict.path, "config/sodium-options.json");
|
||||
assert_eq!(
|
||||
conflict.source,
|
||||
ConflictSourceView::ClientOverrides,
|
||||
"叠层胜者那一侧的来源"
|
||||
);
|
||||
assert_eq!(conflict.reason, ConflictReasonView::UserModified);
|
||||
assert_eq!(conflict.pack_size, 4_200);
|
||||
assert_eq!(conflict.disk_size, 5_100, "目标现有体积要出现在清单里");
|
||||
assert_eq!(conflict.disk_sha1.len(), 40, "目标现有哈希也要有");
|
||||
assert_eq!(view.lost_bytes, 5_100, "覆盖会丢掉的字节");
|
||||
assert_eq!(view.incoming_bytes, 4_200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clean_scan_is_not_a_reason_to_bother_the_user() {
|
||||
let mut scan = scan_fixture();
|
||||
scan.conflicts.clear();
|
||||
let view = super::super::convert::scan_view(&scan);
|
||||
assert!(!view.needs_decision());
|
||||
assert!(view.is_clean(), "没有冲突也没有错误 ⇒ 可以直接导入");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decisions_are_keep_all_by_default() {
|
||||
// 缺省 = 全部保留:页面不发决策、或发默认值,结果都必须是"一个文件都不动"。
|
||||
let view = ConflictDecisionsView::default();
|
||||
assert_eq!(view, ConflictDecisionsView::KeepAll);
|
||||
assert!(!view.overwrites_everything());
|
||||
let core = super::super::convert::decisions_of(&view);
|
||||
assert_eq!(core.choice_for("config/a.toml"), ConflictChoice::KeepUser);
|
||||
assert_eq!(core.default_choice(), ConflictChoice::KeepUser);
|
||||
assert!(core.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_three_channels_map_one_to_one() {
|
||||
// 全部覆盖
|
||||
let all = super::super::convert::decisions_of(&ConflictDecisionsView::OverwriteAll);
|
||||
assert_eq!(all.choice_for("anything.toml"), ConflictChoice::Overwrite);
|
||||
assert_eq!(all.default_choice(), ConflictChoice::Overwrite);
|
||||
|
||||
// 逐项:点名的照点名走,没点名的落到"保留"。
|
||||
let view = ConflictDecisionsView::per_item(
|
||||
ConflictChoiceView::KeepUser,
|
||||
vec![
|
||||
ConflictDecisionView {
|
||||
path: "mods/pin.jar".to_string(),
|
||||
choice: ConflictChoiceView::Overwrite,
|
||||
},
|
||||
ConflictDecisionView {
|
||||
path: "config/keep.toml".to_string(),
|
||||
choice: ConflictChoiceView::KeepUser,
|
||||
},
|
||||
],
|
||||
);
|
||||
assert_eq!(view.overwrite_count(), 1);
|
||||
assert_eq!(view.keep_count(), 1);
|
||||
assert_eq!(view.len(), 2);
|
||||
assert!(!view.overwrites_everything(), "逐项不等于整批覆盖");
|
||||
|
||||
let core = super::super::convert::decisions_of(&view);
|
||||
assert_eq!(core.choice_for("mods/pin.jar"), ConflictChoice::Overwrite);
|
||||
assert_eq!(
|
||||
core.choice_for("config/keep.toml"),
|
||||
ConflictChoice::KeepUser
|
||||
);
|
||||
assert_eq!(
|
||||
core.choice_for("mods/unlisted.jar"),
|
||||
ConflictChoice::KeepUser,
|
||||
"没点名的路径不许被逐项表顺手覆盖"
|
||||
);
|
||||
assert_eq!(core.overwrite_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scan_runs_without_writing_and_says_nothing_when_there_is_no_conflict() {
|
||||
// 真跑一次后台预检:全新目标(什么都没有)⇒ 没有冲突,且**目标目录没被建出来**。
|
||||
let dir = temp_dir("scan-fresh");
|
||||
let source = dir.join("Better MC");
|
||||
write(
|
||||
&source.join("mmc-pack.json"),
|
||||
br#"{"formatVersion": 1, "components": [
|
||||
{"uid": "net.minecraft", "version": "1.20.1"}]}"#,
|
||||
);
|
||||
write(&source.join(".minecraft/mods/sodium.jar"), &[0x11; 64]);
|
||||
|
||||
let data = dir.join("data");
|
||||
std::fs::create_dir_all(&data).expect("data root");
|
||||
let env = crate::host::HostEnv::at_root(&data);
|
||||
let (tx, rx) = crate::host::job::channel();
|
||||
let request = crate::dto::ImportRequestView {
|
||||
pack: source,
|
||||
instance: None,
|
||||
instance_name: Some("Scan Only".to_string()),
|
||||
decisions: ConflictDecisionsView::default(),
|
||||
};
|
||||
super::super::import::spawn_scan(
|
||||
env,
|
||||
tx,
|
||||
request,
|
||||
9,
|
||||
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
);
|
||||
|
||||
let mut scan = None;
|
||||
for task in rx.iter() {
|
||||
if let crate::host::job::HostTask::Pack { event, epoch } = task {
|
||||
assert_eq!(epoch, 9, "代次必须原样带回");
|
||||
if let crate::dto::PackEvent::Scan(view) = *event {
|
||||
scan = Some(*view);
|
||||
}
|
||||
}
|
||||
}
|
||||
let scan = scan.expect("必须回一条预检事件");
|
||||
assert!(scan.error.is_none(), "{:?}", scan.error);
|
||||
assert!(
|
||||
!scan.needs_decision(),
|
||||
"空目标上不该有冲突:{:?}",
|
||||
scan.conflicts
|
||||
);
|
||||
assert_eq!(scan.format, Some(crate::dto::PackFormatView::Multimc));
|
||||
assert!(
|
||||
!scan.target.exists(),
|
||||
"预检只读:目标目录不许被建出来({})",
|
||||
scan.target.display()
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
Reference in new issue
Block a user