Unlucky Launcher v0.2.2
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

This commit is contained in:
AskaEth committed 2026-11-07 08:46:11 +08:00
commit 1105a86448
1128 files changed
+217164

No files matched your search

@@ -0,0 +1,389 @@
//! 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);
}