fix(java): 把「这一页不存在」与「这一页读不懂」分开,解析失败不再被当成翻到尾巴(M280 r3)

r3 复审 p2-1items:r2 的吞判据按 `JavaErrorKind` 判,而 `Metadata` 这个桶里除了
404/410 与 200+空体,还装着**解析失败**(`parse_release_list`)⇒ 第二页起上游回
「看得懂的状态码 + 读不懂的内容」时,清单被静默截断且标成 `Complete`,用户看到的是
「这个 major 没有版本 X(可用:…)」,而 X 其实在上游。

## 修法:语义信号代替错误种类(复审判据里没选字面方案,选了改动面最小的等价实现)
`fetch_page` 的返回类型变成 `Result<Option<Vec<String>>, JavaError>`,`None` 只由
**新的纯函数 `page_outcome(status, body)`** 产生:404/410 或 200+空体。判据是状态码 +
体形状,**不匹配任何错误文案**。

- `page > 0` 的 `None` ⇒ 翻到尾巴:交回已取到的全部,`truncated = false`(r2 已做对,保留);
- `page == 0` 的 `None` ⇒ 这个大版本真没有 GA 清单:仍然 `Err`(与 r2 之前同口径,
  只是判据从「错误种类」换成「哪一页不存在」);
- **任何页的解析失败 / 非 2xx 非 404 / 网络错误 ⇒ 一律 `Err`**(不再有「读不懂 ⇒ 没有更多」);
- 错误落在最后一页也是 `Err`,不可能再报 `Complete`。

`api/version.rs` 里新增 `page_outcome`(纯)+ `fetch_release_page`(HTTP 那一跳,判定全交给前者),
`fetch_version` 的闭包改调 `fetch_release_page`。只动本 mission scope 内的两个文件,
没有改 `net.rs`,也没有给跨 crate 匹配的 `JavaErrorKind` 加变体(cli / app-iced 的 match 不变)。

## 用例(离线;每条都有能变红的变异)
- `a_missing_page_after_a_full_page_ends_the_tail_cleanly`(判据 1):page0=20 条、page1=None
  ⇒ `Ok((20, false))`,逐条相等、`calls == 2`;
- `a_missing_page_is_404_410_or_an_empty_body_and_nothing_else`:404/410/200+空白 ⇒ `None`;
  正常 JSON ⇒ `Some(names)`;`<html>502</html>` ⇒ `Err(Metadata)`;500/403/429/502 ⇒ `Err(Network)`;
- `a_parse_failure_on_a_later_page_is_an_error_not_the_end_of_the_list`(判据 2):夹具用**真的**
  `parse_release_list("<html>502</html>").unwrap_err()` ⇒ 必须 `Err`,且报的是解析失败本身;
  `Err` 分支在签名上就带不出部分结果;
- `an_error_on_the_last_allowed_page_is_never_reported_as_complete`(判据 3):错误落在
  `page == MAX_PAGES-1` ⇒ 必须 `Err`,不许 `Ok((_, false))`;
- `a_missing_first_page_is_still_an_error`:第一页就不存在 ⇒ `Err`;
- `a_network_error_on_a_later_page_is_not_swallowed`:网络错误照抛。

变异(做完都还原,`git status` 干净):R3-A 把 page>0 的 None 当失败 ⇒ 判据 1 那条红;
R3-B 把 Metadata 重新并进吞掉的分支 ⇒ 判据 2/3 两条红(panic 原文就是 `(…20 条…, false)`,
即复审实测的那个 bug);R3-C 把 `page_outcome` 的解析失败并成 `None` ⇒ 底座那条红。

## 顺带两条(复审 P3)
- P3-1:报告里「钉住上游越界页就是 404」那句改成正确表述(夹具手写,钉的是「收到『这一页
  不存在』要把已取到的交出去」);`--lib` 数字按 tip 重数(`aac0eef` 上是 880,本轮 +3 ⇒ 883)。
- P3-2:删掉 `catalog/tests.rs` 里冗余的否定式断言(紧邻的正向断言已蕴含它),
  保留两条正向断言(`Adoptium 里可用` / `Oracle 镜像目录里可用`)。

门禁(CARGO_TARGET_DIR=C:/Temp/ull-target/wt-280):core --lib 883 / --test game 120 /
--test launch 91、cli --all-targets、fmt --check、clippy -D warnings 全部 EXIT=0。
This commit is contained in:
worker-javapin committed 2026-11-12 22:34:07 +08:00
1 parent aac0eef1b8
commit a0aeccf303
3 files changed
+228 -60

No files matched your search

+80 -20
View File
@@ -125,36 +125,45 @@ pub fn parse_release(
binary.into_release(name, release.version_data) binary.into_release(name, release.version_data)
} }
/// 翻页把该大版本的 GA release 名全部取回来(按页拼,直到「本页不满一页」或到 [`MAX_PAGES`])。 /// 翻页把该大版本的 GA release 名全部取回来(按页拼,直到「这一页不存在」/「本页不满一页」/ 到 [`MAX_PAGES`])。
/// ///
/// `fetch_page(page)` 由调用方给:真跑时是一发 HTTP,单测里是夹具闭包——所以「翻几页 / 什么时候 /// `fetch_page(page)` 由调用方给:真跑时是一发 HTTP(见 [`fetch_release_page`]),单测里是夹具闭包
/// 停 / 到上限了没有」这段判断**完全离线可测**。 /// ——所以「翻几页 / 什么时候停 / 到上限了没有」这段判断**完全离线可测**。
///
/// 返回 `Ok(None)` 的语义是**「上游明确说没有这一页」**(404/410 或 200+空体),
/// 与「这一页读不懂 / 网络坏了」是两件事:
///
/// * `Ok(None)` 在**第一页** ⇒ 「这个大版本真没有 GA 清单」,仍然 `Err`(与 r2 之前同口径);
/// * `Ok(None)` 在**第二页及以后** ⇒「翻到尾巴」,把已取到的交出去,`truncated = false`;
/// * `Err(..)` ⇒ **一律照抛**(含第二页及以后)。读不懂的响应一旦被当成「没有更多」,
/// 用户会看到「这个 major 没有版本 X」,而 X 其实在上游——一句可证伪的假话。
/// ///
/// 返回值第二项 = 是否**因为到上限**而停(还有没看到的页)。错误文案要用它决定说 /// 返回值第二项 = 是否**因为到上限**而停(还有没看到的页)。错误文案要用它决定说
/// 「上游就这些」还是「只查了前 N 条」。 /// 「上游就这些」还是「只查了前 N 条」。
/// ///
/// # Errors /// # Errors
/// ///
/// **第一页**取不到(网络 / 404 / 解析失败)时直接抛错——那才是「这个 major 真没有 GA 清单」。 /// 任何一页**读不懂**(解析失败 / 非 2xx 非 404 / 网络)都照抛,不吞。
/// 第二页及以后取不到时**不抛**:上游对越界页回 HTTP 404 + 空体(2026-11-12 实测),
/// 所以那是「翻到尾巴」的正常收尾(见下方注释)。
pub async fn collect_releases<F, Fut>(mut fetch_page: F) -> Result<(Vec<String>, bool), JavaError> pub async fn collect_releases<F, Fut>(mut fetch_page: F) -> Result<(Vec<String>, bool), JavaError>
where where
F: FnMut(usize) -> Fut, F: FnMut(usize) -> Fut,
Fut: std::future::Future<Output = Result<Vec<String>, JavaError>>, Fut: std::future::Future<Output = Result<Option<Vec<String>>, JavaError>>,
{ {
let mut all: Vec<String> = Vec::new(); let mut all: Vec<String> = Vec::new();
for page in 0..MAX_PAGES { for page in 0..MAX_PAGES {
let names = match fetch_page(page).await { let Some(names) = fetch_page(page).await? else {
Ok(names) => names, if page == 0 {
// 越界页不是空数组,而是 404(`net::get_text` 把它归成 Metadata)。某条 GA 线的条数 // 第一页就不存在 = 这个大版本真没有 GA 清单(不是「翻到尾巴」)。
// 恰好是 20 / 40 / 60 时,最后一页是满页 ⇒ 这里会多要一页 ⇒ 那一页 404。 // 与 r2 之前的行为一致:那一条仍然报错,只是判据从「错误的种类」换成了
// 这时**必须把已经取到的前几页交出去**(否则整条 `--version` 路径被一句与用户请求 // 「哪一页不存在」。
// 无关的 404 打死);第一页的 404 仍然报错,走下面的 `Err(error) => return`。 return Err(JavaError::new(
Err(error) if page > 0 && error.kind() == JavaErrorKind::Metadata => { JavaErrorKind::Metadata,
return Ok((all, false)); "Adoptium 没有该大版本的 GA 清单(HTTP 404 / 空响应)",
));
} }
Err(error) => return Err(error), // 「这一页不存在」= 翻到尾巴。上游对越界页回 404(2026-11-12 实测),
// 某条 GA 线的条数恰好是 20 / 40 / 60 时就会撞上这一发;此时已取到的前几页必须交出去。
return Ok((all, false));
}; };
let short_page = names.len() < PAGE_SIZE; let short_page = names.len() < PAGE_SIZE;
all.extend(names); all.extend(names);
@@ -165,6 +174,60 @@ where
Ok((all, true)) Ok((all, true))
} }
/// 一页响应的判定:`Ok(None)` = 「上游明确说没有这一页」,`Err` = 读不懂或网络坏。
///
/// **判据是语义的**(状态码 + 体的形状),不匹配任何错误文案:
///
/// | 上游情形 | 结果 |
/// | --- | --- |
/// | 404 / 410(越界页实测就是这个) | `Ok(None)` |
/// | 200 + 空体 | `Ok(None)`(等价于「没有这一页」) |
/// | 其它非 2xx(5xx / 403 / 429…) | `Err(Network)` |
/// | 200 + 不是预期 JSON | `Err(Metadata)` —— **不许**被当成「没有更多」 |
/// | 200 + 正常清单 | `Ok(Some(names))` |
///
/// # Errors
///
/// 非 2xx 且不是 404/410 时是 [`JavaErrorKind::Network`];体读不懂时是
/// [`JavaErrorKind::Metadata`]。
pub fn page_outcome(status: u16, body: &str) -> Result<Option<Vec<String>>, JavaError> {
if status == 404 || status == 410 {
return Ok(None);
}
if !(200..300).contains(&status) {
return Err(JavaError::new(
JavaErrorKind::Network,
format!("Adoptium GA 清单返回 HTTP {status}"),
));
}
if body.trim().is_empty() {
return Ok(None);
}
parse_release_list(body).map(Some)
}
/// 取一页 GA 清单(HTTP 那一跳)。判定全交给 [`page_outcome`],这里只负责把响应取回来。
async fn fetch_release_page(
client: &reqwest::Client,
url: &str,
) -> Result<Option<Vec<String>>, JavaError> {
let response = client.get(url).send().await.map_err(|error| {
JavaError::new(
JavaErrorKind::Network,
format!("取 Adoptium GA 清单失败:{error}({url})"),
)
.with_hint("检查网络/代理;已装的 JDK 仍可用 `ull java list` 看到")
})?;
let status = response.status().as_u16();
let body = response.text().await.map_err(|error| {
JavaError::new(
JavaErrorKind::Network,
format!("读 Adoptium GA 清单响应失败:{error}({url})"),
)
})?;
page_outcome(status, &body)
}
/// 在枚举出来的 release 名里挑用户要的那一版(**不取最新、不取第一条**)。 /// 在枚举出来的 release 名里挑用户要的那一版(**不取最新、不取第一条**)。
#[must_use] #[must_use]
pub fn pick_release<'a>(releases: &'a [String], wanted: &str) -> Option<&'a String> { pub fn pick_release<'a>(releases: &'a [String], wanted: &str) -> Option<&'a String> {
@@ -184,10 +247,7 @@ pub async fn fetch_version(
) -> Result<JdkRelease, JavaError> { ) -> Result<JdkRelease, JavaError> {
let (releases, truncated) = collect_releases(|page| { let (releases, truncated) = collect_releases(|page| {
let url = releases_url(major, target, page); let url = releases_url(major, target, page);
async move { async move { fetch_release_page(client, &url).await }
let body = net::get_text(client, &url, "Adoptium GA 清单").await?;
parse_release_list(&body)
}
}) })
.await?; .await?;
let Some(name) = pick_release(&releases, wanted).cloned() else { let Some(name) = pick_release(&releases, wanted).cloned() else {
+146 -33
View File
@@ -4,8 +4,8 @@
//! **纯搬移**,既有断言一字未改。 //! **纯搬移**,既有断言一字未改。
use super::{ use super::{
collect_releases, parse_release, parse_release_list, pick_release, release_url, releases_url, collect_releases, page_outcome, parse_release, parse_release_list, pick_release, release_url,
MAX_PAGES, PAGE_SIZE, releases_url, MAX_PAGES, PAGE_SIZE,
}; };
use crate::game::java::error::{JavaError, JavaErrorKind}; use crate::game::java::error::{JavaError, JavaErrorKind};
use crate::game::java::target::AdoptiumTarget; use crate::game::java::target::AdoptiumTarget;
@@ -124,11 +124,12 @@ fn page_one() -> Vec<String> {
} }
/// 假的上游:按 page 返回夹具(`pages` 用完之后一律空页)。 /// 假的上游:按 page 返回夹具(`pages` 用完之后一律空页)。
fn two_pages(page: usize) -> Result<Vec<String>, JavaError> { fn two_pages(page: usize) -> Result<Option<Vec<String>>, JavaError> {
match page { match page {
0 => Ok(page_zero()), 0 => Ok(Some(page_zero())),
1 => Ok(page_one()), 1 => Ok(Some(page_one())),
_ => Ok(Vec::new()), // 越界页:上游说「没有这一页」(404/410 或 200+空体)
_ => Ok(None),
} }
} }
@@ -194,9 +195,11 @@ async fn a_never_ending_catalog_stops_at_the_page_cap_and_says_so() {
calls.set(calls.get() + 1); calls.set(calls.get() + 1);
async move { async move {
let _ = page; let _ = page;
Ok((0..PAGE_SIZE) Ok(Some(
(0..PAGE_SIZE)
.map(|index| format!("jdk-17.0.{}+1", index + 1)) .map(|index| format!("jdk-17.0.{}+1", index + 1))
.collect::<Vec<_>>()) .collect::<Vec<_>>(),
))
} }
}) })
.await .await
@@ -206,64 +209,157 @@ async fn a_never_ending_catalog_stops_at_the_page_cap_and_says_so() {
assert!(truncated, "到上限时必须标成截断,文案才能如实说"); assert!(truncated, "到上限时必须标成截断,文案才能如实说");
} }
/// **越界页是 404 而不是空数组**(2026-11-12 实测):某条 GA 线的条数恰好是 20 的整数倍时, /// **判据 1(别退回去)**:某条 GA 线的条数恰好是 20 的整数倍时,最后一页是满页 ⇒ 会多要一页
/// 最后一页是满页 ⇒ 会多要一页 ⇒ 那一页 404。这时必须把**已经取到的**交出去。 /// ⇒ 上游对那一页说「没有这一页」(404/410 或 200+空体,见 [`page_outcome`])⇒
/// 必须把**已经取到的**交出去,且 `truncated = false`。
/// ///
/// 变异证明:把 `collect_releases` 里 `page > 0` 的分支去掉(改回无条件 `?`) /// 它真正钉住的是「`collect_releases` 收到『这一页不存在』时要把已取到的交出去」;
/// ⇒ 本用例红(`Err(Metadata)` 被抛出去,前 20 条连同一句无关的 404 一起作废)。 /// **不是**「上游越界页就是 404」——后者只能靠 curl 实测,夹具手写不出来
/// (r3 复审 P3-1 的更正)。
///
/// 变异证明:把 `collect_releases` 里 `let Some(names) = … else { return Ok((all, false)) }`
/// 的 `None` 分支改成 `return Err(…)`(即「没有这一页」也当失败)⇒ 本用例红。
#[tokio::test] #[tokio::test]
async fn a_404_on_the_page_after_a_full_page_ends_the_tail_cleanly() { async fn a_missing_page_after_a_full_page_ends_the_tail_cleanly() {
let calls = std::cell::Cell::new(0usize); let calls = std::cell::Cell::new(0usize);
let result = collect_releases(|page| { let result = collect_releases(|page| {
calls.set(calls.get() + 1); calls.set(calls.get() + 1);
async move { async move {
match page { match page {
0 => Ok(page_zero()), 0 => Ok(Some(page_zero())),
// 上游越界页的真实形态:HTTP 404 + 空体,被 `net::get_text` 归成 Metadata。 // 越界页的语义信号:上游明确说「没有这一页」。
_ => Err(JavaError::new( _ => Ok(None),
JavaErrorKind::Metadata,
"Adoptium GA 清单 不存在(HTTP 404):https://api.adoptium.net/…&page=1",
)),
} }
} }
}) })
.await; .await;
let (releases, truncated) = result.expect("第一页拿到了就是拿到了,越界页的 404 不该作废它"); let (releases, truncated) = result.expect("第一页拿到了就是拿到了,越界页不该作废它");
assert_eq!(releases.len(), PAGE_SIZE, "正好一页,条数要精确"); assert_eq!(releases.len(), PAGE_SIZE, "正好一页,条数要精确");
assert_eq!(releases, page_zero()); assert_eq!(releases, page_zero(), "逐条相等(不是「大概 20 个」)");
assert!(!truncated, "这是「翻到尾巴」,不是「到上限」"); assert!(!truncated, "这是「翻到尾巴」,不是「到上限」");
assert_eq!( assert_eq!(
calls.get(), calls.get(),
2, 2,
"确实多要了第二页(这正是上游 404 的那一发)" "确实多要了第二页(这正是上游说 404 的那一发)"
); );
} }
/// **反例**:第一页就 404 ⇒ 仍然是 Err(那才是「这个 major 没有 GA 清单」),不许被吞。 /// **判据 1 的语义底座**:`page_outcome` 把「这一页不存在」与「这一页读不懂」分开,
/// 判据是状态码 + 体的形状,**不匹配任何错误文案**。
/// ///
/// 变异证明:把 `page > 0` 的判断改成 `page >= 0`(即第一页也吞)⇒ 本用例红。 /// 变异证明:把 404/410 那一支删掉 ⇒ 第一条红;把空体那一支删掉 ⇒ 第三条红;
/// 把解析失败并进 `Ok(None)` ⇒ 最后一条红。
#[test]
fn a_missing_page_is_404_410_or_an_empty_body_and_nothing_else() {
assert_eq!(page_outcome(404, "").expect("404 = 没有这一页"), None);
assert_eq!(
page_outcome(410, "<html>gone</html>").expect("410 = 没有这一页"),
None
);
assert_eq!(
page_outcome(200, " \n\t ").expect("200 + 空体 = 没有这一页"),
None
);
// 正常清单:真的解析出名字(顺序即上游给的顺序)。
assert_eq!(
page_outcome(200, &list_body()).expect("应解析"),
Some(vec![
"jdk-21.0.13+9".to_string(),
"jdk-21.0.12+8".to_string()
])
);
// 读不懂 = 错误,**不许**被当成「没有这一页」。
let parse_failure = page_outcome(200, "<html>502 Bad Gateway</html>").expect_err("HTML 应被拒");
assert_eq!(parse_failure.kind(), JavaErrorKind::Metadata);
// 其它非 2xx 是网络侧,同样不许被当成「没有这一页」。
for status in [500u16, 403, 429, 502] {
let error = page_outcome(status, "").expect_err("非 2xx 应报错");
assert_eq!(error.kind(), JavaErrorKind::Network, "status={status}");
}
}
/// **判据 2(r3 的 p2 本体)**:第二页**读不懂**(解析失败)⇒ 必须 `Err`,绝不许 `Ok`。
///
/// 夹具用的是**真的** `parse_release_list` 产出的错误(不是手写字符串),所以它跟着解析实现走。
/// 返回类型是 `Result<(Vec<String>, bool), JavaError>`,`Err` 分支在类型上就**不可能**带出
/// 部分结果——「不能既 Err 又把 all 交出去」由签名保证,用例断言的是「不许变成 Ok」。
///
/// 变异证明:在 `collect_releases` 里把 `Metadata` 错误重新并进「翻到尾巴」的分支
/// (`Err(e) if e.kind() == Metadata => return Ok((all, false))`)⇒ 本用例红。
#[tokio::test] #[tokio::test]
async fn a_404_on_the_first_page_is_still_an_error() { async fn a_parse_failure_on_a_later_page_is_an_error_not_the_end_of_the_list() {
let result = collect_releases(|_page| async { let parse_failure = parse_release_list("<html>502 Bad Gateway</html>")
Err::<Vec<String>, JavaError>(JavaError::new( .expect_err("HTML 不是 GA 清单,必须报错");
assert_eq!(
parse_failure.kind(),
JavaErrorKind::Metadata, JavaErrorKind::Metadata,
"Adoptium 没有 99 的 GA 产物(HTTP 404)", "先确认这一类错误确实落在 Metadata 桶里(这正是 r3 那条 p2 的入口)"
)) );
let result = collect_releases(|page| {
let error = parse_failure.clone();
async move {
match page {
0 => Ok(Some(page_zero())),
_ => Err(error),
}
}
}) })
.await; .await;
let error = result.expect_err("第一页 404 = 这个 major 真没有清单,必须报错"); let error = result.expect_err("第二页读不懂 ⇒ 必须报错,不许静默截断");
assert_eq!(error.kind(), JavaErrorKind::Metadata); assert_eq!(error.kind(), JavaErrorKind::Metadata);
assert!(error.message().contains("404"), "{}", error.message()); assert!(
error.message().contains("不是预期 JSON"),
"报的必须是解析失败本身,不是一句无关的 404:{}",
error.message()
);
} }
/// 网络错误(非 Metadata)在任何一页都不许被当成「翻到尾巴」——那是真坏了。 /// **判据 3**:错误落在**最后一页**(`page == MAX_PAGES - 1`)时同样不许报「清单完整」——
/// 那是被错误截断,不是「上游就这些」。
///
/// 变异证明:同判据 2 的那个变异(把 `Metadata` 并进吞掉的分支)⇒ 本用例红
/// (会拿到 `Ok((…, false))`,即文案会走 `Complete`)。
#[tokio::test]
async fn an_error_on_the_last_allowed_page_is_never_reported_as_complete() {
let parse_failure =
parse_release_list("<html>502</html>").expect_err("HTML 不是 GA 清单,必须报错");
let full_page: Vec<String> = (0..PAGE_SIZE)
.map(|index| format!("jdk-17.0.{}+1", index + 1))
.collect();
let result = collect_releases(|page| {
let error = parse_failure.clone();
let full_page = full_page.clone();
async move {
if page + 1 == MAX_PAGES {
Err(error)
} else {
Ok(Some(full_page))
}
}
})
.await;
let error = result.expect_err("最后一页读不懂也必须报错,不许报「完整」");
assert_eq!(error.kind(), JavaErrorKind::Metadata);
assert!(
error.message().contains("不是预期 JSON"),
"{}",
error.message()
);
}
/// 网络错误在任何一页都不许被当成「翻到尾巴」——那是真坏了。
///
/// 变异证明:把 `fetch_page` 的错误改成一律 `Ok(None)` ⇒ 本用例红。
#[tokio::test] #[tokio::test]
async fn a_network_error_on_a_later_page_is_not_swallowed() { async fn a_network_error_on_a_later_page_is_not_swallowed() {
let result = collect_releases(|page| async move { let result = collect_releases(|page| async move {
match page { match page {
0 => Ok(page_zero()), 0 => Ok(Some(page_zero())),
_ => Err(JavaError::new( _ => Err(JavaError::new(
JavaErrorKind::Network, JavaErrorKind::Network,
"连不上 Adoptium API:connection reset", "连不上 Adoptium API:connection reset",
@@ -274,3 +370,20 @@ async fn a_network_error_on_a_later_page_is_not_swallowed() {
let error = result.expect_err("网络错误要冒泡"); let error = result.expect_err("网络错误要冒泡");
assert_eq!(error.kind(), JavaErrorKind::Network); assert_eq!(error.kind(), JavaErrorKind::Network);
} }
/// **第一页就不存在**:那是「这个大版本真没有 GA 清单」,仍然报错(与 r2 之前同口径),
/// 不许被当成「翻到尾巴」而返回空清单——否则用户会看到
/// 「没有版本 X(上游一个都没列出来)」这种什么都说明不了的文案。
///
/// 变异证明:把 `collect_releases` 里 `if page == 0` 那一支删掉 ⇒ 本用例红。
#[tokio::test]
async fn a_missing_first_page_is_still_an_error() {
let result = collect_releases(|_page| async { Ok::<_, JavaError>(None) }).await;
let error = result.expect_err("第一页就不存在 = 这个 major 没有 GA 清单");
assert_eq!(error.kind(), JavaErrorKind::Metadata);
assert!(
error.message().contains("没有该大版本的 GA 清单"),
"{}",
error.message()
);
}
+2 -7
View File
@@ -170,8 +170,8 @@ fn pin_refusals_are_distinct_and_each_explains_itself() {
errors[0].message().contains("21.0.13+9"), errors[0].message().contains("21.0.13+9"),
"可用版本要列出来" "可用版本要列出来"
); );
// P3-3:连接词由 `source` 自带,模板不再补空格 —— 两种拼法都必须读得通, // P3-3:连接词由 `source` 自带,模板不再补空格 —— 两种拼法都必须读得通。
// 且 Oracle 那句不许出现「镜像目录 里」这种多一个空格的样子。 // (正向断言已蕴含「没有多余空格」,所以不留反向断言:r3 复审 P3-2 判它冗余。)
assert!( assert!(
errors[0].message().contains("Adoptium 里可用"), errors[0].message().contains("Adoptium 里可用"),
"{}", "{}",
@@ -182,11 +182,6 @@ fn pin_refusals_are_distinct_and_each_explains_itself() {
"{}", "{}",
errors[1].message() errors[1].message()
); );
assert!(
!errors[1].message().contains("镜像目录 里"),
"多一个空格:{}",
errors[1].message()
);
assert!(errors[2].message().contains("只内置了 21.0.13.0.2")); assert!(errors[2].message().contains("只内置了 21.0.13.0.2"));
assert!(errors[2] assert!(errors[2]
.hint() .hint()