Files
Unlucky-Launcher/crates/cli/src/download/tests.rs
T
worker-mirrorwire 69e3e198bf fix(download): M294 r1 —— JDK 下载页与 CLI 也接上源选择,文档同步
复审 p1:发布版 GUI 的「下载 JDK」页是第二处 DownloadEngine::new(要 with_progress
拿走引擎),此前只读了 gh_proxy,region / mirror_preference 到不了那里;报告却把 GUI
写成「已接」。复审 p2:CLI 四条命令同样不认这两个键 —— 同一份 launcher.json 在 GUI 与
CLI 上表现不同,等于把「设置项说假话」挪到 CLI 侧。

- app-iced downloads/engine.rs:新增 SourceSelection(只读键、不翻译)+
  engine_with(.., &SourceSelection),在 gh-proxy 三态之前叠
  DownloadTuning::with_source_selection(..).apply_source_selection(..)
- Request 加 source_selection 字段,构造点(app/update/settings/java/downloads.rs)
  用 SourceSelection::of_config(&config) 显式传值(不从缓存根反推配置路径:
  反推失败会静默按 auto 收场,正是本 mission 要消灭的形状)
- cli download.rs 的 tuning():链 .with_source_selection(..),四条命令共用这一处装配
- core 的 summary():把「地区 / 镜像偏好」两档印进 CLI 那行生效值(CLI 既然读了,
  少印两项就是新的半真话)
- shared.rs 注释说清「共享引擎」与本文件的第二处装配点;app-iced 模块头补对照表
- 新判据:JDK 下载页与 CLI 各一条断候选源顺序的用例,均配变异验证(换回裸
  EngineConfig::default() / 裸 tuning 即红);补一条 region=cn + gh_proxy=off 的
  叠加次序守卫(复审 P3-2)
- docs/download-settings.md:§1 表格两行、§5 拆「已接 / 未接」、新增 §3.1.1 覆盖面
  表、§6 补 M14-M16 三条变异、§7 补「镜像可达性没验」

连带(不加就编不过,已报备 tower):downloads/tests.rs 的 Request 字面量 +1 行、
downloads/tests/gh_proxy.rs 两处 engine_with 调用点补第四参。
2026-11-13 09:00:00 +08:00

392 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! [`super`] 的单测(M203):`launcher.json` 的 `download.*` 真的进到四条命令拿到的引擎里
//! (`install` / `java` / `pack` / `update`——`update` 复用 `install` 的装配,自更新只下一个
//! artifact,所以并发在那条路径上不可观测,随设置变的是每源重试次数与 gh-proxy 前缀)。
//!
//! 断言读的是**引擎的真实字段**——`config().max_concurrency` 与 `Semaphore` 的
//! `available_permits()`(`concurrency_probe`)——不是「函数被调用过」:后者在回落写错、
//! 或者命令又自建了一个裸引擎时照样绿。gh-proxy 那一条断言的是**候选链**(真行为),不是配置值。
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::{json, Value};
use unlucky_core::download::config::{
DownloadTuning, DEFAULT_MAX_ATTEMPTS_PER_SOURCE, DEFAULT_MAX_CONCURRENCY, KEY_CONCURRENCY,
};
use unlucky_core::download::types::{DownloadRequest, ResourceKind};
use unlucky_core::download::DownloadEngine;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use crate::cli::Globals;
use crate::context::Ctx;
/// 一次性临时目录(cli 没有 dev-dependency 的 tempfile;与 `commands/config/tests.rs` 同一手法)。
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"ull-cli-download-{tag}-{}-{seq}-{nanos}",
std::process::id()
));
std::fs::create_dir_all(&path).expect("建临时目录");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// 数据根钉在临时目录里:`launcher.json` 落在 `<dir>/.ULL/config/launcher.json`。
fn ctx_at(dir: &TempDir) -> Ctx {
let globals = Globals {
data_dir: Some(dir.path().to_path_buf()),
..Globals::default()
};
Ctx::resolve(&globals).expect("解析数据根")
}
/// 直接写 `launcher.json`——落点用 [`Ctx::setting_path_for`](`ull config set` 用的那个表达式),
/// **不是**实现读侧用的 `ctx.launcher_config()`。
///
/// 两个表达式今天指向同一份文件(`download.*` 不是数据根键 ⇒ 回落 `launcher_config()`),但写侧
/// 走「用户改配置那条路」的 API,实现哪天改读别的文件时这条用例会红,而不是跟着一起漂移。
fn write_launcher_config(ctx: &Ctx, document: Value) -> PathBuf {
let path = ctx.setting_path_for(KEY_CONCURRENCY);
std::fs::create_dir_all(path.parent().expect("配置目录")).expect("建配置目录");
std::fs::write(&path, serde_json::to_vec(&document).expect("序列化"))
.expect("写 launcher.json");
path
}
/// 「引擎真的按这个并发跑」:配置字段 + 闸门许可数,两处都读真实值。
fn assert_concurrency(engine: &DownloadEngine, expected: usize, case: &str) {
assert_eq!(
engine.config().max_concurrency,
expected,
"{case}:`EngineConfig` 里的并发"
);
assert_eq!(
engine.concurrency_probe(),
expected,
"{case}:`Semaphore` 真实许可数"
);
}
/// **`install` 吃设置**:`download.concurrency` 改了就变;同一次进程里再改一次也立刻变(不是只读一次)。
///
/// 抓得住的变异:`commands::install::build_engine` 换回裸 `EngineConfig::default()` ⇒ 3 / 5 两处红。
/// 抓不住的:真实下载时「只有 3 条连接在飞」的时序(离线验不了,见报告「没验的部分」)。
#[test]
fn install_engine_takes_the_concurrency_from_the_setting_store() {
let dir = TempDir::new("install");
let ctx = ctx_at(&dir);
// 没有 launcher.json = 没配过 ⇒ 出厂值(这也是「回落」的第一档)。
assert_concurrency(
&crate::commands::install::build_engine(&ctx),
DEFAULT_MAX_CONCURRENCY,
"未配置",
);
write_launcher_config(&ctx, json!({ "download": { "concurrency": 3 } }));
assert_concurrency(
&crate::commands::install::build_engine(&ctx),
3,
"concurrency=3",
);
write_launcher_config(&ctx, json!({ "download": { "concurrency": 5 } }));
assert_concurrency(
&crate::commands::install::build_engine(&ctx),
5,
"concurrency=5",
);
}
/// **`java` 吃设置**:并发与每个源的尝试次数都进引擎。
///
/// 抓得住的变异:`commands::java::build_engine` 换回裸 `EngineConfig::default()` ⇒ 探针停在 8、次数停在 3,红。
#[test]
fn java_engine_takes_the_concurrency_and_retry_from_the_setting_store() {
let dir = TempDir::new("java");
let ctx = ctx_at(&dir);
write_launcher_config(
&ctx,
json!({ "download": { "concurrency": 3, "retry_limit": 4 } }),
);
let engine = crate::commands::java::build_engine(&ctx);
assert_concurrency(&engine, 3, "concurrency=3");
assert_eq!(
engine.config().max_attempts_per_source,
4,
"retry_limit=4 要真的落到引擎上(出厂是 3)"
);
}
/// **`pack` 吃设置**(四条命令各有一条独立断言:把任意一处改回裸引擎,只有那一条红)。
///
/// 抓得住的变异:`commands::pack::build_engine` 换回裸 `EngineConfig::default()` ⇒ 2 处红。
#[test]
fn pack_engine_takes_the_concurrency_from_the_setting_store() {
let dir = TempDir::new("pack");
let ctx = ctx_at(&dir);
write_launcher_config(&ctx, json!({ "download": { "concurrency": 2 } }));
assert_concurrency(
&crate::commands::pack::build_engine(&ctx),
2,
"concurrency=2",
);
}
/// **`update` 吃设置**(第 4 条路径,`commands/update.rs::engine`):自更新也只拿出厂值是不行的。
///
/// 这条路径上「并发」实际不可观测(自更新只下**一个** artifact,闸门开 1 还是 8 都只有一条连接),
/// 但引擎参数是同一处装配出来的,用并发打点最省事;真正随设置变的是每源重试次数与 gh-proxy 前缀。
/// 抓得住的变异:`commands::update::engine` 换回裸 `EngineConfig::default()` ⇒ 4 ≠ 8 处红,另五条绿。
#[test]
fn update_engine_takes_the_concurrency_from_the_setting_store() {
let dir = TempDir::new("update");
let ctx = ctx_at(&dir);
write_launcher_config(&ctx, json!({ "download": { "concurrency": 4 } }));
assert_concurrency(&crate::commands::update::engine(&ctx), 4, "concurrency=4");
}
/// **`gh_proxy` 的 CLI 侧闭环**:手写 `"off"` ⇒ 候选链里真的没有 gh-proxy(读的是 `plan()`,不是配置值)。
///
/// 两段式:先在镜像优先的地区档案下确认**出厂**(空串 = 跟随内置)确实生成 gh-proxy 候选,再把它
/// 改成 `off` 确认候选消失——只断言后者的话,「本来就没有候选」会让用例白绿。
/// 抓得住的变异:`download::tuning` 不读 `KEY_GH_PROXY`(传 `None`)⇒ 第二段仍有 gh-proxy 候选,红;
/// 把空串当成关(`gh_proxy_override("")` 返回 `Off`)⇒ 第一段没有候选,红(回落用例的
/// `tuning == default` 同时红——两处都该红,实测如此)。
/// 抓不住的:真实网络下这个前缀能不能通(离线只验候选链的**生成**)。
#[test]
fn the_gh_proxy_setting_reaches_the_candidate_chain() {
use unlucky_core::download::cache::now_ms;
use unlucky_core::download::region::{store_cached, RegionProfile};
let dir = TempDir::new("ghproxy");
let ctx = ctx_at(&dir);
// gh-proxy 只在「镜像优先」的地区档案下生成候选:先把该数据根的 region.json 钉住。
let cache_root = ctx.layout().cache();
store_cached(
&cache_root.join("region.json"),
&RegionProfile::mirror_first(now_ms()),
)
.expect("地区档案");
let url = "https://github.com/o/r/releases/download/v1/a.jar";
let chain = |ctx: &Ctx| -> Vec<String> {
let request =
DownloadRequest::new(ResourceKind::Other, url, ctx.layout().cache().join("a.jar"));
crate::download::engine(ctx)
.plan(&request, 0)
.into_iter()
.map(|candidate| candidate.url)
.collect()
};
let has_gh_proxy = |chain: &[String]| chain.iter().any(|url| url.contains("gh-proxy"));
// 出厂(未配置):空串 = 跟随内置默认 ⇒ 有 gh-proxy 候选。
assert!(has_gh_proxy(&chain(&ctx)), "出厂该套内置前缀");
// 写 off:候选里不该再有 gh-proxy(其余候选仍在)。
write_launcher_config(&ctx, json!({ "download": { "gh_proxy": "off" } }));
let off = chain(&ctx);
assert!(!has_gh_proxy(&off), "off 档还有 gh-proxy 候选:{off:?}");
assert!(!off.is_empty(), "关掉加速不等于没有候选:{off:?}");
}
/// **反向断言**:键缺失 / 越界 / 负数 / 类型不对 ⇒ 回落出厂值,而且回落是**静默的**。
///
/// 「静默」在这里是结构性的:`crate::download::engine` 的返回类型没有错误通道,坏设置只进
/// `SettingStore::problems()`,调用方(安装流程)连一个 `?` 都不用加——`ull install` 不会因为一条
/// 写坏的设置而中断,只是按出厂值跑。
///
/// 抓得住的变异:删掉 `DownloadTuning::from_settings` 的区间过滤 ⇒ 9_999 那条把许可数变成 64,红;
/// 把 CLI 的读数改成 `expect`/报错 ⇒ 坏值文件这几档 panic 或编译不过,红。
/// 抓不住的:`problems()` 的内容与措辞(那是 core 的 `setting` 模块,本用例只借它证明文件真的坏)。
#[test]
fn missing_or_illegal_values_fall_back_to_the_factory_defaults_silently() {
let dir = TempDir::new("fallback");
let ctx = ctx_at(&dir);
assert_eq!(
super::tuning(&ctx),
DownloadTuning::default(),
"没有 launcher.json 就该是出厂调优"
);
for (case, document) in [
(
"越界",
json!({ "download": { "concurrency": 9_999, "retry_limit": 99 } }),
),
(
"负数",
json!({ "download": { "concurrency": -4, "retry_limit": -1 } }),
),
(
"类型不对",
json!({ "download": { "concurrency": "八", "retry_limit": "很多" } }),
),
] {
let written = write_launcher_config(&ctx, document);
// 先证明这份文件**真的**是坏的(否则「回落」只是键不存在,用例就成了空断言)。
let store = SettingStore::load(&written, launcher_schema());
assert!(
!store.problems().is_empty(),
"{case}:这份文件该被判为有问题"
);
assert_eq!(
super::tuning(&ctx),
DownloadTuning::default(),
"{case}:坏值必须回落出厂调优"
);
let engine = crate::commands::install::build_engine(&ctx);
assert_concurrency(&engine, DEFAULT_MAX_CONCURRENCY, case);
assert_eq!(
engine.config().max_attempts_per_source,
DEFAULT_MAX_ATTEMPTS_PER_SOURCE,
"{case}:尝试次数也回落出厂值"
);
}
}
/// **M292 / P21 判据的 CLI 侧闭合**:三个超时键从 `launcher.json` 进到**生产路径**的引擎配置,而且
/// 改过设置时终端上那行说明写的是**生效值**。
///
/// 为什么断言 [`super::announce`]:台账原文的判据是「改完能在 `--dry-run` 或日志里看到生效值」。
/// CLI 没有打印调优的 `--dry-run`(那几处 `--dry-run` 只出计划清单 / 版本串),所以这一行走
/// stderr,而它的内容由 `announce` 产出——**断言这个函数就是断言终端上那行**(不是另拼一份旁证)。
/// 走 stderr 而不是 `logs/launcher.log` 的理由见 `announce` 的文档:那个日志的目录是进程级全局,
/// 在装配点调 `set_dir` 会让一个读路径改全局状态(M292 第一版就是这么写的,`commands::sessions`
/// 的 launcher.log 用例当场红了)。
///
/// 抓得住:① `download::tuning` 少读一个键 ⇒ 引擎字段与那行说明同时红;② `announce` 对出厂值也返回
/// `Some` ⇒ 第二段红(每个 `ull install` 都多一行噪声);③ 把汇总写成常量 ⇒ 说明里的数字对不上。
/// 抓不住:真机慢链路上「预算到底够不够」(离线环境没有真链路,见 M292 报告的未验项)。
#[test]
fn the_timeout_settings_reach_the_engine_and_the_console_line() {
use std::time::Duration;
let dir = TempDir::new("timeouts");
let ctx = ctx_at(&dir);
write_launcher_config(
&ctx,
json!({ "download": {
"throughput_floor_kib": 16,
"stall_timeout_secs": 300,
"response_timeout_secs": 180,
}}),
);
// 出厂档不该打那一行(否则每个命令都多一行噪声)。
assert!(
super::announce(&DownloadTuning::default()).is_none(),
"出厂值不该打说明"
);
// 生产路径:`ull install` 取引擎的那个入口(四条命令共用这一处装配)。
let engine = crate::commands::install::build_engine(&ctx);
assert_eq!(
engine.config().min_avg_throughput,
16 * 1024,
"throughput_floor_kib=16 ⇒ 引擎里是 16 KiB/s(字节/秒)"
);
assert_eq!(
engine.config().chunk_stall_timeout,
Duration::from_secs(300)
);
assert_eq!(engine.config().response_timeout, Duration::from_secs(180));
// 终端上那行(同一份 `summary()`,同一个 `announce`)。
let line = super::announce(&super::tuning(&ctx)).expect("改过设置 ⇒ 该有一行说明");
assert!(line.contains("吞吐下限=16KiB/s"), "{line}");
assert!(line.contains("无进展窗口=300s"), "{line}");
assert!(line.contains("单次请求上界=180s"), "{line}");
// 不设 ⇒ 引擎回到出厂值,终端也不再打说明(「不设 = 当前行为」的 CLI 侧证据)。
write_launcher_config(&ctx, json!({ "download": {} }));
let engine = crate::commands::install::build_engine(&ctx);
assert_eq!(
engine.config().response_timeout,
Duration::from_secs(60),
"未设置 ⇒ 出厂 60s"
);
assert!(
super::announce(&super::tuning(&ctx)).is_none(),
"全出厂值不该打说明"
);
// 非法值:三个键一起写成 0 / 越界 ⇒ 回落出厂,且说明行不出现(不是「打一行假的」)。
write_launcher_config(
&ctx,
json!({ "download": {
"throughput_floor_kib": 0,
"stall_timeout_secs": 0,
"response_timeout_secs": 99_999,
}}),
);
assert_eq!(super::tuning(&ctx), DownloadTuning::default());
assert!(super::announce(&super::tuning(&ctx)).is_none());
}
/// 一个 URL 在 CLI 装配出来的引擎里的**源名顺序**(`plan()` 是引擎真正的选源决策点)。
fn plan_sources(engine: &DownloadEngine) -> Vec<String> {
let url = "https://libraries.minecraft.net/com/google/guava/guava/32.0.0/guava-32.0.0.jar";
let dest = std::env::temp_dir().join("ull-cli-plan-guava.jar");
let request = DownloadRequest::new(ResourceKind::Library, url.to_string(), dest);
engine
.plan(&request, 0)
.into_iter()
.map(|c| c.source)
.collect()
}
/// **M294 r1(P2-1)**:两键进 CLI 四条命令的生产装配点,终端那行说明也含它们。
///
/// 判据是**候选顺序**:两段都让设置与探测结论相反,没接上时必红(变异:`download-settings.md` M15)。
#[test]
fn the_source_selection_settings_reach_the_cli_engine() {
use unlucky_core::download::region::{store_cached, RegionProfile};
let dir = TempDir::new("source-selection");
let ctx = ctx_at(&dir);
let cache = ctx.layout().cache();
std::fs::create_dir_all(&cache).expect("建缓存根");
let now = unlucky_core::download::cache::now_ms();
let region_json = cache.join("region.json");
// 探测=镜像优先 + 设置 official ⇒ 官方第一条(未接线时是 bmclapi)。
store_cached(&region_json, &RegionProfile::mirror_first(now)).expect("档案");
let mirror = json!({ "download": { "mirror_preference": "official" } });
write_launcher_config(&ctx, mirror);
let engine = crate::commands::install::build_engine(&ctx);
assert_eq!(plan_sources(&engine), ["official", "bmclapi"], "官方第一条");
let line = super::announce(&super::tuning(&ctx)).expect("该有一行");
assert!(line.contains("镜像偏好=official"), "{line}");
assert!(line.contains("地区=auto"), "{line}");
// 探测=直连 + 设置 cn ⇒ 镜像第一条(未接线时是 official)。
store_cached(&region_json, &RegionProfile::direct(now)).expect("档案");
write_launcher_config(&ctx, json!({ "download": { "region": "cn" } }));
let engine = crate::commands::install::build_engine(&ctx);
assert_eq!(plan_sources(&engine), ["bmclapi", "official"], "镜像第一条");
let line = super::announce(&super::tuning(&ctx)).expect("该有一行");
assert!(line.contains("地区=cn"), "{line}");
assert!(line.contains("镜像偏好=auto"), "{line}");
}