From 7b79d5c3976e27a7bcdc563fc9e32e8a9dd8499b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 21:42:15 +0800 Subject: [PATCH] =?UTF-8?q?chore(m197):=20=E4=B8=89=E5=A4=84=E6=8B=86?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=AE=88=20400=20=E8=A1=8C=E7=A1=AC=E7=BA=BF?= =?UTF-8?q?=EF=BC=88=E5=A4=8D=E5=AE=A1=20r1=20=E5=90=8E=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=86=85=E5=AE=B9=E6=8A=8A=E4=B8=89=E4=B8=AA=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E9=A1=B6=E8=BF=87=E7=BA=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `core/src/setting/keys.rs` 396(原 400 → 我的 +15 到 415):`download.proxy_mode` / `proxy_url` / `gh_proxy` 三键搬进新 `keys/proxy.rs`(`specs()` + 一句「空串 ≠ 关」的守卫), 主表留一行 `.with_all(proxy::specs())`——与 `font` / `update` 同一种形状。 - `core/tests/download/engine_mock_test.rs` 407 → 364:404 那条用例整条搬进 `tests/download/engine_mock/not_found.rs`(`#[path]` 子模块,仍是 `engine_mock` 这个 target)。 - `pages/settings/java/downloads/tests.rs` 423 → 313:gh-proxy 两条守卫搬进 `downloads/tests/gh_proxy.rs`(`#[path = "tests/gh_proxy.rs"]`)。 纯搬移 + 一条新单测(`keys/proxy.rs` 的「空串 = 跟随内置默认,不是关」);行为不变。 --- .../pages/settings/java/downloads/tests.rs | 116 +---------------- .../settings/java/downloads/tests/gh_proxy.rs | 118 ++++++++++++++++++ crates/core/src/setting/keys.rs | 31 +---- crates/core/src/setting/keys/proxy.rs | 77 ++++++++++++ .../tests/download/engine_mock/not_found.rs | 67 ++++++++++ .../core/tests/download/engine_mock_test.rs | 56 +-------- 6 files changed, 274 insertions(+), 191 deletions(-) create mode 100644 crates/app-iced/src/pages/settings/java/downloads/tests/gh_proxy.rs create mode 100644 crates/core/src/setting/keys/proxy.rs create mode 100644 crates/core/tests/download/engine_mock/not_found.rs diff --git a/crates/app-iced/src/pages/settings/java/downloads/tests.rs b/crates/app-iced/src/pages/settings/java/downloads/tests.rs index 5e2450d..f7b6a59 100644 --- a/crates/app-iced/src/pages/settings/java/downloads/tests.rs +++ b/crates/app-iced/src/pages/settings/java/downloads/tests.rs @@ -308,116 +308,6 @@ fn every_download_template_renders_without_leftover_placeholders() { } } -// --------------------------------------------------------------- P2-3:`download.gh_proxy` 的取值与真消费者 - -/// 设置原文 → 三态:空 = 跟随内置默认,`off` 一族 = 关,其余 = 覆盖前缀。 -/// -/// 变异证明:删掉 `gh_proxy_from` 里那段 `matches!(lowered, "off" | …)` ⇒ 本用例红 -/// (`off` 会被当成一个前缀 `off`,拼出 `offhttps://…` 这种 URL)。 -#[test] -fn the_gh_proxy_setting_maps_to_three_states() { - use super::engine::gh_proxy_from; - use super::GhProxy; - - assert_eq!(gh_proxy_from(""), GhProxy::Builtin, "空 = 跟随内置默认"); - assert_eq!(gh_proxy_from(" "), GhProxy::Builtin); - for off in [ - "off", "OFF", " none ", "direct", "Disable", "disabled", "no", - ] { - assert_eq!(gh_proxy_from(off), GhProxy::Off, "{off} 应表示关掉"); - } - assert_eq!( - gh_proxy_from(" https://gh.example/ "), - GhProxy::Prefix("https://gh.example/".to_string()), - "前后空白要去掉,值原样保留" - ); -} - -/// **真消费者**:`download.gh_proxy` 落盘 → 引擎档案 → **候选链里真的出现该前缀**。 -/// -/// 三步都要成立才算「接了」:① 键读得出来;② 档案被覆盖;③ `expand()` 用它生成候选。 -/// 只断言 ① 会漏掉「读了但没用」(本 mission 修的就是这一类),只断言 ② 会漏掉「expand 不认 -/// 档案里的前缀」。 -/// -/// 变异证明:让 `engine_with` 忽略 `gh_proxy` 参数(`let profile = profile;`)⇒ ②③ 两条红; -/// 把 `gh_proxy_of_config` 写成恒 `Builtin` ⇒ ① 先红。 -#[test] -fn the_gh_proxy_setting_reaches_the_candidate_chain() { - use super::engine::engine_with; - use super::{gh_proxy_of_config, GhProxy}; - use unlucky_core::download::cache::now_ms; - use unlucky_core::download::region::{store_cached, RegionProfile}; - use unlucky_core::download::types::{DownloadRequest, ResourceKind}; - use unlucky_core::download::ProgressEmitter; - use unlucky_core::setting::keys::launcher_schema; - use unlucky_core::setting::store::SettingStore; - - let dir = std::env::temp_dir().join(format!("ull-gh-proxy-{}-{}", std::process::id(), line!())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("cache")).expect("cache"); - std::fs::create_dir_all(dir.join("config")).expect("config"); - let config = dir.join("config").join("launcher.json"); - - // ① 键读得出来 - let mut store = SettingStore::load(&config, launcher_schema()); - store - .set( - "download.gh_proxy", - "https://gh.example/".to_string().into(), - ) - .expect("schema 认识 download.gh_proxy"); - store.save().expect("写盘"); - assert_eq!( - gh_proxy_of_config(&config), - GhProxy::Prefix("https://gh.example/".to_string()) - ); - - // region.json 钉成镜像优先:否则 gh-proxy 候选本来就不生成,测不出「设了有没有用」。 - let cache = dir.join("cache"); - store_cached( - &cache.join("region.json"), - &RegionProfile::mirror_first(now_ms()), - ) - .expect("写 region.json"); - - let engine = engine_with( - &cache, - ProgressEmitter::silent(), - &gh_proxy_of_config(&config), - ); - // ② 档案被覆盖 - assert_eq!( - engine.region().gh_proxy.as_deref(), - Some("https://gh.example/"), - "设置值必须覆盖档案里的前缀" - ); - - // ③ 候选链里真的出现这个前缀 - let request = DownloadRequest::new( - ResourceKind::Library, - "https://github.com/owner/repo/releases/download/v1/a.jar".to_string(), - dir.join("a.jar"), - ); - let plan = engine.plan(&request, 0); - assert!( - plan.iter() - .any(|candidate| candidate.url.starts_with("https://gh.example/")), - "gh-proxy 候选没出现在候选链里:{:?}", - plan.iter() - .map(|candidate| candidate.url.clone()) - .collect::>() - ); - - // 关掉那一档:一个代理候选都不生成(同一份 region.json,只换设置值)。 - let off = engine_with(&cache, ProgressEmitter::silent(), &GhProxy::Off); - assert_eq!(off.region().gh_proxy, None, "off 必须把前缀清掉"); - let plan = off.plan(&request, 0); - assert!( - !plan - .iter() - .any(|candidate| candidate.url.starts_with("https://gh.example/")), - "关掉之后不该再有 gh-proxy 候选" - ); - - let _ = std::fs::remove_dir_all(&dir); -} +/// `download.gh_proxy` 的两条守卫(M197):取值映射 + 真消费者。拆到子模块守 400 行硬线。 +#[path = "tests/gh_proxy.rs"] +mod gh_proxy; diff --git a/crates/app-iced/src/pages/settings/java/downloads/tests/gh_proxy.rs b/crates/app-iced/src/pages/settings/java/downloads/tests/gh_proxy.rs new file mode 100644 index 0000000..5684af7 --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/downloads/tests/gh_proxy.rs @@ -0,0 +1,118 @@ +//! `download.gh_proxy` 的两条守卫(M197):取值映射 + 「真的接上了」。 +//! +//! 从 `downloads/tests.rs` 拆出来守 400 行硬线(拆法与 `agents::tests` 一致:断言跟着它守的 +//! 东西走)。这里只说一件事:设置值必须**改变候选链**,而不只是被读出来。 + +// --------------------------------------------------------------- P2-3:`download.gh_proxy` 的取值与真消费者 + +/// 设置原文 → 三态:空 = 跟随内置默认,`off` 一族 = 关,其余 = 覆盖前缀。 +/// +/// 变异证明:删掉 `gh_proxy_from` 里那段 `matches!(lowered, "off" | …)` ⇒ 本用例红 +/// (`off` 会被当成一个前缀 `off`,拼出 `offhttps://…` 这种 URL)。 +#[test] +fn the_gh_proxy_setting_maps_to_three_states() { + use crate::pages::settings::java::downloads::engine::gh_proxy_from; + use crate::pages::settings::java::downloads::GhProxy; + + assert_eq!(gh_proxy_from(""), GhProxy::Builtin, "空 = 跟随内置默认"); + assert_eq!(gh_proxy_from(" "), GhProxy::Builtin); + for off in [ + "off", "OFF", " none ", "direct", "Disable", "disabled", "no", + ] { + assert_eq!(gh_proxy_from(off), GhProxy::Off, "{off} 应表示关掉"); + } + assert_eq!( + gh_proxy_from(" https://gh.example/ "), + GhProxy::Prefix("https://gh.example/".to_string()), + "前后空白要去掉,值原样保留" + ); +} + +/// **真消费者**:`download.gh_proxy` 落盘 → 引擎档案 → **候选链里真的出现该前缀**。 +/// +/// 三步都要成立才算「接了」:① 键读得出来;② 档案被覆盖;③ `expand()` 用它生成候选。 +/// 只断言 ① 会漏掉「读了但没用」(本 mission 修的就是这一类),只断言 ② 会漏掉「expand 不认 +/// 档案里的前缀」。 +/// +/// 变异证明:让 `engine_with` 忽略 `gh_proxy` 参数(`let profile = profile;`)⇒ ②③ 两条红; +/// 把 `gh_proxy_of_config` 写成恒 `Builtin` ⇒ ① 先红。 +#[test] +fn the_gh_proxy_setting_reaches_the_candidate_chain() { + use crate::pages::settings::java::downloads::engine::engine_with; + use crate::pages::settings::java::downloads::{gh_proxy_of_config, GhProxy}; + use unlucky_core::download::cache::now_ms; + use unlucky_core::download::region::{store_cached, RegionProfile}; + use unlucky_core::download::types::{DownloadRequest, ResourceKind}; + use unlucky_core::download::ProgressEmitter; + use unlucky_core::setting::keys::launcher_schema; + use unlucky_core::setting::store::SettingStore; + + let dir = std::env::temp_dir().join(format!("ull-gh-proxy-{}-{}", std::process::id(), line!())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("cache")).expect("cache"); + std::fs::create_dir_all(dir.join("config")).expect("config"); + let config = dir.join("config").join("launcher.json"); + + // ① 键读得出来 + let mut store = SettingStore::load(&config, launcher_schema()); + store + .set( + "download.gh_proxy", + "https://gh.example/".to_string().into(), + ) + .expect("schema 认识 download.gh_proxy"); + store.save().expect("写盘"); + assert_eq!( + gh_proxy_of_config(&config), + GhProxy::Prefix("https://gh.example/".to_string()) + ); + + // region.json 钉成镜像优先:否则 gh-proxy 候选本来就不生成,测不出「设了有没有用」。 + let cache = dir.join("cache"); + store_cached( + &cache.join("region.json"), + &RegionProfile::mirror_first(now_ms()), + ) + .expect("写 region.json"); + + let engine = engine_with( + &cache, + ProgressEmitter::silent(), + &gh_proxy_of_config(&config), + ); + // ② 档案被覆盖 + assert_eq!( + engine.region().gh_proxy.as_deref(), + Some("https://gh.example/"), + "设置值必须覆盖档案里的前缀" + ); + + // ③ 候选链里真的出现这个前缀 + let request = DownloadRequest::new( + ResourceKind::Library, + "https://github.com/owner/repo/releases/download/v1/a.jar".to_string(), + dir.join("a.jar"), + ); + let plan = engine.plan(&request, 0); + assert!( + plan.iter() + .any(|candidate| candidate.url.starts_with("https://gh.example/")), + "gh-proxy 候选没出现在候选链里:{:?}", + plan.iter() + .map(|candidate| candidate.url.clone()) + .collect::>() + ); + + // 关掉那一档:一个代理候选都不生成(同一份 region.json,只换设置值)。 + let off = engine_with(&cache, ProgressEmitter::silent(), &GhProxy::Off); + assert_eq!(off.region().gh_proxy, None, "off 必须把前缀清掉"); + let plan = off.plan(&request, 0); + assert!( + !plan + .iter() + .any(|candidate| candidate.url.starts_with("https://gh.example/")), + "关掉之后不该再有 gh-proxy 候选" + ); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/core/src/setting/keys.rs b/crates/core/src/setting/keys.rs index 8be239d..cd735ee 100644 --- a/crates/core/src/setting/keys.rs +++ b/crates/core/src/setting/keys.rs @@ -37,8 +37,10 @@ pub const JAVA_INSTALL_ROOT_UNSET: &str = ""; /// 必须同一份集合(setting 层不依赖 game 层),一致性由 `tests/setting/java_install_keys.rs` 守。 pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu", "termux"]; -/// 拆到各自文件的键域:中文字体 CDN(M168)/ 自更新(M161)。主表各留一行登记。 +/// 拆到各自文件的键域:中文字体 CDN(M168)/ 自更新(M161)/ 代理与 gh-proxy(M197)。 +/// 主表各留一行登记。 pub mod font; +pub mod proxy; pub mod update; /// launcher.json 当前 schema 版本。 @@ -132,30 +134,9 @@ pub fn launcher_schema() -> SettingSchema { AiPolicy::audited(), "镜像偏好:auto(健康度评分)/ official(官方源优先)/ mirror(镜像优先)", )) - .with(SettingSpec::enumeration( - "download.proxy_mode", - "none", - PROXY_MODES, - AiPolicy::sensitive(), - "代理模式:none / system / manual", - )) - .with(SettingSpec::text( - "download.proxy_url", - "", - 512, - AiPolicy::sensitive(), - "manual 代理地址(如 http://127.0.0.1:7890)", - )) - // M197:gh-proxy 前缀的用户覆盖。语义在 `core::download::region`: - // 空串 = 跟随内置默认(镜像表里的 gh-proxy.com,且只有镜像优先/国内判定才启用); - // `off` = 明确关掉;其余非空值 = 用这个前缀覆盖。 - .with(SettingSpec::text( - "download.gh_proxy", - "", - 256, - AiPolicy::sensitive(), - "GitHub 下载的加速前缀(gh-proxy)。留空 = 跟随内置默认(镜像表里的 gh-proxy.com,仅镜像优先/国内判定时启用);填 off 关闭;填一个 URL 前缀则覆盖它", - )) + // M197:代理与 gh-proxy 三键(`download.proxy_mode` / `proxy_url` / `gh_proxy`)搬到 + // [`proxy`]——主表这一段的注释也一起搬过去,见那个模块的表格。 + .with_all(proxy::specs()) .with(SettingSpec::path( "java.selected_path", AiPolicy::sensitive(), diff --git a/crates/core/src/setting/keys/proxy.rs b/crates/core/src/setting/keys/proxy.rs new file mode 100644 index 0000000..9608c73 --- /dev/null +++ b/crates/core/src/setting/keys/proxy.rs @@ -0,0 +1,77 @@ +//! 代理与 gh-proxy 三键(M197 从 `keys.rs` 拆出:那边正好贴着 400 行硬线)。 +//! +//! 三个键是一件事的两层:前两个是 **HTTP 代理**(整条请求走代理),第三个是 **gh-proxy 前缀** +//! (只把 GitHub 上的 URL 改写成 ``,其余流量直连)。放在一起是因为用户问的往往是 +//! 「我下不动 GitHub 怎么办」,答案要么在这三个里面、要么在镜像表里。 +//! +//! | 键 | 语义 | 谁消费 | +//! |---|---|---| +//! | `download.proxy_mode` | `none` / `system` / `manual` | 引擎的 HTTP 客户端(`http::build_client`) | +//! | `download.proxy_url` | manual 模式的代理地址 | 同上 | +//! | `download.gh_proxy` | 空 = 跟随内置默认;`off` = 关;其它 = 覆盖前缀 | `app-iced` 的 JDK 下载作业(M197),其它下载走同一份 `region.json` | +//! +//! `download.gh_proxy` 的语义细节在 `core::download::region`:**空串不等于关**——空串是「没有 +//! 覆盖」,仍按地区判定与内置镜像表走(国内判成镜像优先时用 `gh-proxy.com`)。要关必须写 `off`, +//! 因为「我不想用加速」和「我还没决定」是两件事。 + +use crate::setting::spec::{AiPolicy, SettingSpec}; + +use super::PROXY_MODES; + +/// 三个键的完整登记项(主表一行 `.with_all(proxy::specs())`)。 +#[must_use] +pub fn specs() -> Vec { + vec![ + SettingSpec::enumeration( + "download.proxy_mode", + "none", + PROXY_MODES, + AiPolicy::sensitive(), + "代理模式:none / system / manual", + ), + SettingSpec::text( + "download.proxy_url", + "", + 512, + AiPolicy::sensitive(), + "manual 代理地址(如 http://127.0.0.1:7890)", + ), + // M197:gh-proxy 前缀的用户覆盖(任务书那句「(默认值可改)」的落点)。 + SettingSpec::text( + "download.gh_proxy", + "", + 256, + AiPolicy::sensitive(), + "GitHub 下载的加速前缀(gh-proxy)。留空 = 跟随内置默认(镜像表里的 gh-proxy.com,仅镜像优先/国内判定时启用);填 off 关闭;填一个 URL 前缀则覆盖它", + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 三个键都在、且**空串 ≠ 关**这条语义写在描述里(M197 复审要求它与行为一致)。 + /// + /// 变异证明:把 `gh_proxy` 的描述改成「留空 = 关闭」⇒ 本用例红(实现里空串是「跟随内置默认」, + /// `downloads::engine::gh_proxy_from("")` 返回 `Builtin` 而不是 `Off`)。 + #[test] + fn the_gh_proxy_description_says_empty_is_not_off() { + let specs = specs(); + let ids: Vec<&str> = specs.iter().map(|spec| spec.key).collect(); + assert_eq!( + ids, + vec![ + "download.proxy_mode", + "download.proxy_url", + "download.gh_proxy" + ] + ); + let gh = specs + .iter() + .find(|spec| spec.key == "download.gh_proxy") + .expect("gh_proxy 已登记"); + assert!(gh.doc.contains("留空 = 跟随内置默认"), "{}", gh.doc); + assert!(gh.doc.contains("填 off 关闭"), "{}", gh.doc); + } +} diff --git a/crates/core/tests/download/engine_mock/not_found.rs b/crates/core/tests/download/engine_mock/not_found.rs new file mode 100644 index 0000000..13cc50c --- /dev/null +++ b/crates/core/tests/download/engine_mock/not_found.rs @@ -0,0 +1,67 @@ +//! `SourceFailed.kind` 的另一半(M197 复审 P2-2):**404 是「该源没有这个文件」**,不是「源坏了」。 +//! +//! 两档在引擎里的后果不同:`NotFound` 走 `record_missing`(不冷却源),`ServerError` 走 +//! `record_failure`。院校镜像只留最新补丁,请求旧补丁必然 404——判成「源坏了」会把唯一可用镜像 +//! 冷却掉(M33/M34 实测)。 +//! +//! 变异证明:把 `engine.rs` / `bytes.rs` 的 `kind: err.kind` 改成常量 `Network` ⇒ 本条与 +//! `reports_progress_events_and_errors_when_all_sources_fail` 一起红。 +//! +//! 拆成子模块:`engine_mock_test.rs` 加了这条之后 407 行,越过 400 行硬线(`guards.yml` 的 +//! `file-size`),于是把整条用例搬出来。 + +use std::sync::Arc; + +use unlucky_core::download::cache::ContentCache; +use unlucky_core::download::engine::DownloadEngine; +use unlucky_core::download::error::DownloadErrorKind; +use unlucky_core::download::health::HealthRegistry; +use unlucky_core::download::mirrors::MirrorTable; +use unlucky_core::download::progress::{ProgressEmitter, ProgressEvent}; +use unlucky_core::download::types::{DownloadRequest, ResourceKind}; + +use super::support::{temp_dir, MockResponse, MockServer}; +use super::{direct_region, fast_config}; + +#[tokio::test] +async fn a_404_is_classified_as_not_found() { + let server = MockServer::start(vec![ + MockResponse::status(404), + MockResponse::status(404), + MockResponse::status(404), + MockResponse::status(404), + ]) + .await; + let url = server.url("/gone.jar"); + let dir = temp_dir("engine-404"); + let (emitter, mut events) = ProgressEmitter::channel(); + let engine = DownloadEngine::new( + fast_config(1), + Arc::new(MirrorTable::builtin()), + Arc::new(direct_region()), + Arc::new(HealthRegistry::in_memory()), + Arc::new(ContentCache::open(dir.join("cache"))), + ) + .with_progress(emitter); + engine + .download(DownloadRequest::new( + ResourceKind::Library, + url.as_str(), + dir.join("gone.jar"), + )) + .await + .expect_err("只有 404 的源必须失败"); + + let mut seen: Vec = Vec::new(); + while let Ok(event) = events.try_recv() { + if let ProgressEvent::SourceFailed { kind, .. } = event { + seen.push(kind); + } + } + assert!(!seen.is_empty(), "至少要有一条 SourceFailed"); + assert!( + seen.iter().all(|kind| *kind == DownloadErrorKind::NotFound), + "404 必须分类成 NotFound,实际:{seen:?}" + ); + server.stop(); +} diff --git a/crates/core/tests/download/engine_mock_test.rs b/crates/core/tests/download/engine_mock_test.rs index 5b9ce04..30604c4 100644 --- a/crates/core/tests/download/engine_mock_test.rs +++ b/crates/core/tests/download/engine_mock_test.rs @@ -359,56 +359,6 @@ async fn reports_progress_events_and_errors_when_all_sources_fail() { server.stop(); } -/// `SourceFailed.kind` 的另一半:**404 不是「源坏了」**,是「该源没有这个文件」。 -/// -/// 这两档在引擎里的后果完全不同(`NotFound` 走 `record_missing`,不冷却源;`ServerError` 走 -/// `record_failure`)。把两者混成一个常量最贵的地方就在这里:院校镜像只留最新补丁,请求旧补丁 -/// 必然 404,判成「源坏了」会把唯一可用镜像冷却掉(M33/M34 实测)。 -/// -/// 变异证明:把 `engine.rs` / `bytes.rs` 两处 `kind: err.kind` 改成 `DownloadErrorKind::NotFound` -/// 常量 ⇒ 上面那条 500 用例红;改成 `Network` ⇒ 本条红。 -#[tokio::test] -async fn a_404_is_classified_as_not_found_and_never_cools_the_source_down() { - let server = MockServer::start(vec![ - MockResponse::status(404), - MockResponse::status(404), - MockResponse::status(404), - MockResponse::status(404), - ]) - .await; - let url = server.url("/gone.jar"); - let dir = temp_dir("engine-404"); - let cache = Arc::new(ContentCache::open(dir.join("cache"))); - let (emitter, mut events) = ProgressEmitter::channel(); - let engine = DownloadEngine::new( - fast_config(1), - Arc::new(MirrorTable::builtin()), - Arc::new(direct_region()), - Arc::new(HealthRegistry::in_memory()), - cache, - ) - .with_progress(emitter); - - let error = engine - .download(DownloadRequest::new( - ResourceKind::Library, - url.as_str(), - dir.join("gone.jar"), - )) - .await - .expect_err("只有 404 的源必须失败"); - assert_eq!(error.kind, DownloadErrorKind::AllSourcesFailed); - - let mut seen: Vec = Vec::new(); - while let Ok(event) = events.try_recv() { - if let ProgressEvent::SourceFailed { kind, .. } = event { - seen.push(kind); - } - } - assert!(!seen.is_empty(), "至少要有一条 SourceFailed"); - assert!( - seen.iter().all(|kind| *kind == DownloadErrorKind::NotFound), - "404 必须分类成 NotFound,实际:{seen:?}" - ); - server.stop(); -} +/// 「404 不是源坏了」的守卫(M197 P2-2 的第二档):拆到子模块守 400 行硬线。 +#[path = "engine_mock/not_found.rs"] +mod not_found;