test(app-iced): M102 r3 护栏补到「页面调用点」——回退 body/软断点接线一律红,等价重构不再假红

r2 复审抓的两条 p1 都是「守卫只到函数内部」:把页面调用点改回 frame::single_column(变体 G)、
或把日志页两处 wrap::breakable 接线摘掉(变体 I),420 条全绿,而视觉复审 ① / ③ 原样回来。
r3 用 r2 自建的 layout/source.rs 把守卫推进到调用点,并把三条 p2 的假红/假强一并修掉:

- 新增 `single_column_pages_call_the_body_box_not_the_centred_one`:四个页面文件
  (download / logs / instances / instances/detail,共 7 个调用点)必须出现 `frame::body(`、
  不许出现 `frame::single_column(`;narrow_detail 单独再断言一次。(变体 G → 红)
- 新增 `the_page_wires_the_soft_breaks_into_both_rendering_paths`:扫 `lines::entry` 与
  `diagnostic::summary_block`,必须含 `wrap::breakable(`(变体 I → 红);
  同时把 `log_text_carries_soft_breaks_but_keeps_the_original` 的 docstring 改成它实际守的东西
  (断点表 + 原文可还原),不再冒充「抓得住接线」。
- p2-1:`frame_body_is_built_from_the_block_quantities` 改用新的
  `source::function_region`(函数体 + 本文件内被转调的函数,最多两层)⇒ 把 body 拆成
  `body` → `body_impl` 的等价重构不再假红(H1 → 绿),换回 centered 仍红(A → 红)。
  顺带修掉 region 的泛型签名 bug:早先拿 `fn {name}(` 当定义 needle,
  遇到 `fn body_impl<'a>(` 会 panic(护栏自己假红);现在按 `fn {name}` 找定义,并补了一条
  `the_region_follows_delegation_into_a_generic_function` 自测。
- p2-2:`list_column_spaces_rows_only_from_the_shared_constant` 的禁词收窄到
  `let mut rows = column(…)` **这一条**构建链(新工具 `source::statement`,停在第一个 `;`),
  于是 `column![head, scroller]` 上加合法间距不再假红(J → 绿),行容器叠间距仍红(D → 红)。
- p2-3:`frame::body` 的 align_x 改成「出现就必须是 `.align_x(Alignment::Start)`」,
  不再被 `.align_y(Alignment::Start)` 顺手满足;「干脆不写 align_x」(默认 Start)视为等价重构放行。
- p2-4(措辞):`layout/block.rs` 文档写明 1440 上限是**有意行为变化**(≥1576 窗宽),
  1180/900 上才是零像素变化。

变异矩阵(本轮自跑,逐条回滚,`git status` 已核):A/C/D/E/G/I 全红且各自只红对应那一条;
H1(body_impl 转调)/H2b(删显式 align_x)/H3(ROW_SPACING 经局部变量)/J(head/scroller 加合法间距)
全绿。基线 `cargo test -p unlucky-app-iced --lib` = 426 passed / 0 failed。
This commit is contained in:
AskaEth committed 2026-11-04 19:53:43 +08:00
1 parent 824bb36f8e
commit f499961e5b
6 files changed
+238 -21

No files matched your search

+14 -8
View File
@@ -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"),
@@ -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` **没有几何内省接口**,「块摆在哪」在单测里量不到。所以把「贴左」写成一个
@@ -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;
@@ -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");
}
+32 -4
View File
@@ -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();
+58 -9
View File
@@ -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`(页面唯一的列宽来源)不会越界;