diff --git a/crates/core/src/game/java/api.rs b/crates/core/src/game/java/api.rs index 467e93d..1174e71 100644 --- a/crates/core/src/game/java/api.rs +++ b/crates/core/src/game/java/api.rs @@ -1,6 +1,8 @@ //! Adoptium(Eclipse Temurin)版本查询:`api.adoptium.net` → 一个可下载的 JDK 归档。 //! -//! 只用 v3 的 `assets/latest/{major}/hotspot` 这一条接口(D13:自动下载只装最新 GA): +//! **最新 GA**(`assets/latest/{major}/hotspot`,D13 的自动下载口径)走本模块; +//! 「指定版本」走子模块 [`version`](`assets/feature_releases` 翻页枚举 → `assets/release_name` +//! 取那一份),两者共用同一套响应结构。本模块只描述 latest 这条: //! //! ```text //! GET https://api.adoptium.net/v3/assets/latest/21/hotspot?os=windows&arch=x64&image_type=jdk&vendor=eclipse diff --git a/crates/core/src/game/java/api/version.rs b/crates/core/src/game/java/api/version.rs index 6ed0f9d..a1a5120 100644 --- a/crates/core/src/game/java/api/version.rs +++ b/crates/core/src/game/java/api/version.rs @@ -135,7 +135,9 @@ pub fn parse_release( /// /// # Errors /// -/// 任何一页取不到(网络 / 解析失败)时直接把该错误抛出去。 +/// **第一页**取不到(网络 / 404 / 解析失败)时直接抛错——那才是「这个 major 真没有 GA 清单」。 +/// 第二页及以后取不到时**不抛**:上游对越界页回 HTTP 404 + 空体(2026-11-12 实测), +/// 所以那是「翻到尾巴」的正常收尾(见下方注释)。 pub async fn collect_releases(mut fetch_page: F) -> Result<(Vec, bool), JavaError> where F: FnMut(usize) -> Fut, @@ -143,7 +145,17 @@ where { let mut all: Vec = Vec::new(); for page in 0..MAX_PAGES { - let names = fetch_page(page).await?; + let names = match fetch_page(page).await { + Ok(names) => names, + // 越界页不是空数组,而是 404(`net::get_text` 把它归成 Metadata)。某条 GA 线的条数 + // 恰好是 20 / 40 / 60 时,最后一页是满页 ⇒ 这里会多要一页 ⇒ 那一页 404。 + // 这时**必须把已经取到的前几页交出去**(否则整条 `--version` 路径被一句与用户请求 + // 无关的 404 打死);第一页的 404 仍然报错,走下面的 `Err(error) => return`。 + Err(error) if page > 0 && error.kind() == JavaErrorKind::Metadata => { + return Ok((all, false)); + } + Err(error) => return Err(error), + }; let short_page = names.len() < PAGE_SIZE; all.extend(names); if short_page { diff --git a/crates/core/src/game/java/api/version/tests.rs b/crates/core/src/game/java/api/version/tests.rs index e2bea9a..26e543c 100644 --- a/crates/core/src/game/java/api/version/tests.rs +++ b/crates/core/src/game/java/api/version/tests.rs @@ -205,3 +205,72 @@ async fn a_never_ending_catalog_stops_at_the_page_cap_and_says_so() { assert_eq!(releases.len(), MAX_PAGES * PAGE_SIZE); assert!(truncated, "到上限时必须标成截断,文案才能如实说"); } + +/// **越界页是 404 而不是空数组**(2026-11-12 实测):某条 GA 线的条数恰好是 20 的整数倍时, +/// 最后一页是满页 ⇒ 会多要一页 ⇒ 那一页 404。这时必须把**已经取到的**交出去。 +/// +/// 变异证明:把 `collect_releases` 里 `page > 0` 的分支去掉(改回无条件 `?`) +/// ⇒ 本用例红(`Err(Metadata)` 被抛出去,前 20 条连同一句无关的 404 一起作废)。 +#[tokio::test] +async fn a_404_on_the_page_after_a_full_page_ends_the_tail_cleanly() { + let calls = std::cell::Cell::new(0usize); + let result = collect_releases(|page| { + calls.set(calls.get() + 1); + async move { + match page { + 0 => Ok(page_zero()), + // 上游越界页的真实形态:HTTP 404 + 空体,被 `net::get_text` 归成 Metadata。 + _ => Err(JavaError::new( + JavaErrorKind::Metadata, + "Adoptium GA 清单 不存在(HTTP 404):https://api.adoptium.net/…&page=1", + )), + } + } + }) + .await; + + let (releases, truncated) = result.expect("第一页拿到了就是拿到了,越界页的 404 不该作废它"); + assert_eq!(releases.len(), PAGE_SIZE, "正好一页,条数要精确"); + assert_eq!(releases, page_zero()); + assert!(!truncated, "这是「翻到尾巴」,不是「到上限」"); + assert_eq!( + calls.get(), + 2, + "确实多要了第二页(这正是上游 404 的那一发)" + ); +} + +/// **反例**:第一页就 404 ⇒ 仍然是 Err(那才是「这个 major 没有 GA 清单」),不许被吞。 +/// +/// 变异证明:把 `page > 0` 的判断改成 `page >= 0`(即第一页也吞)⇒ 本用例红。 +#[tokio::test] +async fn a_404_on_the_first_page_is_still_an_error() { + let result = collect_releases(|_page| async { + Err::, JavaError>(JavaError::new( + JavaErrorKind::Metadata, + "Adoptium 没有 99 的 GA 产物(HTTP 404)", + )) + }) + .await; + + let error = result.expect_err("第一页 404 = 这个 major 真没有清单,必须报错"); + assert_eq!(error.kind(), JavaErrorKind::Metadata); + assert!(error.message().contains("404"), "{}", error.message()); +} + +/// 网络错误(非 Metadata)在任何一页都不许被当成「翻到尾巴」——那是真坏了。 +#[tokio::test] +async fn a_network_error_on_a_later_page_is_not_swallowed() { + let result = collect_releases(|page| async move { + match page { + 0 => Ok(page_zero()), + _ => Err(JavaError::new( + JavaErrorKind::Network, + "连不上 Adoptium API:connection reset", + )), + } + }) + .await; + let error = result.expect_err("网络错误要冒泡"); + assert_eq!(error.kind(), JavaErrorKind::Network); +} diff --git a/crates/core/src/game/java/catalog.rs b/crates/core/src/game/java/catalog.rs index 1c521ba..3361aeb 100644 --- a/crates/core/src/game/java/catalog.rs +++ b/crates/core/src/game/java/catalog.rs @@ -237,14 +237,16 @@ pub fn version_pin_error( let vendor_id = vendor.id(); match vendor { JdkVendor::Temurin | JdkVendor::Oracle => { + // `source` 自己带连接词:拉丁文名与中文名需要的空格不一样, + // 模板里再补一个空格就会拼出「Oracle 镜像目录 里可用」。 let source = if vendor == JdkVendor::Temurin { - "Adoptium" + "Adoptium 里" } else { - "Oracle 镜像目录" + "Oracle 镜像目录里" }; JavaError::new( JavaErrorKind::Metadata, - format!("{vendor_id} 的 Java {major} 没有版本 {wanted}({source} 里可用:{listed})"), + format!("{vendor_id} 的 Java {major} 没有版本 {wanted}({source}可用:{listed})"), ) .with_hint("不给 --version 就装该大版本最新的 GA;指定版本要写上面列出的形态") } diff --git a/crates/core/src/game/java/catalog/tests.rs b/crates/core/src/game/java/catalog/tests.rs index 3745363..5203d19 100644 --- a/crates/core/src/game/java/catalog/tests.rs +++ b/crates/core/src/game/java/catalog/tests.rs @@ -170,6 +170,23 @@ fn pin_refusals_are_distinct_and_each_explains_itself() { errors[0].message().contains("21.0.13+9"), "可用版本要列出来" ); + // P3-3:连接词由 `source` 自带,模板不再补空格 —— 两种拼法都必须读得通, + // 且 Oracle 那句不许出现「镜像目录 里」这种多一个空格的样子。 + assert!( + errors[0].message().contains("Adoptium 里可用"), + "{}", + errors[0].message() + ); + assert!( + errors[1].message().contains("Oracle 镜像目录里可用"), + "{}", + errors[1].message() + ); + assert!( + !errors[1].message().contains("镜像目录 里"), + "多一个空格:{}", + errors[1].message() + ); assert!(errors[2].message().contains("只内置了 21.0.13.0.2")); assert!(errors[2] .hint() diff --git a/crates/core/src/game/java/install/plan.rs b/crates/core/src/game/java/install/plan.rs index ff60a0d..87d635b 100644 --- a/crates/core/src/game/java/install/plan.rs +++ b/crates/core/src/game/java/install/plan.rs @@ -1,8 +1,16 @@ //! `install` 的**只读取**规划段:`--dry-run` 用它算出「会装哪一版、打哪个 URL」。 //! -//! 从 `install.rs` 拆出来只为守 400 行硬线(M280 r1 给回退链加了守卫后那边到 401 行); -//! 纯搬移,语义未变。与 `install/one.rs`(真装的一步)、`install/chain.rs`(判据与骨架) -//! 同一个拆法。 +//! 从 `install.rs` 拆出来的直接原因是守 400 行硬线(M280 r1 给回退链加了守卫后那边到 401 行), +//! 但**不是纯搬移**,三处语义变了,别照着「搬移」去读它: +//! +//! 1. 签名从 `plan(engine, location, major)` 变成 `plan(engine, location, &InstallRequest)` +//! —— 版本这一维(`request.version`)现在参与选择; +//! 2. 函数体不再自己写 `for` + `continue`,改成走 [`super::chain::chain_fetch`], +//! **与 `install` 共用同一份判据**(取版本 + 「版本必须对得上」的兜底闸); +//! 3. `request.priority`(设置里的 `java.vendorPriority`)现在参与选择顺序;旧实现用 +//! `vendor::order(&[], domestic)`,等于忽略这条设置。 +//! +//! 与 `install/one.rs`(真装的一步)、`install/chain.rs`(判据与骨架)同一个拆法。 use std::path::PathBuf;