fix(m160-r1): 预检失败不再静默空转 + 写侧条目数闸 + 判据归一 + 冲突页对齐

复审 reviewer-modpackpick-r1(p2-1items / fix-then-merge)的返工。

P2-1 预检失败被吞掉(错误呈现回归)
- host::import::spawn_scan 在 `scan.error.is_some()` 时**再发一条 PackEvent::Report**
  (`convert::failed_report_of` 把 core 的 ImportError 含 hint 逐字段搬过去),于是页面回到
  既有的「致命 —— 已回滚」结果块,不再落回入口态。
- convert 里把失败报告的组装收成一处(`failed_report` / `failed_report_of` → `failed_report_with`)。
- 证据:`a_failed_scan_still_reaches_the_screen_as_a_report` 断言**事件序列**
  `["scan-with-error", "report-failed"]` + 报告里 code/message 与预检同源(MultiMC
  `formatVersion: 2`,detect 认得、read 才失败);`a_clean_scan_does_not_emit_a_report` 是对照。
  变异验证:删掉那条 Report 发送 ⇒ 该用例红。

P3-1 写侧补条目数闸
- `WriteLimits` 增加 `entries`(生产口径 = `PACK_MAX_ENTRIES`);`preflight` 在**读盘之前**
  按「清单 1 条 + 覆盖文件 N 条」判 `count > limit`。
- 证据:`the_write_side_refuses_a_pack_over_the_unified_entry_count`(20001 条声明只放 1 个
  真文件 ⇒ 报 `pack_limits` 而不是 `pack_io`,即闸在读盘前)、
  `the_entry_count_gate_is_the_same_number_as_the_read_side`(两侧同一个数)。
  变异验证:注释掉 `guard_entries` 调用 ⇒ 前一条红。

P3-2 「内容相同」判据归一
- 删掉 `scan::judge` 里与 `merge::decide` 重复的 `disk.sha1 == pack_sha1` 早退,只留
  `decide().reason?`;两处文档互相指路(`merge.rs` 注明这是唯一判据)。
- 变异验证:把 `decide` 的相同分支改成"冲突" ⇒ **8 条**红(含 4 条 scan 用例与 merge 单测)。

P3-3/P3-4 口径与注释
- `TogglePick` 注释改成与代码一致(保留项留在表里,靠 `decisions()` 过滤)。
- `unknown_remote` 的计数与文案统一:判不了的两种来路(补全前没路径 / 有路径但缺 sha1)都算,
  注记载明两者;新增 `a_remote_entry_with_a_path_but_no_sha1_is_counted_as_unjudgeable`。

视觉(tower 看图给的三条)
- 冲突行两种取值都改成**同几何的 pill**(保留=描边、覆盖=tonal),各自右对齐进同宽度的槽 ⇒
  右边缘同一条线;路径列吃 `Fill` 把槽推到行右缘。
- 列表与动作行之间加 8dp 呼吸;分页行的 Previous/Next 用 `Space::Fill` 顶到右端,
  与内容列对齐(不再是右侧一大片留白)。
- 重新出图:C:/Temp/m160-shots/pick-light.png / pick-dark.png。

顺手
- `format/write.rs` 的单测拆到 `format/write/tests.rs`(纯搬移,守 400 行),并把里面两个
  自己拼 `%TEMP%` 路径的用例改用 `TempRoot`(断言红了也不留垃圾)。
- `import_decisions.rs` 的夹具拆到 `tests/decisions_support.rs`(纯搬移,守 400 行)。

证据:fmt 干净、clippy -D warnings 干净、`cargo test --workspace --all-targets -j 4`
= 2512 passed / 0 failed / **4 ignored**(51 个 test binary);跑前/跑后 `%TEMP%` 的
`ull-pack-*` 计数 1922→1922、`ull-pack-*-zip64-*` 0→0、`ull-import-*` 1360→1360(增量 0),
df 106G→106G 余量不变。
This commit is contained in:
worker-modpackpick authored and AskaEth committed 2026-11-07 13:52:44 +08:00
1 parent 4defc41e8d
commit d568cc6f37
14 files changed
+611 -257

No files matched your search

@@ -251,6 +251,33 @@ pub(super) fn failed_report(
label: &str,
message: &str,
) -> ImportReportView {
failed_report_with(
target,
pack,
ImportErrorView {
code: code.to_string(),
label: label.to_string(),
message: message.to_string(),
hint: None,
},
)
}
/// 一条 core 的 [`ImportError`] → 失败报告(**逐字段照搬**,含 `hint`)。
///
/// 预检失败(`scan.error`)走它:那些错本来就是 core 判出来的(`pack_traversal` /
/// `pack_limits` / `pack_metadata` …),没必要在宿主层再解释一遍,更不能把 `hint`
/// ("先装好游戏再导出"这种可执行下一步)丢掉——丢掉的话用户看到的就是"失败了,然后呢"。
pub(super) fn failed_report_of(
target: PathBuf,
pack: &Path,
error: &ImportError,
) -> ImportReportView {
failed_report_with(target, pack, error_view(error))
}
/// 组装:形状只此一处,文案由调用方给。
fn failed_report_with(target: PathBuf, pack: &Path, error: ImportErrorView) -> ImportReportView {
ImportReportView {
status: ImportStatusView::Failed,
format: None,
@@ -265,12 +292,7 @@ pub(super) fn failed_report(
missing: Vec::new(),
rejected: Vec::new(),
warnings: Vec::new(),
error: Some(ImportErrorView {
code: code.to_string(),
label: label.to_string(),
message: message.to_string(),
hint: None,
}),
error: Some(error),
registered: None,
bytes_written: 0,
rolled_back: true,
@@ -82,11 +82,29 @@ pub(super) fn spawn_scan(
if cancel.load(Ordering::SeqCst) {
return;
}
// 先发预检结果(`error` 也照原样带出去:调用方按"有没有冲突"分流要用它)。
send(
&tx,
epoch,
PackEvent::Scan(Box::new(convert::scan_view(&scan))),
);
// **预检自己没算出来 ⇒ 必须再发一条结果事件**(复审 r1 P2-1)。
//
// 只发 `Scan(error)` 的话,页面那边 `pick` 为空(它只由 `conflicts` 生成)、进度与报告
// 又被 `Scan` 清空 ⇒ 落回入口态,用户点完「导入」屏幕上一个字都不变(静默空转)。
// 走 `Report` 就回到 M160 之前那条既有形状:结果块的「致命 —— 已回滚」把原因画出来,
// UI 一行不用改;`code`/`label`/`message`/`hint` 全部从 `scan.error` 搬。
if let Some(error) = &scan.error {
send(
&tx,
epoch,
PackEvent::Report(Box::new(convert::failed_report_of(
target,
&request.pack,
error,
))),
);
}
});
}
@@ -183,3 +183,121 @@ fn the_scan_runs_without_writing_and_says_nothing_when_there_is_no_conflict() {
let _ = std::fs::remove_dir_all(&dir);
}
/// 事件序列的名字(断言"用户到底能不能看见"用)。
fn kind_of(event: &crate::dto::PackEvent) -> &'static str {
match event {
crate::dto::PackEvent::Progress(_) => "progress",
crate::dto::PackEvent::Scan(scan) if scan.error.is_some() => "scan-with-error",
crate::dto::PackEvent::Scan(_) => "scan",
crate::dto::PackEvent::Report(report) if report.error.is_some() => "report-failed",
crate::dto::PackEvent::Report(_) => "report",
crate::dto::PackEvent::Probe(_) => "probe",
crate::dto::PackEvent::Preview(_) => "preview",
crate::dto::PackEvent::Exported(_) => "exported",
crate::dto::PackEvent::ExportFailed(_) => "export-failed",
crate::dto::PackEvent::Cancelled => "cancelled",
crate::dto::PackEvent::Cleared => "cleared",
}
}
/// 跑一次 `spawn_scan`,把事件按顺序收回来。
fn run_scan(source: std::path::PathBuf, data: &std::path::Path) -> Vec<crate::dto::PackEvent> {
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 Rework".to_string()),
decisions: ConflictDecisionsView::default(),
};
super::super::import::spawn_scan(
env,
tx,
request,
3,
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
);
let mut events = Vec::new();
for task in rx.iter() {
if let crate::host::job::HostTask::Pack { event, epoch } = task {
assert_eq!(epoch, 3, "代次必须原样带回");
events.push(*event);
}
}
events
}
#[test]
fn a_failed_scan_still_reaches_the_screen_as_a_report() {
// 复审 r1 P2-1 的回归钉:**这条用例验的是"用户看得见",不是"字段被填了"**。
//
// 达标形状(旧行为 / M160 之前):`Import` 失败 → `PackEvent::Report` → 结果块画「已回滚」+ 原因。
// 出错形状(本 diff 引入的回归):只发 `Scan(error)` ⇒ 页面 pick 为空、report 被清空 ⇒ 入口态,
// 屏幕上什么都不发生。所以这里逐一断言**事件序列**,而不只看 `scan.error`。
//
// 可达面:detect 认得、read 才失败的包。这里用最普通的一种——MultiMC 的
// `formatVersion: 2`(Prism 自己也会整份丢弃,不是刻意构造的畸形包)。
let dir = temp_dir("scan-error");
let source = dir.join("Prism 2");
write(
&source.join("mmc-pack.json"),
br#"{"formatVersion": 2, "components": [
{"uid": "net.minecraft", "version": "1.20.1"}]}"#,
);
write(&source.join(".minecraft/mods/sodium.jar"), &[0x11; 32]);
let data = dir.join("data");
std::fs::create_dir_all(&data).expect("data root");
let events = run_scan(source, &data);
let kinds: Vec<&str> = events.iter().map(kind_of).collect();
assert_eq!(
kinds,
vec!["scan-with-error", "report-failed"],
"预检自己失败时必须再发一条结果事件(否则页面静默空转)"
);
let crate::dto::PackEvent::Scan(scan) = &events[0] else {
panic!("第一条必须是预检结果");
};
let error = scan.error.as_ref().expect("预检要带出错误");
assert_eq!(
error.code, "pack_metadata",
"formatVersion != 1 是清单自身非法"
);
assert!(
error.message.contains("formatVersion"),
"原因要能读:{}",
error.message
);
// 结果事件:三态是 Failed、原因与预检那份同源(code/label/message 都搬过来)。
let crate::dto::PackEvent::Report(report) = &events[1] else {
panic!("第二条必须是结果");
};
assert_eq!(report.status, crate::dto::ImportStatusView::Failed);
let reported = report.error.as_ref().expect("报告要带出原因");
assert_eq!(reported.code, error.code);
assert_eq!(reported.message, error.message);
assert!(!reported.label.is_empty(), "人读大类名也要有");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_clean_scan_does_not_emit_a_report() {
// 对照:干净预检只发一条 Scan(多发 Report 会把页面从"清单"顶到"结果")。
let dir = temp_dir("scan-clean");
let source = dir.join("Vanilla");
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; 32]);
let data = dir.join("data");
std::fs::create_dir_all(&data).expect("data root");
let kinds: Vec<&str> = run_scan(source, &data).iter().map(kind_of).collect();
assert_eq!(kinds, vec!["scan"], "干净预检只发一条");
let _ = std::fs::remove_dir_all(&dir);
}