From 47ed270293d8e274791a8a41b4bb105a565f58f5 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 7 Nov 2026 17:00:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(m162-r3):=20=E9=87=91=E8=A1=A8=E6=9D=A1?= =?UTF-8?q?=E6=95=B0=E4=B8=8E=E5=86=85=E5=AE=B9=E5=90=8C=E6=BA=90=EF=BC=88?= =?UTF-8?q?=E5=A0=B5=E4=BD=8F=E3=80=8C=E5=B0=91=E6=8C=82=E4=B8=80=E6=AE=B5?= =?UTF-8?q?=E4=BB=8D=E7=BB=BF=E3=80=8D=E7=9A=84=E5=81=87=E7=BB=BF=EF=BC=89?= =?UTF-8?q?+=20=E8=A1=A5=E4=B8=A4=E6=9D=A1=E8=A6=86=E7=9B=96=E7=BC=BA?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1(r3 抓到的假绿):`golden::len()` 从「各段 len() 相加」改成 **`rows().count()`**。 r3 实测的变异是:删掉 `rows()` 里的 `chain(tail2::ROWS)` 后 `split_keeps_every_translation` 仍绿——因为条数来自一处手工聚合(仍含 tail2 ⇒ 459 == `Key::ALL.len()`)而内容只遍历 `rows()`(少了 213 行)。现在条数与内容同源,漏挂一段当场对不上。 - 变异复现(删 chain):`rows().count()=246 len()=246 Key::ALL.len()=459` ⇒ 用例 **FAILED**(`left: 459 / right: 246`,「key 条数与拆分前不一致」); - 恢复后:`rows().count()=459 len()=459 Key::ALL.len()=459` ⇒ **ok**; - 断言一字未改(没有放宽成「只比条数」或「只比前缀」)。 顺带三条: 1. `golden.rs` 的 `rows()` 文档补上 `tail2`(并写明「加段必须也 chain 上,条数与内容只能从 这一条序列导出」),与上一条同族。 2. `online/mod.rs` 模块表里 `[complete]` 的职责改成「按摘要认文件(`identity_of`)」, 与「补全循环已删、生产在 `import::complete`」一致;批量 POST 那段也点明循环位置。 3. 补两条 pack 用例走不到的覆盖(`import/complete.rs` 的 `#[cfg(test)] mod tests`): - `an_entry_without_sha512_is_counted_not_guessed`:有 path、有 downloads、无 sha512 ⇒ `unfilled += 1` **且一次请求都不发**(mrpack 读者强制 sha512,这条是防御分支); - `an_existing_download_list_is_never_extended`:已有直链时 `downloads` **逐字相等** (长度 + 内容),不再用「包含」这种测不出重复追加的断言。 两条都验过能红:把「缺 sha512 不计数」与「总是追加直链」同时变异 ⇒ 两条 **FAILED**; 恢复后 **ok**。 登记项(r3 指出的 scan 注记)也顺手收了:`Completion::note` 在 `filled == 0` 时改说 「按 sha512 从 Modrinth 补全 sha1:一条也没补到(补不出 N 条)」,并且不再带 「其余条目照旧按『缺省保留』处置」(那是导入场景的尾缀,预检里没有这回事)。用例 `offline_with_nothing_filled_does_not_say_zero_items_were_filled` 与 `import_online::only_a_real_offline_stops_the_completion_round` 都钉住「真断网 + 一条没补到」 既不说「补全了 0 条」也不说「缺省保留」。 全仓行数(guards.yml::file-size 同口径):**0 个超限**。 --- crates/core/src/addon/import/complete.rs | 198 ++++++++++++++++++- crates/core/src/addon/online/mod.rs | 5 +- crates/core/src/addon/tests/import_online.rs | 10 + crates/i18n/src/catalog/golden.rs | 13 +- 4 files changed, 216 insertions(+), 10 deletions(-) diff --git a/crates/core/src/addon/import/complete.rs b/crates/core/src/addon/import/complete.rs index 802aefb..c19468e 100644 --- a/crates/core/src/addon/import/complete.rs +++ b/crates/core/src/addon/import/complete.rs @@ -48,12 +48,24 @@ impl Completion { if self.filled == 0 && !self.offline { return None; } - let mut message = format!( - "{lead}按 sha512 从 Modrinth 补全了 {} 条远端条目的 sha1(补不出 {} 条)", - self.filled, self.unfilled - ); + let mut message = if self.filled == 0 { + // 一条也没补到(只可能是断网):说「补全了 0 条」读起来像成功。 + format!( + "{lead}按 sha512 从 Modrinth 补全 sha1:一条也没补到(补不出 {} 条)", + self.unfilled + ) + } else { + format!( + "{lead}按 sha512 从 Modrinth 补全了 {} 条远端条目的 sha1(补不出 {} 条)", + self.filled, self.unfilled + ) + }; if self.offline { - message.push_str(";补全因未联网提前停止,其余条目照旧按「缺省保留」处置"); + message.push_str(if self.filled == 0 { + ";补全因未联网提前停止" + } else { + ";补全因未联网提前停止,其余条目照旧按「缺省保留」处置" + }); } Some(ImportIssue::new(keys::NOTE_REMOTE_COMPLETED, message)) } @@ -133,3 +145,179 @@ pub(crate) async fn complete(request: &ImportRequest<'_>, plan: &mut ImportPlan) fn is_sha1(hex: &str) -> bool { hex.len() == 40 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) } + +#[cfg(test)] +mod tests { + //! 覆盖 **pack 用例走不到**的两条分支(r3 复审指出的缺口): + //! `sha512` 缺失臂与「已有 downloads 不许追加第二个直链」。 + //! + //! mrpack 的读者要求 `hashes.sha512` 必填,所以「有 path 但没有 sha512」在真实文件里进不来 + //! ——它是**防御分支**。直接构造 `ImportPlan` 是唯一能让它变红的办法(不是放宽断言)。 + + use std::path::Path; + use std::sync::atomic::{AtomicU32, Ordering}; + + use super::{complete, Completion}; + use crate::addon::import::fetch::{ + FetchError, FetchFuture, FetchRequest, FetchedFile, ModrinthFileMeta, PackFetcher, + }; + use crate::addon::import::plan::{ImportPlan, PlanRemote}; + use crate::addon::import::report::FileSource; + use crate::addon::import::ImportRequest; + use crate::addon::pack::{PackFile, PackManifest}; + use crate::download::blocking::block_on; + + /// 只回答 `modrinth_file_by_sha512` 的假 fetcher(并记下被问了几次)。 + struct Lookup { + meta: Result, + asked: AtomicU32, + } + + impl PackFetcher for Lookup { + fn modrinth_file_by_sha512<'a>( + &'a self, + _sha512: &'a str, + ) -> FetchFuture<'a, Result> { + Box::pin(async move { + self.asked.fetch_add(1, Ordering::SeqCst); + match &self.meta { + Ok(meta) => Ok(meta.clone()), + Err(error) => Err(error.clone()), + } + }) + } + + fn fetch_file<'a>( + &'a self, + _request: FetchRequest<'a>, + ) -> FetchFuture<'a, Result> { + Box::pin(async move { Err(FetchError::unsupported("本用例不下载")) }) + } + } + + fn plan_with(file: PackFile) -> ImportPlan { + ImportPlan { + format: "modrinth".to_string(), + manifest: PackManifest::default(), + mc_version: "1.20.1".to_string(), + loader: None, + version_id: "1.20.1".to_string(), + rejected: Vec::new(), + notes: Vec::new(), + overrides: Vec::new(), + remote: vec![PlanRemote { + file, + path: Some("mods/no-sha1.jar".to_string()), + source: FileSource::Modrinth, + disabled: false, + }], + max_memory_mb: None, + } + } + + fn entry(sha512: Option<&str>, downloads: &[&str]) -> PackFile { + let mut file = PackFile::with_path("mods/no-sha1.jar"); + file.downloads = downloads.iter().map(|url| (*url).to_string()).collect(); + if let Some(sha512) = sha512 { + file.hashes.insert("sha512".to_string(), sha512.to_string()); + } + file + } + + fn meta(sha1: &str, url: &str) -> ModrinthFileMeta { + ModrinthFileMeta { + sha1: Some(sha1.to_string()), + size: Some(16), + url: Some(url.to_string()), + file_name: Some("no-sha1.jar".to_string()), + } + } + + #[test] + fn an_entry_without_sha512_is_counted_not_guessed() { + // 防御分支:有 path、有 downloads、**没有 sha512** ⇒ 无从反查,如实计数、不发请求。 + let lookup = Lookup { + meta: Ok(meta(&"a".repeat(40), "stub://from-source")), + asked: AtomicU32::new(0), + }; + let mut plan = plan_with(entry(None, &["stub://original"])); + let request = ImportRequest::new(Path::new("pack.mrpack"), Path::new("instance")) + .with_fetcher(&lookup); + let summary = block_on(complete(&request, &mut plan)); + assert_eq!(summary.filled, 0); + assert_eq!(summary.unfilled, 1, "缺 sha512 要计入「补不出」"); + assert_eq!( + lookup.asked.load(Ordering::SeqCst), + 0, + "没有摘要就不许发请求" + ); + assert_eq!(plan.remote[0].file.sha1(), None, "补不出就是补不出,不许猜"); + } + + #[test] + fn an_existing_download_list_is_never_extended() { + // 已有直链 ⇒ 站点给的直链**不许**再追加一条(列表必须逐字相同)。 + let lookup = Lookup { + meta: Ok(meta(&"a".repeat(40), "stub://from-source")), + asked: AtomicU32::new(0), + }; + let mut plan = plan_with(entry(Some(&"c".repeat(128)), &["stub://original"])); + let request = ImportRequest::new(Path::new("pack.mrpack"), Path::new("instance")) + .with_fetcher(&lookup); + let summary = block_on(complete(&request, &mut plan)); + assert_eq!(summary.filled, 1); + assert_eq!(plan.remote[0].file.sha1(), Some("a".repeat(40).as_str())); + assert_eq!( + plan.remote[0].file.downloads, + vec!["stub://original".to_string()], + "已有直链时不许追加第二个(长度与内容都要逐字相同)" + ); + assert_eq!(lookup.asked.load(Ordering::SeqCst), 1); + } + + #[test] + fn offline_with_nothing_filled_does_not_say_zero_items_were_filled() { + let note = Completion { + filled: 0, + unfilled: 3, + offline: true, + } + .note("预检前") + .expect("断网要留一条注记"); + assert!( + note.message.contains("一条也没补到"), + "断网且一条没补到时不许说「补全了 0 条」:{}", + note.message + ); + assert!( + !note.message.contains("补全了 0 条"), + "文案读起来像成功:{}", + note.message + ); + assert!( + !note.message.contains("缺省保留"), + "预检场景没有「缺省保留」这回事:{}", + note.message + ); + + // 真的补到过东西时,仍是原来那句(并带上「其余条目按缺省保留」)。 + let note = Completion { + filled: 2, + unfilled: 1, + offline: true, + } + .note("") + .expect("补到过就要留注记"); + assert!(note.message.contains("补全了 2 条")); + assert!(note.message.contains("缺省保留")); + + // 没补到、也没断网(例如所有摘要站点都不认得)⇒ 不占一行。 + assert!(Completion { + filled: 0, + unfilled: 2, + offline: false + } + .note("") + .is_none()); + } +} diff --git a/crates/core/src/addon/online/mod.rs b/crates/core/src/addon/online/mod.rs index 74b2f8a..d548b17 100644 --- a/crates/core/src/addon/online/mod.rs +++ b/crates/core/src/addon/online/mod.rs @@ -24,7 +24,8 @@ //! `reqwest::Client`(与 `addon::import::fetch` 同一条纪律)。 //! //! 没有 POST:Modrinth 的批量接口(`POST /v2/version_files`)需要 POST 请求体,而引擎的 -//! `DownloadRequest` 没有请求体字段。补全路径因此按**单摘要 GET** 逐条查(见 [`complete`]), +//! `DownloadRequest` 没有请求体字段。补全路径因此按**单摘要 GET** 逐条查(循环在 +//! `crate::addon::import::complete`,本模块只用 [`identity_of`] 认文件), //! mrpack 里缺 `sha1` 的条目本来就是少数,不值得为它引入第二条 HTTP 路径。 //! //! # 离线不是「空结果」 @@ -43,7 +44,7 @@ //! | [`transport`] | HTTP 出口 trait + 生产实现(走下载引擎)+ 错误分类 | //! | [`modrinth`] | 端点拼装与响应解析(纯逻辑,测试直接喂录制 JSON) | //! | [`download`] | 取下载地址之后那一步:引擎下载 + **sha1/sha512 复核** + 重试 + 不留半截文件 | -//! | [`complete`] | 远端条目补全:缺 `sha1` 的 mrpack 条目按 `sha512` 反查 | +//! | [`complete`] | 按摘要认文件([`identity_of`]):站点返回的版本里哪条才是我们要的那个 | pub mod complete; pub mod download; diff --git a/crates/core/src/addon/tests/import_online.rs b/crates/core/src/addon/tests/import_online.rs index 8855e10..8b03811 100644 --- a/crates/core/src/addon/tests/import_online.rs +++ b/crates/core/src/addon/tests/import_online.rs @@ -219,6 +219,16 @@ fn only_a_real_offline_stops_the_completion_round() { assert!(scan.is_ok(), "{:?}", scan.error); assert_eq!(scan.unknown_remote, 2, "没联网 ⇒ 两条都判不了"); assert!(scan.conflicts.is_empty()); + // 断网且一条也没补到:注记不许说「补全了 0 条」(读起来像成功),也不许带「缺省保留」 + // ——那句是导入场景的尾缀,预检里没有这回事(r3 登记项)。 + let note = scan + .notes + .iter() + .find(|note| note.message.contains("sha512")) + .expect("断网要留一条补全注记"); + assert!(note.message.contains("一条也没补到"), "{}", note.message); + assert!(!note.message.contains("补全了 0 条"), "{}", note.message); + assert!(!note.message.contains("缺省保留"), "{}", note.message); } /// 清单自带 `sha1` 时补全不许插手(也不许改写清单里的摘要)。 diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 166d720..ddc258a 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -61,10 +61,12 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("InstancesLaunchReset", "Reset to global", "恢复跟随全局"), ]; -/// 金表的**全量**行(三段:本文件 + [`tail`] + [`tail2`],顺序即 `Key::ALL`)。 +/// 金表的**全量**行(本文件 → [`tail`] → [`tail2`],顺序即 `Key::ALL`)。 /// -/// 拆成两段只为守 400 行硬线(M157 加了 19 行启动参数文案后越线);对调用方是**一条**序列, +/// 拆成三段只为守 400 行硬线(M157/M159 往金表追加文案后越线);对调用方是**一条**序列, /// `catalog/tests.rs` 的逐字节断言不用关心分段。 +/// +/// ⚠️ 加段时**必须**在这里也 chain 上:条数与内容都只能从这一条序列导出(见 [`len`])。 pub(super) fn rows() -> impl Iterator { GOLDEN .iter() @@ -73,8 +75,13 @@ pub(super) fn rows() -> impl Iterator usize { - GOLDEN.len() + tail::ROWS.len() + tail2::ROWS.len() + rows().count() } mod tail;