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:
1 parent
391ebf88b7
commit
d8ca411117
8 files changed
+414
-81
No files matched your search
@@ -26,8 +26,8 @@ use unlucky_core::game::install::execute;
|
||||
|
||||
use super::report_loader::ProcessorSection;
|
||||
use super::{
|
||||
build_engine, ensure_name_free, failure_error, loader_processors, map_install_error,
|
||||
register_instance, report, report_loader, vanilla, CONCURRENCY,
|
||||
build_engine, ensure_name_free, failure_error, loader_processors, map_install_error, progress,
|
||||
register_instance, report_loader, vanilla, CONCURRENCY,
|
||||
};
|
||||
use crate::cli::InstallArgs;
|
||||
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))
|
||||
.map_err(|error| map_install_error(&error, ctx))?;
|
||||
if !install_report.is_complete() {
|
||||
|
||||
@@ -25,7 +25,7 @@ use unlucky_core::download::DownloadEngine;
|
||||
use unlucky_core::game::arguments::Environment;
|
||||
use unlucky_core::game::install::InstallReport;
|
||||
|
||||
use super::{failure_error, report, CONCURRENCY};
|
||||
use super::{failure_error, progress, CONCURRENCY};
|
||||
use crate::cli::InstallArgs;
|
||||
use crate::context::Ctx;
|
||||
use crate::error::CliError;
|
||||
@@ -86,7 +86,7 @@ pub(super) fn execute(
|
||||
plan: &ProcessorPlan,
|
||||
args: &InstallArgs,
|
||||
) -> 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(
|
||||
engine,
|
||||
&plan.libraries,
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
mod loader;
|
||||
mod loader_processors;
|
||||
mod progress;
|
||||
mod report;
|
||||
mod report_loader;
|
||||
mod vanilla;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -1,84 +1,15 @@
|
||||
//! 安装结果的两种呈现形态:人类可读行 + `--json` 载荷,外加 stderr 实时进度。
|
||||
//! 安装结果的两种呈现形态:人类可读行 + `--json` 载荷(stderr 实时进度见 [`super::progress`])。
|
||||
//!
|
||||
//! 拆出来是因为 `install` 的流程(规划 → 下载 → 登记)与排版彼此独立:
|
||||
//! 前者要能读懂,后者要能被脚本断言。`--dry-run` 与真实安装共用同一份排版,
|
||||
//! 差别只是多出逐文件清单与源选择。
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
use unlucky_core::download::DownloadEngine;
|
||||
use unlucky_core::game::install::{
|
||||
FileOutcome, InstallPlan, InstallReport, ProgressSink, Registration,
|
||||
};
|
||||
use unlucky_core::game::install::{FileOutcome, InstallPlan, InstallReport, Registration};
|
||||
|
||||
use crate::context::Ctx;
|
||||
use crate::output::{progress_line, 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);
|
||||
}
|
||||
}
|
||||
use crate::output::Report;
|
||||
|
||||
/// 排版一次安装结果。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
||||
@@ -21,7 +21,7 @@ use unlucky_core::game::install::{
|
||||
};
|
||||
|
||||
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::context::Ctx;
|
||||
@@ -142,7 +142,7 @@ pub(super) fn plan_and_install(
|
||||
"旧版(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?;
|
||||
Ok(Attempt {
|
||||
plan,
|
||||
|
||||
@@ -117,7 +117,8 @@
|
||||
{
|
||||
"id": "bmclapi-libraries",
|
||||
"from": "https://libraries.minecraft.net",
|
||||
"to": "https://bmclapi2.bangbang93.com"
|
||||
"to": "https://bmclapi2.bangbang93.com",
|
||||
"inject": "/maven"
|
||||
},
|
||||
{
|
||||
"id": "bmclapi-assets-objects",
|
||||
|
||||
@@ -30,15 +30,115 @@ fn prefix_rule_preserves_sub_path() {
|
||||
.expect("libraries 规则缺失");
|
||||
let candidate = rule.apply(LIBRARIES_URL).expect("重写失败");
|
||||
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!(
|
||||
candidate.url,
|
||||
LIBRARIES_URL.replace(
|
||||
"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, ®ion);
|
||||
let mirror = candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.source == "bmclapi")
|
||||
.unwrap_or_else(|| panic!("{official} 没有 bmclapi 候选:{candidates:?}"));
|
||||
assert_eq!(
|
||||
mirror.url, expected,
|
||||
"库镜像 URL 形状不对(官方 {official})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_rule_injects_path_prefix() {
|
||||
let table = MirrorTable::builtin();
|
||||
|
||||
Reference in new issue
Block a user