Files
Unlucky-Launcher/crates/core/src/addon/tests/mod.rs
T
worker-modpackpick 4defc41e8d 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)。
2026-11-07 13:52:43 +08:00

175 lines
6.7 KiB
Rust

//! `addon` 的**导入管线**测试(M111):三格式端到端 + 三方合并 + 必须先失败的恶意样本。
//!
//! # 夹具来源
//!
//! [`fixtures`] 不是本目录的文件,而是用 `#[path]` 直接吃 M110 的
//! `addon/pack/tests/fixtures.rs`——**同一份手造包只维护一处**。它的来源声明也在那边:
//! 三份 CF / mrpack / MultiMC 包全部手造(字段取自 analyse/05 的 HMCL 解析器形状),
//! 没有一个来自真实导出;`mods/half.jar` / `config/broken.toml` / `mods/broken.jar`
//! 是刻意造的坏文件(导入器不许崩,也不许悄悄丢)。
//!
//! 为了让 `mod` 路径上的 `use super::{temp_root, write_file, zip_dir}` 解析得到,本文件把
//! 这三个辅助函数放在与 `addon/pack/tests/mod.rs` 同名同形的位置。
//!
//! # 全部离线
//!
//! 网络一律走 [`support::StubFetcher`](内存里的假文件),没有一个用例发请求。
// `fixtures.rs` 被 `addon::pack::tests` 与本目录**同时**编译(同一份手造包只维护一处),
// 所以 clippy 的 `duplicate_mod` 在这里是刻意为之:改成两份副本才是真的错。
#[allow(clippy::duplicate_mod)]
#[path = "../pack/tests/fixtures.rs"]
mod fixtures;
mod import_curseforge;
mod import_decisions;
mod import_failures;
mod import_layering;
mod import_merge;
mod import_modrinth;
mod import_multimc;
mod import_report;
mod roundtrip_three;
mod support;
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
/// 一次性临时根(`Drop` 即删,panic 也删)。实现与 `pack::tests` 共用一份:
/// [`crate::addon::temp::TempRoot`](两族用例都会建临时目录,守卫只此一处)。
pub(crate) use crate::addon::temp::TempRoot;
/// 写一个文件(自动建父目录)。
pub(crate) fn write_file(path: &Path, bytes: &[u8]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("建目录");
}
std::fs::write(path, bytes).expect("写文件");
}
/// 把一个目录打成 zip(`Stored`,方便断言字节)。
pub(crate) fn zip_dir(source: &Path, target: &Path) {
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).expect("建 zip 目录");
}
let file = File::create(target).expect("建 zip");
let mut writer = zip::ZipWriter::new(file);
let options =
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let mut pending = vec![PathBuf::new()];
while let Some(relative) = pending.pop() {
let dir = source.join(&relative);
let mut children: Vec<_> = std::fs::read_dir(&dir)
.expect("读目录")
.collect::<Result<Vec<_>, _>>()
.expect("目录项");
children.sort_by_key(std::fs::DirEntry::file_name);
for child in children {
let relative = relative.join(child.file_name());
if child.file_type().expect("类型").is_dir() {
pending.push(relative);
continue;
}
let bytes = std::fs::read(child.path()).expect("读夹具文件");
let entry = relative.to_string_lossy().replace('\\', "/");
writer.start_file(entry, options).expect("写 zip 条目");
writer.write_all(&bytes).expect("写 zip 内容");
}
}
writer.finish().expect("收尾 zip");
}
/// 用一张显式条目表打 zip(恶意样本用)。
pub(crate) fn write_zip(target: &Path, entries: &[(&str, &[u8])]) {
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).expect("建 zip 目录");
}
let file = File::create(target).expect("建 zip");
let mut writer = zip::ZipWriter::new(file);
let options =
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, bytes) in entries {
writer.start_file(*name, options).expect("写 zip 条目");
writer.write_all(bytes).expect("写 zip 内容");
}
writer.finish().expect("收尾 zip");
}
/// 打一个带**符号链接条目**的 zip(`S_IFLNK` + 目标当内容)。
pub(crate) fn write_zip_with_symlink(
target: &Path,
entries: &[(&str, &[u8])],
link_name: &str,
link_target: &str,
) {
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).expect("建 zip 目录");
}
let file = File::create(target).expect("建 zip");
let mut writer = zip::ZipWriter::new(file);
let options =
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, bytes) in entries {
writer.start_file(*name, options).expect("写 zip 条目");
writer.write_all(bytes).expect("写 zip 内容");
}
writer
.add_symlink(link_name, link_target, options)
.expect("写符号链接条目");
writer.finish().expect("收尾 zip");
}
/// 递归列出目录下的文件(`/` 分隔的相对路径,排序)。
///
/// "失败后目标目录零残留"就是靠它断言的:`Failed` 之后这里必须返回空表。
pub(crate) fn list_tree(root: &Path) -> Vec<String> {
fn walk(base: &Path, dir: &Path, out: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk(base, &path, out);
} else {
out.push(
path.strip_prefix(base)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/"),
);
}
}
}
let mut out = Vec::new();
walk(root, root, &mut out);
out.sort();
out
}
/// 基线相对实例根的路径(`/` 分隔,给 `list_tree` 断言用)。
///
/// 从 `PackBaseline::relative_path()` 派生,**不在测试里手抄**目录名——手抄的那一份正是
/// 复审 r5 P1-1 的成因(读写两条路径各写各的)。
pub(crate) fn baseline_rel() -> String {
crate::addon::import::PackBaseline::relative_path()
.to_string_lossy()
.replace('\\', "/")
}
/// 读一个文件成字符串(断言覆盖内容用)。
pub(crate) fn read_text(path: &Path) -> String {
let bytes =
std::fs::read(path).unwrap_or_else(|error| panic!("读 {} 失败:{error}", path.display()));
String::from_utf8_lossy(&bytes).to_string()
}
/// 断言目录不存在(= 零残留)。
pub(crate) fn assert_absent(path: &Path) {
assert!(
!path.exists(),
"目标目录应当不存在(零残留),实际:{:?}",
list_tree(path)
);
}