复审 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` 同时红(两处都该红)。
267 lines
11 KiB
Rust
267 lines
11 KiB
Rust
//! [`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}:尝试次数也回落出厂值"
|
||
);
|
||
}
|
||
}
|