diff --git a/crates/core/src/download/config.rs b/crates/core/src/download/config.rs index 20929a1..3de7b08 100644 --- a/crates/core/src/download/config.rs +++ b/crates/core/src/download/config.rs @@ -223,178 +223,5 @@ pub fn publish(cache_root: &Path, next: DownloadTuning) -> bool { } #[cfg(test)] -mod tests { - use super::*; - use crate::setting::keys::{launcher_schema, launcher_version}; - - /// **守门③**:schema 的默认值 / 区间 / 键名与 `EngineConfig::default()` / 调优默认值取同一组常量。 - /// 变异证明(只改实现侧):`EngineConfig::default()` 的并发写回 64、schema 默认值写成 16、 - /// `MAX_CONCURRENCY` 改了而 schema 仍写 64 ⇒ 各自红。 - #[test] - fn the_schema_contract_is_single_sourced() { - let schema = launcher_schema(); - assert_eq!(schema.version(), launcher_version()); - let cfg = EngineConfig::default(); - let tune = DownloadTuning::default(); - - // 并发 1~64(0 张许可没意义);尝试次数 **0**~10(P2-1:0 = 只试一次,旧配置里真有)。 - let contract: [(&str, i64, i64, usize); 2] = [ - ( - KEY_CONCURRENCY, - i64::try_from(DEFAULT_MAX_CONCURRENCY).expect("装得下"), - 1, - MAX_CONCURRENCY, - ), - ( - KEY_RETRY_LIMIT, - i64::from(DEFAULT_MAX_ATTEMPTS_PER_SOURCE), - 0, - usize::try_from(MAX_ATTEMPTS_PER_SOURCE).expect("装得下"), - ), - ]; - for (key, default, min, max) in contract { - let spec = schema.spec(key).expect("键已登记"); - assert_eq!(spec.default, serde_json::json!(default), "{key}"); - assert_eq!( - spec.kind, - crate::setting::spec::ValueKind::Int { - min, - max: i64::try_from(max).expect("装得下"), - }, - "{key}" - ); - } - assert_eq!(cfg.max_concurrency, DEFAULT_MAX_CONCURRENCY); - assert_eq!(tune.max_concurrency, DEFAULT_MAX_CONCURRENCY); - assert_eq!(cfg.max_attempts_per_source, DEFAULT_MAX_ATTEMPTS_PER_SOURCE); - assert_eq!( - tune.max_attempts_per_source, - DEFAULT_MAX_ATTEMPTS_PER_SOURCE - ); - } - - /// `SettingSpec.doc` 是 `&'static str`(`concat!` 插不进常量),描述里的数字只能手写——这条钉住它, - /// 改常量不改文案就红(`catalog.rs` 会把这些 doc 当 JSON schema 的 description 发出去)。 - #[test] - fn the_key_docs_quote_the_real_limits() { - let schema = launcher_schema(); - let doc = |key: &str| schema.spec(key).expect("键已登记").doc; - assert!(doc(KEY_CONCURRENCY).contains(&format!("1~{MAX_CONCURRENCY}"))); - assert!(doc(KEY_CONCURRENCY).contains(&format!("出厂 {DEFAULT_MAX_CONCURRENCY}"))); - assert!(doc(KEY_RETRY_LIMIT).contains(&format!("出厂 {DEFAULT_MAX_ATTEMPTS_PER_SOURCE}"))); - } - - /// 未填 / 越界回落默认(不阻断下载);**`retry_limit: 0` 是合法值**,语义 = 只试一次。 - /// - /// 变异证明:把并发的区间过滤删掉 ⇒ `0` / `9999` 两条红;把尝试次数的 `.max(1)` 删掉 ⇒ - /// `0 → 1` 那条红(引擎会拿到 0 次尝试);把 `0` 判成越界 ⇒ 同样红。 - #[test] - fn missing_or_illegal_values_fall_back_to_the_defaults() { - assert_eq!( - DownloadTuning::from_settings(None, None, None), - DownloadTuning::default() - ); - - let just_over = i64::try_from(MAX_CONCURRENCY).expect("上限装得下") + 1; - for bad in [0_i64, -1, 9_999, just_over] { - let tune = DownloadTuning::from_settings(Some(bad), None, None); - assert_eq!(tune.max_concurrency, DEFAULT_MAX_CONCURRENCY, "{bad}"); - } - // 0 与 1 都 = 只试一次:旧配置里的 0 不许回落成出厂 3(那正是「界面 0、引擎 3」)。 - for zero_or_one in [0_i64, 1] { - let tune = DownloadTuning::from_settings(None, Some(zero_or_one), None); - assert_eq!(tune.max_attempts_per_source, 1, "{zero_or_one}"); - } - for bad in [-1_i64, 9_999] { - let tune = DownloadTuning::from_settings(None, Some(bad), None); - assert_eq!( - tune.max_attempts_per_source, - DEFAULT_MAX_ATTEMPTS_PER_SOURCE - ); - } - - let tune = DownloadTuning::from_settings(Some(2), Some(2), None); - assert_eq!(tune.max_concurrency, 2); - assert_eq!(tune.max_attempts_per_source, 2); - } - - /// 三态:**空串 ≠ 关**(这条是「设置项说假话」的高发点,M197 的键语义同源)。 - #[test] - fn empty_gh_proxy_means_follow_builtin_not_off() { - assert_eq!(gh_proxy_override(""), GhProxyOverride::Builtin); - assert_eq!(gh_proxy_override(" "), GhProxyOverride::Builtin); - for off in [ - "off", "OFF", " none ", "direct", "disable", "disabled", "no", - ] { - assert_eq!(gh_proxy_override(off), GhProxyOverride::Off, "{off}"); - } - assert_eq!( - gh_proxy_override(" https://gh.example/ "), - GhProxyOverride::Prefix("https://gh.example/".to_string()) - ); - // 未填(键不存在)也是 Builtin,不是 Off。 - assert_eq!( - DownloadTuning::from_settings(None, None, None).gh_proxy, - GhProxyOverride::Builtin - ); - } - - /// `apply_gh_proxy` 的三档真的改到档案上、并且**改到候选链上**(`expand()` 读的就是档案里那个 - /// 前缀;自定义前缀那一档的链断言在 `shared.rs`)。变异证明:`Off` 分支改成原样返回 ⇒ `off` 红。 - #[test] - fn gh_proxy_override_reaches_the_profile_and_the_chain() { - use crate::download::mirrors::{MirrorTable, SOURCE_GH_PROXY}; - use crate::download::types::ResourceKind; - - let url = "https://github.com/o/r/releases/download/v1/a.jar"; - let table = MirrorTable::builtin(); - let sources = |profile: &RegionProfile| -> Vec { - table - .expand(url, ResourceKind::Other, profile) - .into_iter() - .map(|candidate| candidate.source) - .collect() - }; - - let mirror_first = RegionProfile::mirror_first(0); - let kept = DownloadTuning::default().apply_gh_proxy(mirror_first.clone()); - assert_eq!(kept.gh_proxy, mirror_first.gh_proxy, "Builtin 不该动档案"); - assert!( - sources(&kept).contains(&SOURCE_GH_PROXY.to_string()), - "出厂套前缀" - ); - - let off = DownloadTuning::from_settings(None, None, Some("off")) - .apply_gh_proxy(mirror_first.clone()); - assert!( - !sources(&off).contains(&SOURCE_GH_PROXY.to_string()), - "关了就没候选" - ); - - let prefix = DownloadTuning::from_settings(None, None, Some("https://gh.example/")) - .apply_gh_proxy(mirror_first); - assert_eq!(prefix.gh_proxy.as_deref(), Some("https://gh.example/")); - assert!(prefix.gh_proxy_enabled(), "前缀生效"); - } - - /// 每个数据根的调优互不串味(一个根改成 2 不该改另一个根的引擎)。 - /// 变异证明:把 `wanted`/`publish` 的键改回单一进程级槽位 ⇒ 第二个根读到 2,红。 - #[test] - fn tuning_is_per_cache_root_and_no_change_is_reported_as_such() { - let a = PathBuf::from("/tmp/ull-tuning-a"); - let b = PathBuf::from("/tmp/ull-tuning-b"); - assert_eq!(wanted(&a), DownloadTuning::default()); - - let changed = DownloadTuning::from_settings(Some(2), None, None); - assert!(publish(&a, changed.clone()), "真变了要报变更"); - assert_eq!(wanted(&a), changed); - assert!(!publish(&a, changed.clone()), "同值不该报变更"); - assert_eq!(wanted(&b), DownloadTuning::default(), "别的根不受影响"); - - assert!( - publish(&a, DownloadTuning::default()), - "恢复默认也是一次变更" - ); - assert_eq!(wanted(&a), DownloadTuning::default()); - } -} +#[path = "config/tests.rs"] +mod tests; diff --git a/crates/core/src/download/config/tests.rs b/crates/core/src/download/config/tests.rs new file mode 100644 index 0000000..49d2add --- /dev/null +++ b/crates/core/src/download/config/tests.rs @@ -0,0 +1,175 @@ +//! `download::config` 的单测:**从 `config.rs` 拆出只为守 400 行硬线**(M289 加超时字段前先纯搬移,逐字节未改)。 + +use super::*; +use crate::setting::keys::{launcher_schema, launcher_version}; + +/// **守门③**:schema 的默认值 / 区间 / 键名与 `EngineConfig::default()` / 调优默认值取同一组常量。 +/// 变异证明(只改实现侧):`EngineConfig::default()` 的并发写回 64、schema 默认值写成 16、 +/// `MAX_CONCURRENCY` 改了而 schema 仍写 64 ⇒ 各自红。 +#[test] +fn the_schema_contract_is_single_sourced() { + let schema = launcher_schema(); + assert_eq!(schema.version(), launcher_version()); + let cfg = EngineConfig::default(); + let tune = DownloadTuning::default(); + + // 并发 1~64(0 张许可没意义);尝试次数 **0**~10(P2-1:0 = 只试一次,旧配置里真有)。 + let contract: [(&str, i64, i64, usize); 2] = [ + ( + KEY_CONCURRENCY, + i64::try_from(DEFAULT_MAX_CONCURRENCY).expect("装得下"), + 1, + MAX_CONCURRENCY, + ), + ( + KEY_RETRY_LIMIT, + i64::from(DEFAULT_MAX_ATTEMPTS_PER_SOURCE), + 0, + usize::try_from(MAX_ATTEMPTS_PER_SOURCE).expect("装得下"), + ), + ]; + for (key, default, min, max) in contract { + let spec = schema.spec(key).expect("键已登记"); + assert_eq!(spec.default, serde_json::json!(default), "{key}"); + assert_eq!( + spec.kind, + crate::setting::spec::ValueKind::Int { + min, + max: i64::try_from(max).expect("装得下"), + }, + "{key}" + ); + } + assert_eq!(cfg.max_concurrency, DEFAULT_MAX_CONCURRENCY); + assert_eq!(tune.max_concurrency, DEFAULT_MAX_CONCURRENCY); + assert_eq!(cfg.max_attempts_per_source, DEFAULT_MAX_ATTEMPTS_PER_SOURCE); + assert_eq!( + tune.max_attempts_per_source, + DEFAULT_MAX_ATTEMPTS_PER_SOURCE + ); +} + +/// `SettingSpec.doc` 是 `&'static str`(`concat!` 插不进常量),描述里的数字只能手写——这条钉住它, +/// 改常量不改文案就红(`catalog.rs` 会把这些 doc 当 JSON schema 的 description 发出去)。 +#[test] +fn the_key_docs_quote_the_real_limits() { + let schema = launcher_schema(); + let doc = |key: &str| schema.spec(key).expect("键已登记").doc; + assert!(doc(KEY_CONCURRENCY).contains(&format!("1~{MAX_CONCURRENCY}"))); + assert!(doc(KEY_CONCURRENCY).contains(&format!("出厂 {DEFAULT_MAX_CONCURRENCY}"))); + assert!(doc(KEY_RETRY_LIMIT).contains(&format!("出厂 {DEFAULT_MAX_ATTEMPTS_PER_SOURCE}"))); +} + +/// 未填 / 越界回落默认(不阻断下载);**`retry_limit: 0` 是合法值**,语义 = 只试一次。 +/// +/// 变异证明:把并发的区间过滤删掉 ⇒ `0` / `9999` 两条红;把尝试次数的 `.max(1)` 删掉 ⇒ +/// `0 → 1` 那条红(引擎会拿到 0 次尝试);把 `0` 判成越界 ⇒ 同样红。 +#[test] +fn missing_or_illegal_values_fall_back_to_the_defaults() { + assert_eq!( + DownloadTuning::from_settings(None, None, None), + DownloadTuning::default() + ); + + let just_over = i64::try_from(MAX_CONCURRENCY).expect("上限装得下") + 1; + for bad in [0_i64, -1, 9_999, just_over] { + let tune = DownloadTuning::from_settings(Some(bad), None, None); + assert_eq!(tune.max_concurrency, DEFAULT_MAX_CONCURRENCY, "{bad}"); + } + // 0 与 1 都 = 只试一次:旧配置里的 0 不许回落成出厂 3(那正是「界面 0、引擎 3」)。 + for zero_or_one in [0_i64, 1] { + let tune = DownloadTuning::from_settings(None, Some(zero_or_one), None); + assert_eq!(tune.max_attempts_per_source, 1, "{zero_or_one}"); + } + for bad in [-1_i64, 9_999] { + let tune = DownloadTuning::from_settings(None, Some(bad), None); + assert_eq!( + tune.max_attempts_per_source, + DEFAULT_MAX_ATTEMPTS_PER_SOURCE + ); + } + + let tune = DownloadTuning::from_settings(Some(2), Some(2), None); + assert_eq!(tune.max_concurrency, 2); + assert_eq!(tune.max_attempts_per_source, 2); +} + +/// 三态:**空串 ≠ 关**(这条是「设置项说假话」的高发点,M197 的键语义同源)。 +#[test] +fn empty_gh_proxy_means_follow_builtin_not_off() { + assert_eq!(gh_proxy_override(""), GhProxyOverride::Builtin); + assert_eq!(gh_proxy_override(" "), GhProxyOverride::Builtin); + for off in [ + "off", "OFF", " none ", "direct", "disable", "disabled", "no", + ] { + assert_eq!(gh_proxy_override(off), GhProxyOverride::Off, "{off}"); + } + assert_eq!( + gh_proxy_override(" https://gh.example/ "), + GhProxyOverride::Prefix("https://gh.example/".to_string()) + ); + // 未填(键不存在)也是 Builtin,不是 Off。 + assert_eq!( + DownloadTuning::from_settings(None, None, None).gh_proxy, + GhProxyOverride::Builtin + ); +} + +/// `apply_gh_proxy` 的三档真的改到档案上、并且**改到候选链上**(`expand()` 读的就是档案里那个 +/// 前缀;自定义前缀那一档的链断言在 `shared.rs`)。变异证明:`Off` 分支改成原样返回 ⇒ `off` 红。 +#[test] +fn gh_proxy_override_reaches_the_profile_and_the_chain() { + use crate::download::mirrors::{MirrorTable, SOURCE_GH_PROXY}; + use crate::download::types::ResourceKind; + + let url = "https://github.com/o/r/releases/download/v1/a.jar"; + let table = MirrorTable::builtin(); + let sources = |profile: &RegionProfile| -> Vec { + table + .expand(url, ResourceKind::Other, profile) + .into_iter() + .map(|candidate| candidate.source) + .collect() + }; + + let mirror_first = RegionProfile::mirror_first(0); + let kept = DownloadTuning::default().apply_gh_proxy(mirror_first.clone()); + assert_eq!(kept.gh_proxy, mirror_first.gh_proxy, "Builtin 不该动档案"); + assert!( + sources(&kept).contains(&SOURCE_GH_PROXY.to_string()), + "出厂套前缀" + ); + + let off = DownloadTuning::from_settings(None, None, Some("off")) + .apply_gh_proxy(mirror_first.clone()); + assert!( + !sources(&off).contains(&SOURCE_GH_PROXY.to_string()), + "关了就没候选" + ); + + let prefix = DownloadTuning::from_settings(None, None, Some("https://gh.example/")) + .apply_gh_proxy(mirror_first); + assert_eq!(prefix.gh_proxy.as_deref(), Some("https://gh.example/")); + assert!(prefix.gh_proxy_enabled(), "前缀生效"); +} + +/// 每个数据根的调优互不串味(一个根改成 2 不该改另一个根的引擎)。 +/// 变异证明:把 `wanted`/`publish` 的键改回单一进程级槽位 ⇒ 第二个根读到 2,红。 +#[test] +fn tuning_is_per_cache_root_and_no_change_is_reported_as_such() { + let a = PathBuf::from("/tmp/ull-tuning-a"); + let b = PathBuf::from("/tmp/ull-tuning-b"); + assert_eq!(wanted(&a), DownloadTuning::default()); + + let changed = DownloadTuning::from_settings(Some(2), None, None); + assert!(publish(&a, changed.clone()), "真变了要报变更"); + assert_eq!(wanted(&a), changed); + assert!(!publish(&a, changed.clone()), "同值不该报变更"); + assert_eq!(wanted(&b), DownloadTuning::default(), "别的根不受影响"); + + assert!( + publish(&a, DownloadTuning::default()), + "恢复默认也是一次变更" + ); + assert_eq!(wanted(&a), DownloadTuning::default()); +} diff --git a/crates/core/src/download/engine.rs b/crates/core/src/download/engine.rs index e782e78..8ad6173 100644 --- a/crates/core/src/download/engine.rs +++ b/crates/core/src/download/engine.rs @@ -35,6 +35,8 @@ use super::types::{ SOURCE_OFFICIAL, }; +mod inspect; + /// 一次成功尝试的产物描述。 pub(crate) struct AttemptOk { /// 文件总字节数。 @@ -94,50 +96,6 @@ impl DownloadEngine { self } - /// 配置。 - #[must_use] - pub fn config(&self) -> &EngineConfig { - &self.cfg - } - - /// **真实并发闸门**此刻的许可数(探针,M193):读 [`Self::acquire`] 会去抢的那张 - /// `Semaphore`,**不是** `config().max_concurrency`。有下载在飞时偏小,空闲等于容量。 - #[must_use] - pub fn concurrency_probe(&self) -> usize { - self.sem.available_permits() - } - - /// HTTP 客户端(同一条连接池与超时配置):给「一锤子查询」用(如 Adoptium 版本查询), - /// 没必要为了一个 GET 再造一个 client。 - #[must_use] - pub fn client(&self) -> &reqwest::Client { - &self.http - } - - /// 当前地区档案(设置页展示)。 - #[must_use] - pub fn region(&self) -> &RegionProfile { - &self.region - } - - /// 镜像表(`game::java` 的 Oracle / MS / Zulu 分发表在这里查)。 - #[must_use] - pub fn mirrors(&self) -> &MirrorTable { - &self.mirrors - } - - /// 健康度注册表(诊断页)。 - #[must_use] - pub fn health(&self) -> &HealthRegistry { - &self.health - } - - /// 内容寻址缓存。 - #[must_use] - pub fn cache(&self) -> &ContentCache { - &self.cache - } - /// 候选规划:镜像展开 → 官方位置保持不变 → 其余按健康度排序。 #[must_use] pub fn plan(&self, req: &DownloadRequest, now_ms: u64) -> Vec { diff --git a/crates/core/src/download/engine/inspect.rs b/crates/core/src/download/engine/inspect.rs new file mode 100644 index 0000000..d03b2c4 --- /dev/null +++ b/crates/core/src/download/engine/inspect.rs @@ -0,0 +1,58 @@ +//! 引擎的只读查询面:**从 `engine.rs` 拆出只为守 400 行硬线**(M289 加失速诊断前先纯搬移,逐字节未改)。 +//! +//! 这里只有「读」——配置、并发闸门探针、HTTP 客户端、地区档案、镜像表、健康度、内容缓存。 +//! 编排(候选循环、退避、落盘)仍在 [`super`]。 + +use super::super::cache::ContentCache; +use super::super::config::EngineConfig; +use super::super::health::HealthRegistry; +use super::super::mirrors::MirrorTable; +use super::super::region::RegionProfile; +use super::DownloadEngine; + +impl DownloadEngine { + /// 配置。 + #[must_use] + pub fn config(&self) -> &EngineConfig { + &self.cfg + } + + /// **真实并发闸门**此刻的许可数(探针,M193):读 [`Self::acquire`] 会去抢的那张 + /// `Semaphore`,**不是** `config().max_concurrency`。有下载在飞时偏小,空闲等于容量。 + #[must_use] + pub fn concurrency_probe(&self) -> usize { + self.sem.available_permits() + } + + /// HTTP 客户端(同一条连接池与超时配置):给「一锤子查询」用(如 Adoptium 版本查询), + /// 没必要为了一个 GET 再造一个 client。 + #[must_use] + pub fn client(&self) -> &reqwest::Client { + &self.http + } + + /// 当前地区档案(设置页展示)。 + #[must_use] + pub fn region(&self) -> &RegionProfile { + &self.region + } + + /// 镜像表(`game::java` 的 Oracle / MS / Zulu 分发表在这里查)。 + #[must_use] + pub fn mirrors(&self) -> &MirrorTable { + &self.mirrors + } + + /// 健康度注册表(诊断页)。 + #[must_use] + pub fn health(&self) -> &HealthRegistry { + &self.health + } + + /// 内容寻址缓存。 + #[must_use] + pub fn cache(&self) -> &ContentCache { + &self.cache + } + +}