diff --git a/crates/app-iced/src/pages/instances/tests.rs b/crates/app-iced/src/pages/instances/tests.rs index 9ed915d..2053713 100644 --- a/crates/app-iced/src/pages/instances/tests.rs +++ b/crates/app-iced/src/pages/instances/tests.rs @@ -57,25 +57,31 @@ fn list_viewport_fits_whole_rows_and_still_overflow() { ); } -/// **源码结构**:`list_column()` 的列表行只许用 [`ROW_SPACING`] 这一个间距真源。 +/// **源码结构**:列表**行容器**的构建链上不许叠页级间距,行距只能来自 [`ROW_SPACING`]。 /// -/// 为什么读源码:变异实测(M102 r1)把 `.spacing(ui.space(Space::X1))` 直接加回 `list_column()`, -/// 上面那条几何断言照样绿——因为它算的是常量,看不见页面又叠了一层。iced 的 Element 又没有 -/// 内省接口,所以只能断言「这段代码里不许出现别的间距写法」。 +/// 为什么读源码:变异实测(M102 r1)把 `.spacing(ui.space(Space::X1))` 直接加回 `list_column()` +/// 的行容器上,上面那条几何断言照样绿——它算的是常量,看不见页面又叠了一层。iced 的 Element +/// 又没有内省接口,所以只能断言这段代码的形状。 /// -/// **抓得住**:任何在列表行上写字面量 `.spacing(…)` 的页级附加量(视觉复审 ② 的原样); -/// **抓不住**:换个写法绕过(先赋给局部变量再 `.spacing`)——它守的是这段 5 行代码的契约。 +/// **禁词范围只到「`let mut rows = column(…);` 这一条语句链」**(r2 复审 P2-2 的订正): +/// 早先扫整个 `list_column()` 并禁一切 `.spacing(ui.space(Space::`,那样将来在 +/// `column![head, scroller]` 上加一条合法间距会**假红**。现在只钉行容器那条链。 +/// +/// **抓得住**:行容器上又写字面量 `.spacing(…)`(视觉复审 ② 的页级附加量,含「先存局部变量 +/// 再 `.spacing(gap)`」的写法);`ROW_SPACING` / `LIST_BOTTOM_INSET` 两个真源被摘掉; +/// **抓不住**:行距被算进别处再传进 `list_row()`(那要改组件几何,代码评审能看出来)。 #[test] fn list_column_spaces_rows_only_from_the_shared_constant() { let body = source::function_body(include_str!("../instances.rs"), "fn list_column"); + let rows_chain = source::statement(include_str!("../instances.rs"), "let mut rows = column("); assert!( body.contains("ROW_SPACING"), "列表行距必须从 ROW_SPACING 取,不许另写:\n{body}" ); assert!( - !body.contains(".spacing(ui.space(Space::"), - "`list_column()` 里又出现了字面量间距(M102 ② 的页级附加量):\n{body}" + !rows_chain.contains(".spacing("), + "行容器的构建链上又出现了间距(M102 ② 的页级附加量):\n{rows_chain}" ); assert!( body.contains("LIST_BOTTOM_INSET"), diff --git a/crates/app-iced/src/pages/layout/block.rs b/crates/app-iced/src/pages/layout/block.rs index 55e3138..7b4fae6 100644 --- a/crates/app-iced/src/pages/layout/block.rs +++ b/crates/app-iced/src/pages/layout/block.rs @@ -27,6 +27,13 @@ //! 贴左不变:块左缘仍是 `rail 88 + gutter 24 = 112`,封顶后**右侧留白**(不是居中)。 //! 这是有意的:页头标题在左,正文块跟着左缘;把块居中又会回到视觉复审 ① 的「内容缩在中间」。 //! +//! ## 这一层唯一的行为变化,以及它从哪一版开始 +//! +//! 封顶是 M102 r2 引入的**有意行为变化**:`≥ 1576` 窗宽(`112 + 1440 + 24`)下块宽不再跟随可用宽度。 +//! 在 1180 / 900 上它**不生效**——那两张验收图与 r1 逐字节相同(9/9 `cmp`)。所以对外口径是 +//! 「**≤1440 窗宽零像素变化;更宽的窗口按本上限有意改变**」,不是笼统的「零变化」。 +//! (`frame::body` 那行 `padding(left(body_offset))` 在同一轮加进来,值恒 0 ⇒ 像素中性。) +//! //! ## 偏移为什么是个常量函数而不是 `align_x` //! //! iced 的 `Element` **没有几何内省接口**,「块摆在哪」在单测里量不到。所以把「贴左」写成一个 diff --git a/crates/app-iced/src/pages/layout/source.rs b/crates/app-iced/src/pages/layout/source.rs index b406577..0a592e8 100644 --- a/crates/app-iced/src/pages/layout/source.rs +++ b/crates/app-iced/src/pages/layout/source.rs @@ -35,5 +35,70 @@ pub(crate) fn function_body<'a>(source: &'a str, signature: &str) -> &'a str { &rest[..end] } +/// 从 `start` 起到**第一个 `;`** 为止的一条语句(构建链断言用)。 +/// +/// 例如 `statement(instances.rs, "let mut rows = column(")` 拿到的是 +/// `let mut rows = column(...).padding(...).width(Length::Fill);` 这一整条链—— +/// 于是「这条链上不许叠间距」可以精确到**这一个**容器,不会连带禁掉旁边 `column![head, scroller]` +/// 上的合法间距。 +/// +/// # Panics +/// `start` 不存在时 panic(守卫的前置条件坏了要立刻红)。 +pub(crate) fn statement<'a>(source: &'a str, start: &str) -> &'a str { + let from = source + .find(start) + .unwrap_or_else(|| panic!("源码里找不到语句起点 {start:?}")); + + let rest = &source[from..]; + let end = rest.find(';').map_or(rest.len(), |index| index + 1); + &rest[..end] +} + +/// `signature` 的函数体 **+ 它内部转调的同文件函数体**(最多两层)。 +/// +/// 为什么不只切一个函数:**等价重构不该假红**。把 `frame::body` 拆成 +/// `body` → `body_impl` 两层再转调,盒子一个像素都没变,`function_body` 却会拿到三行转调桩、 +/// needle 全缺(r2 复审的变异 H1 就是这样假红的)。这里把「本文件内被它调用的函数」也算进契约, +/// 于是「拆一层再转调」放行,「换成居中盒子」照样红。 +/// +/// 边界:只跟**同一文件内**的转调(跨文件转调、先赋局部变量再建盒不在覆盖内,见调用点 docstring)。 +pub(crate) fn function_region(source: &str, signature: &str) -> String { + let mut region = function_body(source, signature).to_owned(); + + for _ in 0..2 { + let mut grew = false; + let mut cursor = 0; + + while let Some(found) = source[cursor..].find("fn ") { + let at = cursor + found + "fn ".len(); + cursor = at; + + let name: String = source[at..] + .chars() + .take_while(|ch| ch.is_alphanumeric() || *ch == '_') + .collect(); + if name.is_empty() { + continue; + } + + let called = format!("{name}("); + let defined = format!("fn {name}"); + if region.contains(&called) && !region.contains(&defined) && source.contains(&defined) { + region.push('\n'); + // `defined` 不带 `(`:签名可能带泛型(`fn body_impl<'a>(`), + // 带括号的 needle 会找不到定义、直接把护栏自己搞 panic。 + region.push_str(function_body(source, &defined)); + grew = true; + } + } + + if !grew { + break; + } + } + + region +} + #[cfg(test)] mod tests; diff --git a/crates/app-iced/src/pages/layout/source/tests.rs b/crates/app-iced/src/pages/layout/source/tests.rs index 9a8f7ff..85c5855 100644 --- a/crates/app-iced/src/pages/layout/source/tests.rs +++ b/crates/app-iced/src/pages/layout/source/tests.rs @@ -41,8 +41,70 @@ fn the_helper_also_works_on_signatures_with_generics_and_multiline_params() { assert!(gamma.trim_end().ends_with('}'), "{gamma}"); } +/// `statement` 停在第一个 `;`:构建链断言靠它精确到一个容器。 +#[test] +fn a_statement_stops_at_the_first_semicolon() { + let source = "\ +let mut rows = column(entries) + .padding(Padding::from([0.0, ui.space(Space::X2)])) + .width(Length::Fill); +let other = 1; +"; + let chain = statement(source, "let mut rows = column("); + assert!(chain.starts_with("let mut rows"), "{chain}"); + assert!(chain.ends_with(".width(Length::Fill);"), "{chain}"); + assert!(!chain.contains("let other"), "越过语句边界:{chain}"); +} + +/// `function_region` 把**本文件内被调用的函数**也算进契约(等价重构不假红)。 +#[test] +fn the_region_follows_one_level_of_delegation() { + let source = "\ +pub fn outer(a: u8) -> u8 { + inner(a) + 1 +} + +fn inner(a: u8) -> u8 { + a * 2 +} + +fn unrelated(a: u8) -> u8 { + a - 9 +} +"; + let region = function_region(source, "pub fn outer("); + assert!(region.contains("inner(a) + 1"), "{region}"); + assert!(region.contains("a * 2"), "转调目标没进来:{region}"); + assert!(!region.contains("a - 9"), "把无关函数也扫进来了:{region}"); +} + +/// 转调目标带**泛型签名**时也要跟进去(`frame::body` → `body_impl<'a>` 就是这种形状)。 +/// +/// 这条是变异 H1 抓出来的:早先的实现拿 `fn {name}(` 当定义 needle,遇到 +/// `fn body_impl<'a>(` 找不到 ⇒ 护栏自己 panic ⇒ 对等价重构**假红**。 +#[test] +fn the_region_follows_delegation_into_a_generic_function() { + let source = "\ +pub fn wrapper(v: u8) -> u8 { + helper(v) + 1 +} + +fn helper<'a>(v: u8) -> u8 { + v * 3 +} +"; + let region = function_region(source, "pub fn wrapper("); + assert!(region.contains("v * 3"), "泛型签名没跟进去:{region}"); +} + #[test] #[should_panic(expected = "找不到签名")] fn a_missing_signature_fails_loudly() { let _ = function_body(SAMPLE, "pub fn nope"); } + +#[test] +#[should_panic(expected = "找不到语句起点")] +fn a_missing_statement_start_fails_loudly() { + let _ = statement(SAMPLE, "let nope = 1"); +} diff --git a/crates/app-iced/src/pages/logs/tests.rs b/crates/app-iced/src/pages/logs/tests.rs index f5841f3..64a4f60 100644 --- a/crates/app-iced/src/pages/logs/tests.rs +++ b/crates/app-iced/src/pages/logs/tests.rs @@ -11,7 +11,7 @@ use unlucky_i18n::Lang; use crate::theme::Families; -use super::super::layout::{frame_metrics, measure_cap_px, wrap, PaneKit}; +use super::super::layout::{frame_metrics, measure_cap_px, source, wrap, PaneKit}; use super::model::{self, GROUPS, SUMMARY_HEADER}; use super::{demo, lines, *}; @@ -44,10 +44,13 @@ fn page_is_single_column_and_never_wider_than_the_measure_cap() { } } -/// **M102 ③**:日志行与一行摘要都带软断点——否则 iced 只能按字形硬断,`gl_o` / `peration` 复现。 +/// **M102 ③**:`wrap::breakable` 这张表本身是好的,且**只改显示**(原文能还原)。 /// -/// **抓得住**:`entry()` / `summary_block()` 又把原文直接喂给 `mono_owned`(软断点不见了); -/// **抓不住**:渲染器最终断在哪一列(那要量图,报告里有前后对照)。 +/// **抓得住**:断点表被掏空(`BREAK_AFTER` 少了一档 ⇒ 长标识符又没有断点)、 +/// `breakable` 开始污染原文(`strip` 还不了原); +/// **抓不住**:页面到底有没有把 `breakable` 接在渲染路径上——那条由 +/// [`the_page_wires_the_soft_breaks_into_both_rendering_paths`] 扫调用点守 +/// (r2 复审的变异 I:两处接线去掉后这条仍全绿,所以 docstring 不能替它吹牛)。 #[test] fn log_text_carries_soft_breaks_but_keeps_the_original() { let logs = demo::crash(); @@ -79,6 +82,31 @@ fn log_text_carries_soft_breaks_but_keeps_the_original() { ); } +/// **源码结构(调用点)**:日志页必须把 `wrap::breakable` 接在**两条渲染路径**上。 +/// +/// r2 复审的变异 I:把 `lines::entry()` 的 `mono_owned(ui, wrap::breakable(body), …)` 改回 +/// `mono(ui, body, …)`、把 `diagnostic::summary_block()` 的 `wrap::breakable(&logs.summary())` +/// 改回原文 ⇒ 420 条全绿,而视觉复审 ③(`graphics.gl_o` / `peration_failure`)原样回来。 +/// 断点表那侧已经 [`log_text_carries_soft_breaks_but_keeps_the_original`] 守着了, +/// 这里守的是**接线**:页面真把显示串过了一遍它。 +/// +/// **抓得住**:任一处接线被摘掉(日志行 / 一行摘要); +/// **抓不住**:接线写得对但断点表被掏空(那是上一条的事)。 +#[test] +fn the_page_wires_the_soft_breaks_into_both_rendering_paths() { + let entry = source::function_body(include_str!("lines.rs"), "fn entry"); + assert!( + entry.contains("wrap::breakable("), + "日志行没接软断点(`graphics.gl_operation_failure` 会被按字形劈开):\n{entry}" + ); + + let summary = source::function_body(include_str!("diagnostic.rs"), "fn summary_block"); + assert!( + summary.contains("wrap::breakable("), + "一行摘要没接软断点:\n{summary}" + ); +} + #[test] fn mono_lines_use_the_mono_stack_with_the_body_small_metrics() { let ui = ui(); diff --git a/crates/app-iced/src/pages/tests.rs b/crates/app-iced/src/pages/tests.rs index eacf9d4..6d7ca0f 100644 --- a/crates/app-iced/src/pages/tests.rs +++ b/crates/app-iced/src/pages/tests.rs @@ -138,24 +138,34 @@ fn body_block_starts_at_the_header_title_and_fills_the_page() { /// `centered(metrics.column, content)`(**就是视觉复审 ① 的改前写法**)不会红任何数值断言 —— /// M102 r1 的变异实测就是这样漏掉的(`pages/tests.rs` 那条只喂 `frame_metrics()` 与 /// `body_width()`)。所以这条盯的是**摆法**:宽度来自 `body_width(metrics)`、左内边距来自 -/// `body_offset(metrics)`、对齐 `Start`,且不出现居中/散文列宽。 +/// `body_offset(metrics)`、`align_x` 只许是 `Start`(不写 = container 默认 Start,等价), +/// 且不出现居中/散文列宽。 /// -/// **抓得住**:`body()` 被换回居中、`align_x` 改成 `Center`、宽度改回 `metrics.column`; -/// **抓不住**:绕过这段契约的重写(先取局部变量再建盒)——它守的是这 8 行的写法契约。 +/// 扫的是 [`layout::source::function_region`](函数体 + 本文件内被它转调的函数), +/// 所以「拆成 `body` → `body_impl` 两层再转调」这种**等价重构不假红**(r2 复审的变异 H1)。 +/// +/// **抓得住**:`body()` 被换回居中(变异 A)、宽度改回 `metrics.column`、`align_x` 改成 `Center`; +/// **抓不住**:跨文件转调、或先赋局部变量再建盒的写法(见 `layout/source.rs` 的边界说明)。 #[test] fn frame_body_is_built_from_the_block_quantities() { - let body = layout::source::function_body(include_str!("frame.rs"), "pub fn body<'a>("); + let body = layout::source::function_region(include_str!("frame.rs"), "pub fn body<'a>("); - for needle in [ - "body_width(metrics)", - "body_offset(metrics)", - "Alignment::Start", - ] { + for needle in ["body_width(metrics)", "body_offset(metrics)"] { assert!( body.contains(needle), "`frame::body` 必须从 {needle} 建盒(贴左 + 块宽 + 零偏移):\n{body}" ); } + + // `align_x` 出现就必须是 Start:`.align_y(Alignment::Start)` 单独存在不算数(r2 复审 P2-3), + // 而「干脆不写 align_x」(container 默认 Start)视为等价重构,放行。 + if let Some(at) = body.find(".align_x(") { + assert!( + body[at..].starts_with(".align_x(Alignment::Start)"), + "`frame::body` 的 align_x 只许是 Start:\n{body}" + ); + } + for forbidden in [ "centered(", "single_column(", @@ -188,6 +198,45 @@ fn frame_prose_is_built_from_the_prose_width() { } } +/// **源码结构(调用点)**:单列页必须调 `frame::body`,不许退回 `frame::single_column`。 +/// +/// r2 复审的变异 G:把四个页面文件里的 7 个调用点(`download.rs` 2 / `logs.rs` 3 / +/// `instances.rs` 1 / `instances/detail.rs` 1)一键改回 `frame::single_column` ⇒ 420 条全绿, +/// 而视觉复审 ① 原样回来。r2 的护栏只盯 `frame::body` **内部**,`single_column` 又是为设置页 +/// 合法保留的,所以「哪个页面用它」必须单独守。 +/// +/// 逐文件断言(而不是只数一条调用):**同一个文件里退一个调用点**也会让 +/// `frame::single_column(` 出现,立刻红。 +/// +/// **抓得住**:页面退回居中的老写法(视觉复审 ① 原样); +/// **抓不住**:新增的页面文件(它不在这个清单里——新页要自己登记)。 +#[test] +fn single_column_pages_call_the_body_box_not_the_centred_one() { + for (name, source) in [ + ("download.rs", include_str!("download.rs")), + ("logs.rs", include_str!("logs.rs")), + ("instances.rs", include_str!("instances.rs")), + ("instances/detail.rs", include_str!("instances/detail.rs")), + ] { + assert!( + source.contains("frame::body("), + "{name} 没有调用 frame::body —— 正文块不再从块宽/块偏移建盒" + ); + assert!( + !source.contains("frame::single_column("), + "{name} 退回了 frame::single_column(= 424 居中,视觉复审 ① 的原样)" + ); + } + + // 实例页窄窗那一屏的唯一入口也得走 body + let narrow = + layout::source::function_body(include_str!("instances/detail.rs"), "fn narrow_detail"); + assert!( + narrow.contains("frame::body("), + "narrow_detail 没走 frame::body:\n{narrow}" + ); +} + /// **M84 的行宽红线**:任何页、任何窗口宽度、任何版面,正文列都不许超过 424px。 /// /// **抓得住**:`frame_metrics` 的 `column`(页面唯一的列宽来源)不会越界;