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:
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);
|
||||
}
|
||||
Reference in new issue
Block a user