fix(java): Temurin 指定版本要翻页 + 三层「不静默回落」补上守卫(M280 r1)

复审 r1 判 p2-2items / fix-then-merge。两条都在这轮修掉。

## P2-1 Temurin 只读第一页 ⇒ 上游有的版本被报「没有」
`page_size` 上限 20 是上游硬约束,但 8/11/17 线各有 26 条 GA(实测 17/ga page=0 回
20 条、page=1 回 6 条)。原来只读第一页,`17.0.4` 这种「上游有、第一页没有」的版本
被报成「没有这个版本」,而且「等 20 个」是我们的窗口而不是上游总数。

- `releases_url(major, target, page)` 带页码;`collect_releases(fetch_page)` 是**纯**骨架
  (分页由闭包注入),翻到「本页不满一页」为止,上限 `MAX_PAGES = 5`;
- `fetch_version` 用 `pick_release(&releases, wanted)` 挑(不取最新、不取第一条);
- 文案:`catalog::VersionListScope::{Complete, Truncated}` +
  `available_versions_text(.., scope)`。完整时说「等 N 个」(= 上游总数),
  到上限时才说「只查了前 N 条,后面可能还有」——不再把窗口当全集。
- 真机复现(复审给的命令):`17 --version 17.0.4` 现在命中 `17.0.4+8`;
  `17 --version 17` 命中第二页最后一条 `17+35`;`17 --version 17.0.99` 报「等 26 个」。

## P2-2 三层保证一条守卫都没有(复审的变异 A/B/C)
现在每条都有能变红的用例:

| 守卫 | 位置 | 用例 |
| --- | --- | --- |
| Temurin 命中而非取最新 | `api/version.rs::pick_release` | `the_pinned_version_wins_over_the_newest_entry` + 两页用例 |
| 兜底闸(版本必须对上) | `catalog::version_mismatch` | `catalog/tests.rs::the_version_gate_rejects_...` |
| 装配层的「记失败继续走链」 | `install/chain.rs::resolve_one` / `chain_fetch` | `a_release_that_is_not_...`、`a_mismatch_records_a_failure_and_the_chain_moves_on`、`nothing_matching_...` |
| 钉死一家不换家 | `install()` 全链(`--vendor zulu --version`) | `a_pinned_vendor_that_cannot_pin_is_tried_alone`(断言「试过 1」+ `latest=true` + 一个字节没落盘) |

`install` 的循环与 `--dry-run` 的 `plan` 现在**共用同一份判据**(`resolve_one`):
`install` 逐家调它,`plan` 用 `chain_fetch` 驱动整条链。

## 拆分(守 400 行硬线)
- `catalog.rs` 400 → `catalog.rs` 278 + `catalog/tests.rs` 229(既有 7 条用例搬走,
  唯一差异是新 API 的 `VersionListScope::Complete` 参数)
- `install.rs` → `install/plan.rs`(`plan` 段落;文档与签名原样,函数体改成走
  `chain_fetch`,这正是「同一份判据」的要求)+ `install/chain.rs`(新骨架与守卫,
  `install.rs` 361 行)
- `api/version.rs` 417 → `api/version.rs` 214 + `api/version/tests.rs` 207

## 门禁(CARGO_TARGET_DIR=C:/Temp/ull-target/wt-280)
core --lib 870 / --test game 120 / --test launch 91、cli --all-targets、fmt --check、
clippy -D warnings 全部 EXIT=0。
This commit is contained in:
worker-javapin authored and AskaEth committed 2026-11-12 22:05:19 +08:00
1 parent 9fded45d9e
commit 1353c044c3
11 files changed
+850 -311

No files matched your search

+65 -96
View File
@@ -13,13 +13,21 @@
//! (openapi 里 `page_size` 的 `maximum` 就是 20)把前者翻成后者。同一份响应的 release 名
//! 列表同时是「这个大版本有哪些版本」的唯一来源——版本不存在时就靠它列给人看。
//!
//! **必须翻页**(M280 r1):`page_size` 上限 20 是上游硬约束,而 8 / 11 / 17 线各有 26 条 GA
//! (2026-11-12 实测:`17/ga` page=0 回 20 条、page=1 回 6 条)。只读第一页会把
//! `17.0.4+8` 这种「上游有、第一页没有」的版本报成「没有这个版本」——文案还会把窗口当总数。
//! 所以这里按 `page` 翻到「本页不满一页」为止,上限 [`MAX_PAGES`] 页;真到上限就把清单标成
//! [`VersionListScope::Truncated`],错误文案里如实写「只查了前 N 条」。
//!
//! 这里**只有取**:匹配用 [`matches_version`],匹配不上时把可用清单交给
//! [`version_pin_error`] 生成错误。**不换家、不回落 latest**(那是 `install` / `source` 的事)。
use serde::Deserialize;
use super::{ApiBinary, ApiVersion, ADOPTIUM_API_BASE};
use crate::game::java::catalog::{canonical_version, matches_version, version_pin_error};
use crate::game::java::catalog::{
canonical_version, matches_version, version_pin_error, VersionListScope,
};
use crate::game::java::error::{JavaError, JavaErrorKind};
use crate::game::java::net;
use crate::game::java::release::JdkRelease;
@@ -29,11 +37,14 @@ use crate::game::java::vendor::JdkVendor;
/// `feature_releases` 的每页条数(Adoptium 允许的上限)。
pub const PAGE_SIZE: usize = 20;
/// 某大版本的 GA 清单地址(新 → 旧)。
/// 最多翻几页(正常最多 2 页;5 页 = 100 条,纯防御,别让上游的异常响应把内存吃掉)。
pub const MAX_PAGES: usize = 5;
/// 某大版本第 `page` 页的 GA 清单地址(新 → 旧,`page` 从 0 起)。
#[must_use]
pub fn releases_url(major: u32, target: AdoptiumTarget) -> String {
pub fn releases_url(major: u32, target: AdoptiumTarget, page: usize) -> String {
format!(
"{ADOPTIUM_API_BASE}/assets/feature_releases/{major}/ga?os={}&arch={}&image_type=jdk&page=0&page_size={PAGE_SIZE}&sort_order=DESC&vendor=eclipse",
"{ADOPTIUM_API_BASE}/assets/feature_releases/{major}/ga?os={}&arch={}&image_type=jdk&page={page}&page_size={PAGE_SIZE}&sort_order=DESC&vendor=eclipse",
target.os, target.arch
)
}
@@ -114,6 +125,40 @@ pub fn parse_release(
binary.into_release(name, release.version_data)
}
/// 翻页把该大版本的 GA release 名全部取回来(按页拼,直到「本页不满一页」或到 [`MAX_PAGES`])。
///
/// `fetch_page(page)` 由调用方给:真跑时是一发 HTTP,单测里是夹具闭包——所以「翻几页 / 什么时候
/// 停 / 到上限了没有」这段判断**完全离线可测**。
///
/// 返回值第二项 = 是否**因为到上限**而停(还有没看到的页)。错误文案要用它决定说
/// 「上游就这些」还是「只查了前 N 条」。
///
/// # Errors
///
/// 任何一页取不到(网络 / 解析失败)时直接把该错误抛出去。
pub async fn collect_releases<F, Fut>(mut fetch_page: F) -> Result<(Vec<String>, bool), JavaError>
where
F: FnMut(usize) -> Fut,
Fut: std::future::Future<Output = Result<Vec<String>, JavaError>>,
{
let mut all: Vec<String> = Vec::new();
for page in 0..MAX_PAGES {
let names = fetch_page(page).await?;
let short_page = names.len() < PAGE_SIZE;
all.extend(names);
if short_page {
return Ok((all, false));
}
}
Ok((all, true))
}
/// 在枚举出来的 release 名里挑用户要的那一版(**不取最新、不取第一条**)。
#[must_use]
pub fn pick_release<'a>(releases: &'a [String], wanted: &str) -> Option<&'a String> {
releases.iter().find(|name| matches_version(wanted, name))
}
/// 查一次「某大版本的指定版本」。
///
/// # Errors
@@ -125,22 +170,30 @@ pub async fn fetch_version(
target: AdoptiumTarget,
wanted: &str,
) -> Result<JdkRelease, JavaError> {
let list = net::get_text(client, &releases_url(major, target), "Adoptium GA 清单").await?;
let releases = parse_release_list(&list)?;
let picked = releases
.iter()
.find(|name| matches_version(wanted, name))
.cloned();
let Some(name) = picked else {
let (releases, truncated) = collect_releases(|page| {
let url = releases_url(major, target, page);
async move {
let body = net::get_text(client, &url, "Adoptium GA 清单").await?;
parse_release_list(&body)
}
})
.await?;
let Some(name) = pick_release(&releases, wanted).cloned() else {
let versions = releases
.iter()
.map(|name| canonical_version(name).to_string())
.collect::<Vec<_>>();
let scope = if truncated {
VersionListScope::Truncated
} else {
VersionListScope::Complete
};
return Err(version_pin_error(
JdkVendor::Temurin,
major,
wanted,
&versions,
scope,
));
};
let body = net::get_text(client, &release_url(&name, target), "Adoptium 指定版本产物").await?;
@@ -158,88 +211,4 @@ pub async fn fetch_version(
}
#[cfg(test)]
mod tests {
use super::{parse_release, parse_release_list, release_url, releases_url};
use crate::game::java::error::JavaErrorKind;
use crate::game::java::target::AdoptiumTarget;
const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64");
/// 真机 `feature_releases/21/ga` 响应的裁剪(2026-11-12,只留两条与用到的字段)。
fn list_body() -> String {
r#"[
{"release_name":"jdk-21.0.13+9","version_data":{"major":21,"semver":"21.0.13+9.0.LTS"},"binaries":[]},
{"release_name":"jdk-21.0.12+8","version_data":{"major":21,"semver":"21.0.12+8.0.LTS"},"binaries":[]}
]"#
.to_string()
}
/// 真机 `assets/release_name/eclipse/jdk-21.0.13+9` 响应的裁剪。
fn release_body() -> String {
r#"{
"release_name": "jdk-21.0.13+9",
"version_data": {"major": 21, "semver": "21.0.13+9.0.LTS", "build": 9},
"binaries": [
{
"os": "windows", "architecture": "x64", "image_type": "jdk", "jvm_impl": "hotspot",
"package": {
"name": "OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip",
"link": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.13%2B9/OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip",
"checksum": "a4dbaa05fca4bd89d84584e58c459bd732efebb1722af80ab13c6516832004f9",
"size": 205421281
}
}
]
}"#
.to_string()
}
#[test]
fn urls_target_the_versioned_endpoints_never_latest() {
let releases = releases_url(21, WINDOWS);
assert!(releases.starts_with("https://api.adoptium.net/v3/assets/feature_releases/21/ga?"));
assert!(releases.contains("page_size=20"));
assert!(releases.contains("sort_order=DESC"));
assert!(!releases.contains("latest"));
let release = release_url("jdk-21.0.13+9", WINDOWS);
assert!(release
.starts_with("https://api.adoptium.net/v3/assets/release_name/eclipse/jdk-21.0.13+9?"));
assert!(release.contains("os=windows") && release.contains("arch=x64"));
assert!(!release.contains("latest"));
}
#[test]
fn release_list_keeps_the_upstream_order_and_names() {
assert_eq!(
parse_release_list(&list_body()).expect("应解析"),
vec!["jdk-21.0.13+9".to_string(), "jdk-21.0.12+8".to_string()]
);
let error = parse_release_list("<html>502</html>").expect_err("HTML 应被拒");
assert_eq!(error.kind(), JavaErrorKind::Metadata);
}
#[test]
fn release_body_becomes_the_pinned_archive() {
let release = parse_release(&release_body(), "jdk-21.0.13+9", WINDOWS).expect("应解析");
assert_eq!(release.version, "21.0.13+9", "版本串取自 release 名");
assert_eq!(release.semver.as_deref(), Some("21.0.13+9.0.LTS"));
assert_eq!(release.major, 21);
assert_eq!(
release.archive.name,
"OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip"
);
assert_eq!(release.archive.size, 205_421_281);
assert!(release.archive.url.contains("temurin21-binaries"));
}
#[test]
fn other_platform_has_no_pinned_archive() {
let error = parse_release(
&release_body(),
"jdk-21.0.13+9",
AdoptiumTarget::new("windows", "x86-32"),
)
.expect_err("x86-32 应没有条目");
assert_eq!(error.kind(), JavaErrorKind::Metadata);
}
}
mod tests;
@@ -0,0 +1,207 @@
//! `api::version` 的单测:端点形状、两页夹具下的翻页、按版本挑而不是挑最新。
//!
//! 从 `version.rs` 拆出来只为守 400 行硬线(M280 r1 加了 P2-1/P2-2 的守卫后那边到 417 行);
//! **纯搬移**,既有断言一字未改。
use super::{
collect_releases, parse_release, parse_release_list, pick_release, release_url, releases_url,
MAX_PAGES, PAGE_SIZE,
};
use crate::game::java::error::{JavaError, JavaErrorKind};
use crate::game::java::target::AdoptiumTarget;
const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64");
/// 真机 `feature_releases/21/ga` 响应的裁剪(2026-11-12,只留两条与用到的字段)。
fn list_body() -> String {
r#"[
{"release_name":"jdk-21.0.13+9","version_data":{"major":21,"semver":"21.0.13+9.0.LTS"},"binaries":[]},
{"release_name":"jdk-21.0.12+8","version_data":{"major":21,"semver":"21.0.12+8.0.LTS"},"binaries":[]}
]"#
.to_string()
}
/// 真机 `assets/release_name/eclipse/jdk-21.0.13+9` 响应的裁剪。
fn release_body() -> String {
r#"{
"release_name": "jdk-21.0.13+9",
"version_data": {"major": 21, "semver": "21.0.13+9.0.LTS", "build": 9},
"binaries": [
{
"os": "windows", "architecture": "x64", "image_type": "jdk", "jvm_impl": "hotspot",
"package": {
"name": "OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip",
"link": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.13%2B9/OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip",
"checksum": "a4dbaa05fca4bd89d84584e58c459bd732efebb1722af80ab13c6516832004f9",
"size": 205421281
}
}
]
}"#
.to_string()
}
#[test]
fn urls_target_the_versioned_endpoints_never_latest() {
let releases = releases_url(21, WINDOWS, 0);
assert!(releases.starts_with("https://api.adoptium.net/v3/assets/feature_releases/21/ga?"));
assert!(releases.contains("page_size=20"));
assert!(releases.contains("page=0"));
// 页码真的进 URL(翻页不是装饰)。
assert!(releases_url(21, WINDOWS, 1).contains("page=1"));
assert!(releases.contains("sort_order=DESC"));
assert!(!releases.contains("latest"));
let release = release_url("jdk-21.0.13+9", WINDOWS);
assert!(release
.starts_with("https://api.adoptium.net/v3/assets/release_name/eclipse/jdk-21.0.13+9?"));
assert!(release.contains("os=windows") && release.contains("arch=x64"));
assert!(!release.contains("latest"));
}
#[test]
fn release_list_keeps_the_upstream_order_and_names() {
assert_eq!(
parse_release_list(&list_body()).expect("应解析"),
vec!["jdk-21.0.13+9".to_string(), "jdk-21.0.12+8".to_string()]
);
let error = parse_release_list("<html>502</html>").expect_err("HTML 应被拒");
assert_eq!(error.kind(), JavaErrorKind::Metadata);
}
#[test]
fn release_body_becomes_the_pinned_archive() {
let release = parse_release(&release_body(), "jdk-21.0.13+9", WINDOWS).expect("应解析");
assert_eq!(release.version, "21.0.13+9", "版本串取自 release 名");
assert_eq!(release.semver.as_deref(), Some("21.0.13+9.0.LTS"));
assert_eq!(release.major, 21);
assert_eq!(
release.archive.name,
"OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip"
);
assert_eq!(release.archive.size, 205_421_281);
assert!(release.archive.url.contains("temurin21-binaries"));
}
#[test]
fn other_platform_has_no_pinned_archive() {
let error = parse_release(
&release_body(),
"jdk-21.0.13+9",
AdoptiumTarget::new("windows", "x86-32"),
)
.expect_err("x86-32 应没有条目");
assert_eq!(error.kind(), JavaErrorKind::Metadata);
}
/// 真机 `17/ga` 的两页形状(2026-11-12 实测 26 条:page=0 满 20 条、page=1 只有 6 条)。
/// 目标版本 `jdk-17.0.4+8` 在**第二页**,第一页 20 条里没有它。
fn page_zero() -> Vec<String> {
// 17.0.21 … 17.0.6 共 16 条 + 4 条补丁变体,凑满一页 20 条;
// 这一页里**没有** 17.0.4(它只在第二页)。
let mut names: Vec<String> = (0..16)
.map(|index| format!("jdk-17.0.{}+9", 21 - index))
.collect();
for filler in ["17.0.5.1+1", "17.0.5+11", "17.0.6.1+1", "17.0.10.1+1"] {
names.push(format!("jdk-{filler}"));
}
assert_eq!(names.len(), PAGE_SIZE);
assert!(
pick_release(&names, "17.0.4").is_none(),
"第一页不许有 17.0.4"
);
names
}
fn page_one() -> Vec<String> {
vec![
"jdk-17.0.4.1+1".to_string(),
"jdk-17.0.4+8".to_string(),
"jdk-17.0.3+7".to_string(),
"jdk-17+35".to_string(),
"jdk-16.0.2+7".to_string(),
"jdk-16+36".to_string(),
]
}
/// 假的上游:按 page 返回夹具(`pages` 用完之后一律空页)。
fn two_pages(page: usize) -> Result<Vec<String>, JavaError> {
match page {
0 => Ok(page_zero()),
1 => Ok(page_one()),
_ => Ok(Vec::new()),
}
}
/// **P2-1 守卫**:`17.0.4` 只在第二页出现,必须翻到第二页才命中。
///
/// 变异证明(复审实测过的那个):把 `collect_releases` 的循环改成「只取第一页」
/// ⇒ 本用例红(`pick_release` 找不到 `jdk-17.0.4+8`)。
#[tokio::test]
async fn a_version_on_the_second_page_is_still_found() {
let pages: std::cell::RefCell<Vec<usize>> = std::cell::RefCell::new(Vec::new());
let (releases, truncated) = collect_releases(|page| {
pages.borrow_mut().push(page);
async move { two_pages(page) }
})
.await
.expect("两页都应取到");
assert_eq!(*pages.borrow(), vec![0, 1], "应该正好翻两页");
assert_eq!(releases.len(), PAGE_SIZE + page_one().len());
assert!(!truncated, "第二页不满一页 ⇒ 清单是完整的");
assert_eq!(
pick_release(&releases, "17.0.4").map(String::as_str),
Some("jdk-17.0.4+8"),
"第二页那一版必须能被选中"
);
assert_eq!(
pick_release(&releases, "17.0.4.1").map(String::as_str),
Some("jdk-17.0.4.1+1")
);
}
/// **P2-2 守卫 1**:列表里同时有目标与更新的版本时,必须选**目标**,不许取第一条/最新。
///
/// 变异证明(复审的变异 B):把 `pick_release` 换成 `releases.first()`
/// ⇒ 本用例第一条红(会拿到 `jdk-17.0.21+9`)。
#[test]
fn the_pinned_version_wins_over_the_newest_entry() {
let releases = vec![
"jdk-17.0.21+9".to_string(),
"jdk-17.0.12+7".to_string(),
"jdk-17.0.4+8".to_string(),
];
assert_eq!(
pick_release(&releases, "17.0.4").map(String::as_str),
Some("jdk-17.0.4+8"),
"点名 17.0.4 时不许拿最新的 17.0.21+9"
);
assert_eq!(
pick_release(&releases, "17.0.12+7").map(String::as_str),
Some("jdk-17.0.12+7")
);
assert_eq!(pick_release(&releases, "17.0.99"), None);
}
/// 上限兜底:上游每页都满(异常情况)时,翻到 [`MAX_PAGES`] 页就停,并如实标「还有更多」。
///
/// 变异证明:把 `collect_releases` 的 `for page in 0..MAX_PAGES` 改成 `loop`
/// ⇒ 夹具永远满页,本用例挂死/超时(红)。
#[tokio::test]
async fn a_never_ending_catalog_stops_at_the_page_cap_and_says_so() {
let calls = std::cell::Cell::new(0usize);
let (releases, truncated) = collect_releases(|page| {
calls.set(calls.get() + 1);
async move {
let _ = page;
Ok((0..PAGE_SIZE)
.map(|index| format!("jdk-17.0.{}+1", index + 1))
.collect::<Vec<_>>())
}
})
.await
.expect("每页都成功");
assert_eq!(calls.get(), MAX_PAGES, "翻满上限就停");
assert_eq!(releases.len(), MAX_PAGES * PAGE_SIZE);
assert!(truncated, "到上限时必须标成截断,文案才能如实说");
}
+31 -153
View File
@@ -174,9 +174,29 @@ pub fn version_mismatch(wanted: Option<&str>, found: &str) -> Option<String> {
(!matches_version(wanted, found)).then(|| format!("返回的版本是 {found},不是请求的 {wanted}"))
}
/// 可用版本清单的完整度:决定错误文案里那句「还有没有更多」怎么说。
///
/// 上游的版本清单有三种形态:Oracle 目录页一次就能看全、Microsoft 只有一份快照、
/// Zulu/Termux 压根没有清单([`Complete`]);Temurin 要走分页,翻到上限还没翻完时
/// 只能如实说「只查了前 N 条」——**不许把窗口当全集**。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VersionListScope {
/// 上游的清单已经全看到了。
Complete,
/// 只看了上游的前若干条(Temurin 翻页到上限)。
Truncated,
}
/// 可用版本列表的人读形态(错误里列给人看;超过 `limit` 条就收口成「等 N 个」)。
///
/// `scope` 只影响尾巴那句:`Complete` 时「等 N 个」就是上游总数,`Truncated` 时补一句
/// 「只查了前 N 条」,因为这时 N 是**我们的窗口**而不是上游的总数。
#[must_use]
pub fn available_versions_text(versions: &[String], limit: usize) -> String {
pub fn available_versions_text(
versions: &[String],
limit: usize,
scope: VersionListScope,
) -> String {
if versions.is_empty() {
return "(上游一个都没列出来)".to_string();
}
@@ -186,10 +206,16 @@ pub fn available_versions_text(versions: &[String], limit: usize) -> String {
.map(String::as_str)
.collect::<Vec<_>>()
.join(" / ");
if versions.len() > limit {
let listed = if versions.len() > limit {
format!("{shown} 等 {} 个", versions.len())
} else {
shown
};
match scope {
VersionListScope::Complete => listed,
VersionListScope::Truncated => {
format!("{listed}(只查了前 {} 条,后面可能还有)", versions.len())
}
}
}
@@ -204,8 +230,9 @@ pub fn version_pin_error(
major: u32,
wanted: &str,
available: &[String],
scope: VersionListScope,
) -> JavaError {
let listed = available_versions_text(available, 10);
let listed = available_versions_text(available, 10, scope);
let try_other = "要这个版本换一家:`--vendor temurin`(Adoptium 全量补丁)或 `--vendor oracle`";
let vendor_id = vendor.id();
match vendor {
@@ -248,153 +275,4 @@ pub fn version_pin_error(
}
#[cfg(test)]
mod tests {
use super::{choices, default_major, find, vendors_for, BASELINE_MAJOR};
use crate::game::java::vendor::{ids, JdkVendor};
use super::{available_versions_text, matches_version, version_pin_error};
/// 目录必须覆盖用户点名的三档(8 / 17 / 21),并钉住基准版本。
///
/// 变异证明:从 `CHOICES` 里删掉 8 那一项 ⇒ 本用例红(`find(8)` 为 `None`)。
#[test]
fn the_catalog_covers_eight_seventeen_and_twenty_one() {
for major in [8, 17, 21] {
assert!(find(major).is_some(), "目录里必须有 Java {major}");
}
assert_eq!(BASELINE_MAJOR, 17, "D13 的基准版本是 17");
assert!(find(BASELINE_MAJOR).expect("基准版本在目录里").baseline);
assert_eq!(
choices().len(),
5,
"目录是 8/11/17/21/25 五档,改动要同步本断言与 UI 文案"
);
assert!(
choices()
.windows(2)
.all(|pair| pair[0].major < pair[1].major),
"目录必须升序(UI 直接按这个顺序画)"
);
}
/// 「8 只能由 Temurin / Zulu 承接」:Oracle 从 9 起、MS 已停发 8。
///
/// 变异证明:把 `vendors_for` 里的 `supports` 过滤删掉 ⇒ 本用例红(8 会列出 oracle/microsoft)。
#[test]
fn java_eight_is_only_served_by_the_vendors_that_still_ship_it() {
assert_eq!(ids(&vendors_for(8, &[], true)), vec!["temurin", "zulu"]);
assert_eq!(
ids(&vendors_for(21, &[], true)),
vec!["temurin", "oracle", "microsoft", "zulu"],
"21 四家都在(国内默认序)"
);
assert!(
!vendors_for(8, &[], true).contains(&JdkVendor::Microsoft),
"微软已停发 8"
);
}
/// 用户优先级要真的排在最前面(与 `install` 的链同源)。
#[test]
fn the_user_priority_leads_the_vendor_list() {
let priority = vec![JdkVendor::Zulu];
assert_eq!(ids(&vendors_for(17, &priority, true))[0], "zulu");
assert_eq!(ids(&vendors_for(17, &priority, true)).len(), 4);
}
/// 「该装哪个」对齐到目录里的一档:向上取整,没有上下文给基准,超出目录给基准。
#[test]
fn the_default_major_rounds_up_into_the_catalog() {
assert_eq!(default_major(None), 17);
assert_eq!(default_major(Some(8)), 8, "1.12.2 → 8");
assert_eq!(default_major(Some(16)), 17, "1.17 要 16 → 目录里的 17");
assert_eq!(default_major(Some(17)), 17);
assert_eq!(default_major(Some(21)), 21, "1.20.5+ → 21");
assert_eq!(default_major(Some(25)), 25, "26.1 → 25");
assert_eq!(default_major(Some(99)), 17, "超出目录时退回基准");
}
/// 四家的版本串用同一把尺子量(`matches_version` 是唯一判据)。
///
/// 变异证明:把 `matches_version` 换成 `starts_with` ⇒ 本用例红
/// (`21.0.13` 会命中 MS 的 `21.0.13.0.2`,那是另一个版本)。
#[test]
fn version_matching_is_shared_and_never_prefix_fuzzy() {
// Temurin:公共部分 ↔ 带构建号;semver 与 release 名两种写法都算同一份。
assert!(matches_version("21.0.13", "21.0.13+9"));
assert!(matches_version("21.0.13+9", "jdk-21.0.13+9"));
assert!(matches_version("21.0.13+9", "21.0.13+9.0.LTS"));
assert!(matches_version(" 21.0.13 ", "21.0.13"));
// Oracle:目录名就是三段,公共部分一致即命中。
assert!(matches_version("21.0.2", "21.0.2"));
// 少一位、多一段都不是同一个版本(不许前缀近似)。
assert!(!matches_version("21.0", "21.0.13"));
assert!(!matches_version("21.0.13", "21.0.13.0.2"));
assert!(!matches_version("21.0.13+8", "21.0.13+9"));
// 用户点名了构建号、候选没给 → 不算命中。
assert!(!matches_version("21.0.13+9", "21.0.13"));
assert!(!matches_version("", "21.0.13"));
}
/// 可用版本列表:给几条就列几条,多了收口(错误文案不能无限长)。
#[test]
fn available_versions_text_caps_the_list() {
let versions: Vec<String> = ["21.0.13", "21.0.12", "21.0.11"]
.iter()
.map(|text| (*text).to_string())
.collect();
assert_eq!(
available_versions_text(&versions, 10),
"21.0.13 / 21.0.12 / 21.0.11"
);
assert_eq!(
available_versions_text(&versions, 2),
"21.0.13 / 21.0.12 等 3 个"
);
assert!(available_versions_text(&[], 5).contains("一个都没列出来"));
}
/// 四家的「这家不能按你要的版本装」文案必须**互不相同**、各自说清原因,且给下一步。
///
/// 变异证明:把某两家共用同一句「不支持指定版本」⇒ 本用例红。
#[test]
fn pin_refusals_are_distinct_and_each_explains_itself() {
let snapshot = vec!["21.0.13.0.2".to_string()];
let listed = vec!["21.0.13+9".to_string()];
let errors = [
version_pin_error(JdkVendor::Temurin, 21, "21.0.5", &listed),
version_pin_error(JdkVendor::Oracle, 21, "21.0.5", &["21.0.2".to_string()]),
version_pin_error(JdkVendor::Microsoft, 21, "21.0.5", &snapshot),
version_pin_error(JdkVendor::Zulu, 21, "21.0.5", &[]),
version_pin_error(JdkVendor::Termux, 21, "21.0.5", &[]),
];
for (index, left) in errors.iter().enumerate() {
for right in errors.iter().skip(index + 1) {
assert_ne!(
left.message(),
right.message(),
"每家的拒绝理由必须各说各的"
);
}
assert!(
left.message().contains("21.0.5"),
"错误里要带上用户要的版本"
);
}
assert!(
errors[0].message().contains("21.0.13+9"),
"可用版本要列出来"
);
assert!(errors[2].message().contains("只内置了 21.0.13.0.2"));
assert!(errors[2]
.hint()
.unwrap_or_default()
.contains("--vendor microsoft"));
assert!(errors[3].message().contains("latest=true"));
assert!(errors[3]
.hint()
.unwrap_or_default()
.contains("--vendor temurin"));
assert!(errors[4].message().contains("系统包"));
}
}
mod tests;
+229
View File
@@ -0,0 +1,229 @@
//! `catalog` 的单测:目录、版本判据、拒绝文案、兜底闸。
//!
//! 从 `catalog.rs` 拆出来只为守 400 行硬线(M280 r1 要在这里加「兜底闸」与「清单被截断」
//! 的用例,留在主文件会到 470+);**纯搬移**,既有断言一字未改。
use super::{
available_versions_text, choices, default_major, find, matches_version, vendors_for,
version_mismatch, version_pin_error, VersionListScope, BASELINE_MAJOR,
};
use crate::game::java::vendor::{ids, JdkVendor};
/// 目录必须覆盖用户点名的三档(8 / 17 / 21),并钉住基准版本。
///
/// 变异证明:从 `CHOICES` 里删掉 8 那一项 ⇒ 本用例红(`find(8)` 为 `None`)。
#[test]
fn the_catalog_covers_eight_seventeen_and_twenty_one() {
for major in [8, 17, 21] {
assert!(find(major).is_some(), "目录里必须有 Java {major}");
}
assert_eq!(BASELINE_MAJOR, 17, "D13 的基准版本是 17");
assert!(find(BASELINE_MAJOR).expect("基准版本在目录里").baseline);
assert_eq!(
choices().len(),
5,
"目录是 8/11/17/21/25 五档,改动要同步本断言与 UI 文案"
);
assert!(
choices()
.windows(2)
.all(|pair| pair[0].major < pair[1].major),
"目录必须升序(UI 直接按这个顺序画)"
);
}
/// 「8 只能由 Temurin / Zulu 承接」:Oracle 从 9 起、MS 已停发 8。
///
/// 变异证明:把 `vendors_for` 里的 `supports` 过滤删掉 ⇒ 本用例红(8 会列出 oracle/microsoft)。
#[test]
fn java_eight_is_only_served_by_the_vendors_that_still_ship_it() {
assert_eq!(ids(&vendors_for(8, &[], true)), vec!["temurin", "zulu"]);
assert_eq!(
ids(&vendors_for(21, &[], true)),
vec!["temurin", "oracle", "microsoft", "zulu"],
"21 四家都在(国内默认序)"
);
assert!(
!vendors_for(8, &[], true).contains(&JdkVendor::Microsoft),
"微软已停发 8"
);
}
/// 用户优先级要真的排在最前面(与 `install` 的链同源)。
#[test]
fn the_user_priority_leads_the_vendor_list() {
let priority = vec![JdkVendor::Zulu];
assert_eq!(ids(&vendors_for(17, &priority, true))[0], "zulu");
assert_eq!(ids(&vendors_for(17, &priority, true)).len(), 4);
}
/// 「该装哪个」对齐到目录里的一档:向上取整,没有上下文给基准,超出目录给基准。
#[test]
fn the_default_major_rounds_up_into_the_catalog() {
assert_eq!(default_major(None), 17);
assert_eq!(default_major(Some(8)), 8, "1.12.2 → 8");
assert_eq!(default_major(Some(16)), 17, "1.17 要 16 → 目录里的 17");
assert_eq!(default_major(Some(17)), 17);
assert_eq!(default_major(Some(21)), 21, "1.20.5+ → 21");
assert_eq!(default_major(Some(25)), 25, "26.1 → 25");
assert_eq!(default_major(Some(99)), 17, "超出目录时退回基准");
}
/// 四家的版本串用同一把尺子量(`matches_version` 是唯一判据)。
///
/// 变异证明:把 `matches_version` 换成 `starts_with` ⇒ 本用例红
/// (`21.0.13` 会命中 MS 的 `21.0.13.0.2`,那是另一个版本)。
#[test]
fn version_matching_is_shared_and_never_prefix_fuzzy() {
// Temurin:公共部分 ↔ 带构建号;semver 与 release 名两种写法都算同一份。
assert!(matches_version("21.0.13", "21.0.13+9"));
assert!(matches_version("21.0.13+9", "jdk-21.0.13+9"));
assert!(matches_version("21.0.13+9", "21.0.13+9.0.LTS"));
assert!(matches_version(" 21.0.13 ", "21.0.13"));
// Oracle:目录名就是三段,公共部分一致即命中。
assert!(matches_version("21.0.2", "21.0.2"));
// 少一位、多一段都不是同一个版本(不许前缀近似)。
assert!(!matches_version("21.0", "21.0.13"));
assert!(!matches_version("21.0.13", "21.0.13.0.2"));
assert!(!matches_version("21.0.13+8", "21.0.13+9"));
// 用户点名了构建号、候选没给 → 不算命中。
assert!(!matches_version("21.0.13+9", "21.0.13"));
assert!(!matches_version("", "21.0.13"));
}
/// 可用版本列表:给几条就列几条,多了收口(错误文案不能无限长)。
#[test]
fn available_versions_text_caps_the_list() {
let versions: Vec<String> = ["21.0.13", "21.0.12", "21.0.11"]
.iter()
.map(|text| (*text).to_string())
.collect();
assert_eq!(
available_versions_text(&versions, 10, VersionListScope::Complete),
"21.0.13 / 21.0.12 / 21.0.11"
);
assert_eq!(
available_versions_text(&versions, 2, VersionListScope::Complete),
"21.0.13 / 21.0.12 等 3 个"
);
assert!(available_versions_text(&[], 5, VersionListScope::Complete).contains("一个都没列出来"));
}
/// 四家的「这家不能按你要的版本装」文案必须**互不相同**、各自说清原因,且给下一步。
///
/// 变异证明:把某两家共用同一句「不支持指定版本」⇒ 本用例红。
#[test]
fn pin_refusals_are_distinct_and_each_explains_itself() {
let snapshot = vec!["21.0.13.0.2".to_string()];
let listed = vec!["21.0.13+9".to_string()];
let errors = [
version_pin_error(
JdkVendor::Temurin,
21,
"21.0.5",
&listed,
VersionListScope::Complete,
),
version_pin_error(
JdkVendor::Oracle,
21,
"21.0.5",
&["21.0.2".to_string()],
VersionListScope::Complete,
),
version_pin_error(
JdkVendor::Microsoft,
21,
"21.0.5",
&snapshot,
VersionListScope::Complete,
),
version_pin_error(
JdkVendor::Zulu,
21,
"21.0.5",
&[],
VersionListScope::Complete,
),
version_pin_error(
JdkVendor::Termux,
21,
"21.0.5",
&[],
VersionListScope::Complete,
),
];
for (index, left) in errors.iter().enumerate() {
for right in errors.iter().skip(index + 1) {
assert_ne!(
left.message(),
right.message(),
"每家的拒绝理由必须各说各的"
);
}
assert!(
left.message().contains("21.0.5"),
"错误里要带上用户要的版本"
);
}
assert!(
errors[0].message().contains("21.0.13+9"),
"可用版本要列出来"
);
assert!(errors[2].message().contains("只内置了 21.0.13.0.2"));
assert!(errors[2]
.hint()
.unwrap_or_default()
.contains("--vendor microsoft"));
assert!(errors[3].message().contains("latest=true"));
assert!(errors[3]
.hint()
.unwrap_or_default()
.contains("--vendor temurin"));
assert!(errors[4].message().contains("系统包"));
}
/// **兜底闸(P2-2 守卫 2)**:实际拿到的版本与请求不符时必须给出原因,相符/没钉版本时必须放行。
///
/// 变异证明:把 `version_mismatch` 改成恒 `None`(复审的变异 A)⇒ 本用例第一条红。
#[test]
fn the_version_gate_rejects_a_different_version_and_lets_everything_else_through() {
let reason = version_mismatch(Some("21.0.12"), "21.0.13+9")
.expect("要 21.0.12、拿到 21.0.13+9 ⇒ 必须拒绝");
assert!(reason.contains("21.0.13+9"), "{reason}");
assert!(reason.contains("不是请求的 21.0.12"), "{reason}");
// 同一个版本(公共部分一致、构建号一致)⇒ 放行。
assert_eq!(version_mismatch(Some("21.0.12"), "21.0.12+8"), None);
assert_eq!(version_mismatch(Some("21.0.12+8"), "jdk-21.0.12+8"), None);
// 没钉版本 ⇒ 恒放行(不许对默认路径误报)。
assert_eq!(version_mismatch(None, "21.0.13+9"), None);
// 构建号不同 ⇒ 拒绝。
assert!(version_mismatch(Some("21.0.12+8"), "21.0.12+9").is_some());
}
/// **清单被截断时不许把窗口当全集(P2-1 文案)**:`Complete` 说「等 N 个」,
/// `Truncated` 必须补一句「只查了前 N 条」。
///
/// 变异证明:把 `Truncated` 分支改成与 `Complete` 相同 ⇒ 本用例第二条红。
#[test]
fn a_truncated_list_never_pretends_to_be_the_whole_catalog() {
let versions: Vec<String> = (0..20).map(|index| format!("17.0.{}", index + 1)).collect();
let complete = available_versions_text(&versions, 2, VersionListScope::Complete);
let truncated = available_versions_text(&versions, 2, VersionListScope::Truncated);
assert_eq!(complete, "17.0.1 / 17.0.2 等 20 个");
assert_ne!(complete, truncated);
assert!(
truncated.contains("只查了前 20 条"),
"截断时必须如实说:{truncated}"
);
// 完整清单的拒绝文案里不许出现「只查了」。
let error = version_pin_error(
JdkVendor::Temurin,
17,
"17.0.99",
&versions,
VersionListScope::Complete,
);
assert!(!error.message().contains("只查了"), "{}", error.message());
}
+18 -55
View File
@@ -42,9 +42,13 @@ use crate::download::{DownloadEngine, DownloadRequest, ResourceKind};
use one::install_one;
mod chain;
mod one;
mod plan;
mod termux_install;
use chain::resolve_one;
/// 归档缓存目录名(在安装根下;托管目录扫描跳过它)。
pub use super::location::CACHE_DIR;
@@ -232,6 +236,7 @@ pub async fn install(
request.major,
wanted,
&[],
catalog::VersionListScope::Complete,
));
}
Some(_) => {
@@ -285,20 +290,20 @@ pub async fn install(
continue;
}
};
let release = match source::fetch(engine, vendor, request.major, target, wanted).await {
// 取版本 + 兜底闸(版本必须真是点名的那一版,任何一家都不许回落 latest):
// 判据在 `chain::resolve_one` 里,`--dry-run` 的 `plan` 走同一个函数。
let release = match resolve_one(wanted, || {
source::fetch(engine, vendor, request.major, target, wanted)
})
.await
{
Ok(release) => release,
Err(error) => {
chain.failed(vendor, error.message().to_string());
reasons.push(format!("{}:{}", vendor.id(), error.message()));
Err(reason) => {
chain.failed(vendor, reason.clone());
reasons.push(format!("{}:{reason}", vendor.id()));
continue;
}
};
// 兜底闸:装成的那一版必须真是用户点名的那一版(任何一家都不许回落 latest)。
if let Some(reason) = catalog::version_mismatch(wanted, &release.version) {
chain.failed(vendor, reason.clone());
reasons.push(format!("{}:{reason}", vendor.id()));
continue;
}
match install_one(engine, location, &release).await {
Ok(prepared) => {
chain.hit(vendor, format!("命中 {}", release.summary()));
@@ -336,57 +341,15 @@ pub async fn install(
// 钉了版本时给一句能照着做的:哪几家真能按版本装(默认路径的提示一个字都不动)。
Err(if wanted.is_some() {
error.with_hint(
"能按版本装的是 temurin / oracle;microsoft 只认它内置的快照,zulu 上游只提供 latest。\
`ull java vendors` 看各发行版的可用性",
"能按版本装的是 `--vendor temurin` 与 `--vendor oracle`;\
microsoft 只认它内置的那份快照,zulu 上游只提供 latest",
)
} else {
error.with_hint("`ull java vendors` 看各发行版的可用性;或手动装 JDK 后设 JAVA_HOME")
})
}
/// 只做规划不落盘:返回将要安装的版本、目标目录与「是否已经装好」。
///
/// `--dry-run` 用它:`request.version` 与 `--vendor` 都参与选择(钉了版本就把这一维钉死),
/// 所以「最后会打哪个 URL、装哪个版本串」在下载之前就能看见——这也是不联网时
/// 唯一能验证版本选择逻辑的输出。
///
/// # Errors
///
/// 与 [`install`] 的查询段相同(平台不支持 / 网络失败 / 该家没有这个版本)。
pub async fn plan(
engine: &DownloadEngine,
location: &JavaInstallRoot,
request: &InstallRequest,
) -> Result<(JdkRelease, PathBuf, bool), JavaError> {
let target = AdoptiumTarget::host()?;
let domestic = engine.region().is_domestic();
let wanted = request.version.as_deref();
let mut reasons: Vec<String> = Vec::new();
for vendor in chain_order(request, domestic) {
let release = match source::fetch(engine, vendor, request.major, target, wanted).await {
Ok(release) => release,
Err(error) => {
reasons.push(format!("{}:{}", vendor.id(), error.message()));
continue;
}
};
if let Some(reason) = catalog::version_mismatch(wanted, &release.version) {
reasons.push(format!("{}:{reason}", vendor.id()));
continue;
}
let root = location.jdk_dir(release.major, release.vendor, &release.version);
let ready = extract::is_ready(&root, release.sha256());
return Ok((release, root, ready));
}
Err(JavaError::new(
JavaErrorKind::Metadata,
format!(
"没有可用的 Java {}({} )",
request.major,
reasons.join(";")
),
))
}
pub use plan::plan;
fn majors_text(vendor: JdkVendor) -> String {
vendor
+220
View File
@@ -0,0 +1,220 @@
//! `install` 回退链的骨架:一次尝试的判据(取到 / 版本不符 / 失败)与「按顺序试、命中即停」。
//!
//! 单独成模块有两个理由:
//!
//! 1. `install.rs` 贴着 400 行硬线(M280 r1 加了这些守卫后到 440+);
//! 2. 这段是**纯判断**——真正的「取版本」由闭包注入,所以「版本不符 ⇒ 记失败继续走链」
//! 与「钉死一家时只试它一家」都能**离线**测。复审的变异 A(把
//! `catalog::version_mismatch` 改成恒 `None`)就是靠这里的用例变红的:没有它,
//! 「绝不把 latest 当用户要的版本装下去」只有代码、没有守卫。
use std::future::Future;
use super::super::catalog;
use super::super::error::JavaError;
use super::super::release::JdkRelease;
use super::super::vendor::JdkVendor;
/// 一次尝试的结果:`Ok` = 这一版就是用户要的;`Err` = 一句话原因(进降级记录)。
pub(super) type Attempt = Result<JdkRelease, String>;
/// 一次尝试的**判据**:取不到 → 原因;取到了但版本不是用户点名的那一版 → 拒绝。
///
/// `fetch` 由调用方给(真跑是 `source::fetch`,单测是夹具),所以这道闸离线可测。
/// `wanted = None`(没钉版本)时恒放行——默认路径不受任何影响。
pub(super) async fn resolve_one<F, Fut>(wanted: Option<&str>, fetch: F) -> Attempt
where
F: FnOnce() -> Fut,
Fut: Future<Output = Result<JdkRelease, JavaError>>,
{
match fetch().await {
Err(error) => Err(error.message().to_string()),
Ok(release) => match catalog::version_mismatch(wanted, &release.version) {
Some(reason) => Err(reason),
None => Ok(release),
},
}
}
/// 回退链骨架:按顺序试,第一个通过的即命中;失败与版本不符都变成一条原因继续下一家。
///
/// 返回 `(命中的发行版与版本, 每家失败的原因)`。`plan`(`--dry-run`)用它;`install` 还要记
/// `ChainLog` 与「不支持该大版本」的跳过原因,所以自己驱动循环,但**唯一入口仍是
/// [`resolve_one`]**——两边的判据只有一份。
pub(super) async fn chain_fetch<F, Fut>(
vendors: &[JdkVendor],
wanted: Option<&str>,
mut fetch: F,
) -> (Option<(JdkVendor, JdkRelease)>, Vec<(JdkVendor, String)>)
where
F: FnMut(JdkVendor) -> Fut,
Fut: Future<Output = Result<JdkRelease, JavaError>>,
{
let mut failures: Vec<(JdkVendor, String)> = Vec::new();
for candidate in vendors {
let vendor = *candidate;
match resolve_one(wanted, || fetch(vendor)).await {
Ok(release) => return (Some((vendor, release)), failures),
Err(reason) => failures.push((vendor, reason)),
}
}
(None, failures)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use crate::download::RegionProfile;
use crate::download::{
ContentCache, DownloadEngine, EngineConfig, HealthRegistry, MirrorTable,
};
use crate::game::java::error::JavaError;
use crate::game::java::install::{self, InstallRequest, JavaInstallRoot};
use crate::game::java::release::{JdkArchive, JdkRelease};
use crate::game::java::vendor::JdkVendor;
use super::{chain_fetch, resolve_one};
fn release(version: &str) -> JdkRelease {
JdkRelease {
vendor: JdkVendor::Temurin,
major: 21,
version: version.to_string(),
semver: None,
release_name: format!("jdk-{version}"),
archive: JdkArchive {
name: format!("jdk-{version}.zip"),
url: "https://example.invalid/x.zip".to_string(),
sha256: "a".repeat(64),
size: 1,
},
}
}
fn temp_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("ull-javapin-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建临时目录");
dir
}
/// **P2-2 守卫(兜底闸)**:拿到的版本不是点名的那一版 ⇒ 拒绝,原因里带双方版本号。
///
/// 变异证明(复审的变异 A):把 `catalog::version_mismatch` 改成恒 `None`
/// ⇒ 本用例第一条红(`expect_err` 拿到 `Ok`)。
#[tokio::test]
async fn a_release_that_is_not_the_requested_version_is_rejected() {
let refused = resolve_one(Some("21.0.12"), || async { Ok(release("21.0.13+9")) })
.await
.expect_err("要 21.0.12、供应商回 latest 21.0.13+9 ⇒ 必须拒绝");
assert!(refused.contains("21.0.13+9"), "{refused}");
assert!(refused.contains("不是请求的 21.0.12"), "{refused}");
// 相符 ⇒ 放行(公共部分一致也算相符)。
assert!(
resolve_one(Some("21.0.12"), || async { Ok(release("21.0.12+8")) })
.await
.is_ok()
);
// 没钉版本 ⇒ 恒放行(默认路径不许误报)。
assert!(resolve_one(None, || async { Ok(release("21.0.13+9")) })
.await
.is_ok());
// 真正取不到 ⇒ 原因原样带出来。
let failed = resolve_one(Some("21.0.12"), || async {
Err::<JdkRelease, JavaError>(JavaError::new(
crate::game::java::JavaErrorKind::Network,
"连不上",
))
})
.await
.expect_err("取不到就得报错");
assert_eq!(failed, "连不上");
}
/// **P2-2 守卫(继续走链)**:第一家回落的 latest 必须被记成失败,第二家(真给了那一版)命中。
///
/// 变异证明:把 `chain_fetch` 的 `Err(reason) => failures.push(..)` 改成直接返回
/// ⇒ 本用例红(命中变成第一家那个不符的版本)。
#[tokio::test]
async fn a_mismatch_records_a_failure_and_the_chain_moves_on() {
let vendors = [JdkVendor::Temurin, JdkVendor::Oracle];
let (hit, failures) = chain_fetch(&vendors, Some("21.0.12"), |vendor| async move {
match vendor {
// 第一家没按版本取(就是复审担心的那种 bug:静默回落 latest)。
JdkVendor::Temurin => Ok(release("21.0.13+9")),
_ => Ok(release("21.0.12+8")),
}
})
.await;
let (vendor, picked) = hit.expect("第二家应命中");
assert_eq!(vendor, JdkVendor::Oracle, "命中不许是第一家");
assert_eq!(picked.version, "21.0.12+8");
assert_eq!(failures.len(), 1);
assert_eq!(failures[0].0, JdkVendor::Temurin);
assert!(failures[0].1.contains("不是请求的 21.0.12"));
}
/// 全都不符 ⇒ 没有命中,每家一条原因(命令据此退出非 0 并列出全部原因)。
#[tokio::test]
async fn nothing_matching_means_no_hit_and_every_reason_is_kept() {
let vendors = [JdkVendor::Temurin, JdkVendor::Oracle];
let (hit, failures) = chain_fetch(&vendors, Some("21.0.12"), |_vendor| async {
Ok(release("21.0.13+9"))
})
.await;
assert!(hit.is_none(), "一家都没给对版本 ⇒ 不许命中");
assert_eq!(failures.len(), 2);
assert!(failures
.iter()
.all(|(_, reason)| reason.contains("不是请求的")));
}
/// **P2-2 守卫(不换家)**:钉死一家、而这家不支持指定版本 ⇒ `install` 只试它一家
/// (错误里的「试过 N」必须是 1),一个请求都不发。
///
/// 变异证明:把 `source::pinned` 里 Zulu 的拒绝改成回落 `fetch_latest`
/// ⇒ 本用例红(错误里不再有 `latest=true`,且「试过」不再是 1 条原因)。
#[tokio::test]
async fn a_pinned_vendor_that_cannot_pin_is_tried_alone() {
let dir = temp_dir("pinned-alone");
let cache = Arc::new(ContentCache::open(dir.join("cache")));
let engine = DownloadEngine::new(
EngineConfig::default(),
Arc::new(MirrorTable::builtin()),
Arc::new(RegionProfile::direct(0)),
Arc::new(HealthRegistry::in_memory()),
cache,
);
let location = JavaInstallRoot::custom(dir.join("java-managed"));
let request = InstallRequest::with_vendor(21, JdkVendor::Zulu)
.with_version(Some("21.0.13".to_string()));
let error = install::install(&engine, &location, request)
.await
.expect_err("zulu 上游只有 latest ⇒ 必须报错");
assert!(
error.message().contains("试过 1"),
"只许试点名的那一家:{}",
error.message()
);
assert!(
error.message().contains("latest=true"),
"{}",
error.message()
);
assert!(
error
.hint()
.unwrap_or_default()
.contains("--vendor temurin"),
"要给出能做的下一步:{:?}",
error.hint()
);
// 一个字节都没落盘(连目录都不该建出来)。
assert!(!dir.join("java-managed").exists());
let _ = std::fs::remove_dir_all(&dir);
}
}
+59
View File
@@ -0,0 +1,59 @@
//! `install` 的**只读取**规划段:`--dry-run` 用它算出「会装哪一版、打哪个 URL」。
//!
//! 从 `install.rs` 拆出来只为守 400 行硬线(M280 r1 给回退链加了守卫后那边到 401 行);
//! 纯搬移,语义未变。与 `install/one.rs`(真装的一步)、`install/chain.rs`(判据与骨架)
//! 同一个拆法。
use std::path::PathBuf;
use super::super::error::{JavaError, JavaErrorKind};
use super::super::extract;
use super::super::location::JavaInstallRoot;
use super::super::release::JdkRelease;
use super::super::source;
use super::super::target::AdoptiumTarget;
use super::{chain::chain_fetch, chain_order, InstallRequest};
use crate::download::DownloadEngine;
/// 只做规划不落盘:返回将要安装的版本、目标目录与「是否已经装好」。
///
/// `--dry-run` 用它:`request.version` 与 `--vendor` 都参与选择(钉了版本就把这一维钉死),
/// 所以「最后会打哪个 URL、装哪个版本串」在下载之前就能看见——这也是不联网时
/// 唯一能验证版本选择逻辑的输出。
///
/// # Errors
///
/// 与 [`install`] 的查询段相同(平台不支持 / 网络失败 / 该家没有这个版本)。
pub async fn plan(
engine: &DownloadEngine,
location: &JavaInstallRoot,
request: &InstallRequest,
) -> Result<(JdkRelease, PathBuf, bool), JavaError> {
let target = AdoptiumTarget::host()?;
let domestic = engine.region().is_domestic();
let wanted = request.version.as_deref();
let candidates = chain_order(request, domestic);
let (hit, failures) = chain_fetch(&candidates, wanted, |vendor| {
source::fetch(engine, vendor, request.major, target, wanted)
})
.await;
match hit {
Some((_, release)) => {
let root = location.jdk_dir(release.major, release.vendor, &release.version);
let ready = extract::is_ready(&root, release.sha256());
Ok((release, root, ready))
}
None => Err(JavaError::new(
JavaErrorKind::Metadata,
format!(
"没有可用的 Java {}({} )",
request.major,
failures
.iter()
.map(|(vendor, reason)| format!("{}:{reason}", vendor.id()))
.collect::<Vec<_>>()
.join(";")
),
)),
}
}
+2 -1
View File
@@ -16,7 +16,7 @@
//! 3. **GitHub 侧没有任何资产**(`microsoft/openjdk` 的 release `assets` 全空),
//! 所以 gh-proxy 对这条链毫无用处——`domestic_mirrors` 实测为空数组。
use super::catalog::{matches_version, version_pin_error};
use super::catalog::{matches_version, version_pin_error, VersionListScope};
use super::error::{JavaError, JavaErrorKind};
use super::net;
use super::release::{parse_sha256_text, JdkArchive, JdkRelease};
@@ -187,6 +187,7 @@ pub async fn fetch_version(
major,
wanted,
&[known.to_string()],
VersionListScope::Complete,
));
}
fetch_latest(client, table, major, target).await
+10 -2
View File
@@ -14,7 +14,7 @@
//!
//! 「存在不信状态码」的理由见 `oracle.rs` 头:华为云对任意不存在的路径都回 200 + 门户 HTML。
use super::super::catalog::{matches_version, version_pin_error};
use super::super::catalog::{matches_version, version_pin_error, VersionListScope};
use super::super::error::{JavaError, JavaErrorKind};
use super::super::vendor::JdkVendor;
@@ -102,7 +102,15 @@ pub(super) fn select_version(
.iter()
.find(|version| matches_version(wanted, version))
.cloned()
.ok_or_else(|| version_pin_error(JdkVendor::Oracle, major, wanted, &available))
.ok_or_else(|| {
version_pin_error(
JdkVendor::Oracle,
major,
wanted,
&available,
VersionListScope::Complete,
)
})
}
/// 「目录页里没有这个大版本」。
+7 -3
View File
@@ -82,9 +82,13 @@ async fn pinned(
}
// 上游只给最新一份(Zulu)/ 由系统包管理器定版本(Termux):明确报错,
// 绝不换成别的版本假装装成了用户点名的那一版。
JdkVendor::Zulu | JdkVendor::Termux => {
Err(catalog::version_pin_error(vendor, major, wanted, &[]))
}
JdkVendor::Zulu | JdkVendor::Termux => Err(catalog::version_pin_error(
vendor,
major,
wanted,
&[],
catalog::VersionListScope::Complete,
)),
}
}