fix(core): bmclapi 库镜像补 /maven;install 进度行改说实话

D1(high):mirrors.json 的 bmclapi-libraries 少了 inject,官方
libraries.minecraft.net/<path> 被重写成镜像根 + <path>(BMCLAPI 的 maven 仓库挂在
/maven 下),实测 70/70 个库全 404、全部回落官方源(单请求 EMA 6.3 s)。本轮实测:
guava:32.1.2-jre 无 /maven → 404 / 9 B;带 /maven → 200(2 次 302)/ 3041591 B,
sha1 与官方逐字节一致。补法与 bmclapi-assets-objects 的 /assets 同一机制(inject),
不把 /maven 拼进 to(to 是镜像根)。

守卫两道,抓不同的东西:
- 形态:凡 from 指向 maven 仓库的规则,to+inject 必须以 /maven 结尾(并点名覆盖的
  5 条规则 id,删/改名都会红);
- 行为:4 个真实官方 maven 根展开后必须逐字等于 /maven/<子路径>。
原有 prefix_rule_preserves_sub_path 的期望值曾把 D1 的 bug 钉成期望,一并改正。
变异(删掉 inject)实测三条同时红,恢复后 mirrors 14 passed。

D11(low):进度回调只累加「处理到第几个文件」,还打「下载 {done}/{total}」,
重装同版本(全部已就绪)时会连打 3961 行「下载」而一个字节都没下。拆出
install/progress.rs:计数全部来自 FileOutcome.status(Downloaded/Skipped/Failed),
下载数为 0 时人读行不出现「下载」二字(改说「已就绪 d/t(跳过 N 个)」),--json 的
progress 事件补 downloaded/skipped/failed。用例构造 96 个全 Skipped ⇒ 断言每行都不含
「下载」且末行为「已就绪 96/96(跳过 96 个)」;变异(退回旧文本)实测红。

其它镜像表(assets / jar / version_manifest / 索引 / fabric / forge)的 inject 形状
实测一致,另有 liteloader 目标路径 404、quilt maven 无规则两条【不同类】观察,
不在本轮范围,报告里交回 tower。
This commit is contained in:
AskaEth committed 2026-11-09 13:57:32 +08:00
1 parent 391ebf88b7
commit d8ca411117
8 files changed
+414 -81

No files matched your search

+3 -3
View File
@@ -26,8 +26,8 @@ use unlucky_core::game::install::execute;
use super::report_loader::ProcessorSection; use super::report_loader::ProcessorSection;
use super::{ use super::{
build_engine, ensure_name_free, failure_error, loader_processors, map_install_error, build_engine, ensure_name_free, failure_error, loader_processors, map_install_error, progress,
register_instance, report, report_loader, vanilla, CONCURRENCY, register_instance, report_loader, vanilla, CONCURRENCY,
}; };
use crate::cli::InstallArgs; use crate::cli::InstallArgs;
use crate::context::Ctx; use crate::context::Ctx;
@@ -119,7 +119,7 @@ pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result<Rep
} }
} }
let sink = report::progress_sink(args.progress, args.json_progress, plan.files().len()); let sink = progress::progress_sink(args.progress, args.json_progress, plan.files().len());
let install_report = block_on(execute(&engine, &plan.install, CONCURRENCY, sink)) let install_report = block_on(execute(&engine, &plan.install, CONCURRENCY, sink))
.map_err(|error| map_install_error(&error, ctx))?; .map_err(|error| map_install_error(&error, ctx))?;
if !install_report.is_complete() { if !install_report.is_complete() {
@@ -25,7 +25,7 @@ use unlucky_core::download::DownloadEngine;
use unlucky_core::game::arguments::Environment; use unlucky_core::game::arguments::Environment;
use unlucky_core::game::install::InstallReport; use unlucky_core::game::install::InstallReport;
use super::{failure_error, report, CONCURRENCY}; use super::{failure_error, progress, CONCURRENCY};
use crate::cli::InstallArgs; use crate::cli::InstallArgs;
use crate::context::Ctx; use crate::context::Ctx;
use crate::error::CliError; use crate::error::CliError;
@@ -86,7 +86,7 @@ pub(super) fn execute(
plan: &ProcessorPlan, plan: &ProcessorPlan,
args: &InstallArgs, args: &InstallArgs,
) -> Result<(InstallReport, ProcessorRun), CliError> { ) -> Result<(InstallReport, ProcessorRun), CliError> {
let sink = report::progress_sink(args.progress, args.json_progress, plan.libraries.len()); let sink = progress::progress_sink(args.progress, args.json_progress, plan.libraries.len());
let libraries = block_on(processors::download_libraries( let libraries = block_on(processors::download_libraries(
engine, engine,
&plan.libraries, &plan.libraries,
+1
View File
@@ -12,6 +12,7 @@
mod loader; mod loader;
mod loader_processors; mod loader_processors;
mod progress;
mod report; mod report;
mod report_loader; mod report_loader;
mod vanilla; mod vanilla;
+300
View File
@@ -0,0 +1,300 @@
//! stderr 实时进度(原版安装 / 加载器库 / processor 依赖库三段共用)。
//!
//! **M210/D11 的教训**:进度行的数字必须是**已经发生的事实**(每个文件的 [`FileStatus`]),
//! 不能拿「处理到第几个文件」冒充「下载了几个」。旧实现在回调里只累加一个 `done` 就打印
//! `下载 {done}/{total}`,于是重装同版本(3961 个文件全部已就绪、一个字节都没下)时会连打
//! `下载 32/3961 … 3961/3961`——进度条在跑,下载量是 0,用户在读一句假话。
//!
//! 现在分三个计数(下载 / 跳过 / 失败),人读行的措辞随计数走:下载数为 0 时**不出现「下载」
//! 二字**,改说「已就绪 N/M(跳过 N 个)」。
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use unlucky_core::game::install::{FileOutcome, FileStatus, ProgressSink};
use crate::output::progress_line;
/// 每处理多少个文件打一行进度(3000 个资源下约 90 行,够用又不刷屏)。
const PROGRESS_EVERY: usize = 32;
/// 进度里最多逐条打印几条失败(其余交给最终错误汇总)。
const MAX_PROGRESS_FAILURES: usize = 5;
/// 一次进度快照:每个字段都是**真实结局**的计数。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct Snapshot {
/// 已处理文件数(含跳过与失败)。
pub done: usize,
/// 计划文件总数。
pub total: usize,
/// 本次真的下载并落盘的文件数。
pub downloaded: usize,
/// 已存在且校验通过、本次跳过的文件数。
pub skipped: usize,
/// 失败文件数。
pub failed: usize,
}
impl Snapshot {
/// 人读进度行。
///
/// 下载数为 0 时**不提「下载」**:本次一个字节都没下,再写「下载 0/N」也只是换个说法的假进度。
pub(crate) fn text(&self) -> String {
let failure = if self.failed > 0 {
format!(" / 失败 {}", self.failed)
} else {
String::new()
};
if self.downloaded == 0 {
format!(
"已就绪 {}/{}(跳过 {} 个{failure})",
self.done, self.total, self.skipped
)
} else {
format!(
"下载 {} 个 / 跳过 {} 个(进度 {}/{}{failure})",
self.downloaded, self.skipped, self.done, self.total
)
}
}
/// `--json` 模式的进度事件(字段名稳定,`done`/`total` 保持旧名以兼容脚本)。
fn json(&self) -> serde_json::Value {
serde_json::json!({
"event": "progress",
"done": self.done,
"total": self.total,
"downloaded": self.downloaded,
"skipped": self.skipped,
"failed": self.failed,
})
}
}
/// 计数:回调来自多个下载线程,全部原子累加。
#[derive(Debug, Default)]
struct Tally {
done: AtomicUsize,
downloaded: AtomicUsize,
skipped: AtomicUsize,
failed: AtomicUsize,
failure_lines: AtomicUsize,
}
impl Tally {
/// 记一个结局,返回记完之后的快照。
fn observe(&self, outcome: &FileOutcome, total: usize) -> Snapshot {
let done = self.done.fetch_add(1, Ordering::Relaxed) + 1;
match outcome.status {
FileStatus::Downloaded => {
self.downloaded.fetch_add(1, Ordering::Relaxed);
}
FileStatus::Skipped => {
self.skipped.fetch_add(1, Ordering::Relaxed);
}
FileStatus::Failed => {
self.failed.fetch_add(1, Ordering::Relaxed);
}
}
Snapshot {
done,
total,
downloaded: self.downloaded.load(Ordering::Relaxed),
skipped: self.skipped.load(Ordering::Relaxed),
failed: self.failed.load(Ordering::Relaxed),
}
}
}
/// 实时进度回调(只写 stderr,不污染 `--json` 的 stdout)。
///
/// 两种形态:人类模式打文本行;`--json` 模式打 **JSONL**(一行一条事件,`event` 字段分流),
/// 这样脚本既能吃 stdout 的最终信封,也能实时跟进而不必解析文本。
///
/// 失败行只打前几条:整源不可达时会一口气失败上千个文件,全打出来既没用又淹没原因。
pub fn progress_sink(enabled: bool, json: bool, total: usize) -> Option<ProgressSink> {
sink_with(enabled, json, total, Arc::new(progress_line))
}
/// 可注入输出端的构造器(测试据此抓行;生产只有一个调用者 [`progress_sink`])。
fn sink_with(
enabled: bool,
json: bool,
total: usize,
out: Arc<dyn Fn(&str) + Send + Sync>,
) -> Option<ProgressSink> {
if !enabled {
return None;
}
let tally = Arc::new(Tally::default());
Some(Arc::new(move |outcome: &FileOutcome| {
let snapshot = tally.observe(outcome, total);
if let Some(failure) = &outcome.failure {
let seen = tally.failure_lines.fetch_add(1, Ordering::Relaxed);
if seen < MAX_PROGRESS_FAILURES {
emit(
json,
serde_json::json!({
"event": "file_failed",
"done": snapshot.done,
"total": total,
"name": outcome.name,
"role": outcome.role.as_str(),
"kind": failure.kind.as_str(),
"message": failure.message,
}),
&format!(
"失败 {}/{} {}:{}",
snapshot.done, total, outcome.name, failure.message
),
&out,
);
} else if seen == MAX_PROGRESS_FAILURES {
emit(
json,
serde_json::json!({
"event": "progress_truncated",
"suppressed_after": MAX_PROGRESS_FAILURES,
}),
"(更多失败不再逐条打印,见最终错误汇总)",
&out,
);
}
return;
}
if snapshot.done.is_multiple_of(PROGRESS_EVERY) || snapshot.done == total {
emit(json, snapshot.json(), &snapshot.text(), &out);
}
}))
}
/// 一条进度:JSONL 模式打序列化后的一行,人类模式打短文本。
fn emit(
json_mode: bool,
record: serde_json::Value,
text: &str,
out: &Arc<dyn Fn(&str) + Send + Sync>,
) {
if json_mode {
let line = record.to_string();
out(&line);
} else {
out(text);
}
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use unlucky_core::download::DownloadErrorKind;
use unlucky_core::game::install::{FileFailure, FileRole};
use super::*;
/// 抓行的输出端。
fn recorder() -> (Arc<dyn Fn(&str) + Send + Sync>, Arc<Mutex<Vec<String>>>) {
let lines = Arc::new(Mutex::new(Vec::new()));
let sink_lines = Arc::clone(&lines);
let out: Arc<dyn Fn(&str) + Send + Sync> =
Arc::new(move |line: &str| sink_lines.lock().expect("锁").push(line.to_string()));
(out, lines)
}
fn outcome(name: &str, status: FileStatus) -> FileOutcome {
FileOutcome {
role: FileRole::Asset,
name: name.to_string(),
dest: std::path::PathBuf::from(format!("assets/{name}")),
status,
bytes: 1234,
source: Some("cache".to_string()),
failure: None,
}
}
/// **D11 用例**:全部已存在(重装同版本)⇒ 进度行里不出现「下载」,下载计数为 0。
///
/// 抓得住:把「处理序号」当下载量打印的旧实现(它会打出 `下载 32/96` 这类行);
/// 以及任何「下载数 > 0 却说没下载」的表述。抓不住:行里出现的其它夸张词(措辞审查),
/// 也不管单行是否真的落到 stderr(那是 [`crate::output::progress_line`] 的事)。
#[test]
fn all_skipped_progress_never_claims_downloads() {
const TOTAL: usize = 96; // 3 的倍数,必然跨过 PROGRESS_EVERY
let (out, lines) = recorder();
let sink = sink_with(true, false, TOTAL, out).expect("进度开关打开时必须有 sink");
for index in 0..TOTAL {
sink(&outcome(&format!("asset-{index}"), FileStatus::Skipped));
}
let lines = lines.lock().expect("锁").clone();
assert!(!lines.is_empty(), "应当有进度行");
for line in &lines {
assert!(
!line.contains("下载"),
"全部跳过时进度行不许提「下载」:{line}"
);
}
let last = lines.last().expect("至少一行");
assert!(
last.contains(&format!("已就绪 {TOTAL}/{TOTAL}"))
&& last.contains(&format!("跳过 {TOTAL}")),
"全跳过应当说「已就绪 N/N(跳过 N 个)」:{last}"
);
}
/// 同一场景的 `--json` 形态:`downloaded` 必须是 0(脚本据此判断「其实没下」)。
#[test]
fn all_skipped_json_progress_reports_zero_downloads() {
const TOTAL: usize = 32;
let (out, lines) = recorder();
let sink = sink_with(true, true, TOTAL, out).expect("进度开关打开时必须有 sink");
for index in 0..TOTAL {
sink(&outcome(&format!("lib-{index}"), FileStatus::Skipped));
}
let records: Vec<serde_json::Value> = lines
.lock()
.expect("锁")
.iter()
.map(|line| serde_json::from_str(line).expect("JSONL"))
.collect();
let progress = records
.iter()
.find(|record| record["event"] == "progress")
.expect("应当有 progress 事件");
assert_eq!(progress["downloaded"], 0);
assert_eq!(progress["skipped"], TOTAL);
assert_eq!(progress["done"], TOTAL);
assert_eq!(progress["total"], TOTAL);
}
/// 真的有下载时,计数与措辞都跟着事实走(避免「一刀切不打印」这种假修复)。
#[test]
fn mixed_outcomes_report_real_counts() {
let (out, lines) = recorder();
let sink = sink_with(true, false, 4, out).expect("sink");
sink(&outcome("client.jar", FileStatus::Downloaded));
let failed = FileOutcome {
failure: Some(FileFailure {
kind: DownloadErrorKind::Network,
message: "连接被重置".to_string(),
}),
..outcome("b", FileStatus::Failed)
};
sink(&failed);
sink(&outcome("c", FileStatus::Skipped));
sink(&outcome("d", FileStatus::Skipped));
let lines = lines.lock().expect("锁").clone();
// 失败有自己的行(`失败 2/4 b:…`),进度行不拿它冒充下载量。
assert!(
lines.iter().any(|line| line.starts_with("失败 2/4 b")),
"{lines:?}"
);
let last = lines.last().expect("至少一行");
// done == total 时无条件打最后一行。
assert!(last.contains("下载 1 个"), "{last}");
assert!(last.contains("跳过 2 个"), "{last}");
assert!(last.contains("失败 1"), "{last}");
assert!(last.contains("进度 4/4"), "{last}");
}
}
+3 -72
View File
@@ -1,84 +1,15 @@
//! 安装结果的两种呈现形态:人类可读行 + `--json` 载荷,外加 stderr 实时进度。 //! 安装结果的两种呈现形态:人类可读行 + `--json` 载荷(stderr 实时进度见 [`super::progress`])。
//! //!
//! 拆出来是因为 `install` 的流程(规划 → 下载 → 登记)与排版彼此独立: //! 拆出来是因为 `install` 的流程(规划 → 下载 → 登记)与排版彼此独立:
//! 前者要能读懂,后者要能被脚本断言。`--dry-run` 与真实安装共用同一份排版, //! 前者要能读懂,后者要能被脚本断言。`--dry-run` 与真实安装共用同一份排版,
//! 差别只是多出逐文件清单与源选择。 //! 差别只是多出逐文件清单与源选择。
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use serde_json::json; use serde_json::json;
use unlucky_core::download::DownloadEngine; use unlucky_core::download::DownloadEngine;
use unlucky_core::game::install::{ use unlucky_core::game::install::{FileOutcome, InstallPlan, InstallReport, Registration};
FileOutcome, InstallPlan, InstallReport, ProgressSink, Registration,
};
use crate::context::Ctx; use crate::context::Ctx;
use crate::output::{progress_line, Report}; use crate::output::Report;
/// 每处理多少个文件打一行进度(3000 个资源下约 90 行,够用又不刷屏)。
const PROGRESS_EVERY: usize = 32;
/// 进度里最多逐条打印几条失败(其余交给最终错误汇总)。
const MAX_PROGRESS_FAILURES: usize = 5;
/// 实时进度回调(只写 stderr,不污染 `--json` 的 stdout)。
///
/// 两种形态:人类模式打文本行;`--json` 模式打 **JSONL**(一行一条事件,`event` 字段分流),
/// 这样脚本既能吃 stdout 的最终信封,也能实时跟进而不必解析文本。
///
/// 失败行只打前几条:整源不可达时会一口气失败上千个文件,全打出来既没用又淹没原因。
pub fn progress_sink(enabled: bool, json: bool, total: usize) -> Option<ProgressSink> {
if !enabled {
return None;
}
let done = Arc::new(AtomicUsize::new(0));
let failures = Arc::new(AtomicUsize::new(0));
Some(Arc::new(move |outcome: &FileOutcome| {
let index = done.fetch_add(1, Ordering::Relaxed) + 1;
if let Some(failure) = &outcome.failure {
let seen = failures.fetch_add(1, Ordering::Relaxed);
if seen < MAX_PROGRESS_FAILURES {
emit(
json,
json!({
"event": "file_failed",
"done": index,
"total": total,
"name": outcome.name,
"role": outcome.role.as_str(),
"kind": failure.kind.as_str(),
"message": failure.message,
}),
&format!("失败 {index}/{total} {}:{}", outcome.name, failure.message),
);
} else if seen == MAX_PROGRESS_FAILURES {
emit(
json,
json!({"event": "progress_truncated", "suppressed_after": MAX_PROGRESS_FAILURES}),
"(更多失败不再逐条打印,见最终错误汇总)",
);
}
return;
}
if index.is_multiple_of(PROGRESS_EVERY) || index == total {
emit(
json,
json!({"event": "progress", "done": index, "total": total}),
&format!("下载 {index}/{total}"),
);
}
}))
}
/// 一条进度:JSONL 模式打序列化后的一行,人类模式打短文本。
fn emit(json_mode: bool, record: serde_json::Value, text: &str) {
if json_mode {
progress_line(&record.to_string());
} else {
progress_line(text);
}
}
/// 排版一次安装结果。 /// 排版一次安装结果。
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
+2 -2
View File
@@ -21,7 +21,7 @@ use unlucky_core::game::install::{
}; };
use super::{ use super::{
build_engine, ensure_name_free, failure_error, map_install_error, report, CONCURRENCY, build_engine, ensure_name_free, failure_error, map_install_error, progress, report, CONCURRENCY,
}; };
use crate::cli::InstallArgs; use crate::cli::InstallArgs;
use crate::context::Ctx; use crate::context::Ctx;
@@ -142,7 +142,7 @@ pub(super) fn plan_and_install(
"旧版(pre-1.6)清单兜底待补;可先用官方启动器装好该版本再用 `%prog% launch`", "旧版(pre-1.6)清单兜底待补;可先用官方启动器装好该版本再用 `%prog% launch`",
)); ));
} }
let sink = report::progress_sink(progress, json_progress, plan.file_count()); let sink = progress::progress_sink(progress, json_progress, plan.file_count());
let report = execute(engine, &plan, CONCURRENCY, sink).await?; let report = execute(engine, &plan, CONCURRENCY, sink).await?;
Ok(Attempt { Ok(Attempt {
plan, plan,
+2 -1
View File
@@ -117,7 +117,8 @@
{ {
"id": "bmclapi-libraries", "id": "bmclapi-libraries",
"from": "https://libraries.minecraft.net", "from": "https://libraries.minecraft.net",
"to": "https://bmclapi2.bangbang93.com" "to": "https://bmclapi2.bangbang93.com",
"inject": "/maven"
}, },
{ {
"id": "bmclapi-assets-objects", "id": "bmclapi-assets-objects",
+101 -1
View File
@@ -30,15 +30,115 @@ fn prefix_rule_preserves_sub_path() {
.expect("libraries 规则缺失"); .expect("libraries 规则缺失");
let candidate = rule.apply(LIBRARIES_URL).expect("重写失败"); let candidate = rule.apply(LIBRARIES_URL).expect("重写失败");
assert_eq!(candidate.source, "bmclapi"); assert_eq!(candidate.source, "bmclapi");
// 子路径逐字保留(`/maven` 是镜像侧的固定前缀,不属于子路径)。
//
// M210/D1:这条断言此前写的是「换成 `https://bmclapi2.bangbang93.com` 就完事」,等于把
// **丢 inject 的 bug 钉成了期望**——实测 70/70 个库 404(无 `/maven` 回 404 + 9 B,
// 带 `/maven` 回 200、sha1 与官方逐字节一致)。现在把 `/maven` 写进期望值。
assert_eq!( assert_eq!(
candidate.url, candidate.url,
LIBRARIES_URL.replace( LIBRARIES_URL.replace(
"https://libraries.minecraft.net", "https://libraries.minecraft.net",
"https://bmclapi2.bangbang93.com" "https://bmclapi2.bangbang93.com/maven"
) )
); );
} }
/// **守卫(M210/D1)**:每一条「给 libraries 用」的镜像规则,镜像前缀都必须带 `/maven`。
///
/// 官方库域名(`libraries.minecraft.net`)与各 loader 的 maven 在 BMCLAPI 上统一挂在 `/maven`
/// 下;`apply()` 是纯前缀替换,`to` 里少了这一段就会把 `com/google/guava/...` 直接拼到镜像根上。
///
/// 抓得住:删掉/改错任一条库镜像规则的 `inject`(本用例对 `bmclapi-libraries` 的变异会红)。
/// 抓不住:`/maven` **写歪**(如 `/maven2`、`/maven/` 之外的等价路径)只能靠行为用例
/// [`library_urls_expand_to_the_maven_subpath_on_bmclapi`];路径结构本就不同的镜像表(资源对象走
/// `/assets`、元数据走 `/fabric-meta`)不在此判据内;新增但 `from` 不像 maven 仓库的库镜像也不在此列。
#[test]
fn every_library_mirror_rule_carries_the_maven_segment() {
let table = MirrorTable::builtin();
// 判据:「给 libraries 用」= `from` 指向 maven 仓库(host/路径里带 maven,或就是 Mojang 库域名)。
// 新增一条库镜像规则时这条自动纳入,不必改测试。
fn serves_maven(from: &str) -> bool {
host_of(from).unwrap_or_default().contains("maven")
|| from.contains("maven")
|| host_of(from) == Some("libraries.minecraft.net")
}
// 判据覆盖的规则 id(内置表里就是这 5 条;`bmclapi-liteloader` 的 `from` 不像 maven 仓库,不在列,
// 它指向的镜像路径另有一个「镜像上不存在」的观察,见 M210 报告的 §5)。
const EXPECTED: [&str; 5] = [
"bmclapi-libraries",
"bmclapi-forge-maven",
"bmclapi-forge-maven-net",
"bmclapi-neoforge",
"bmclapi-fabric-maven",
];
let mut checked: Vec<&str> = Vec::new();
for rule in table.rules.iter().filter(|rule| serves_maven(&rule.from)) {
let prefix = format!("{}{}", rule.to, rule.inject);
assert!(
prefix.ends_with("/maven"),
"规则 {} 的镜像前缀 `{prefix}` 少了 `/maven`:官方 `{}` 在镜像上挂在 `/maven/` 下,\
丢了它整张表 404(M210/D1)",
rule.id,
rule.from
);
checked.push(rule.id.as_str());
}
// 判据失效(规则被改名/改形/删掉)时这里红:期望的规则一条都不能漏。
for expected in EXPECTED {
assert!(
checked.contains(&expected),
"库镜像规则 {expected} 没被形态判据覆盖到(被改名/改形/删掉了?):{checked:?}"
);
}
// 多出来的(新增 maven 规则)也得登记进 EXPECTED,否则说明它绕过了这道判据的意图审查。
assert_eq!(
checked.len(),
EXPECTED.len(),
"库镜像规则数与预期不符(新增/删除了规则?):{checked:?}"
);
}
/// **行为守卫(M210/D1)**:真实库 URL 展开后必须落在镜像的 `/maven/<子路径>` 上。
///
/// 与上一条互为补充:那条看规则形态,这条看用户实际会请求到的 URL。
/// 抓得住:`inject` 丢失、`to` 里手写 `/maven` 被删、或 `from`/`to` 被改成拼不出 `/maven` 的形态。
/// 抓不住:只覆盖这 4 个官方 maven 根(各处未必都写全);镜像侧真实可达性(要网络,留给 e2e)。
#[test]
fn library_urls_expand_to_the_maven_subpath_on_bmclapi() {
let table = MirrorTable::builtin();
let region = RegionProfile::mirror_first(0);
let cases = [
(
LIBRARIES_URL,
"https://bmclapi2.bangbang93.com/maven/com/google/guava/guava/32.0.0/guava-32.0.0.jar",
),
(
"https://maven.fabricmc.net/net/fabricmc/fabric-loader/0.15.11/fabric-loader-0.15.11.jar",
"https://bmclapi2.bangbang93.com/maven/net/fabricmc/fabric-loader/0.15.11/fabric-loader-0.15.11.jar",
),
(
"https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-installer.jar",
"https://bmclapi2.bangbang93.com/maven/net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-installer.jar",
),
(
"https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.80/neoforge-21.1.80-installer.jar",
"https://bmclapi2.bangbang93.com/maven/net/neoforged/neoforge/21.1.80/neoforge-21.1.80-installer.jar",
),
];
for (official, expected) in cases {
let candidates = table.expand(official, ResourceKind::Library, &region);
let mirror = candidates
.iter()
.find(|candidate| candidate.source == "bmclapi")
.unwrap_or_else(|| panic!("{official} 没有 bmclapi 候选:{candidates:?}"));
assert_eq!(
mirror.url, expected,
"库镜像 URL 形状不对(官方 {official})"
);
}
}
#[test] #[test]
fn asset_rule_injects_path_prefix() { fn asset_rule_injects_path_prefix() {
let table = MirrorTable::builtin(); let table = MirrorTable::builtin();