Files
Unlucky-Launcher/crates/presenter/src/host/addon/pack/tests.rs
T
AskaEth 1105a86448
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Release / 选槽位(默认两个 Windows) (push) Canceled after 0s
Release / build (${{ matrix.slot }}) (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Unlucky Launcher v0.2.2
2026-11-07 08:46:11 +08:00

390 lines
15 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.
//! M113 的证据(都跑真管线 / 真 host,不用假数据):
//!
//! | 用例 | 钉住什么 |
//! |---|---|
//! | [`bad_packs_get_a_named_diagnosis`] | 坏包必须有具名失败,不是静默、不是 panic |
//! | [`preview_counts_match_the_written_report`] | 预检的 N/M 与最终报告**逐项相等** |
//! | [`a_real_pack_runs_through_the_pipeline_end_to_end`] | 正常包能走完管线并落盘 |
//! | [`chinese_instance_names_get_distinct_directories`] | 中文实例名不会塌成同一个目录 |
//! | [`a_failed_new_instance_directory_can_be_reused_for_retry`] | 失败残留的空目录不挡重试 |
//! | [`pack_epoch_is_per_host_and_is_its_own_counter`] | 代次按 host 分开,不再跨 host 串扰 |
//! | [`a_failed_import_releases_the_in_flight_flag_and_keeps_install_cancel_intact`] | 早退路径放回「在跑」计数,取消安装仍是安装的取消 |
//!
//! 夹具(临时目录、写文件、泵 host)在 [`harness`]。
mod harness;
use harness::{pack_events, pump, pump_until, temp_dir, write};
use unlucky_core::addon::export::{
export_instance, scan_instance, ExportRequest, InstanceSnapshot, OfflineCatalog,
DEFAULT_CONTENT_ROOTS,
};
use crate::dto::{ExportClassView, ExportRequestView, PackFormatView};
#[test]
fn bad_packs_get_a_named_diagnosis() {
let dir = temp_dir("badpack");
// ① 把文本文件改名成 .mrpack:连 zip 都不是
let fake = dir.join("fake.mrpack");
write(&fake, b"this is not a zip");
let probe = super::diagnose::of(&fake).expect("打不开的包必须有诊断");
assert!(
!probe.detail.is_empty(),
"诊断要给一句具体原因(不是空字符串)"
);
assert!(
probe.detail.to_lowercase().contains("archive") || probe.detail.contains("zip"),
"诊断要说清是哪一步失败:{}",
probe.detail
);
// ② 目录形态:有 overrides/、没有 manifest.json ⇒ 「像 CF,缺 manifest.json」
let dir_pack = dir.join("half");
write(&dir_pack.join("overrides").join("options.txt"), b"fov=90\n");
if let Some(probe) = super::diagnose::of(&dir_pack) {
assert!(
!probe.saw.is_empty(),
"看到了 overrides/ 就该说出来(saw 不能为空)"
);
if probe.looks_like.is_some() {
assert!(
!probe.missing.is_empty(),
"说「像某个格式」就必须说出缺哪个标记文件"
);
}
}
}
#[test]
fn preview_counts_match_the_written_report() {
let dir = temp_dir("export-eq");
let root = dir.join("instance");
write(&root.join("mods").join("sodium.jar"), &[0x11; 4096]);
write(&root.join("mods").join("lithium.jar"), &[0x22; 2048]);
write(&root.join("mods").join("old.jar.disabled"), &[0x33; 512]);
write(&root.join("config").join("options.txt"), b"fov=90\n");
// 白名单之外的目录不该被扫(否则「将内联 N」会虚高)
write(&root.join("logs").join("latest.log"), b"noise\n");
let request = ExportRequestView {
instance: "survival".to_string(),
format: PackFormatView::Modrinth,
name: "Survival".to_string(),
version: "1.0.0".to_string(),
target: Some(dir.join("out.mrpack")),
};
let env_root = dir.join("data");
std::fs::create_dir_all(&env_root).expect("env");
let env = crate::host::HostEnv::at_root(&env_root);
// 预检(不写盘)
let preview = super::export::preview_of(&root, &env, &request);
// 真写盘:用同一套判据(离线目录 + 默认内容目录 + 直接给的快照)
let snapshot = InstanceSnapshot {
game_version: "1.20.1".to_string(),
loaders: Vec::new(),
};
let target = dir.join("out.mrpack");
let core_request = ExportRequest::new(
&root,
&target,
&unlucky_core::addon::pack::modrinth::MODRINTH,
)
.with_name("Survival")
.with_version("1.0.0")
.with_catalog(&OfflineCatalog)
.with_snapshot(&snapshot);
let report = export_instance(&core_request).expect("导出应当成功");
// 三个计数逐项相等(塔台要求:不是「大致对」)
assert_eq!(
preview.inline_count(),
report.inline_count(),
"内联条数不一致:预检 {} vs 报告 {}",
preview.inline_count(),
report.inline_count()
);
assert_eq!(
preview.skipped_count(),
report.skipped_count(),
"无法导出条数不一致"
);
assert_eq!(
preview.remote_count(),
report.remote_count(),
"远程引用条数不一致"
);
// 逐条:同一个路径在两边必须是同一个类
for file in &preview.files {
let written = report
.files
.iter()
.find(|candidate| candidate.path == file.path)
.unwrap_or_else(|| panic!("预检多出一条:{}", file.path));
let expected = match file.class {
ExportClassView::Remote => unlucky_core::addon::export::FileClass::Remote,
ExportClassView::Inline => unlucky_core::addon::export::FileClass::Inline,
ExportClassView::Skipped => unlucky_core::addon::export::FileClass::Skipped,
};
assert_eq!(written.class, expected, "{} 的归类不一致", file.path);
}
// 白名单外的 logs/ 不许进包(防「扫太宽」把预检数字弄虚)
assert!(
!report
.files
.iter()
.any(|file| file.path.starts_with("logs/")),
"logs/ 不该被扫进整合包"
);
// 同时钉一条:`scan_instance` 的口径就是预览用的那一套
let roots: Vec<String> = DEFAULT_CONTENT_ROOTS
.iter()
.map(|entry| (*entry).to_string())
.collect();
let (files, skipped) = scan_instance(&root, &roots);
assert_eq!(files.len(), preview.inline_count());
assert_eq!(skipped.len(), preview.skipped_count());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_real_pack_runs_through_the_pipeline_end_to_end() {
// 真管线端到端:造一个 MultiMC 实例目录当「包」(目录形态不需要 zip 写入器),
// 走 `import::run` 的**真**路径,看事件流里有没有「进度 + 结果」、文件有没有落地。
let dir = temp_dir("import-e2e");
let source = dir.join("Better MC");
write(
&source.join("mmc-pack.json"),
br#"{"formatVersion": 1, "components": [
{"uid": "net.minecraft", "version": "1.20.1"},
{"uid": "net.fabricmc.fabric-loader", "version": "0.15.11"}]}"#,
);
write(&source.join(".minecraft/mods/sodium.jar"), &[0x11; 1024]);
write(&source.join(".minecraft/config/options.txt"), b"fov=90\n");
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("Imported Pack".to_string()),
overwrite: false,
};
super::import::run(
&env,
&tx,
&request,
7,
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
);
drop(tx);
let mut progress = 0usize;
let mut report = None;
for task in rx.iter() {
match task {
crate::host::job::HostTask::Pack { event, epoch } => {
assert_eq!(epoch, 7, "代次必须原样带回(取消判定靠它)");
match *event {
crate::dto::PackEvent::Progress(Some(_)) => progress += 1,
crate::dto::PackEvent::Report(view) => report = Some(*view),
other => panic!("意料之外的事件:{other:?}"),
}
}
other => panic!("意料之外的任务:{other:?}"),
}
}
assert!(progress > 0, "至少要有一条进度事件");
let report = report.expect("必须有结果事件");
assert_ne!(
report.status,
crate::dto::ImportStatusView::Failed,
"正常包不该失败:{:?}",
report.error
);
assert_eq!(
report.format,
Some(crate::dto::PackFormatView::Multimc),
"应当认成 MultiMC 格式"
);
assert!(
report.written() > 0,
"报告里要至少有写盘的文件:{:?}",
report.files
);
// 落盘位置以**报告里的 target** 为准(那是 host 真算出来的目录,界面显示的也是它)
let landed = &report.target;
assert!(
landed.join("mods/sodium.jar").is_file(),
"文件没有落到实例目录:{}",
landed.display()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn chinese_instance_names_get_distinct_directories() {
// 复审 r1 P2-8:slug() 只留 ASCII,中文名会全塌成 `pack`,第二个就撞目录
let first = super::convert::dir_name("我的整合包");
let second = super::convert::dir_name("另一个整合包");
assert_ne!(first, second, "两个不同的中文名必须得到不同目录");
assert_eq!(
first,
super::convert::dir_name("我的整合包"),
"同一个名字要稳定(不然重试会漂到新目录)"
);
assert!(first.starts_with("pack-"), "退化时要留可读前缀:{first}");
// 判据与注释对齐(复审 r2 P2-2):只含 ASCII 字母数字/空格/`-`/`_` ⇒ 保持原样
assert_eq!(super::convert::dir_name("Better-MC"), "Better-MC");
assert_eq!(
super::convert::dir_name("Better MC"),
"Better-MC",
"空格折成 `-`、**不加**哈希"
);
assert_eq!(
super::convert::dir_name("pack"),
"pack",
"名字就叫 pack 也保留"
);
// 折过**别的**字符(这里是 `.`)就必须补哈希,否则不同名字会撞同一个目录
let versioned = super::convert::dir_name("Survival 1.20.1");
assert!(versioned.starts_with("Survival-1-20-1-"), "{versioned}");
assert_ne!(versioned, "Survival-1-20-1", "折过 `.` 就要补哈希");
}
#[test]
fn a_failed_new_instance_directory_can_be_reused_for_retry() {
// 复审 r1 P2-9:失败回滚留下空目录时会挡住重试
let dir = temp_dir("retry");
let data = dir.join("data");
std::fs::create_dir_all(&data).expect("data root");
let env = crate::host::HostEnv::at_root(&data);
let request = crate::dto::ImportRequestView {
pack: dir.join("whatever.mrpack"),
instance: None,
instance_name: Some("Retry Me".to_string()),
overwrite: false,
};
// 第一次:目录不存在 ⇒ 建出来
let (first, register) = super::import::target_of(&env, &request).expect("首次应当可以建目录");
assert!(register);
assert!(first.is_dir());
// 第二次(模拟上一次失败留下的空目录)⇒ 复用,而不是报「已存在」
let (second, _) = super::import::target_of(&env, &request).expect("空目录应当可复用");
assert_eq!(first, second);
// 目录里已经有东西 ⇒ 拒绝(不能往别人的实例里倒)
std::fs::write(second.join("marker.txt"), b"x").expect("write");
assert!(
super::import::target_of(&env, &request).is_err(),
"非空目录必须拒绝"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn pack_epoch_is_per_host_and_is_its_own_counter() {
// 复审 r2 P3-6:代次**按 host** 分开(不再是跨 host 共享的裸静态量)。
// 两个 host(两个临时根)各 bump 一次,互不影响 —— 这正是并行测试串扰的根因。
let one = temp_dir("epoch-one");
let two = temp_dir("epoch-two");
let env_one = crate::host::HostEnv::at_root(one.join("data"));
let env_two = crate::host::HostEnv::at_root(two.join("data"));
let before_one = super::pack_epoch(&env_one);
let before_two = super::pack_epoch(&env_two);
super::bump_pack_epoch(&env_one);
assert_eq!(
super::pack_epoch(&env_one),
before_one.wrapping_add(1),
"自己这个 host 的代次 +1"
);
assert_eq!(
super::pack_epoch(&env_two),
before_two,
"**另一个** host 的代次不受影响(P3-6 的根因)"
);
assert_eq!(super::in_flight(&env_two), 0, "在跑计数也是每 host 一份");
let _ = std::fs::remove_dir_all(&one);
let _ = std::fs::remove_dir_all(&two);
}
#[test]
fn a_failed_import_releases_the_in_flight_flag_and_keeps_install_cancel_intact() {
// 复审 r2 P2-1 的护栏:早退路径必须把「在跑」计数放回去,否则之后**下载页**的
// 「取消安装」会被当成导入取消(不发 `InstallCancelled`,还给内容库画一句假话)。
use crate::dto::{
AppCommand, AppEvent, Backend, CoreEvent, ImportRequestView, PackCommand, PackEvent,
};
let dir = temp_dir("inflight-guard");
let data = dir.join("data");
std::fs::create_dir_all(&data).expect("data root");
let host_env = crate::host::HostEnv::at_root(&data);
let mut host = crate::host::CoreHost::headless(host_env.clone());
// ① 派一次**必然早退**的导入:包文件不存在 ⇒ 探测当场失败,根本不进管线
host.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
ImportRequestView {
pack: dir.join("missing.mrpack"),
instance: None,
instance_name: Some("Guess".to_string()),
overwrite: false,
},
)))));
let events = pump_until(&mut host, |all| {
pack_events(all)
.iter()
.any(|event| matches!(event, PackEvent::Probe(_)))
});
assert!(
pack_events(&events)
.iter()
.any(|event| matches!(event, PackEvent::Probe(_))),
"坏包必须给出探测诊断(而不是静默);实际收到 {events:?}"
);
assert_eq!(
super::in_flight(&host_env),
0,
"早退之后「在跑」计数必须归零(RAII guard 负责,不靠手工 finished)"
);
// ② 此后下载页的「取消安装」必须照常发 `InstallCancelled`,且**不能**冒出 pack 的取消
host.dispatch(AppCommand::CancelInstall);
let events = pump(&mut host);
assert!(
events
.iter()
.any(|event| matches!(event, AppEvent::Core(CoreEvent::InstallCancelled))),
"没有导入在跑时,取消安装必须照常给回执(否则用户点了取消毫无反应)"
);
assert!(
!pack_events(&events)
.iter()
.any(|event| matches!(event, PackEvent::Cancelled)),
"此刻没有导入在跑,不该给内容库发「导入还在后台跑」"
);
let _ = std::fs::remove_dir_all(&dir);
}