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
@@ -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