test(app-iced): M102 r2 护栏改成「抓得住页面换摆法」——块偏移/行距/散文接线全部有源码结构断言

r1 复审的两条 P1 都是假护栏:把修复原样回滚(A:frame::body 换回居中 424;
C:内缩 X2→X1;D:行距加回 4px)后测试仍全绿,因为断言只喂 frame_metrics() 与常量重算,
既不观察 frame::body,也不引用页面常量。本轮把「摆法」做成可断言的东西:

- 新增 `pages/layout/block.rs`:body_width(pane 宽封顶 1440)、body_offset(恒 0 = 贴左)、
  prose_width(= metrics.column ≤424)三个纯量 + 单测。frame::body / frame::prose 从它们建盒,
  frame::body_width 改成 pub use(不另算一份)。
- 新增 `pages/layout/source.rs`(cfg(test)):把某个函数的实现文本切出来。iced 的 Element
  没有几何内省,量不到「块摆在哪」,只能断言「照哪个数建的」;仓里已有 no_hardcoded_tokens
  扫源码、download/tests 用 include_str! 扫 key 的先例。
- 新护栏(每条都做过变异实测,见报告):
  · frame_body_is_built_from_the_block_quantities(A 变异红)
  · frame_prose_is_built_from_the_prose_width(E 变异红)
  · list_column_spaces_rows_only_from_the_shared_constant(D 变异红)
  · instances 几何断言改用页面真源 LIST_BOTTOM_INSET.dp() / ROW_SPACING(C 变异红)
  · the_detail_note_goes_through_the_prose_box(散文接线)
- P2:wrap::strip 的「会删原文自带 U+200B」写成限界 + 一条断言;docs 口径从 layout.rs
  挪到 layout/block.rs(layout.rs 399 → 389)。

零像素变化:改后 9 张图与 r1 的 m102-after 逐张 cmp 完全相同;2560×760 新出三张证明块宽封顶
1440(112..1551)且仍贴左。
This commit is contained in:
AskaEth committed 2026-11-04 19:53:41 +08:00
1 parent 89d98184c1
commit 824bb36f8e
11 files changed
+469 -67

No files matched your search

+64 -14
View File
@@ -1,24 +1,28 @@
//! `instances` 的断言:演示数据与窄窗退化。
//! `instances` 的断言:演示数据、窄窗退化,以及**页面摆法**(源码结构断言)。
use super::*;
use super::super::layout::source;
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass};
/// **M102 几何判据**:列表可视区正好放下整数行——末行不会被卡片底边切半行。
///
/// 算式全部取真源(1180×760,harness 路径):
/// 算式里的三个页面真源**直接引用**(不写字面量),所以改页面常量会让这条立刻红:
/// [`LIST_BOTTOM_INSET`](滚动区底部内缩)、[`ROW_SPACING`](页级行距,`None` = 0)。
///
/// ```text
/// 卡片高 = 760 − 标题栏 48 − 两行页头 84 − 2×gutter 24 = 580
/// 列头 = 上 12 + LabelMedium 16 + 下 4 = 32
/// 内缩 = Space::X2 = 8
/// 内缩 = LIST_BOTTOM_INSET = Space::X2 = 8
/// 可视区 = 580 − 32 − 8 = 540
/// 行高 = ListItem 两行档 44 + 16 = 60 (页级行距 0)
/// 行高 = ListItem 两行档 44 + 16 = 60
/// pitch = 行高 + ROW_SPACING(None ⇒ 0) = 60
/// 整行数 = 540 / 60 = 9 行整
/// ```
///
/// **抓得住**:页级行距被加回来(64 的节奏立刻除不尽 ⇒ 末行只剩半行,视觉复审 ② 的原样)、
/// 列头/内缩算错、演示数据被缩到不够滚(独立滚动条消失);
/// **抓不住**:真实像素(报告里的前后对照图 + 量测表负责)。
/// **抓得住**:页面把内缩换档(`LIST_BOTTOM_INSET` → `X1` 立刻除不尽)、行距真源被改、
/// 列头算错、演示数据被缩到不够滚(独立滚动条消失);
/// **抓不住**:`list_column()` 里被另写一层 `.spacing(…)`(那一条由
/// `list_column_spaces_rows_only_from_the_shared_constant` 扫源码守)。
#[test]
fn list_viewport_fits_whole_rows_and_still_overflow() {
use unlucky_design::spacing::LayoutMetrics;
@@ -31,26 +35,72 @@ fn list_viewport_fits_whole_rows_and_still_overflow() {
assert_eq!(card, 580.0, "1180×760 下列表卡片的内部高度");
let head = Space::X3.dp() + TypeRole::LabelMedium.style().line_height_px + Space::X1.dp();
let viewport = card - head - Space::X2.dp();
assert_eq!(viewport, 540.0, "列表滚动区高度");
let viewport = card - head - LIST_BOTTOM_INSET.dp();
assert_eq!(viewport, 540.0, "列表滚动区高度必须以页面的内缩真源算");
// 行距归零(页级不再附加):行节奏 = ListItem 两行档 60
// 页级行距的**唯一真源**:None = 这一层不加间距
let gap = ROW_SPACING.map_or(0.0, Space::dp);
let row = lm.row_height + Space::X4.dp();
assert_eq!(row, 60.0);
let pitch = row + gap;
assert_eq!(pitch, 60.0, "行节奏 = ListItem 两行档 60(不留页级行距)");
assert_eq!(
viewport % row,
viewport % pitch,
0.0,
"可视区 {viewport} 不是 {row} 的整数倍 ⇒ 末行会被切半行"
"可视区 {viewport} 不是 {pitch} 的整数倍 ⇒ 末行会被切半行(ROW_SPACING={ROW_SPACING:?})"
);
// 同时必须还能滚:12 行内容高过可视区,截图里才看得到独立滚动条
let content = DEMO.len() as f32 * row;
let content = DEMO.len() as f32 * pitch;
assert!(
content > viewport,
"列表内容 {content} 不比可视区 {viewport} 高,截图里看不到独立滚动条"
);
}
/// **源码结构**:`list_column()` 的列表行只许用 [`ROW_SPACING`] 这一个间距真源。
///
/// 为什么读源码:变异实测(M102 r1)把 `.spacing(ui.space(Space::X1))` 直接加回 `list_column()`,
/// 上面那条几何断言照样绿——因为它算的是常量,看不见页面又叠了一层。iced 的 Element 又没有
/// 内省接口,所以只能断言「这段代码里不许出现别的间距写法」。
///
/// **抓得住**:任何在列表行上写字面量 `.spacing(…)` 的页级附加量(视觉复审 ② 的原样);
/// **抓不住**:换个写法绕过(先赋给局部变量再 `.spacing`)——它守的是这段 5 行代码的契约。
#[test]
fn list_column_spaces_rows_only_from_the_shared_constant() {
let body = source::function_body(include_str!("../instances.rs"), "fn list_column");
assert!(
body.contains("ROW_SPACING"),
"列表行距必须从 ROW_SPACING 取,不许另写:\n{body}"
);
assert!(
!body.contains(".spacing(ui.space(Space::"),
"`list_column()` 里又出现了字面量间距(M102 ② 的页级附加量):\n{body}"
);
assert!(
body.contains("LIST_BOTTOM_INSET"),
"底部内缩必须从 LIST_BOTTOM_INSET 取:\n{body}"
);
}
/// **散文接线**:详情页的正文段必须进 `frame::prose`(424),不许直接塞进 `frame::body`(1044)。
///
/// M102 把「块宽」与「散文列宽」拆成两个量之后,接错一行代码就会让 361 字符的正文在 1180 窗宽下
/// 排成 170 字符一行,而不红任何测试。这条断言把接线本身钉住。
#[test]
fn the_detail_note_goes_through_the_prose_box() {
let body = source::function_body(include_str!("../instances/detail.rs"), "fn push_text");
assert!(
body.contains("frame::prose("),
"正文段没有走 frame::prose:\n{body}"
);
assert!(
!body.contains("frame::body("),
"正文段被塞进了整页块(会失去 72 字符行宽承诺):\n{body}"
);
}
/// 元信息卡必须装得下**卡内最长的那条值**(视觉 r2:136 漏算 padding,版本串折行让卡底不齐)。
#[test]
fn meta_card_is_wide_enough_for_the_longest_value() {