fix(m162-r2): 金表沿用主线的拆分边界 + phase/pipeline 回到 400 行内

金表(rebase 到 M161 后重写这一段):**采用 main 的拆分边界**——`golden/tail.rs`
(`InstancesLaunchReset` 之后、`PackTargetNew` 之前)+ `golden/tail2.rs`
(`PackInstanceName` 之后),不再保留本分支原来那套 205/221 的切法。
本分支新增的 6 条 key(`ContentOffline{Title,Body}`、`ContentDetail{Fetching,FetchFailed,
VersionCount,VersionLine}`)按 `Key::ALL` 的顺序并入 main 的 `tail.rs`;
`golden.rs` 的 `rows()` 保持 `GOLDEN + tail + tail2` 三段串联。(`len()` 与内容同源的修正
在下一个提交里。)

其余两处(本来就是我推过线的,现在回到红线内):
- `import/phase.rs` 397 → **400**:只动注释(`Resolved` 的两个摘要字段共用一句、
  `resolve`/`settle` 的文档各并一行),代码语义零改动。
- `import/pipeline.rs` 399 → **400**:把补全注记(14 行)搬到 `import/complete.rs` 的
  `Completion::note(lead)` 旁边,管线只剩一行 `complete_and_note(...)`;
  `scan_pack_online` 也改用同一个 `note("预检前")`——注记文案由两处变一处。

同时删除与 `import/complete.rs` 重复的 `online::complete::{complete_files, CompletionSummary}`
(生产零调用方),只留生产在用的 `identity_of`。
This commit is contained in:
AskaEth committed 2026-11-07 17:37:13 +08:00
1 parent b394316df4
commit ecd24f0608
12 files changed
+249 -262

No files matched your search

+37 -2
View File
@@ -12,8 +12,10 @@
//! 1. **只补 sha1 / 字节数 / 缺的直链**,绝不改写清单里已有的值(清单说多大就是多大);
//! 2. **CurseForge 条目不走这里**:它们连路径都没有,补全要 `projectID/fileID` 端点(需要 key),
//! 那条路仍是 `PackFetcher::curse_file` 的活;
//! 3. **离线立刻停**:第一条网络类失败之后剩下的条目一律算「补不出」,不拿 N 次超时惩罚用户
//! (与 `online::complete` 同一条纪律,只是这里换成按路径过滤的导入视图)。
//! 3. **只有「没联网」才立刻停**:第一条 [`FetchErrorKind::Offline`] 之后剩下的条目一律算
//! 「补不出」,不拿 N 次超时惩罚用户;**404 / 5xx 不停**——真实 mrpack 的文件大多托管在
//! CurseForge CDN,Modrinth 的 `version_file` 对它们必然 404,把「补不出」当「没联网」
//! 会让「能补就补」只在第一条恰好托管在 Modrinth 时成立(r1 P1-1 后果 B)。
//!
//! # 为什么要改 `plan.remote` 而不是 `manifest.files`
//!
@@ -22,6 +24,7 @@
use super::fetch::FetchErrorKind;
use super::plan::ImportPlan;
use super::report::{keys, ImportIssue, ImportReport};
use super::ImportRequest;
/// 一次补全的结果。
@@ -35,6 +38,38 @@ pub(crate) struct Completion {
pub(crate) offline: bool,
}
impl Completion {
/// 补全结果 → 报告注记(`None` = 既没补到、也没因断网停下,不必占一行)。
///
/// `lead` 是场景前缀(导入用空串、预检用「预检前」):**文案只此一处**,两个入口不许
/// 各写一句——r1 抓到的「404 却写因未联网提前停止」正是两处文案各自演进的后果。
/// 现在 `offline` 只由真 `Offline` 置位,这句话只会在真断网时出现。
pub(crate) fn note(&self, lead: &str) -> Option<ImportIssue> {
if self.filled == 0 && !self.offline {
return None;
}
let mut message = format!(
"{lead}按 sha512 从 Modrinth 补全了 {} 条远端条目的 sha1(补不出 {} 条)",
self.filled, self.unfilled
);
if self.offline {
message.push_str(";补全因未联网提前停止,其余条目照旧按「缺省保留」处置");
}
Some(ImportIssue::new(keys::NOTE_REMOTE_COMPLETED, message))
}
}
/// 补全 + 把注记写进报告(导入管线唯一的入口:一行调用,文案与计数都在本模块)。
pub(crate) async fn complete_and_note(
request: &ImportRequest<'_>,
plan: &mut ImportPlan,
report: &mut ImportReport,
) {
if let Some(note) = complete(request, plan).await.note("") {
report.warnings.push(note);
}
}
/// 跑一次补全(就地改 `plan.remote`)。
pub(crate) async fn complete(request: &ImportRequest<'_>, plan: &mut ImportPlan) -> Completion {
let mut state = Completion::default();
+3 -5
View File
@@ -225,13 +225,12 @@ pub(crate) async fn fetch_remote(
struct Resolved {
path: String,
url: String,
sha1: Option<String>,
sha1: Option<String>, // 两档摘要各自可能缺(CF 只有 sha1、mrpack 可能只有 sha512)
sha512: Option<String>,
size: Option<u64>,
}
/// 把条目补全成"有路径、有地址"。
///
/// `Ok(None)` = 这条补不出来(进 `missing`);`Err` = 致命(本地 IO / 内容不符)。
async fn resolve(
request: &ImportRequest<'_>,
@@ -278,8 +277,8 @@ async fn resolve(
Ok(Some(Resolved {
path: format!("{dir}/{}", meta.file_name),
url,
// CF 官方只给 sha1,这一档没有可核的期望值。
sha1: meta.sha1,
// CF 侧没有 sha512 可核(官方只给 sha1);空着就是「不校验这一档」。
sha512: None,
size: meta.size,
}))
@@ -288,8 +287,7 @@ async fn resolve(
/// 合并判定 + 决策 → 实际动作。
///
/// `picked` 是这一次的处置(逐项决策或整批档,见 [`super::decision::ConflictDecisions`]):
/// 它**只对冲突生效**——`Install` / `SkipIdentical` 两个非冲突结论不受它影响,所以"用户点错
/// 一个保留"不会把一份全新文件漏掉。
/// 它**只对冲突生效**——`Install` / `SkipIdentical` 不受它影响,所以"用户点错一条保留"不会漏装。
fn settle(
decision: merge::MergeDecision,
disk_exists: bool,
+1 -15
View File
@@ -48,21 +48,7 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
Err(error) => return abandoned(report, error),
};
account_plan(&mut report, &plan);
// M162:远端条目补全(缺 `sha1` 的 mrpack 条目按 `sha512` 反查)。补不出的仍如实计数,
// 因此这一步**不影响**三态,只是让后面的摘要校验与合并判定有期望值可用。
let completion = super::complete::complete(request, &mut plan).await;
if completion.filled > 0 || completion.offline {
let mut message = format!(
"按 sha512 从 Modrinth 补全了 {} 条远端条目的 sha1(补不出 {} 条)",
completion.filled, completion.unfilled
);
if completion.offline {
message.push_str(";补全因未联网提前停止,其余条目照旧按「缺省保留」处置");
}
report
.warnings
.push(ImportIssue::new(keys::NOTE_REMOTE_COMPLETED, message));
}
super::complete::complete_and_note(request, &mut plan, &mut report).await;
let baseline = {
let (baseline, warnings) = load_baseline(request.target, &plan);
+2 -10
View File
@@ -204,16 +204,8 @@ pub async fn scan_pack_online(request: &ImportRequest<'_>) -> ImportScan {
};
let completion = super::complete::complete(request, &mut plan).await;
fill(request, &plan, &mut scan);
if completion.filled > 0 {
let mut message = format!(
"预检前按 sha512 从 Modrinth 补全了 {} 条远端条目的 sha1(补不出 {} 条)",
completion.filled, completion.unfilled
);
if completion.offline {
message.push_str(";补全因未联网提前停止");
}
scan.notes
.push(ImportIssue::new(keys::NOTE_REMOTE_COMPLETED, message));
if let Some(note) = completion.note("预检前") {
scan.notes.push(note);
}
scan
}
+3 -4
View File
@@ -48,10 +48,9 @@ pub use loader::{
LoaderSource, LoaderSupport,
};
pub use online::{
complete_files, download_verified, ArtifactFetcher, CompletionSummary, ContentType, Downloaded,
EngineArtifactFetcher, EngineTransport, Modrinth, OnlineError, OnlineErrorKind, ProjectDetail,
ProjectHit, SearchPage, SearchQuery, SortIndex, Transport, Version, VersionFile, VersionQuery,
MODRINTH_API,
download_verified, ArtifactFetcher, ContentType, Downloaded, EngineArtifactFetcher,
EngineTransport, Modrinth, OnlineError, OnlineErrorKind, ProjectDetail, ProjectHit, SearchPage,
SearchQuery, SortIndex, Transport, Version, VersionFile, VersionQuery, MODRINTH_API,
};
pub use pack::{
EnvSupport, PackArchive, PackError, PackErrorKind, PackFile, PackFormat, PackLimits,
+81 -87
View File
@@ -1,89 +1,29 @@
//! 远端条目补全:**mrpack 的 `files[]` 只有 `path`+`downloads`+`hashes` 时,按摘要反查补 sha1**。
//! 按摘要认文件:[`identity_of`] 判断「站点返回的这个版本里,哪个文件才是我们要的那个」。
//!
//! # 补的是什么(以及为什么不许猜)
//! # 为什么只留这一件
//!
//! mrpack 规范里 `files[].hashes.sha512` 必填、`sha1` 可选。少了 `sha1` 的条目在 M160 的预检里
//! 判不了「会不会覆盖用户文件」(判据是 SHA-1),只能记进 `ImportScan::unknown_remote`。
//! 现在按 `sha512` 去 Modrinth 反查:查得到就把**站点给的 sha1**填回去(那是同一个文件在同一
//! 站点上的官方摘要,不是本地推算),查不到就**保持缺**、照旧计数——绝不「猜一个路径填上」,
//! 也绝不拿 sha512 冒充 sha1。
//! 生产上的补全循环只有**一处**:`crate::addon::import::complete`(它走 `PackFetcher`,
//! 因此能区分「站点不认得这个摘要(404)」与「没联网(Offline)」,这决定了要不要提前收工)。
//! 这里曾经还有一份 `complete_files` 循环,r2 复审指出「看着像生产代码其实没人调、两套循环
//! 迟早漂移」⇒ 已删除。本文件只留**判定**,两个调用点共用:
//!
//! # 一致性检查(反查结果必须能对上)
//! | 调用方 | 用它做什么 |
//! |--------|------------|
//! | `addon::import::fetch`(生产补全) | 按 sha512 反查回来的版本 → 取出那份文件的官方 sha1/大小/直链 |
//! | `online::complete` 的用例 | 钉住「对不上就不许用」 |
//!
//! 站点返回的版本里可能不止一个文件(`primary` 之外还有 `sources.jar`)。所以只在
//! **`hashes.sha512` 与请求值逐字相同**的那个文件上取 `sha1`;一个都对不上就当「补不出」
//! ——宁可老实计数,也不把别的东西的摘要装进来。
//! # 一致性比「有值」重要
//!
//! # 离线时立刻停
//!
//! 无网时逐条重试是拿 N 次超时惩罚用户。第一条网络类失败就把 [`CompletionSummary::offline`]
//! 置位并停止请求,剩下的条目一律算「补不出」(预检的注记本来就会说「有 N 条判不了」)。
//! 站点返回的版本里可能有多个文件(`primary` 之外还有 `sources.jar`)。只在
//! **`hashes.sha512` 与请求值逐字相同**的那条上取 `sha1`;一个都对不上就返回 `None`
//! ——宁可让上游记「补不出」,也不把别的东西的摘要装进来。
use super::model::Version;
use super::modrinth::{HashAlgorithm, Modrinth};
use super::transport::Transport;
use crate::addon::pack::PackFile;
/// 一次补全的结果(**只做计数与一句事实,不改任何判定口径**)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct CompletionSummary {
/// 补出了 sha1 的条目数
pub completed: usize,
/// 补不出的条目数(缺 sha512 / 站点查不到 / 摘要对不上)
pub unfilled: usize,
/// 是否因为「没联网」提前收工
pub offline: bool,
}
impl CompletionSummary {
/// 有没有真的补到东西。
#[must_use]
pub const fn changed(&self) -> bool {
self.completed > 0
}
}
/// 逐条补 `sha1`(就地改 `files`,返回值只做计数)。
pub async fn complete_files<T: Transport + ?Sized>(
client: &Modrinth<'_, T>,
files: &mut [PackFile],
) -> CompletionSummary {
let mut summary = CompletionSummary::default();
for file in files.iter_mut() {
if file.sha1().is_some() {
// 已经有 sha1:不需要补(也不该覆盖清单里的声明)。
continue;
}
let Some(sha512) = file.sha512().map(str::to_string) else {
summary.unfilled += 1;
continue;
};
if summary.offline {
summary.unfilled += 1;
continue;
}
match client.version_by_hash(HashAlgorithm::Sha512, &sha512).await {
Ok(version) => match identity_of(&version, &sha512) {
Some(identity) => {
apply(file, &identity);
summary.completed += 1;
}
None => summary.unfilled += 1,
},
Err(error) => {
if error.is_offline() {
summary.offline = true;
}
summary.unfilled += 1;
}
}
}
summary
}
/// 从版本里找出**摘要对得上**的那个文件,取出它的 sha1/大小/地址。
///
/// `None` = 这个版本里没有任何文件的 sha512 与请求值相同(站点数据不一致 / 版本被换过)。
/// `None` = 这个版本里没有任何文件的 sha512 与请求值相同(站点数据不一致 / 版本被换过),
/// 或者对上的那条没有 sha1 可用。
#[must_use]
pub fn identity_of(version: &Version, sha512: &str) -> Option<Identity> {
let file = version.files.iter().find(|file| {
@@ -109,17 +49,71 @@ pub struct Identity {
pub url: Option<String>,
}
/// 把补全结果写回条目:**只填空缺,不覆盖清单里已有的值**。
fn apply(file: &mut PackFile, identity: &Identity) {
file.hashes
.entry("sha1".to_string())
.or_insert_with(|| identity.sha1.clone());
if file.size.is_none() {
file.size = identity.size;
}
if file.downloads.iter().all(|url| url.trim().is_empty()) {
if let Some(url) = &identity.url {
file.downloads.push(url.clone());
#[cfg(test)]
mod tests {
use super::identity_of;
use crate::addon::online::model::{Version, VersionFile};
fn version(files: Vec<VersionFile>) -> Version {
Version {
id: "v1".to_string(),
project_id: "p1".to_string(),
files,
..Version::default()
}
}
fn file(sha1: Option<&str>, sha512: &str, primary: bool) -> VersionFile {
let mut file = VersionFile {
url: "https://cdn.modrinth.com/data/p1/v1/a.jar".to_string(),
filename: "a.jar".to_string(),
size: 2048,
primary,
..VersionFile::default()
};
file.hashes.insert("sha512".to_string(), sha512.to_string());
if let Some(sha1) = sha1 {
file.hashes.insert("sha1".to_string(), sha1.to_string());
}
file
}
#[test]
fn the_matching_file_wins_even_when_it_is_not_the_primary() {
let wanted = "a".repeat(128);
let other = "b".repeat(128);
let version = version(vec![
file(Some(&"1".repeat(40)), &other, true),
file(Some(&"2".repeat(40)), &wanted, false),
]);
let identity = identity_of(&version, &wanted).expect("摘要对得上,必须给出身份");
assert_eq!(identity.sha1, "2".repeat(40));
assert_eq!(identity.size, Some(2048));
assert!(identity.url.is_some());
}
#[test]
fn a_version_without_that_hash_yields_nothing() {
let version = version(vec![file(Some(&"1".repeat(40)), &"b".repeat(128), true)]);
assert_eq!(identity_of(&version, &"c".repeat(128)), None);
}
#[test]
fn a_matching_file_without_a_sha1_is_not_usable() {
// 站点只给了 sha512:这时没有可填的 sha1,宁可「补不出」也不许拿 sha512 冒充。
let wanted = "a".repeat(128);
let version = version(vec![file(None, &wanted, true)]);
assert_eq!(identity_of(&version, &wanted), None);
}
#[test]
fn hex_case_does_not_matter() {
let wanted = "AbCd".repeat(32);
let version = version(vec![file(
Some(&"1".repeat(40)),
&wanted.to_ascii_lowercase(),
true,
)]);
assert!(identity_of(&version, &wanted).is_some());
}
}
+5 -3
View File
@@ -30,8 +30,10 @@
//! # 离线不是「空结果」
//!
//! 无网时上层必须能说清「未联网」而不是「没有匹配的内容」,所以错误是枚举不是字符串:
//! 网络类失败一律映射成 [`OnlineErrorKind::NotConnected`](见 [`OnlineError`]),
//! presenter 再把它翻成 DTO 的 `ContentFailureKind::Offline`。
//! 引擎把叶子成因(404 / 5xx / 网络)放在 `DownloadError::leaf` 上,[`OnlineError`] 按它映射
//! 成 `NotFound` / `Server` / `NotConnected`(见 `transport::From<DownloadError>` 的映射表),
//! presenter 再翻成 DTO 的 `ContentFailureKind::{Offline,NotFound,Server}`。
//! 没有叶子时保守归到 `NotConnected`——宁可说「检查网络」,也不声称「没有匹配的内容」。
//!
//! # 模块
//!
@@ -49,7 +51,7 @@ pub mod model;
pub mod modrinth;
pub mod transport;
pub use complete::{complete_files, CompletionSummary};
pub use complete::identity_of;
pub use download::{
digest_file, download_verified, ArtifactFetcher, Digest, Downloaded, EngineArtifactFetcher,
FetchArgs, RETRY_ATTEMPTS,
+35
View File
@@ -168,3 +168,38 @@ fn a_broken_payload_is_malformed_not_empty() {
let error = block_on(client.search(&query("sodium"))).expect_err("形状不对必须报错");
assert_eq!(error.kind(), OnlineErrorKind::Malformed);
}
// ------------------------------------------------ 按摘要反查(补全路径的那两个端点行为)
#[test]
fn version_file_lookup_parses_the_recorded_payload() {
let transport = FixtureTransport::new().with(
&format!("{MODRINTH_API}/version_file/"),
fixtures::VERSION_FILE_A,
);
let client = Modrinth::new(&transport);
let version = block_on(client.version_by_hash(HashAlgorithm::Sha512, fixtures::LOOKUP_SHA512))
.expect("录制响应应当解析成功");
assert_eq!(version.id, "ver-a");
assert_eq!(version.game_versions, vec!["1.20.1".to_string()]);
// 身份判定只认「sha512 逐字相同」的那条:同版本里的 sources.jar 不许被当成它
let identity = crate::addon::online::complete::identity_of(&version, fixtures::LOOKUP_SHA512)
.expect("摘要对得上就该给出身份");
assert_eq!(identity.sha1, fixtures::LOOKUP_SHA1);
assert_eq!(identity.size, Some(4096));
let other = crate::addon::online::complete::identity_of(&version, fixtures::OTHER_SHA512)
.expect("另一个文件也认得出来");
assert_ne!(other.sha1, fixtures::LOOKUP_SHA1, "两档摘要不许串");
}
#[test]
fn a_hash_the_source_does_not_know_is_not_found_and_not_offline() {
// 真实 mrpack 大多托管在 CurseForge CDN:Modrinth 对这个 sha512 必然 404。
let transport = FixtureTransport::new().missing(&format!("{MODRINTH_API}/version_file/"));
let client = Modrinth::new(&transport);
let error = block_on(client.version_by_hash(HashAlgorithm::Sha512, &"a1".repeat(64)))
.expect_err("站点不认得就该报错");
assert_eq!(error.kind(), OnlineErrorKind::NotFound);
assert!(!error.is_offline(), "404 不是「没联网」");
}
@@ -1,135 +0,0 @@
//! 远端条目补全:能补就补、补不出照旧如实计数、离线立刻停。
use crate::addon::online::{complete_files, Modrinth};
use crate::addon::pack::PackFile;
use crate::download::blocking::block_on;
use super::fixtures;
use super::support::FixtureTransport;
const HASH_PREFIX: &str = "https://api.modrinth.com/v2/version_file/";
fn entry(sha512: Option<&str>) -> PackFile {
let mut file = PackFile::with_path("mods/sodium-fabric-0.5.11.jar");
file.downloads
.push("https://cdn.modrinth.com/data/AANobbMI/versions/ver-a/sodium.jar".to_string());
file.size = None;
if let Some(sha512) = sha512 {
file.hashes.insert("sha512".to_string(), sha512.to_string());
}
file
}
#[test]
fn a_missing_sha1_is_filled_from_the_real_source() {
let transport = FixtureTransport::new().with(HASH_PREFIX, fixtures::VERSION_FILE_A);
let client = Modrinth::new(&transport);
let mut files = vec![entry(Some(fixtures::LOOKUP_SHA512))];
let summary = block_on(complete_files(&client, &mut files));
assert_eq!(summary.completed, 1);
assert_eq!(summary.unfilled, 0);
assert!(!summary.offline);
assert!(summary.changed());
assert_eq!(files[0].sha1(), Some(fixtures::LOOKUP_SHA1));
assert_eq!(files[0].size, Some(4096));
// 清单里已有的直链不被覆盖
assert_eq!(files[0].downloads.len(), 1);
assert!(files[0].downloads[0].ends_with("sodium.jar"));
}
#[test]
fn an_entry_that_already_has_a_sha1_is_left_alone() {
let transport = FixtureTransport::new();
let client = Modrinth::new(&transport);
let mut file = entry(Some(fixtures::LOOKUP_SHA512));
file.hashes.insert("sha1".to_string(), "9".repeat(40));
let mut files = vec![file];
let summary = block_on(complete_files(&client, &mut files));
assert_eq!(summary.completed, 0);
assert_eq!(
summary.unfilled, 0,
"已经有 sha1 的条目既不算补到也不算补不出"
);
assert_eq!(files[0].sha1(), Some("9".repeat(40).as_str()));
assert!(transport.seen().is_empty(), "不该发请求");
}
#[test]
fn an_entry_without_sha512_cannot_be_completed() {
let transport = FixtureTransport::new();
let client = Modrinth::new(&transport);
let mut files = vec![entry(None)];
let summary = block_on(complete_files(&client, &mut files));
assert_eq!(summary.completed, 0);
assert_eq!(summary.unfilled, 1);
assert_eq!(files[0].sha1(), None, "补不出就是补不出,不许猜");
assert!(transport.seen().is_empty());
}
#[test]
fn a_source_that_does_not_know_the_hash_is_counted_as_unfilled() {
let transport = FixtureTransport::new().missing(HASH_PREFIX);
let client = Modrinth::new(&transport);
let mut files = vec![entry(Some(fixtures::LOOKUP_SHA512))];
let summary = block_on(complete_files(&client, &mut files));
assert_eq!(summary.completed, 0);
assert_eq!(summary.unfilled, 1);
assert!(!summary.offline, "404 不是「没联网」");
assert_eq!(files[0].sha1(), None);
}
#[test]
fn a_version_whose_hashes_do_not_match_is_not_used() {
// 路由回的是同一个版本对象,但请求的是另一个 sha512 ⇒ 身份对不上
let transport = FixtureTransport::new().with(HASH_PREFIX, fixtures::VERSION_FILE_A);
let client = Modrinth::new(&transport);
let mut files = vec![entry(Some(fixtures::OTHER_SHA512))];
// OTHER_SHA512 在同一个版本里存在,但那条是 sources.jar:仍算能对上(站点给的摘要就是它)
let summary = block_on(complete_files(&client, &mut files));
assert_eq!(summary.completed, 1);
assert_eq!(
files[0].sha1(),
Some("4444444444444444444444444444444444444444")
);
// 而一个版本里根本没有的摘要 ⇒ 补不出
let mut unknown = vec![entry(Some(&"e".repeat(128)))];
let summary = block_on(complete_files(&client, &mut unknown));
assert_eq!(summary.completed, 0);
assert_eq!(summary.unfilled, 1);
assert_eq!(unknown[0].sha1(), None);
}
#[test]
fn offline_stops_after_one_request_and_counts_the_rest_as_unfilled() {
let transport = FixtureTransport::new().offline(HASH_PREFIX);
let client = Modrinth::new(&transport);
let mut files = vec![
entry(Some(fixtures::LOOKUP_SHA512)),
entry(Some(fixtures::LOOKUP_SHA512)),
entry(Some(fixtures::LOOKUP_SHA512)),
];
let summary = block_on(complete_files(&client, &mut files));
assert!(summary.offline);
assert_eq!(summary.completed, 0);
assert_eq!(summary.unfilled, 3);
assert_eq!(transport.seen().len(), 1, "离线后不该逐条重试");
}
#[test]
fn a_download_url_is_only_added_when_the_entry_has_none() {
let transport = FixtureTransport::new().with(HASH_PREFIX, fixtures::VERSION_FILE_A);
let client = Modrinth::new(&transport);
let mut file = entry(Some(fixtures::LOOKUP_SHA512));
file.downloads.clear();
let mut files = vec![file];
let summary = block_on(complete_files(&client, &mut files));
assert_eq!(summary.completed, 1);
assert_eq!(
files[0].downloads,
vec![
"https://cdn.modrinth.com/data/AANobbMI/versions/ver-a/sodium-fabric-0.5.11.jar"
.to_string()
]
);
}
@@ -8,7 +8,6 @@
mod api;
mod classify;
mod complete;
mod download;
mod fixtures;
mod support;
@@ -220,3 +220,82 @@ fn only_a_real_offline_stops_the_completion_round() {
assert_eq!(scan.unknown_remote, 2, "没联网 ⇒ 两条都判不了");
assert!(scan.conflicts.is_empty());
}
/// 清单自带 `sha1` 时补全不许插手(也不许改写清单里的摘要)。
fn pack_with_sha1_and_sha512(tag: &str) -> (TempRoot, std::path::PathBuf) {
let root = TempRoot::new(tag);
let dir = root.join("src");
let index = format!(
r#"{{"game":"minecraft","formatVersion":1,"versionId":"1.0.0","name":"两档摘要都有",
"files":[{{"path":"{PATH}",
"hashes":{{"sha1":"{sha1}","sha512":"{SHA512}"}},
"downloads":["{DOWNLOAD}"],"fileSize":{size}}}],
"dependencies":{{"minecraft":"1.20.1"}}}}"#,
sha1 = sha1(PACK_BYTES),
size = PACK_BYTES.len()
);
write_file(&dir.join("modrinth.index.json"), index.as_bytes());
let pack = root.join("pack.mrpack");
zip_dir(&dir, &pack);
(root, pack)
}
#[test]
fn an_entry_that_already_has_a_sha1_is_left_alone() {
let (root, pack) = pack_with_sha1_and_sha512("online-keeps-sha1");
let target = root.join("instance");
write_file(&target.join(PATH), DISK_BYTES);
// 站点那边挂一个**不一样**的 sha1:真去反查并写回的话,这条就会被改写。
let fetcher = StubFetcher::new().with_modrinth(SHA512, meta(&"e".repeat(40)));
let scan = block_on(scan_pack_online(
&ImportRequest::new(&pack, &target).with_fetcher(&fetcher),
));
assert!(scan.is_ok(), "{:?}", scan.error);
let conflict = scan.conflicts.first().expect("磁盘内容不同 ⇒ 要判出冲突");
assert_eq!(
conflict.pack_sha1,
sha1(PACK_BYTES),
"清单里的 sha1 不许被改写"
);
assert!(
!scan
.notes
.iter()
.any(|note| note.message.contains("补全了")),
"没有条目需要补 ⇒ 不该写「补全了 N 条」的注记"
);
}
/// 清单没写 `downloads`:补全给的直链要能真的用上(否则这条只会进 missing)。
fn pack_without_downloads(tag: &str) -> (TempRoot, std::path::PathBuf) {
let root = TempRoot::new(tag);
let dir = root.join("src");
let index = format!(
r#"{{"game":"minecraft","formatVersion":1,"versionId":"1.0.0","name":"没有直链",
"files":[{{"path":"{PATH}","hashes":{{"sha512":"{SHA512}"}},
"downloads":[],"fileSize":{size}}}],
"dependencies":{{"minecraft":"1.20.1"}}}}"#,
size = PACK_BYTES.len()
);
write_file(&dir.join("modrinth.index.json"), index.as_bytes());
let pack = root.join("pack.mrpack");
zip_dir(&dir, &pack);
(root, pack)
}
#[test]
fn a_completed_entry_can_supply_the_missing_download_url() {
let (root, pack) = pack_without_downloads("online-url-from-source");
let target = root.join("instance");
let fetcher = StubFetcher::new()
.with_modrinth(SHA512, meta(&sha1(PACK_BYTES)))
.with_file(DOWNLOAD, PACK_BYTES);
let report = block_on(import_pack(
&ImportRequest::new(&pack, &target).with_fetcher(&fetcher),
));
assert_eq!(report.status, ImportStatus::Success, "{:?}", report.error);
assert_eq!(
read_text(&target.join(PATH)),
String::from_utf8_lossy(PACK_BYTES)
);
}
@@ -137,6 +137,9 @@ const SOURCES: &[&str] = &[
// M160:预检(冲突清单)与逐文件决策的引用也要算进这张表,
// 否则 `NOTE_REMOTE_PATH_UNKNOWN` 会被判成"死常量"。
include_str!("../import/scan.rs"),
// M162:补全注记的文案搬到了 `complete.rs`(原来在 pipeline/scan 各写一份),
// 不把它算进这张表的话 `NOTE_REMOTE_COMPLETED` 会被判成死常量。
include_str!("../import/complete.rs"),
include_str!("../import/decision.rs"),
include_str!("../import/stage.rs"),
include_str!("../mod.rs"),