diff --git a/crates/app-iced/src/pages/instances/tests.rs b/crates/app-iced/src/pages/instances/tests.rs index 2053713..88629a9 100644 --- a/crates/app-iced/src/pages/instances/tests.rs +++ b/crates/app-iced/src/pages/instances/tests.rs @@ -57,36 +57,81 @@ fn list_viewport_fits_whole_rows_and_still_overflow() { ); } -/// **源码结构**:列表**行容器**的构建链上不许叠页级间距,行距只能来自 [`ROW_SPACING`]。 +/// **源码结构**:`list_column()` 里对行容器的间距**只能**来自 [`ROW_SPACING`]。 /// -/// 为什么读源码:变异实测(M102 r1)把 `.spacing(ui.space(Space::X1))` 直接加回 `list_column()` -/// 的行容器上,上面那条几何断言照样绿——它算的是常量,看不见页面又叠了一层。iced 的 Element -/// 又没有内省接口,所以只能断言这段代码的形状。 +/// 为什么读源码:变异实测(M102 r1)把 `.spacing(ui.space(Space::X1))` 加回行容器后, +/// 几何断言照样绿——它算的是常量,看不见页面又叠了一层。iced 的 Element 又没有内省接口, +/// 所以只能断言这段代码的形状。 /// -/// **禁词范围只到「`let mut rows = column(…);` 这一条语句链」**(r2 复审 P2-2 的订正): -/// 早先扫整个 `list_column()` 并禁一切 `.spacing(ui.space(Space::`,那样将来在 -/// `column![head, scroller]` 上加一条合法间距会**假红**。现在只钉行容器那条链。 +/// ## 两层判据(r4 复审的 M4 订正) /// -/// **抓得住**:行容器上又写字面量 `.spacing(…)`(视觉复审 ② 的页级附加量,含「先存局部变量 -/// 再 `.spacing(gap)`」的写法);`ROW_SPACING` / `LIST_BOTTOM_INSET` 两个真源被摘掉; -/// **抓不住**:行距被算进别处再传进 `list_row()`(那要改组件几何,代码评审能看出来)。 +/// | 层 | 判据 | 抓的是 | +/// |---|---|---| +/// | ① 构建链 | `let mut rows = column(…);` 这条语句链上不许有 `.spacing(` | 直接写在链上的间距(变异 D) | +/// | ② 语句序列 | `list_column()` 里**每一处** `rows.spacing(…)` 的实参都要**顺绑定追回 `ROW_SPACING`** | **另起一条语句**再叠间距(变异 M4,含「先把令牌挪进一条 `let`」的写法) | +/// +/// 为什么②要追绑定而不是比字面:合法写法有几种形状——`rows = rows.spacing(ui.space(spacing))` +/// 直接取自 `if let Some(spacing) = ROW_SPACING`,或先 `let spacing = ROW_SPACING;` 再中转 +/// (变异 H3)。`layout/source.rs::derived_from` 顺 `let` / `Some(…) =` 两种绑定往回追最多三跳, +/// 于是「真源」放行、「中转了一层 `Space::X1`」判否。 +/// +/// **抓得住**:构建链上的字面量间距(D)、另起语句的行距(M4)、`ROW_SPACING` / `LIST_BOTTOM_INSET` +/// 两个真源被摘掉;`column![head, scroller]` 上的合法间距不受影响(变异 J,绿); +/// **抓不住**:把间距算进别处再以非令牌形式传进 `list_row()`(那要改组件几何,代码评审能看出来)、 +/// 以及绕过 `rows.spacing(…)` 这个调用形状的写法。 #[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("); + let file = include_str!("../instances.rs"); + let body = source::function_region(file, "fn list_column"); + let rows_chain = source::statement(file, "let mut rows = column("); assert!( body.contains("ROW_SPACING"), "列表行距必须从 ROW_SPACING 取,不许另写:\n{body}" ); - assert!( - !rows_chain.contains(".spacing("), - "行容器的构建链上又出现了间距(M102 ② 的页级附加量):\n{rows_chain}" - ); assert!( body.contains("LIST_BOTTOM_INSET"), "底部内缩必须从 LIST_BOTTOM_INSET 取:\n{body}" ); + + // ① 行容器的构建链上不许叠间距(`column![head, scroller]` 上的合法间距不在这条链里) + assert!( + !rows_chain.contains(".spacing("), + "行容器的构建链上又出现了间距(M102 ② 的页级附加量):\n{rows_chain}" + ); + + // ② 语句序列:每一处 `rows.spacing(…)` 的实参都必须**顺绑定追回 `ROW_SPACING`** + // (直接用它、或 `let spacing = ROW_SPACING` 再中转都算;先 `let extra = Space::X1;` + // 再 `rows.spacing(ui.space(extra))` 则红——r4 复审的变异 M4) + let applied = source::call_arguments(&body, "rows.spacing("); + assert!( + !applied.is_empty(), + "`list_column()` 里没有把 ROW_SPACING 应用到行容器的调用:\n{body}" + ); + for argument in &applied { + let ident = argument + .split_once("ui.space(") + .map(|(_, rest)| rest) + .unwrap_or(argument); + let ident: String = ident + .chars() + .take_while(|ch| ch.is_alphanumeric() || *ch == '_') + .collect(); + + assert!( + argument.contains("ui.space("), + "行容器的间距实参必须走令牌栅格(`ui.space(…)`),收到 {argument:?}" + ); + assert!( + !argument.contains("Space::"), + "行容器的间距实参里直接写了令牌档位:{argument:?}\n{body}" + ); + assert!( + source::derived_from(&body, &ident, "ROW_SPACING"), + "行容器的间距不是从 ROW_SPACING 来的(`{ident}` 追不到 ROW_SPACING)——\ + M102 ② 的页级附加量不许另起一条语句塞回来:{argument:?}\n{body}" + ); + } } /// **散文接线**:详情页的正文段必须进 `frame::prose`(424),不许直接塞进 `frame::body`(1044)。 diff --git a/crates/app-iced/src/pages/layout/source.rs b/crates/app-iced/src/pages/layout/source.rs index 0a592e8..28e74f7 100644 --- a/crates/app-iced/src/pages/layout/source.rs +++ b/crates/app-iced/src/pages/layout/source.rs @@ -54,19 +54,47 @@ pub(crate) fn statement<'a>(source: &'a str, start: &str) -> &'a str { &rest[..end] } -/// `signature` 的函数体 **+ 它内部转调的同文件函数体**(最多两层)。 +/// `signature` 的函数体 **+ 它内部转调的函数体**(同文件,最多两层)。 /// /// 为什么不只切一个函数:**等价重构不该假红**。把 `frame::body` 拆成 /// `body` → `body_impl` 两层再转调,盒子一个像素都没变,`function_body` 却会拿到三行转调桩、 -/// needle 全缺(r2 复审的变异 H1 就是这样假红的)。这里把「本文件内被它调用的函数」也算进契约, +/// needle 全缺(r2 复审的变异 H1 就是这样假红的)。这里把「被它调用的函数」也算进契约, /// 于是「拆一层再转调」放行,「换成居中盒子」照样红。 /// -/// 边界:只跟**同一文件内**的转调(跨文件转调、先赋局部变量再建盒不在覆盖内,见调用点 docstring)。 +/// 跨文件转调用 [`region_across`]。两者都不跟「先赋局部变量再建盒」的写法。 pub(crate) fn function_region(source: &str, signature: &str) -> String { - let mut region = function_body(source, signature).to_owned(); + region_across(&[source], signature) +} + +/// 多文件版的 [`function_region`]:[`expand`] 会在给定的**所有**文件里按名字找转调目标。 +/// +/// 存在的理由(r4 复审的变异 M6):把盒子原样搬进 `layout/block.rs::body_box` 再由 +/// `frame::body` 转调,是**等价重构**,单文件版会把 needle 全丢掉并假红。 +/// 现在把 `frame.rs` 与 `layout/block.rs` 一起交进来,搬运不再被惩罚。 +/// +/// # Panics +/// `signature` 在任何一个文件里都找不到时 panic(护栏前置条件坏了要立刻红)。 +pub(crate) fn region_across(sources: &[&str], signature: &str) -> String { + let mut region = sources + .iter() + .find(|source| source.contains(signature)) + .map(|source| function_body(source, signature).to_owned()) + .unwrap_or_else(|| panic!("这几个文件里都找不到签名 {signature:?}")); for _ in 0..2 { - let mut grew = false; + if !expand(&mut region, sources) { + break; + } + } + + region +} + +/// 把 `region` 里出现、且定义在 `sources` 某处的函数体补进来。返回是否有增长。 +fn expand(region: &mut String, sources: &[&str]) -> bool { + let mut grew = false; + + for source in sources { let mut cursor = 0; while let Some(found) = source[cursor..].find("fn ") { @@ -81,23 +109,126 @@ pub(crate) fn function_region(source: &str, signature: &str) -> String { continue; } - let called = format!("{name}("); + // `defined` 不带 `(`:签名可能带泛型(`fn body_impl<'a>(`), + // 带括号的 needle 会找不到定义、直接把护栏自己搞 panic。 let defined = format!("fn {name}"); - if region.contains(&called) && !region.contains(&defined) && source.contains(&defined) { + if region.contains(&format!("{name}(")) + && !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; + grew +} + +/// `haystack` 里每一处 `callee(…)` 调用的**实参文本**(不含括号本身)。 +/// +/// 括号按深度配对,所以 `ui.space(Space::X1)` 这种嵌套实参会完整取到 `ui.space(Space::X1)`; +/// 找不到闭合括号时取到文本末尾(宁可让断言看见多余文本,也不静默返回空)。 +pub(crate) fn call_arguments<'a>(haystack: &'a str, callee: &str) -> Vec<&'a str> { + let mut found = Vec::new(); + let mut from = 0; + + while let Some(index) = haystack[from..].find(callee) { + let start = from + index + callee.len(); + let mut depth = 1usize; + let mut end = haystack.len(); + + for (offset, ch) in haystack[start..].char_indices() { + match ch { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + end = start + offset; + break; + } + } + _ => {} + } + } + + found.push(&haystack[start..end]); + from = end.max(start); + } + + found +} + +/// 顺绑定链往回追:`ident` 是否(直接或经至多 3 跳中转)来自 `root`。 +/// +/// 认两种绑定形状(本仓就这两种): +/// +/// - `let = ;` +/// - `if let Some() = {`(`while let` 同理,形状一样) +/// +/// 为什么需要它(r4 复审的变异 M4):把写死的令牌先挪到一条 `let extra = Space::X1;`, +/// 再 `rows = rows.spacing(ui.space(extra));` —— 只看实参文本「有没有 `Space::`」会漏掉它。 +/// 顺着绑定追回源头就能区分「真的来自 `ROW_SPACING`(含 `let spacing = ROW_SPACING` 再中转, +/// 变异 H3)」与「中转了一层写死令牌」。 +pub(crate) fn derived_from(body: &str, ident: &str, root: &str) -> bool { + let mut frontier = vec![ident.to_owned()]; + + for _ in 0..3 { + let mut next = Vec::new(); + + for name in &frontier { + for rhs in binding_rhs(body, name) { + if rhs.contains(root) { + return true; + } + + next.extend( + rhs.split(|ch: char| !(ch.is_alphanumeric() || ch == '_')) + .filter(|token| { + token + .chars() + .next() + .is_some_and(|first| first.is_alphabetic()) + }) + .map(str::to_owned), + ); + } + } + + if next.is_empty() { + return false; + } + frontier = next; + } + + false +} + +/// `ident` 在 `body` 里的绑定表达式(`let` 与 `Some(ident) =` 两种形状)。 +fn binding_rhs<'a>(body: &'a str, ident: &str) -> Vec<&'a str> { + let mut rhs = Vec::new(); + + for pattern in [format!("let {ident} = "), format!("let mut {ident} = ")] { + let mut from = 0; + while let Some(index) = body[from..].find(&pattern) { + let start = from + index + pattern.len(); + let end = body[start..].find(';').map_or(body.len(), |at| start + at); + rhs.push(&body[start..end]); + from = end; } } - region + let pattern = format!("Some({ident}) = "); + let mut from = 0; + while let Some(index) = body[from..].find(&pattern) { + let start = from + index + pattern.len(); + let end = body[start..].find('{').map_or(body.len(), |at| start + at); + rhs.push(&body[start..end]); + from = end; + } + + rhs } #[cfg(test)] diff --git a/crates/app-iced/src/pages/layout/source/tests.rs b/crates/app-iced/src/pages/layout/source/tests.rs index 85c5855..007d71b 100644 --- a/crates/app-iced/src/pages/layout/source/tests.rs +++ b/crates/app-iced/src/pages/layout/source/tests.rs @@ -103,6 +103,74 @@ fn a_missing_signature_fails_loudly() { let _ = function_body(SAMPLE, "pub fn nope"); } +/// 跨文件转调也要跟进去(`frame::body` → `layout::block::body_box` 就是这种形状)。 +/// +/// 变异 M6(把盒子原样搬进 `layout/block.rs`)在单文件版下假红;多文件版应当放行。 +#[test] +fn the_region_follows_delegation_across_files() { + let frame = "\ +pub fn body(a: u8) -> u8 { + block::body_box(a) + 1 +} +"; + let block = "\ +pub fn body_box(a: u8) -> u8 { + a * 7 +} +"; + let region = region_across(&[frame, block], "pub fn body("); + assert!(region.contains("body_box(a) + 1"), "{region}"); + assert!(region.contains("a * 7"), "跨文件转调没跟进去:{region}"); +} + +/// `call_arguments` 取到的是**实参原文**(嵌套括号完整),这是 M4 那条护栏的判据基础。 +#[test] +fn call_arguments_returns_the_nested_argument_text() { + let source = "\ +rows = rows.spacing(ui.space(Space::X1)); +rows = rows.spacing(ui.space(step)); +column![head, scroller].spacing(ui.space(Space::X2)); +"; + let args = call_arguments(source, "rows.spacing("); + assert_eq!(args, vec!["ui.space(Space::X1)", "ui.space(step)"]); + assert!( + args[0].contains("Space::") && !args[1].contains("Space::"), + "实参文本要能区分「写死令牌」与「经变量中转」:{args:?}" + ); +} + +/// `derived_from` 顺绑定链追源头:真源放行、中转一层写死令牌判否(变异 M4 的判据)。 +#[test] +fn binding_tracing_separates_the_truth_source_from_a_smuggled_token() { + let honest = "\ + if let Some(spacing) = ROW_SPACING { + rows = rows.spacing(ui.space(spacing)); + } +"; + assert!(derived_from(honest, "spacing", "ROW_SPACING")); + + // 经局部变量中转一次(变异 H3 的等价重构形状):仍要追到 ROW_SPACING + let via_local = "\ + let spacing = ROW_SPACING; + if let Some(step) = spacing { + rows = rows.spacing(ui.space(step)); + } +"; + assert!(derived_from(via_local, "step", "ROW_SPACING")); + + // 把写死令牌挪到另一条 let 再传(变异 M4):追不到 ROW_SPACING + let smuggled = "\ + if let Some(spacing) = ROW_SPACING { + rows = rows.spacing(ui.space(spacing)); + } + + let extra = Space::X1; + rows = rows.spacing(ui.space(extra)); +"; + assert!(!derived_from(smuggled, "extra", "ROW_SPACING")); + assert!(derived_from(smuggled, "spacing", "ROW_SPACING")); +} + #[test] #[should_panic(expected = "找不到语句起点")] fn a_missing_statement_start_fails_loudly() { diff --git a/crates/app-iced/src/pages/logs/tests.rs b/crates/app-iced/src/pages/logs/tests.rs index 64a4f60..7cf4793 100644 --- a/crates/app-iced/src/pages/logs/tests.rs +++ b/crates/app-iced/src/pages/logs/tests.rs @@ -92,15 +92,19 @@ fn log_text_carries_soft_breaks_but_keeps_the_original() { /// /// **抓得住**:任一处接线被摘掉(日志行 / 一行摘要); /// **抓不住**:接线写得对但断点表被掏空(那是上一条的事)。 +/// +/// 用 [`source::function_region`] 而不是 `function_body`(r4 复审的变异 M3):把 +/// `wrap::breakable` 外面套一层同义包装(`fn soft(…) { wrap::breakable(…) }`)再调用,是 +/// **等价重构**,不该假红;而「整条链路都没碰 `wrap::breakable`」照样红。 #[test] fn the_page_wires_the_soft_breaks_into_both_rendering_paths() { - let entry = source::function_body(include_str!("lines.rs"), "fn entry"); + let entry = source::function_region(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"); + let summary = source::function_region(include_str!("diagnostic.rs"), "fn summary_block"); assert!( summary.contains("wrap::breakable("), "一行摘要没接软断点:\n{summary}" diff --git a/crates/app-iced/src/pages/tests.rs b/crates/app-iced/src/pages/tests.rs index 6d7ca0f..39e40fe 100644 --- a/crates/app-iced/src/pages/tests.rs +++ b/crates/app-iced/src/pages/tests.rs @@ -141,14 +141,19 @@ fn body_block_starts_at_the_header_title_and_fills_the_page() { /// `body_offset(metrics)`、`align_x` 只许是 `Start`(不写 = container 默认 Start,等价), /// 且不出现居中/散文列宽。 /// -/// 扫的是 [`layout::source::function_region`](函数体 + 本文件内被它转调的函数), -/// 所以「拆成 `body` → `body_impl` 两层再转调」这种**等价重构不假红**(r2 复审的变异 H1)。 +/// 扫的是 [`layout::source::region_across`]:`frame.rs` **加上** `layout/block.rs`, +/// 于是「拆成 `body` → `body_impl`」(变异 H1)与「把盒子原样搬进 `block::body_box`」(变异 M6) +/// 这两种**等价重构都不假红**,而「换成居中 424 盒子」照样红(变异 A)。 /// -/// **抓得住**:`body()` 被换回居中(变异 A)、宽度改回 `metrics.column`、`align_x` 改成 `Center`; -/// **抓不住**:跨文件转调、或先赋局部变量再建盒的写法(见 `layout/source.rs` 的边界说明)。 +/// **抓得住**:盒子的**语义**变了(居中 / 用散文列宽 / 用 `centered`); +/// **抓不住**:`frame::centered(metrics.column, …)` 这种**与 `frame::body` 逐像素等价**的另写 +/// (r4 复审的 M1/M2 —— 按塔的裁定那是等价重构,护栏不该为此红),也不抓「先赋局部变量再建盒」。 #[test] fn frame_body_is_built_from_the_block_quantities() { - let body = layout::source::function_region(include_str!("frame.rs"), "pub fn body<'a>("); + let body = layout::source::region_across( + &[include_str!("frame.rs"), include_str!("layout/block.rs")], + "pub fn body<'a>(", + ); for needle in ["body_width(metrics)", "body_offset(metrics)"] { assert!( @@ -159,10 +164,12 @@ fn frame_body_is_built_from_the_block_quantities() { // `align_x` 出现就必须是 Start:`.align_y(Alignment::Start)` 单独存在不算数(r2 复审 P2-3), // 而「干脆不写 align_x」(container 默认 Start)视为等价重构,放行。 - if let Some(at) = body.find(".align_x(") { + // 判的是**实参**而不是整段文本,所以 `Alignment::Start` 与 `iced::Alignment::Start` 同等放行 + // (把盒子搬进别的模块后路径要写全,那是等价重构,不该假红 —— r4 复审的变异 M6)。 + for argument in layout::source::call_arguments(&body, ".align_x(") { assert!( - body[at..].starts_with(".align_x(Alignment::Start)"), - "`frame::body` 的 align_x 只许是 Start:\n{body}" + argument.trim().ends_with("Alignment::Start"), + "`frame::body` 的 align_x 只许是 Start,收到 {argument:?}:\n{body}" ); } @@ -182,9 +189,14 @@ fn frame_body_is_built_from_the_block_quantities() { /// **源码结构**:`frame::prose` 的宽度必须来自 `prose_width`(= `metrics.column`),不来自块宽。 /// /// M102 把两个量拆开后,「把散文塞进 1044 的块」不会红任何数值断言;这条盯接线。 +/// 与 `frame::body` 那条一样用 [`layout::source::region_across`](`frame.rs` + `layout/block.rs`), +/// 所以把盒子搬进 `layout/block.rs` 的等价重构不假红;抓的是**语义**(用块宽/可用宽度就红)。 #[test] fn frame_prose_is_built_from_the_prose_width() { - let prose = layout::source::function_body(include_str!("frame.rs"), "pub fn prose<'a>("); + let prose = layout::source::region_across( + &[include_str!("frame.rs"), include_str!("layout/block.rs")], + "pub fn prose<'a>(", + ); assert!( prose.contains("prose_width(metrics)"),