Files
Unlucky-Launcher/crates/cli/src/download/tests.rs
T
AskaEth 7995f62908 fix(m203): 给第 4 条吃设置的路径(ull update)补打点,并清掉 KEY_GH_PROXY 的腐化 doc
复审 r1 P2-1(p2-1items / fix-then-merge):`ull update` 复用 `install::build_engine` 后成了第 4 条
吃设置的路径,但没有任何用例钉它——把 update 换回裸引擎,四条 download::tests 全绿。按 pack 的完全
同形接缝补上:

- `commands/update.rs` 新增 `pub(crate) fn engine(ctx)`(`job()` 改调它),doc 写明这条路径上并发
  不可观测(自更新只下一个 artifact),随设置变的是每源重试次数与 gh-proxy 前缀;
- `download/tests.rs` 新增 `update_engine_takes_the_concurrency_from_the_setting_store`:
  写 concurrency=4 ⇒ 断言 `config().max_concurrency` 与 `concurrency_probe()` 都是 4。

顺带收掉复审的三条观察:
1. 新增 `the_gh_proxy_setting_reaches_the_candidate_chain`:镜像优先地区档案下,出厂(空串 = 跟随
   内置)确实生成 gh-proxy 候选;手写 `gh_proxy: "off"` 后候选里没有 gh-proxy(读的是 `plan()` 的
   候选链,不是配置值;两段式,避免「本来就没有候选」的白绿)。
2. 测试写侧改用 `ctx.setting_path_for(KEY_CONCURRENCY)`(`ull config set` 用的那个表达式),不再与
   实现读侧的 `launcher_config()` 共用同一个表达式——实现改读别的文件时用例会红而不是一起漂移。
3. `core/download/config.rs` 的 `KEY_GH_PROXY` doc 已腐化(写「暂未登记、M197 才登记、别删这条弱
   断言」,而该键在 main 上就已登记、本文件根本没有针对该常量的断言):改成事实,并保留「弱在哪」
   ——常量值与 schema 登记没被断言绑在一起,改名只会让宿主侧静默退化成 Builtin。

变异验证(只改实现侧,逐次还原):
- update::engine 换回裸 `EngineConfig::default()` ⇒ 只有 update 那条红(left 8 / right 4),另五条绿;
- `download::tuning` 不读 `KEY_GH_PROXY`(传 None)⇒ 只有 gh_proxy 那条红(off 档仍有
  `https://gh-proxy.com/...` 候选),另五条绿;
- `gh_proxy_override("")` 返回 Off ⇒ gh_proxy 那条的「出厂该套内置前缀」与回落用例的
  `tuning == default` 同时红(两处都该红)。
2026-11-09 10:07:41 +08:00

267 lines
11 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}:尝试次数也回落出厂值"
);
}
}