From a0aeccf303b406cfded4c635073466ea50ca6932 Mon Sep 17 00:00:00 2001 From: worker-javapin Date: Thu, 12 Nov 2026 22:34:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(java):=20=E6=8A=8A=E3=80=8C=E8=BF=99?= =?UTF-8?q?=E4=B8=80=E9=A1=B5=E4=B8=8D=E5=AD=98=E5=9C=A8=E3=80=8D=E4=B8=8E?= =?UTF-8?q?=E3=80=8C=E8=BF=99=E4=B8=80=E9=A1=B5=E8=AF=BB=E4=B8=8D=E6=87=82?= =?UTF-8?q?=E3=80=8D=E5=88=86=E5=BC=80=EF=BC=8C=E8=A7=A3=E6=9E=90=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E4=B8=8D=E5=86=8D=E8=A2=AB=E5=BD=93=E6=88=90=E7=BF=BB?= =?UTF-8?q?=E5=88=B0=E5=B0=BE=E5=B7=B4=EF=BC=88M280=20r3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r3 复审 p2-1items:r2 的吞判据按 `JavaErrorKind` 判,而 `Metadata` 这个桶里除了 404/410 与 200+空体,还装着**解析失败**(`parse_release_list`)⇒ 第二页起上游回 「看得懂的状态码 + 读不懂的内容」时,清单被静默截断且标成 `Complete`,用户看到的是 「这个 major 没有版本 X(可用:…)」,而 X 其实在上游。 ## 修法:语义信号代替错误种类(复审判据里没选字面方案,选了改动面最小的等价实现) `fetch_page` 的返回类型变成 `Result>, 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)`;`502` ⇒ `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("502").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。 --- crates/core/src/game/java/api/version.rs | 100 ++++++++-- .../core/src/game/java/api/version/tests.rs | 183 ++++++++++++++---- crates/core/src/game/java/catalog/tests.rs | 9 +- 3 files changed, 230 insertions(+), 62 deletions(-) diff --git a/crates/core/src/game/java/api/version.rs b/crates/core/src/game/java/api/version.rs index a1a5120..f5af093 100644 --- a/crates/core/src/game/java/api/version.rs +++ b/crates/core/src/game/java/api/version.rs @@ -125,36 +125,45 @@ pub fn parse_release( 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 条」。 /// /// # Errors /// -/// **第一页**取不到(网络 / 404 / 解析失败)时直接抛错——那才是「这个 major 真没有 GA 清单」。 -/// 第二页及以后取不到时**不抛**:上游对越界页回 HTTP 404 + 空体(2026-11-12 实测), -/// 所以那是「翻到尾巴」的正常收尾(见下方注释)。 +/// 任何一页**读不懂**(解析失败 / 非 2xx 非 404 / 网络)都照抛,不吞。 pub async fn collect_releases(mut fetch_page: F) -> Result<(Vec, bool), JavaError> where F: FnMut(usize) -> Fut, - Fut: std::future::Future, JavaError>>, + Fut: std::future::Future>, JavaError>>, { let mut all: Vec = Vec::new(); for page in 0..MAX_PAGES { - 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)); + let Some(names) = fetch_page(page).await? else { + if page == 0 { + // 第一页就不存在 = 这个大版本真没有 GA 清单(不是「翻到尾巴」)。 + // 与 r2 之前的行为一致:那一条仍然报错,只是判据从「错误的种类」换成了 + // 「哪一页不存在」。 + return Err(JavaError::new( + JavaErrorKind::Metadata, + "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; all.extend(names); @@ -165,6 +174,60 @@ where 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>, 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>, 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 名里挑用户要的那一版(**不取最新、不取第一条**)。 #[must_use] pub fn pick_release<'a>(releases: &'a [String], wanted: &str) -> Option<&'a String> { @@ -184,10 +247,7 @@ pub async fn fetch_version( ) -> Result { 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) - } + async move { fetch_release_page(client, &url).await } }) .await?; let Some(name) = pick_release(&releases, wanted).cloned() else { diff --git a/crates/core/src/game/java/api/version/tests.rs b/crates/core/src/game/java/api/version/tests.rs index 26e543c..7003d74 100644 --- a/crates/core/src/game/java/api/version/tests.rs +++ b/crates/core/src/game/java/api/version/tests.rs @@ -4,8 +4,8 @@ //! **纯搬移**,既有断言一字未改。 use super::{ - collect_releases, parse_release, parse_release_list, pick_release, release_url, releases_url, - MAX_PAGES, PAGE_SIZE, + collect_releases, page_outcome, 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; @@ -124,11 +124,12 @@ fn page_one() -> Vec { } /// 假的上游:按 page 返回夹具(`pages` 用完之后一律空页)。 -fn two_pages(page: usize) -> Result, JavaError> { +fn two_pages(page: usize) -> Result>, JavaError> { match page { - 0 => Ok(page_zero()), - 1 => Ok(page_one()), - _ => Ok(Vec::new()), + 0 => Ok(Some(page_zero())), + 1 => Ok(Some(page_one())), + // 越界页:上游说「没有这一页」(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); async move { let _ = page; - Ok((0..PAGE_SIZE) - .map(|index| format!("jdk-17.0.{}+1", index + 1)) - .collect::>()) + Ok(Some( + (0..PAGE_SIZE) + .map(|index| format!("jdk-17.0.{}+1", index + 1)) + .collect::>(), + )) } }) .await @@ -206,64 +209,157 @@ async fn a_never_ending_catalog_stops_at_the_page_cap_and_says_so() { assert!(truncated, "到上限时必须标成截断,文案才能如实说"); } -/// **越界页是 404 而不是空数组**(2026-11-12 实测):某条 GA 线的条数恰好是 20 的整数倍时, -/// 最后一页是满页 ⇒ 会多要一页 ⇒ 那一页 404。这时必须把**已经取到的**交出去。 +/// **判据 1(别退回去)**:某条 GA 线的条数恰好是 20 的整数倍时,最后一页是满页 ⇒ 会多要一页 +/// ⇒ 上游对那一页说「没有这一页」(404/410 或 200+空体,见 [`page_outcome`])⇒ +/// 必须把**已经取到的**交出去,且 `truncated = false`。 /// -/// 变异证明:把 `collect_releases` 里 `page > 0` 的分支去掉(改回无条件 `?`) -/// ⇒ 本用例红(`Err(Metadata)` 被抛出去,前 20 条连同一句无关的 404 一起作废)。 +/// 它真正钉住的是「`collect_releases` 收到『这一页不存在』时要把已取到的交出去」; +/// **不是**「上游越界页就是 404」——后者只能靠 curl 实测,夹具手写不出来 +/// (r3 复审 P3-1 的更正)。 +/// +/// 变异证明:把 `collect_releases` 里 `let Some(names) = … else { return Ok((all, false)) }` +/// 的 `None` 分支改成 `return Err(…)`(即「没有这一页」也当失败)⇒ 本用例红。 #[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 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", - )), + 0 => Ok(Some(page_zero())), + // 越界页的语义信号:上游明确说「没有这一页」。 + _ => Ok(None), } } }) .await; - let (releases, truncated) = result.expect("第一页拿到了就是拿到了,越界页的 404 不该作废它"); + let (releases, truncated) = result.expect("第一页拿到了就是拿到了,越界页不该作废它"); assert_eq!(releases.len(), PAGE_SIZE, "正好一页,条数要精确"); - assert_eq!(releases, page_zero()); + assert_eq!(releases, page_zero(), "逐条相等(不是「大概 20 个」)"); assert!(!truncated, "这是「翻到尾巴」,不是「到上限」"); assert_eq!( calls.get(), 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, "gone").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, "502 Bad Gateway").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, bool), JavaError>`,`Err` 分支在类型上就**不可能**带出 +/// 部分结果——「不能既 Err 又把 all 交出去」由签名保证,用例断言的是「不许变成 Ok」。 +/// +/// 变异证明:在 `collect_releases` 里把 `Metadata` 错误重新并进「翻到尾巴」的分支 +/// (`Err(e) if e.kind() == Metadata => return Ok((all, false))`)⇒ 本用例红。 #[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)", - )) +async fn a_parse_failure_on_a_later_page_is_an_error_not_the_end_of_the_list() { + let parse_failure = parse_release_list("502 Bad Gateway") + .expect_err("HTML 不是 GA 清单,必须报错"); + assert_eq!( + parse_failure.kind(), + JavaErrorKind::Metadata, + "先确认这一类错误确实落在 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; - let error = result.expect_err("第一页 404 = 这个 major 真没有清单,必须报错"); + let error = result.expect_err("第二页读不懂 ⇒ 必须报错,不许静默截断"); 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("502").expect_err("HTML 不是 GA 清单,必须报错"); + let full_page: Vec = (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] 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()), + 0 => Ok(Some(page_zero())), _ => Err(JavaError::new( JavaErrorKind::Network, "连不上 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("网络错误要冒泡"); 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() + ); +} diff --git a/crates/core/src/game/java/catalog/tests.rs b/crates/core/src/game/java/catalog/tests.rs index 5203d19..7a22489 100644 --- a/crates/core/src/game/java/catalog/tests.rs +++ b/crates/core/src/game/java/catalog/tests.rs @@ -170,8 +170,8 @@ fn pin_refusals_are_distinct_and_each_explains_itself() { errors[0].message().contains("21.0.13+9"), "可用版本要列出来" ); - // P3-3:连接词由 `source` 自带,模板不再补空格 —— 两种拼法都必须读得通, - // 且 Oracle 那句不许出现「镜像目录 里」这种多一个空格的样子。 + // P3-3:连接词由 `source` 自带,模板不再补空格 —— 两种拼法都必须读得通。 + // (正向断言已蕴含「没有多余空格」,所以不留反向断言:r3 复审 P3-2 判它冗余。) assert!( errors[0].message().contains("Adoptium 里可用"), "{}", @@ -182,11 +182,6 @@ fn pin_refusals_are_distinct_and_each_explains_itself() { "{}", 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()