diff --git a/crates/app-iced/src/app/update/content_pack.rs b/crates/app-iced/src/app/update/content_pack.rs index 8df03b7..fc875fd 100644 --- a/crates/app-iced/src/app/update/content_pack.rs +++ b/crates/app-iced/src/app/update/content_pack.rs @@ -124,7 +124,10 @@ impl App { self.view_dirty = true; } PackMessage::TogglePick(path) => { - // 点上就翻到另一侧;翻回"保留"时把这条从表里删掉(表里只有"偏离缺省档"的项) + // 点一下就翻到另一侧,并把结果**原样记进表里**(保留也记)。 + // 表里为什么保留"保留"项:整批覆盖之后单独放回某几条时,「保留」必须能盖住 + // 整批档;等到发请求时 `PackPageState::decisions` 再把"与缺省档相同"的项过滤掉 + // (所以"一次都没点过"仍然等于 `KeepAll`)。 let choice = self.content.pack.pick_choice(&path); let flipped = match choice { ConflictChoiceView::KeepUser => ConflictChoiceView::Overwrite, diff --git a/crates/app-iced/src/pages/content/packui/view/pick.rs b/crates/app-iced/src/pages/content/packui/view/pick.rs index 28e7ef5..17d86cf 100644 --- a/crates/app-iced/src/pages/content/packui/view/pick.rs +++ b/crates/app-iced/src/pages/content/packui/view/pick.rs @@ -27,7 +27,7 @@ //! [`super::super::model::PAGE_SIZE`] 条以内,翻页只是换数据。 use iced::widget::{column, container, row}; -use iced::{Alignment, Background, Border, Element, Length}; +use iced::{Alignment, Background, Border, Element, Length, Padding}; use unlucky_design::color::Role; use unlucky_design::icons::IconKind; @@ -47,6 +47,10 @@ use super::super::model::{Pick, PickRow}; use super::super::PackMessage; use super::intent; +/// 处置按钮槽的宽度(px):取最宽状态「⟳ Overwrite」的实际占用再留余量, +/// 保证「Keep」与「Overwrite」两种状态的右边缘落在同一条线上。 +const TOGGLE_SLOT: f32 = 132.0; + /// 整块。 pub(super) fn pick_block<'a>( ui: &'a Ui, @@ -132,20 +136,28 @@ pub(super) fn pick_block<'a>( 375.0, )); + // 列表与动作行之间留出与其它区块一致的呼吸(复审 r1 视觉 2):滚到底时最后一行会紧贴 + // 动作行,靠这层 padding 把两者分开(直接调列 spacing 会把行间距也一起拉开)。 block = block.push( - row![ - buttons::filled( - ui, - Button::new(pick.confirm.as_str(), intent(PackMessage::ConfirmImport)) - .leading(IconKind::Check), - ), - buttons::text( - ui, - Button::new(pick.cancel, intent(PackMessage::CancelImport)), - ), - ] - .spacing(ui.space(Space::X2)) - .align_y(Alignment::Center), + container( + row![ + buttons::filled( + ui, + Button::new(pick.confirm.as_str(), intent(PackMessage::ConfirmImport)) + .leading(IconKind::Check), + ), + buttons::text( + ui, + Button::new(pick.cancel, intent(PackMessage::CancelImport)), + ), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center), + ) + .padding(Padding { + top: ui.space(Space::X2), + ..Padding::ZERO + }), ); container(block) @@ -193,6 +205,17 @@ fn pager<'a>(ui: &'a Ui, _catalog: Catalog, pick: &'a Pick) -> Element<'a, PageM Button::new(pick.next, intent(PackMessage::PickPage(pick.page))), ) }; + // 分页行与行内控件共用同一条右锚点(复审 r1 视觉 3):区间文案靠左,两个按钮用 + // `horizontal_space` 顶到右边,于是「Previous · Next」的右边缘与下面每一行的处置槽对齐。 + // 两个按钮进一个**右对齐的槽**,槽的右边缘就是行的右边缘 —— 于是它们的右端与下面每行的 + // 处置槽落在同一条线上(按钮自身的内边距也一起对齐,不会差那十几像素)。 + let buttons_slot = container( + row![previous, next] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center), + ) + .width(Length::Fixed(TOGGLE_SLOT * 2.0)) + .align_x(Alignment::End); row![ frame::text_role( ui, @@ -201,8 +224,8 @@ fn pager<'a>(ui: &'a Ui, _catalog: Catalog, pick: &'a Pick) -> Element<'a, PageM pick.range.as_str(), ui.color(Role::OnSurfaceVariant), ), - previous, - next, + iced::widget::Space::new().width(Length::Fill), + buttons_slot, ] .spacing(ui.space(Space::X2)) .align_y(Alignment::Center) @@ -215,17 +238,22 @@ fn pager<'a>(ui: &'a Ui, _catalog: Catalog, pick: &'a Pick) -> Element<'a, PageM /// 为什么两行:单行里塞四个字段(路径 / 来源 / 体积 / 按钮)会把路径压成一条窄柱, /// 出图实测每行折成四五段——扫读成本比两行高得多。 fn pick_row<'a>(ui: &'a Ui, item: &'a PickRow) -> Element<'a, PageMessage> { + // 两种取值都是**同一种几何**(等高的 pill),只是填充不同: + // · 当前=覆盖 ⇒ **tonal 填充**(这一行会被盖掉,是"待反悔"的那一档) + // · 当前=保留 ⇒ **描边**(缺省档;点了才会覆盖) + // 两种 pill 再各自右对齐进同宽度的槽,于是**右边缘落在同一条线**(复审 r1 视觉 1 的必修项)—— + // 之前一边是描边 pill、一边是无容器的文字按钮,宽窄与右端都不一致。 let toggle = if item.overwrites() { - // 已经是覆盖 ⇒ 用描边按钮(弱化:覆盖是更危险的那一侧),字面写"点了会退回保留" - buttons::outlined( + buttons::tonal( ui, Button::new( item.toggle, intent(PackMessage::TogglePick(item.path.clone())), - ), + ) + .leading(IconKind::Refresh), ) } else { - buttons::text( + buttons::outlined( ui, Button::new( item.toggle, @@ -234,19 +262,29 @@ fn pick_row<'a>(ui: &'a Ui, item: &'a PickRow) -> Element<'a, PageMessage> { .leading(IconKind::Refresh), ) }; - let head = row![ - crate::pages::frame::text_role( - ui, - TypeRole::BodySmall, - false, - item.path.as_str(), - ui.color(Role::OnSurface), - ), - toggle, - ] - .spacing(ui.space(Space::X2)) - .align_y(Alignment::Center) + // 路径那半列必须自己吃掉剩余宽度(`Length::Fill`),否则处置槽会紧跟文字、离右边缘一大截, + // 与分页行的右锚点对不上(出图实测:行内按钮停在 x≈680、分页按钮在 x≈780)。 + let path_slot = container(frame::text_role( + ui, + TypeRole::BodySmall, + false, + item.path.as_str(), + ui.color(Role::OnSurface), + )) .width(Length::Fill); + // 处置按钮自己那一格:定宽 + 右对齐 ⇒ 两种取值的 pill 右边缘落在同一条线上。 + // 右侧再留 8dp:滚动条就贴在内容右边缘(出图实测 x≈791),pill 紧贴着它显得挤。 + let toggle_slot = container(toggle) + .width(Length::Fixed(TOGGLE_SLOT)) + .align_x(Alignment::End) + .padding(Padding { + right: ui.space(Space::X2), + ..Padding::ZERO + }); + let head = row![path_slot, toggle_slot] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center) + .width(Length::Fill); let detail = frame::text_role_owned( ui, diff --git a/crates/core/src/addon/import/merge.rs b/crates/core/src/addon/import/merge.rs index b3e7c39..27ec720 100644 --- a/crates/core/src/addon/import/merge.rs +++ b/crates/core/src/addon/import/merge.rs @@ -76,7 +76,11 @@ pub fn decide(pack_sha1: &str, base_sha1: Option<&str>, disk_sha1: Option<&str>) // 磁盘上没有这个文件。 (None, None) => MergeDecision::plain(MergeAction::Install), (None, Some(_)) => MergeDecision::conflict(ConflictReason::UserDeleted), - // 磁盘上有。 + // 磁盘上有、内容与包内**逐字节相同**(同一份 SHA-1)。 + // + // 这是"内容相同"的**唯一判据**:预检(`super::scan::judge`)刻意不再自己写一遍 + // `disk == pack` 的早退(复审 r1 P3-2),而是走这里的 `reason == None`。改这一行 + // 会同时影响"导入怎么处置"与"预检列不列这条",所以两边的测试都会红。 (Some(disk), _) if disk == pack_sha1 => MergeDecision::plain(MergeAction::SkipIdentical), (Some(_), None) => MergeDecision::conflict(ConflictReason::PreExisting), (Some(disk), Some(base)) if disk == base => MergeDecision::plain(MergeAction::Install), diff --git a/crates/core/src/addon/import/report/keys.rs b/crates/core/src/addon/import/report/keys.rs index 5352818..e92f160 100644 --- a/crates/core/src/addon/import/report/keys.rs +++ b/crates/core/src/addon/import/report/keys.rs @@ -53,7 +53,7 @@ pub const NOTE_ENGINE_ARTIFACTS_DROPPED: &str = "pack.import.note.engine_artifac pub const NOTE_ROLLBACK_INCOMPLETE: &str = "pack.import.note.rollback_incomplete"; /// 注记:同包另一条声明遮蔽了这个路径(说清谁赢、两份内容是否相同)。 pub const NOTE_OVERRIDE_SHADOWED: &str = "pack.import.note.override_shadowed"; -/// 注记:这些远端条目补全前没有路径,预检无法预判它们会不会撞上用户已有文件。 +/// 注记:这些远端条目预检判不了(补全前没有路径 / 有路径但缺 sha1 声明),无法预判冲突。 pub const NOTE_REMOTE_PATH_UNKNOWN: &str = "pack.import.note.remote_path_unknown"; /// 缺失:调用方没给 fetcher(离线导入)。 diff --git a/crates/core/src/addon/import/scan.rs b/crates/core/src/addon/import/scan.rs index 52340b7..87eb0be 100644 --- a/crates/core/src/addon/import/scan.rs +++ b/crates/core/src/addon/import/scan.rs @@ -26,7 +26,8 @@ //! mrpack 的 `files[]` 自带 `path` + `hashes.sha1`,所以能在这里判;判的是"**声明的**摘要 vs //! 磁盘",与导入时"下载下来再校验"是同一份期望值。CurseForge 的 `files[]` 只有 //! `projectID/fileID`,补全前**没有路径**,无从判起 ⇒ 不猜、不进清单,只把条数记进 -//! [`ImportScan::unknown_remote`](界面据此说"还有 N 条补全后才能知道会不会撞车")。 +//! [`ImportScan::unknown_remote`](界面据此说"还有 N 条补全后才能知道会不会撞车")。计数与 +//! 文案同一口径:**判不了**的两种来路(补全前没有路径 / 有路径但缺 `hashes.sha1`)都算。 //! //! # 便宜在哪、贵在哪 //! @@ -98,7 +99,11 @@ pub struct ImportScan { pub loader: Option, /// 冲突清单(按路径排序)。 pub conflicts: Vec, - /// 补全前没有路径、因而无法预判的远端条目数(CF `files[]` 的常态)。 + /// 预检阶段**判不了**的远端条目数。 + /// + /// 判不了有两种来路,计数把两者都算上(口径与注记文案一致,复审 r1 P3-4): + /// ①CurseForge `files[]` 补全前**没有路径**(常态);②极少数 mrpack 条目**有路径但缺 + /// `hashes.sha1`**,没有声明摘要就没法与磁盘现状比对。 pub unknown_remote: usize, /// 注记(`.disabled` / 可选条目 / 基线口径 / 被遮蔽的条目……与导入报告同源)。 pub notes: Vec, @@ -207,7 +212,7 @@ pub fn scan_pack(request: &ImportRequest<'_>) -> ImportScan { } for entry in &plan.remote { let (Some(path), Some(declared)) = (entry.path.as_deref(), entry.file.sha1()) else { - // 没有路径(CF 待补全)或没有声明摘要 ⇒ 判不了,如实计数。 + // 两种来路都算「判不了」:没有路径(CF 待补全)或没有声明摘要(缺 `hashes.sha1`)。 scan.unknown_remote += 1; continue; }; @@ -232,7 +237,7 @@ pub fn scan_pack(request: &ImportRequest<'_>) -> ImportScan { scan.notes.push(ImportIssue::new( keys::NOTE_REMOTE_PATH_UNKNOWN, format!( - "有 {} 条远端条目补全前没有路径(CurseForge files[]),预检判不出它们会不会撞上已有文件;导入时仍按「缺省保留」处置", + "有 {} 条远端条目预检判不了:CurseForge files[] 补全前没有路径,个别条目有路径但没有 sha1 声明;这些条目会不会撞上已有文件只能等补全/下载后才知道,导入时仍按「缺省保留」处置", scan.unknown_remote ), )); @@ -243,6 +248,16 @@ pub fn scan_pack(request: &ImportRequest<'_>) -> ImportScan { } /// 判一个路径:`Some` = 要用户点头的冲突。 +/// +/// # 「内容相同」的判据**只此一处**:[`merge::decide`] +/// +/// 这里**故意不再写** `if disk.sha1 == pack_sha1 { return None }`(复审 r1 P3-2 抓到的重复判据): +/// 那种早退与 `merge::decide` 的 `SkipIdentical` 分支是同一件事的两次实现,改一处漏一处的后果 +/// 就是"预检说没冲突、导入却判 SkipIdentical"或者反过来。 +/// +/// 现在只有一条链路:`decide` 给出 `MergeDecision`,**没有 `reason` 就不是冲突** +/// (`SkipIdentical` 与 `Install` 都是 `reason == None`),`?` 把它一步交代掉。 +/// 于是"内容相同 ⇒ 不进清单"这条结论的源头是 `merge.rs` 的那张表——与导入用的是同一张。 fn judge( path: &str, source: FileSource, @@ -254,10 +269,9 @@ fn judge( ) -> Option { let disk = disk_state(&target.join(path))?; // 包内那条的摘要来自 M110 读包(`override_files` 边读边算),与导入时现算的是同一份字节。 - if disk.sha1 == pack_sha1 { - return None; - } let base_sha1 = baseline.sha1(path).map(str::to_string); + // 唯一判据:`reason` 为 `None` 就是"不是冲突"(内容相同 → `SkipIdentical`; + // 磁盘上没有 → `Install`,而那一条 `disk_state` 已经先返回 `None` 了)。 let decision = merge::decide(pack_sha1, base_sha1.as_deref(), Some(&disk.sha1)); let reason = decision.reason?; Some(ImportConflict { diff --git a/crates/core/src/addon/pack/format/write.rs b/crates/core/src/addon/pack/format/write.rs index 4ed3430..5f637cf 100644 --- a/crates/core/src/addon/pack/format/write.rs +++ b/crates/core/src/addon/pack/format/write.rs @@ -131,6 +131,15 @@ fn preflight( payload: PackPayload<'_>, limits: WriteLimits, ) -> PackResult { + // **条目数闸**(读侧同一条:`PACK_MAX_ENTRIES`)。放在读盘之前:超 2 万条时连一次 + // `metadata()` 都不必做,也顺手证明"闸在碰内容之前"。清单条目自己也占一条。 + let declared_entries = 1 + manifest + .overrides + .iter() + .map(|group| group.files.len()) + .fold(0_usize, usize::saturating_add); + guard_entries(declared_entries, limits.entries)?; + let mut total = json_len; let mut entries: Vec<(String, u64)> = Vec::new(); for group in &manifest.overrides { @@ -163,6 +172,8 @@ pub(super) struct WriteLimits { pub(super) total: u64, /// 单条目上限。 pub(super) entry: u64, + /// 条目数上限(**与读侧同一个数**:清单条目 + 每个覆盖文件)。 + pub(super) entries: usize, } impl WriteLimits { @@ -171,16 +182,37 @@ impl WriteLimits { Self { total: super::super::archive::limits::PACK_MAX_UNCOMPRESSED_BYTES, entry: super::super::archive::limits::PACK_MAX_ENTRY_BYTES, + entries: super::super::archive::limits::PACK_MAX_ENTRIES, } } /// 显式给值(测试用:把边界缩小到可测尺寸)。 #[cfg(test)] - pub(super) const fn new(total: u64, entry: u64) -> Self { - Self { total, entry } + pub(super) const fn new(total: u64, entry: u64, entries: usize) -> Self { + Self { + total, + entry, + entries, + } } } +/// 条目数闸:与读侧的 `PACK_MAX_ENTRIES` **同一份口径**。 +/// +/// 读侧在 `archive::open` 里对"中央目录条数"判 `>= limit` 就拒;写侧产出的是"清单 + N 个覆盖 +/// 文件",比读侧少掉目录条目,所以这里按同一侧的口径判(`count > limit` 拒,`== limit` 许可, +/// 与读侧能读回的最大值一致)。没有这道闸时,一个内容目录超过 2 万条的实例**导得出来、 +/// 自己的导入器拒读**——正是"M152 遗留的限额不衔接"那条。 +fn guard_entries(count: usize, entries_limit: usize) -> PackResult<()> { + if count > entries_limit { + return Err(PackError::limits(format!( + "包内条目数 {count} 超过统一上限 {entries_limit}(导入读取用的也是这个数)" + )) + .with_hint("把内容目录里多余的文件移出实例后再导出;单文件与总量上限也是同一份口径")); + } + Ok(()) +} + /// 单条目闸:与读侧的 `PACK_MAX_ENTRY_BYTES` **同一份口径**。 /// /// 没有这道闸时,"导出 100 MiB 的覆盖文件 → 自己的导入器拒读"是能真实发生的(读侧把一条 @@ -250,148 +282,4 @@ fn guard_with(total: u64, limit: u64) -> PackResult<()> { } #[cfg(test)] -mod tests { - use super::*; - use crate::addon::pack::manifest::{OverrideFile, PackOverrides, PackSide}; - - #[test] - fn the_threshold_is_reached_not_exceeded() { - // `>=` 而不是 `>`:恰好等于阈值就已经需要 ZIP64 了。 - assert!(guard_with(99, 100).is_ok()); - let error = guard_with(100, 100).expect_err("等于阈值必须拒"); - assert_eq!( - error.kind(), - super::super::super::error::PackErrorKind::Limits - ); - assert!(error.message().contains("100"), "{error}"); - assert!(guard_with(101, 100).is_err()); - } - - #[test] - fn the_second_gate_counts_from_the_manifest_length_not_the_preflight_total() { - // P2-1 的窗口:真实总量在 [上限一半, 上限) 之间的包**不许**被拒。这一条不真写 - // 几百 MiB(那要几十秒 deflate 与一大片内存/页面文件),而是直接把窗口里的字节数 - // 喂给第二道闸的计数器——它的起点是清单长度,所以只有"把预检的全量 total 当基数" - // 那种写法才会在这里红(红法是 `spend` 返回 Limits)。 - let limit = WriteLimits::production().total; - let inside = limit / 2 + limit / 4; - let mut written = Written::new(28, limit); - written.spend(inside).expect("没到统一上限,第二道闸不许拒"); - assert!(inside > limit / 2, "样本要落在过拒窗口里"); - - // 对照:撞上上限必须拒,且正好在上限处(不是超一点点才拒)。 - let mut written = Written::new(28, limit); - let error = written.spend(limit).expect_err("撞上限必须拒"); - assert_eq!( - error.kind(), - super::super::super::error::PackErrorKind::Limits - ); - } - - #[test] - fn the_container_does_not_double_count_the_preflight_total() { - // 容器层的窗口回归钉(P2-1),**用几十字节的 limit 把窗口缩小到可测尺寸**: - // `json` 30 字节 + 覆盖文件 40 字节 = 声明总量 70。 - // - 正确实现:第二道闸从 30 起算,写到 70 也不到 100 ⇒ 导出成功; - // - P2-1 的写法(把预检返回的全量 70 当基数):写循环再累加 40 ⇒ 110 ≥ 100 ⇒ 误拒。 - // 真造 2.5 GB 才能暴露同一个 bug,本机资源纪律下不跑(那条留成 `zip64.rs` 里的 - // `#[ignore]` 用例)。 - let root = std::env::temp_dir().join(format!("ull-double-count-{}", std::process::id())); - std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录"); - std::fs::write(root.join("overrides/mods/a.bin"), [0_u8; 40]).expect("写覆盖文件"); - // 清单很短,limit 取"声明总量 + 20 字节"的余量:正确实现只写到 70,重复累计会写到 110。 - let json = "{\"name\":\"0123456789012345\"}"; - let declared = json.len() as u64 + 40; - let limit = declared + 20; - let mut manifest = PackManifest::new("double count"); - manifest.overrides.push(PackOverrides { - dir: "overrides".to_string(), - side: PackSide::Both, - files: vec![OverrideFile { - path: "mods/a.bin".to_string(), - sha1: String::new(), - size: 40, - }], - }); - let target = root.join("out.zip"); - - container( - "curseforge", - "manifest.json", - json, - &manifest, - PackPayload::from_root(&root), - &target, - WriteLimits::new(limit, 1024), - ) - .expect("声明总量没到 limit:写循环不许把预检的 total 重复累计"); - - // 对照:limit 降到声明总量本身 ⇒ 预检就该拒,且临时文件都没建。 - let refused = root.join("refused.zip"); - let error = container( - "curseforge", - "manifest.json", - json, - &manifest, - PackPayload::from_root(&root), - &refused, - WriteLimits::new(declared, 1024), - ) - .expect_err("声明总量达到 limit 必须拒"); - assert_eq!( - error.kind(), - super::super::super::error::PackErrorKind::Limits - ); - assert!(!refused.exists(), "预检失败时目标路径不许出现"); - let _ = std::fs::remove_dir_all(&root); - } - - #[test] - fn the_preflight_measures_declared_sizes_not_content() { - // 预检必须只量 `metadata().len()`:这一条用"小文件 + 3 GiB 稀疏文件"钉住, - // 真读内容的话要么慢要么直接吃 3 GiB 内存。 - let root = std::env::temp_dir().join(format!("ull-preflight-{}", std::process::id())); - std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录"); - std::fs::write(root.join("overrides/mods/small.jar"), b"0123456789").expect("写小文件"); - let huge = root.join("overrides/mods/huge.jar"); - let file = std::fs::File::create(&huge).expect("建稀疏文件"); - file.set_len(3 * 1024 * 1024 * 1024).expect("拉长(稀疏)"); - drop(file); - - let mut manifest = PackManifest::new("preflight"); - manifest.overrides.push(PackOverrides { - dir: "overrides".to_string(), - side: PackSide::Both, - files: vec![ - OverrideFile { - path: "mods/small.jar".to_string(), - sha1: String::new(), - size: 10, - }, - OverrideFile { - path: "mods/huge.jar".to_string(), - sha1: String::new(), - size: 0, - }, - ], - }); - - let total = preflight( - 7, - &manifest, - PackPayload::from_root(&root), - // 本条只关心"预检不读内容",所以两道闸都放到最宽(生产口径见 `tests/limits.rs`)。 - WriteLimits::new( - crate::addon::pack::archive::limits::PACK_ZIP64_BYTES_THR, - crate::addon::pack::archive::limits::PACK_ZIP64_BYTES_THR, - ), - ) - .expect("3 GiB 总量仍在最宽闸之下,预检不许误伤"); - assert_eq!( - total, - 7 + 10 + 3 * 1024 * 1024 * 1024, - "清单 + 每个覆盖文件的声明尺寸" - ); - let _ = std::fs::remove_dir_all(&root); - } -} +mod tests; diff --git a/crates/core/src/addon/pack/format/write/tests.rs b/crates/core/src/addon/pack/format/write/tests.rs new file mode 100644 index 0000000..e705e1f --- /dev/null +++ b/crates/core/src/addon/pack/format/write/tests.rs @@ -0,0 +1,150 @@ +//! `format/write.rs` 的单测:**从 `write.rs` 拆出来守 400 行硬线**。 +//! +//! 拆分口径与仓库其它拆分一致:**纯搬移**,断言与注释一行不改(只在外面加一层 `mod tests;`)。 +//! 这些用例钉的是「闸在碰内容之前」「边界在 `>=` 上」「写循环不许重复累计」三件事。 + +use super::*; +use crate::addon::pack::manifest::{OverrideFile, PackOverrides, PackSide}; +use crate::addon::temp::TempRoot; + +#[test] +fn the_threshold_is_reached_not_exceeded() { + // `>=` 而不是 `>`:恰好等于阈值就已经需要 ZIP64 了。 + assert!(guard_with(99, 100).is_ok()); + let error = guard_with(100, 100).expect_err("等于阈值必须拒"); + assert_eq!( + error.kind(), + super::super::super::error::PackErrorKind::Limits + ); + assert!(error.message().contains("100"), "{error}"); + assert!(guard_with(101, 100).is_err()); +} + +#[test] +fn the_second_gate_counts_from_the_manifest_length_not_the_preflight_total() { + // P2-1 的窗口:真实总量在 [上限一半, 上限) 之间的包**不许**被拒。这一条不真写 + // 几百 MiB(那要几十秒 deflate 与一大片内存/页面文件),而是直接把窗口里的字节数 + // 喂给第二道闸的计数器——它的起点是清单长度,所以只有"把预检的全量 total 当基数" + // 那种写法才会在这里红(红法是 `spend` 返回 Limits)。 + let limit = WriteLimits::production().total; + let inside = limit / 2 + limit / 4; + let mut written = Written::new(28, limit); + written.spend(inside).expect("没到统一上限,第二道闸不许拒"); + assert!(inside > limit / 2, "样本要落在过拒窗口里"); + + // 对照:撞上上限必须拒,且正好在上限处(不是超一点点才拒)。 + let mut written = Written::new(28, limit); + let error = written.spend(limit).expect_err("撞上限必须拒"); + assert_eq!( + error.kind(), + super::super::super::error::PackErrorKind::Limits + ); +} + +#[test] +fn the_container_does_not_double_count_the_preflight_total() { + // 容器层的窗口回归钉(P2-1),**用几十字节的 limit 把窗口缩小到可测尺寸**: + // `json` 30 字节 + 覆盖文件 40 字节 = 声明总量 70。 + // - 正确实现:第二道闸从 30 起算,写到 70 也不到 100 ⇒ 导出成功; + // - P2-1 的写法(把预检返回的全量 70 当基数):写循环再累加 40 ⇒ 110 ≥ 100 ⇒ 误拒。 + // 真造 2.5 GB 才能暴露同一个 bug,本机资源纪律下不跑(那条留成 `zip64.rs` 里的 + // `#[ignore]` 用例)。 + // 临时根用 `TempRoot`(drop 即删、panic 也删):这两个用例以前自己拼 `%TEMP%` 路径、 + // 只在**正常收尾**时删,断言一红就留下 `ull-double-count-*`(M160 r2 的泄漏口径)。 + let root = TempRoot::new("double-count"); + std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录"); + std::fs::write(root.join("overrides/mods/a.bin"), [0_u8; 40]).expect("写覆盖文件"); + // 清单很短,limit 取"声明总量 + 20 字节"的余量:正确实现只写到 70,重复累计会写到 110。 + let json = "{\"name\":\"0123456789012345\"}"; + let declared = json.len() as u64 + 40; + let limit = declared + 20; + let mut manifest = PackManifest::new("double count"); + manifest.overrides.push(PackOverrides { + dir: "overrides".to_string(), + side: PackSide::Both, + files: vec![OverrideFile { + path: "mods/a.bin".to_string(), + sha1: String::new(), + size: 40, + }], + }); + let target = root.join("out.zip"); + + container( + "curseforge", + "manifest.json", + json, + &manifest, + PackPayload::from_root(&root), + &target, + WriteLimits::new(limit, 1024, 16), + ) + .expect("声明总量没到 limit:写循环不许把预检的 total 重复累计"); + + // 对照:limit 降到声明总量本身 ⇒ 预检就该拒,且临时文件都没建。 + let refused = root.join("refused.zip"); + let error = container( + "curseforge", + "manifest.json", + json, + &manifest, + PackPayload::from_root(&root), + &refused, + WriteLimits::new(declared, 1024, 16), + ) + .expect_err("声明总量达到 limit 必须拒"); + assert_eq!( + error.kind(), + super::super::super::error::PackErrorKind::Limits + ); + assert!(!refused.exists(), "预检失败时目标路径不许出现"); +} + +#[test] +fn the_preflight_measures_declared_sizes_not_content() { + // 预检必须只量 `metadata().len()`:这一条用"小文件 + 3 GiB 稀疏文件"钉住, + // 真读内容的话要么慢要么直接吃 3 GiB 内存。 + let root = TempRoot::new("preflight"); + std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录"); + std::fs::write(root.join("overrides/mods/small.jar"), b"0123456789").expect("写小文件"); + let huge = root.join("overrides/mods/huge.jar"); + let file = std::fs::File::create(&huge).expect("建稀疏文件"); + file.set_len(3 * 1024 * 1024 * 1024).expect("拉长(稀疏)"); + drop(file); + + let mut manifest = PackManifest::new("preflight"); + manifest.overrides.push(PackOverrides { + dir: "overrides".to_string(), + side: PackSide::Both, + files: vec![ + OverrideFile { + path: "mods/small.jar".to_string(), + sha1: String::new(), + size: 10, + }, + OverrideFile { + path: "mods/huge.jar".to_string(), + sha1: String::new(), + size: 0, + }, + ], + }); + + let total = preflight( + 7, + &manifest, + PackPayload::from_root(&root), + // 本条只关心"预检不读内容",所以三道闸都放到最宽(生产口径见 `tests/limits.rs`)。 + WriteLimits::new( + crate::addon::pack::archive::limits::PACK_ZIP64_BYTES_THR, + crate::addon::pack::archive::limits::PACK_ZIP64_BYTES_THR, + crate::addon::pack::archive::limits::PACK_MAX_ENTRIES, + ), + ) + .expect("3 GiB 总量仍在最宽闸之下,预检不许误伤"); + assert_eq!( + total, + 7 + 10 + 3 * 1024 * 1024 * 1024, + "清单 + 每个覆盖文件的声明尺寸" + ); +} diff --git a/crates/core/src/addon/pack/tests/limits.rs b/crates/core/src/addon/pack/tests/limits.rs index 90250c1..99cbca6 100644 --- a/crates/core/src/addon/pack/tests/limits.rs +++ b/crates/core/src/addon/pack/tests/limits.rs @@ -51,6 +51,57 @@ fn sparse_file(path: &std::path::Path, length: u64) { file.set_len(length).expect("拉长文件(稀疏)"); } +#[test] +fn the_write_side_refuses_a_pack_over_the_unified_entry_count() { + // 写侧以前只有总量与单条两道闸:内容目录超过 2 万条的实例**导得出来、自己拒读** + // (读侧 `archive::open` 的「条目数超过上限」)。这条钉住第三道闸,以及它挡在**读盘之前**。 + let root = TempRoot::new("limits-entries"); + write_file(&root.join("overrides/mods/a.jar"), b"x"); + let target = root.join("out.zip"); + + // ① 对照:条目数远低于上限时不许误伤。 + CURSEFORGE + .write( + &manifest_of(vec![override_file("mods/a.jar")]), + PackPayload::from_root(&root), + &target, + ) + .expect("单个覆盖条目必须写得出来"); + let _ = std::fs::remove_file(&target); + + // ② 声明 20001 条(上限 20000 + 1):只有**第一条**的文件真的存在,其余 2 万条谁都不在盘上。 + // 报出来的必须是 `pack_limits` 而不是 `pack_io` —— 那就是"条目数闸在读第一个文件之前"的证据 + // (真去 `metadata()` 的话,第二个文件就已经报 `pack_io` 了)。 + let mut files = vec![override_file("mods/a.jar")]; + for index in 1..=PACK_MAX_ENTRIES { + files.push(override_file(&format!("mods/{index:05}.jar"))); + } + assert_eq!(files.len(), PACK_MAX_ENTRIES + 1, "上限 + 1 条"); + let error = CURSEFORGE + .write(&manifest_of(files), PackPayload::from_root(&root), &target) + .expect_err("超过条目数上限必须拒"); + assert_eq!(error.kind(), PackErrorKind::Limits, "{error}"); + assert!( + error.message().contains(&PACK_MAX_ENTRIES.to_string()), + "文案要给出统一上限那个数(读侧用的是同一个):{error}" + ); + assert!( + error.hint().is_some_and(|hint| hint.contains("移出实例")), + "要给可执行的下一步:{error:?}" + ); + assert!(!target.exists(), "预检失败时目标路径不许出现"); +} + +#[test] +fn the_entry_count_gate_is_the_same_number_as_the_read_side() { + // 「三个常量同源」的写侧那一半:生产口径的条目数上限必须**就是**读侧那个常量。 + // 改坏它会先在 `pick` 这层红,而不是等某个用户导出 2 万条内容的实例才发现。 + let production = crate::addon::pack::archive::limits::PACK_MAX_ENTRIES; + let read_side = PackLimits::default().max_entries; + assert_eq!(production, read_side, "写侧与读侧必须是同一个数"); + assert_eq!(read_side, 20_000, "上限本身也钉住(改数值要显式改这里)"); +} + #[test] fn the_three_limits_form_one_chain_and_the_read_side_uses_them() { // ① 关系:单条 ≤ 总量 < ZIP64 ≤ u32。前一半保证"能写出来的包读得回来", diff --git a/crates/core/src/addon/tests/decisions_support.rs b/crates/core/src/addon/tests/decisions_support.rs new file mode 100644 index 0000000..249a1de --- /dev/null +++ b/crates/core/src/addon/tests/decisions_support.rs @@ -0,0 +1,69 @@ +//! `import_decisions` 的夹具与断言小工具:**从 `import_decisions.rs` 拆出来守 400 行硬线**。 +//! +//! 拆分口径与仓库其它拆分一致:**纯搬移**,内容一行不改。这些夹具本身也是"口径"的一部分—— +//! 叠层(`client-overrides/` 胜 `overrides/`)、目标里已存在的用户内容、以及"内容与包内一致" +//! 的那一份,都是冲突判定的边界样本。 + +use super::support::{sha1, StubFetcher}; +use super::{write_file, zip_dir, TempRoot}; +use crate::addon::import::ImportScan; + +/// 夹具里 `client-overrides/` 胜出的那份内容(`overrides/` 里同名文件是不同的内容)。 +pub(super) const KNOWN_CLIENT: &[u8] = b"shared = client\n"; +/// 夹具里被遮蔽的那份(`overrides/` 侧)。 +pub(super) const KNOWN_OVERRIDES: &[u8] = b"shared = overrides\n"; +/// 与目标内容**一致**的那个文件。 +pub(super) const SAME: &[u8] = b"same = 1\n"; +/// 只有 overrides 声明、目标里还有一个不同内容的同名文件。 +pub(super) const LAYER: &[u8] = b"layer from overrides\n"; +/// 目标里用户自己的内容(会被覆盖 / 被保留的那一侧)。 +pub(super) const USER_KNOWN: &[u8] = b"shared = mine\n"; +/// 目标里用户自己的 layer 内容。 +pub(super) const USER_LAYER: &[u8] = b"my layer\n"; + +/// 一份带 **overrides 叠层** 的 mrpack(`client-overrides/` 与 `overrides/` 同名不同内容)+ 一条远端条目。 +pub(super) fn layered_pack(tag: &str) -> (TempRoot, StubFetcher) { + // 返回值 `Deref` 到 `pack.mrpack`,根由它持有(`src/` 在 `root.parent()` 下)。 + let root = TempRoot::at(tag, "pack.mrpack"); + let dir = root.parent().expect("临时根").join("src"); + let remote = b"remote jar bytes"; + let index = format!( + r#"{{"game":"minecraft","formatVersion":1,"versionId":"1.0.0","name":"M160 夹具", + "files":[{{"path":"mods/remote.jar", + "hashes":{{"sha1":"{}","sha512":"{}"}}, + "downloads":["stub://mods/remote.jar"],"fileSize":{}}}], + "dependencies":{{"minecraft":"1.20.1"}}}}"#, + sha1(remote), + "0".repeat(128), + remote.len() + ); + write_file(&dir.join("modrinth.index.json"), index.as_bytes()); + // 叠层:同一路径两份声明,client-overrides 胜(Modrinth 官方语义)。 + write_file(&dir.join("overrides/config/known.toml"), KNOWN_OVERRIDES); + write_file( + &dir.join("client-overrides/config/known.toml"), + KNOWN_CLIENT, + ); + // 内容与目标一致的那个(不在叠层里,来源 = overrides)。 + write_file(&dir.join("overrides/config/same.toml"), SAME); + write_file(&dir.join("overrides/mods/layer.jar"), LAYER); + zip_dir(&dir, &root); + let fetcher = StubFetcher::new().with_file("stub://mods/remote.jar", remote); + (root, fetcher) +} + +/// 预置一个"用户已有文件"的实例目录。 +pub(super) fn seeded(tag: &str) -> TempRoot { + let target = TempRoot::under(tag, "instance"); + write_file(&target.join("config/known.toml"), USER_KNOWN); + write_file(&target.join("config/same.toml"), SAME); + write_file(&target.join("mods/layer.jar"), USER_LAYER); + target +} + +pub(super) fn paths(scan: &ImportScan) -> Vec<&str> { + scan.conflicts + .iter() + .map(|conflict| conflict.path.as_str()) + .collect() +} diff --git a/crates/core/src/addon/tests/import_decisions.rs b/crates/core/src/addon/tests/import_decisions.rs index e5ec7db..4b913ae 100644 --- a/crates/core/src/addon/tests/import_decisions.rs +++ b/crates/core/src/addon/tests/import_decisions.rs @@ -15,74 +15,17 @@ //! //! 全部离线:远端走 [`support::StubFetcher`],没有一条网络请求。 -use super::support::{mrpack_stub, sha1, StubFetcher}; +use super::decisions_support::{ + layered_pack, paths, seeded, KNOWN_CLIENT, SAME, USER_KNOWN, USER_LAYER, +}; +use super::support::{mrpack_stub, sha1}; use super::{read_text, write_file, zip_dir, TempRoot}; use crate::addon::import::{ import_pack, scan_pack, ConflictChoice, ConflictDecisions, ConflictReason, FileAction, - FileSource, ImportRequest, ImportScan, ImportStatus, + FileSource, ImportRequest, ImportStatus, }; use crate::download::blocking::block_on; -/// 夹具里 `client-overrides/` 胜出的那份内容(`overrides/` 里同名文件是不同的内容)。 -const KNOWN_CLIENT: &[u8] = b"shared = client\n"; -/// 夹具里被遮蔽的那份(`overrides/` 侧)。 -const KNOWN_OVERRIDES: &[u8] = b"shared = overrides\n"; -/// 与目标内容**一致**的那个文件。 -const SAME: &[u8] = b"same = 1\n"; -/// 只有 overrides 声明、目标里还有一个不同内容的同名文件。 -const LAYER: &[u8] = b"layer from overrides\n"; -/// 目标里用户自己的内容(会被覆盖 / 被保留的那一侧)。 -const USER_KNOWN: &[u8] = b"shared = mine\n"; -/// 目标里用户自己的 layer 内容。 -const USER_LAYER: &[u8] = b"my layer\n"; - -/// 一份带 **overrides 叠层** 的 mrpack(`client-overrides/` 与 `overrides/` 同名不同内容)+ 一条远端条目。 -fn layered_pack(tag: &str) -> (TempRoot, StubFetcher) { - // 返回值 `Deref` 到 `pack.mrpack`,根由它持有(`src/` 在 `root.parent()` 下)。 - let root = TempRoot::at(tag, "pack.mrpack"); - let dir = root.parent().expect("临时根").join("src"); - let remote = b"remote jar bytes"; - let index = format!( - r#"{{"game":"minecraft","formatVersion":1,"versionId":"1.0.0","name":"M160 夹具", - "files":[{{"path":"mods/remote.jar", - "hashes":{{"sha1":"{}","sha512":"{}"}}, - "downloads":["stub://mods/remote.jar"],"fileSize":{}}}], - "dependencies":{{"minecraft":"1.20.1"}}}}"#, - sha1(remote), - "0".repeat(128), - remote.len() - ); - write_file(&dir.join("modrinth.index.json"), index.as_bytes()); - // 叠层:同一路径两份声明,client-overrides 胜(Modrinth 官方语义)。 - write_file(&dir.join("overrides/config/known.toml"), KNOWN_OVERRIDES); - write_file( - &dir.join("client-overrides/config/known.toml"), - KNOWN_CLIENT, - ); - // 内容与目标一致的那个(不在叠层里,来源 = overrides)。 - write_file(&dir.join("overrides/config/same.toml"), SAME); - write_file(&dir.join("overrides/mods/layer.jar"), LAYER); - zip_dir(&dir, &root); - let fetcher = StubFetcher::new().with_file("stub://mods/remote.jar", remote); - (root, fetcher) -} - -/// 预置一个"用户已有文件"的实例目录。 -fn seeded(tag: &str) -> TempRoot { - let target = TempRoot::under(tag, "instance"); - write_file(&target.join("config/known.toml"), USER_KNOWN); - write_file(&target.join("config/same.toml"), SAME); - write_file(&target.join("mods/layer.jar"), USER_LAYER); - target -} - -fn paths(scan: &ImportScan) -> Vec<&str> { - scan.conflicts - .iter() - .map(|conflict| conflict.path.as_str()) - .collect() -} - #[test] fn scan_lists_only_files_that_exist_and_differ() { let (pack, _) = layered_pack("dec-scan"); @@ -338,6 +281,41 @@ fn curseforge_entries_without_a_path_are_counted_not_guessed() { assert!(paths(&scan).is_empty(), "{:?}", paths(&scan)); } +#[test] +fn a_remote_entry_with_a_path_but_no_sha1_is_counted_as_unjudgeable() { + // 复审 r1 P3-4:`unknown_remote` 的**计数口径**与注记文案要同一件事。 + // 这一条只放 `hashes` 里的 sha512、**不写 sha1**:有路径但没有声明摘要 ⇒ 预检没法比对磁盘, + // 也就不该假装它"没冲突",而是要计入 unknown_remote 并如实注记。 + let root = TempRoot::new("dec-no-sha1"); + let dir = root.join("src"); + let index = format!( + r#"{{"game":"minecraft","formatVersion":1,"versionId":"1.0.0","name":"缺 sha1", + "files":[{{"path":"mods/no-sha1.jar", + "hashes":{{"sha512":"{}"}}, + "downloads":["stub://mods/no-sha1.jar"],"fileSize":16}}], + "dependencies":{{"minecraft":"1.20.1"}}}}"#, + "0".repeat(128) + ); + write_file(&dir.join("modrinth.index.json"), index.as_bytes()); + let pack = root.join("pack.mrpack"); + zip_dir(&dir, &pack); + let target = root.join("instance"); + + let scan = scan_pack(&ImportRequest::new(&pack, &target)); + assert!(scan.is_ok(), "{:?}", scan.error); + assert_eq!(scan.unknown_remote, 1, "有路径但缺 sha1 ⇒ 判不了,要计数"); + let note = scan + .notes + .iter() + .find(|note| note.message_key == crate::addon::import::keys::NOTE_REMOTE_PATH_UNKNOWN) + .expect("要如实注记"); + assert!( + note.message.contains("sha1"), + "文案要把「缺 sha1 声明」这条来路说清楚(计数口径与文案一致):{}", + note.message + ); +} + #[test] fn a_broken_pack_reports_the_error_instead_of_panicking() { let root = TempRoot::new("dec-broken"); diff --git a/crates/core/src/addon/tests/mod.rs b/crates/core/src/addon/tests/mod.rs index 8bd6dbb..b385a35 100644 --- a/crates/core/src/addon/tests/mod.rs +++ b/crates/core/src/addon/tests/mod.rs @@ -17,6 +17,7 @@ // `fixtures.rs` 被 `addon::pack::tests` 与本目录**同时**编译(同一份手造包只维护一处), // 所以 clippy 的 `duplicate_mod` 在这里是刻意为之:改成两份副本才是真的错。 +mod decisions_support; #[allow(clippy::duplicate_mod)] #[path = "../pack/tests/fixtures.rs"] mod fixtures; diff --git a/crates/presenter/src/host/addon/pack/convert.rs b/crates/presenter/src/host/addon/pack/convert.rs index 75aa85e..a319f59 100644 --- a/crates/presenter/src/host/addon/pack/convert.rs +++ b/crates/presenter/src/host/addon/pack/convert.rs @@ -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, diff --git a/crates/presenter/src/host/addon/pack/import.rs b/crates/presenter/src/host/addon/pack/import.rs index 031b8cf..8854b35 100644 --- a/crates/presenter/src/host/addon/pack/import.rs +++ b/crates/presenter/src/host/addon/pack/import.rs @@ -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, + ))), + ); + } }); } diff --git a/crates/presenter/src/host/addon/pack/tests/scan.rs b/crates/presenter/src/host/addon/pack/tests/scan.rs index e2ececc..681f6fb 100644 --- a/crates/presenter/src/host/addon/pack/tests/scan.rs +++ b/crates/presenter/src/host/addon/pack/tests/scan.rs @@ -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 { + 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); +}