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

+19 -18
View File
@@ -12,13 +12,17 @@
//! 页头带与内容区共用同一个 `gutter`(`Padding::from([0.0, metrics.gutter])` / `padding(gutter)`),
//! 所以页头标题的左缘 = `rail 88 + gutter 24 = 112`。正文块必须从**同一条线**起:
//!
//! - 单列页用 [`body`](贴左 + 吃满 `layout::available`);
//! - 成段散文再套一层 [`prose`](宽度 ≤424,保证一行 ≤72 字符);
//! - 单列页用 [`body`](贴左 + 宽 `layout::block::body_width` = pane 宽,封顶 1440);
//! - 成段散文再套一层 [`prose`](宽 `layout::block::prose_width` ≤424,保证一行 ≤72 字符);
//! - 两列页的正文在 [`panes`] 的主列里,左缘 = `112 + 列表列 336 + 间隙 24 = 472`(有据的分栏,不是错位)。
//!
//! 旧 [`single_column`] 把 424 的块**居中**摆进 1044,于是左缘 422、标题 112——视觉复审的 ① 就是这个。
//! 它保留给设置页(`pages/settings/**`,几何归设置页线)。
//!
//! **这三个量都在 [`super::layout::block`](纯函数 + 单测)**;[`body`] / [`prose`] 只是把
//! 「从哪个数建盒」写死在一处,`pages/tests.rs` 有两条**源码结构断言**盯着这两个盒子
//! (iced 的 Element 没有几何内省,量不到摆法,只能断言「照哪个数建的」)。
//!
//! ## 判据落点(`research/12b`)
//!
//! | 部件 | 参考 | 落点 |
@@ -50,7 +54,7 @@ use unlucky_design::typography::TypeRole;
use crate::theme::Ui;
use super::layout::{self, FrameMetrics, PageLayout};
use super::layout::{self, block, FrameMetrics};
use super::Message;
/// 页头操作区之间的间隔(`Space::X2`)。
@@ -162,22 +166,18 @@ pub fn single_column<'a>(
centered(metrics.column, content)
}
/// **页面块宽度**:单列版面 = 整条可用宽度;两列版面 = 主列 pane 宽度。
/// **页面块宽度**:pane 宽(单列 = 可用宽度 / 两列 = 主列 pane)封顶 1440。
///
/// 这是「表格 / 卡片 / 列表能有多宽」的答案,与 [`FrameMetrics::column`](一句话能有多宽,
/// ≤424)是**两个量**。M102 的根因就是把后者当成了前者:424 的块在 1044 里居中,
/// 于是正文左缘比页头标题右移了 310px(1180 窗宽下)。
pub fn body_width(metrics: FrameMetrics) -> f32 {
match metrics.layout {
PageLayout::SingleColumn => metrics.available,
PageLayout::SupportingPane | PageLayout::ListDetail => metrics.main_pane,
}
}
/// 真源在 [`super::layout::block::body_width`](纯量 + 单测);这里只是转发,**不另算一份**。
/// 与 [`FrameMetrics::column`](一句话多宽,≤424)是两个量。
pub use super::layout::block::body_width;
/// **正文块**:从页边距起(= 页头标题那条左缘)、铺满 [`body_width`]。
/// **正文块**:从页边距起(= 页头标题那条左缘)、宽 [`body_width`]、左内边距 [`body_offset`]。
///
/// 与旧 `single_column` 的差别只有两点,但都是判据本身:**贴左**而不是居中,
/// 宽度取 pane 而不是行宽上限。散文仍要自己套 [`prose`],别把整页块拉回 424。
/// 盒子**从纯量建**:宽度取 [`body_width`]、左内边距取 [`super::layout::block::body_offset`]
/// (恒 0)、对齐 `Start`。这三件事的顺序就是契约,`pages/tests.rs` 的
/// `frame_body_is_built_from_the_block_quantities` 逐条扫这段源码(iced 的 Element 没有几何
/// 内省,量不到「摆在哪」,只能断言「照哪个数建的」)。
pub fn body<'a>(
metrics: FrameMetrics,
content: impl Into<Element<'a, Message>>,
@@ -187,10 +187,11 @@ pub fn body<'a>(
.height(Length::Fill)
.align_x(Alignment::Start)
.align_y(Alignment::Start)
.padding(Padding::default().left(block::body_offset(metrics)))
.into()
}
/// **散文块**:宽度锁在 [`FrameMetrics::column`](≤424 = 逐行 ≤72 字符)。
/// **散文块**:宽度锁在 [`super::layout::block::prose_width`](= `metrics.column`,≤424 ⇒ 一行 ≤72 字符)。
///
/// 只包「成段读的文字」(实例详情正文那样的 prose);卡片、列表、Tabs 吃的是 [`body_width`],
/// 它们的宽度不承担行宽承诺(列宽只影响每行放几个字段,不影响读一句话的舒适度)。
@@ -199,7 +200,7 @@ pub fn prose<'a>(
content: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
container(content)
.width(Length::Fixed(metrics.column))
.width(Length::Fixed(block::prose_width(metrics)))
.into()
}
+25 -9
View File
@@ -36,12 +36,14 @@
//!
//! | 改什么 | 值 | 结果 |
//! |---|---|---|
//! | 页级行距 | 4 → **0**(见 `download/rows.rs` 同一条) | 行节奏回到组件的 60 |
//! | 滚动区底部内缩 | 0 → **`Space::X2` 8** | 切点落在卡片的内边距带里,不压描边 |
//! | 页级行距 | 4 → **0**(真源 [`ROW_SPACING`] = `None`) | 行节奏回到组件的 60 |
//! | 滚动区底部内缩 | 0 → **`Space::X2` 8**(真源 [`LIST_BOTTOM_INSET`]) | 切点落在卡片的内边距带里,不压描边 |
//!
//! 于是可视区 = 580 − 列头 32 − 内缩 8 = **540 = 9 × 60**,正好 9 整行:末行完整可见,
//! 第 10 行整体落在裁切线之外(`instances/tests.rs::list_viewport_fits_whole_rows_and_still_overflow`
//! 把这四个数钉住)。列表内容 12×60 = 720 仍高过可视区,独立滚动条照样可见。
//! 第 10 行整体落在裁切线之外。两个数都**只有一个真源**,页面与护栏都从它取
//! (`instances/tests.rs` 引 [`LIST_BOTTOM_INSET`].`dp()` 与 [`ROW_SPACING`] 算 pitch,
//! 另有一条源码结构断言盯着 `list_column()` 不许再叠字面量间距)。
//! 列表内容 12×60 = 720 仍高过可视区,独立滚动条照样可见。
mod data;
mod demo;
@@ -75,11 +77,19 @@ use super::layout::{FrameMetrics, PageLayout};
use super::model::{Instances, Model, Row};
use super::{Action, Message};
/// 列表滚动区的**底部内缩**(`Space::X2`):让裁切线落在卡片的内边距带里,而不是压在 1px 描边上。
/// 列表行距:**`None` = 这一层不加间距**(页面与护栏**共用这一个真源**)。
///
/// 行距归零(`list()` 不写 `.spacing(…)`)与这条内缩是一对,理由见模块文档「行距归零(M102)」
/// 与本页 `instances.rs` 的同名条目。
const LIST_BOTTOM_INSET: Space = Space::X2;
/// M102 的 ② 就是页级附加量:`ListItem` 自己的 60 高已含上下 8px 内边距,页面再叠一道
/// `Space::X1`(4) 就把行节奏变成 64、与可视高度除不尽(1180×760 下末行被切半行)。
/// 所以行距不写成字面量、也不散落在 `list_column` 里,而是这个常量——
/// 页面 `match` 它,`instances/tests.rs` 也从它算 pitch(改这里,测试立刻红)。
pub(super) const ROW_SPACING: Option<Space> = None;
/// 列表滚动区的**底部内缩**:让裁切线落在卡片的内边距带里,而不是压在 1px 描边上。
///
/// 与 [`ROW_SPACING`] 是一对(行距管「切在哪」、内缩管「切在什么底色上」),
/// 两个数都由 `instances/tests.rs` 的几何断言直接引用。
pub(super) const LIST_BOTTOM_INSET: Space = Space::X2;
/// 整页(`block.detail = None` 时详情区显示空态)。
pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, model: &'a Model) -> Element<'a, Message> {
@@ -157,7 +167,7 @@ fn list_column<'a>(
left: ui.space(Space::X3),
});
let rows = column(
let mut rows = column(
block
.rows
.iter()
@@ -167,6 +177,12 @@ fn list_column<'a>(
.padding(Padding::from([0.0, ui.space(Space::X2)]))
.width(Length::Fill);
// 行距只从 `ROW_SPACING` 来(当前是 None = 不加间距)。别在这里写字面量 `.spacing(…)`:
// 那正是视觉复审 ② 的那道页级附加量(`pages/tests.rs` 有一条源码结构断言盯着这段)。
if let Some(spacing) = ROW_SPACING {
rows = rows.spacing(ui.space(spacing));
}
// **独立滚动**:只有列表列这一条 scrollable,详情列不跟着滚。
let scroller = scrollable(rows)
.direction(scrollable::Direction::Vertical(
+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() {
+10 -20
View File
@@ -43,13 +43,10 @@
//! `DevDocs/ops/screens-m68/shell-light-cpu.png` 里那一行**实测 ink 712px**(x=133..845)吻合。
//! 视觉复审的判词因此是「**不能把全宽单卡当页面模板**,官方桌面口径是 supporting-pane / list-detail」。
//!
//! > **两个订正记录**(都是 P2 复审抓出来的):
//! > ① **712 是那一行的墨迹宽度,不是列宽**。早期文档写成「1180 − 88 − 2×24 = 712」,
//! > 那个减法得 1044,712 是 ink——列宽是 1004,ink 只占其中的 71%。
//! > ② **字符数按实测算**:那一行是 **137 字符**(12px × 0.4323em = 5.19px/字符)。
//! > 早期口径拿 `0.5em` 估算的「14px 正文步进」换算,既高估步进、也对错了字号,把事故说轻了。
//! > 现在事故描述一律用**实测步进 × 实际字号**;这也是
//! > `line_length_is_capped_at_72_characters` 里那条「旧口径 504px 能放 83 字符」护栏的由来。
//! > **两个订正记录**(P2 复审抓出):① **712 是那行的墨迹宽,不是列宽**(列宽 1004;旧文档的减法得 1044);
//! > ② 那行是 **137 字符**(12px × 0.4323em = 5.19px/字符),早期拿 `0.5em` 估算既高估步进、也错了字号。
//! > 现在事故描述一律用**实测步进 × 实际字号**——这也是 `line_length_is_capped_at_72_characters`
//! > 里那条「旧口径 504px 能放 83 字符」护栏的由来。
//!
//! ### 逐行口径:72 字符是**最长一行**的上界,不是平均(r2 复审的教训)
//!
@@ -75,19 +72,8 @@
//!
//! ### M102 订正:`column` 是**正文(prose)列宽**,不是页面块宽
//!
//! 视觉复审在 1180×760 的下载 / 日志页抓到「正文列从 x≈423 起、标题在 x≈110,左右各空 ~300px」。
//! 根因不在 [`frame_metrics`] 的宽度分配,而在**把 424 这个「一句话多宽」的上界当成了整页块的宽度**:
//! `pages::frame` 的旧 `single_column` 取 `metrics.column` 并**水平居中**,于是 1044 里只用的 424 被摆到正中。
//!
//! 现在两者分开,口径如下:
//!
//! | 量 | 谁给 | 用在哪 |
//! |---|---|---|
//! | [`FrameMetrics::column`] | 本模块(≤ 424) | **正文/散文**:`frame::prose` 包一层,保证一行 ≤72 字符 |
//! | `pages::frame::body_width` | 本模块的 `available` / `main_pane` | **页面块**:表格、卡片、列表、Tabs 吃满可用宽度 |
//!
//! 左缘一律是 `rail 88 + gutter 24 = 112`——与页头标题同一条线(判据:
//! `pages/tests.rs::body_block_starts_at_the_header_title_and_fills_the_page`)。
//! 视觉复审抓到「正文列左缘 423 对标题 110」,根因是**把 424 当成整页块宽再居中**。
//! 现在三个量分开,口径与常量全在 [`block`](本文件只留指针,免得文档把 file-size 门禁顶到红线)。
//!
//! ### 步进是怎么量的(口径 + 可复现)
//!
@@ -105,8 +91,12 @@
use unlucky_design::spacing::{LayoutMetrics, Space};
use unlucky_design::typography::TypeRole;
pub mod block;
pub mod wrap;
#[cfg(test)]
pub(crate) mod source;
/// 官方 M3 窗口档的 medium 断点(600dp)。
pub const BREAKPOINT_MEDIUM: f32 = 600.0;
/// 官方 M3 窗口档的 expanded 断点(840dp)。
+76
View File
@@ -0,0 +1,76 @@
//! 页面块几何:**块宽 / 块偏移 / 散文列宽**(纯函数,不碰 iced 控件树)。
//!
//! ## 为什么这三个量单独住一个文件(M102 r2)
//!
//! M102 把「一句话多宽」和「整页块多宽」拆成两个量,那段口径原本写在 `layout.rs` 的模块文档里,
//! 连同 1440 上限一起把主文件顶到 **399 行**(CI 的 file-size 门禁是 `wc -l`,只剩 1 行余量)。
//! 挪到本文件:主文件回到安全线以下,**口径与常量同处一室**——谁改上限都得读同一段话。
//!
//! ## 三个量(页面只许从这里取,不许自己算)
//!
//! | 量 | 值 | 用在哪 |
//! |---|---|---|
//! | [`body_width`] | `min(pane, 1440)`;pane = `available`(单列)/ `main_pane`(两列) | 页面块:Tabs / 列表 / 卡片 / 分隔线 |
//! | [`body_offset`] | **0**(贴左) | 块的左缘相对页边距的偏移 —— 0 就是「与页头标题同一条线」 |
//! | [`prose_width`] | [`FrameMetrics::column`](≤424) | 成段散文(`frame::prose`),保证一行 ≤72 字符 |
//!
//! ## 为什么 1440 是上限(tower 拍板,M102 r2)
//!
//! 块宽**没有上限**时,2560 窗宽下单列页的块会一路长到 2424px:一行 80 个英文字符的列表行
//! 空得发慌,中文正文同一行更能排到 170 字。取 **1440**(落在 4px 栅格上,= 360 × 4):
//! 在 1180(可用 1044)与 900(764)上**都不生效**,只拦极宽窗。
//!
//! 与 [`super::measure_cap_px`](424,用 FreeType 实测步进 × 逐行折行模拟推出来的)不同,
//! 1440 是**决策值**不是量出来的:它没有排版学推导,只有「别让列表行排成一条高速路」这一条理由。
//! 所以它只该在这一处出现——谁想改,改这里并同时改 `block/tests.rs` 的断言。
//!
//! 贴左不变:块左缘仍是 `rail 88 + gutter 24 = 112`,封顶后**右侧留白**(不是居中)。
//! 这是有意的:页头标题在左,正文块跟着左缘;把块居中又会回到视觉复审 ① 的「内容缩在中间」。
//!
//! ## 偏移为什么是个常量函数而不是 `align_x`
//!
//! iced 的 `Element` **没有几何内省接口**,「块摆在哪」在单测里量不到。所以把「贴左」写成一个
//! 可断言的纯量 [`body_offset`](= 0),让 `frame::body` 从它建盒,再配一条**源码结构**断言
//! (`pages/tests.rs::frame_body_is_built_from_the_block_quantities`)钉住「盒子的左内边距来自它」。
//! 变异实测:把 `frame::body` 换回 `centered(metrics.column, …)`(视觉复审 ① 的原样)⇒ 该断言红。
use super::{FrameMetrics, PageLayout};
/// 页面块宽度上限(逻辑像素,4px 栅格的 1440 = 360dp × 4)。
///
/// 只拦极宽窗:1180 窗宽下可用 1044、900 下 764,都够不到它。
pub const BODY_MAX_WIDTH: f32 = 1440.0;
/// 页面块宽度(px):pane 宽封顶 1440。
///
/// 单列 = 整条可用宽度;两列 = 主列 pane(列表列 / 侧列已被扣掉)。
pub fn body_width(metrics: FrameMetrics) -> f32 {
let pane = match metrics.layout {
PageLayout::SingleColumn => metrics.available,
PageLayout::SupportingPane | PageLayout::ListDetail => metrics.main_pane,
};
if pane < BODY_MAX_WIDTH {
pane
} else {
BODY_MAX_WIDTH
}
}
/// 页面块相对页边距的**左偏移**(px):恒为 **0**(贴左)。
///
/// 「贴左」是 M102 的头号交付(视觉复审 ① 是「424 居中 ⇒ 左缘 422 对标题 112」),
/// 所以它必须是一个能被断言的数,而不是一处 `align_x` 的写法。
pub const fn body_offset(_metrics: FrameMetrics) -> f32 {
0.0
}
/// 散文块宽度(px)= [`FrameMetrics::column`](≤424 ⇒ 逐行 ≤72 字符)。
///
/// 与 [`body_width`] 是两个量:块可以是 1044,一句话仍然只排 424。
pub const fn prose_width(metrics: FrameMetrics) -> f32 {
metrics.column
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,89 @@
//! `block` 的断言:三个纯量的数值口径。
//!
//! 「块摆在哪」量不到(iced 无 Element 内省),所以这里只钉**数**;
//! 「盒子是不是照这个数摆的」由 `pages/tests.rs` 的源码结构断言负责
//! (`frame_body_is_built_from_the_block_quantities`)。
use super::*;
use crate::pages::layout::{frame_metrics, measure_cap_px, rail_width, PaneKit};
/// 贴左:**任何**版面、任何窗宽,块偏移都是 0。
///
/// 抓得住:有人把偏移改成居中/右对齐(那它就不再是 0);
/// 抓不住:`frame::body` 有没有真的用这个数(源码结构断言负责)。
#[test]
fn the_block_is_always_left_aligned() {
for kit in [PaneKit::Single, PaneKit::Supporting, PaneKit::ListDetail] {
for width in [320.0, 600.0, 720.0, 840.0, 900.0, 1180.0, 1440.0, 2560.0] {
assert_eq!(
body_offset(frame_metrics(kit, width)),
0.0,
"{kit:?} @{width} 的块偏移必须贴左"
);
}
}
}
/// 块宽 = pane 宽(单列 = 可用宽度,两列 = 主列 pane),未到上限时不缩水。
#[test]
fn body_width_is_the_pane_until_the_cap() {
for width in [320.0, 600.0, 840.0, 1180.0] {
let single = frame_metrics(PaneKit::Single, width);
assert_eq!(body_width(single), single.available, "单列 @{width}");
assert_eq!(body_offset(single), 0.0, "@{width}");
}
// 两列版面:块宽 = 主列 pane(低于门槛时 list-detail 会退化成单列,那时块宽回到可用宽度)
let two = frame_metrics(PaneKit::ListDetail, 1180.0);
assert_eq!(body_width(two), two.main_pane);
assert!(two.main_pane < two.available);
let degraded = frame_metrics(PaneKit::ListDetail, 700.0);
assert_eq!(body_width(degraded), degraded.available);
// 1440 上限只拦极宽窗:1180 可用 1044、2560 可用 2424
assert_eq!(frame_metrics(PaneKit::Single, 1180.0).available, 1044.0);
assert_eq!(frame_metrics(PaneKit::Single, 2560.0).available, 2424.0);
assert_eq!(
body_width(frame_metrics(PaneKit::Single, 2560.0)),
BODY_MAX_WIDTH
);
// 两列版面同样封顶(2560 下主列 pane 是 2064)
let wide_two = frame_metrics(PaneKit::ListDetail, 2560.0);
assert!(wide_two.main_pane > BODY_MAX_WIDTH);
assert_eq!(body_width(wide_two), BODY_MAX_WIDTH);
// 封顶之后左缘不变、右侧留白(不是居中)
let huge = frame_metrics(PaneKit::Single, 2560.0);
let left = rail_width() + huge.gutter;
assert_eq!(left, 112.0);
let right_gap = 2560.0 - (left + body_width(huge)) - huge.gutter;
assert_eq!(right_gap, 984.0, "2560 宽下封顶后右侧留白");
assert!(right_gap > 900.0);
}
/// 散文列宽 = `FrameMetrics::column`,永远 ≤ 行宽上限(与块宽脱钩)。
#[test]
fn prose_width_stays_at_the_measure_cap() {
for width in [600.0, 840.0, 900.0, 1180.0, 2560.0] {
let metrics = frame_metrics(PaneKit::Single, width);
assert_eq!(prose_width(metrics), metrics.column, "@{width}");
assert!(prose_width(metrics) <= measure_cap_px());
}
// 块已经封顶的地方,散文列仍是 424 —— 两个量确实分开了
let huge = frame_metrics(PaneKit::Single, 2560.0);
assert_eq!(body_width(huge), BODY_MAX_WIDTH);
assert_eq!(prose_width(huge), measure_cap_px());
assert_eq!(prose_width(huge), 424.0);
}
/// 上限本身:在 4px 栅格上,且远宽于散文列(否则「把两个量拆开」这件事是白做的)。
#[test]
fn the_cap_is_on_the_grid_and_far_wider_than_the_measure() {
assert_eq!(BODY_MAX_WIDTH % 4.0, 0.0, "块宽上限必须在 4px 栅格上");
assert!(
BODY_MAX_WIDTH >= measure_cap_px() * 3.0,
"块宽上限 {BODY_MAX_WIDTH} 必须远宽于散文列 {}",
measure_cap_px()
);
}
@@ -0,0 +1,39 @@
//! **源码结构断言**的小工具:把某个函数的实现文本切出来(只给测试用)。
//!
//! ## 为什么要读源码(而不是量 Element)
//!
//! iced 的 `Element` 没有几何内省接口(`size()` 只是 `Size<Length>`,取不到布局结果),
//! 所以「页面把块摆在哪 / 有没有在行之间叠间距」在单测里**量不到**。M102 r1 的教训正是这个:
//! 护栏只喂 `frame_metrics()` 与常量重算,把 `frame::body` 换回「居中 424」的实现,
//! 测试照样全绿——钉住的是公式,不是摆法。
//!
//! 本仓已有同一条纪律的先例:`tests/no_hardcoded_tokens.rs` 直接扫 `src/**`,
//! `pages/download/tests.rs::every_download_key_is_referenced_by_the_page` 用 `include_str!`
//! 扫页面源码找 key 引用。这里只是把「切出某个函数」这件小事做成一个共用工具,
//! 让护栏能断言「盒子是不是照那个数建的」。
//!
//! ## 边界(它抓不住什么)
//!
//! 它读的是**文本**:换个写法(先赋值给局部变量再 `.width(...)`)就可能绕过。
//! 所以它只配一条**明确的契约**用:`frame::body` / `frame::prose` / `list_column` 这几处的
//! 写法本身就是契约的一部分(它们各自只有 5~10 行),不是为了抠实现细节。
//! 真要守像素,还是得靠软件渲染金图(`shot::diff`,绑 Windows + 字体栈,属另一个决定)。
/// 把 `source` 里第一个 `signature` 起始、到该函数**顶层**右花括号为止的文本切出来。
///
/// 「顶层」= 行首的 `}`(本仓函数都是顶格收尾,内层块都是缩进收尾),所以不会在内层 `}` 提前断。
///
/// # Panics
/// 签名不存在时 panic(护栏自己的前置条件坏了要立刻红,不能静默放行)。
pub(crate) fn function_body<'a>(source: &'a str, signature: &str) -> &'a str {
let start = source
.find(signature)
.unwrap_or_else(|| panic!("源码里找不到签名 {signature:?}"));
let rest = &source[start..];
let end = rest.find("\n}").map_or(rest.len(), |index| index + 2);
&rest[..end]
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,48 @@
//! `source` 自己的断言:切得准、切得住(门禁的门禁)。
//!
//! 口径与 `tests/no_hardcoded_tokens.rs::the_gate_itself_catches_a_planted_violation` 一致:
//! 护栏工具自己先被一条断言钉住,否则它退化时没人发现。
use super::*;
const SAMPLE: &str = "\
/// doc
pub fn alpha() -> u8 {
let inner = |x: u8| {
x
};
inner(1)
}
pub fn beta<'a>(value: &'a str) -> &'a str {
value
}
";
#[test]
fn it_stops_at_the_top_level_closing_brace() {
let alpha = function_body(SAMPLE, "pub fn alpha");
assert!(alpha.starts_with("pub fn alpha"));
assert!(alpha.trim_end().ends_with('}'), "{alpha}");
// 内层闭包的花括号不会提前截断:`inner(1)` 与它的 `};` 都在
assert!(alpha.contains("inner(1)"), "{alpha}");
assert!(!alpha.contains("pub fn beta"), "切到了下一个函数:{alpha}");
let beta = function_body(SAMPLE, "pub fn beta<'a>(");
assert!(beta.contains("value"), "{beta}");
assert!(!beta.contains("inner("), "{beta}");
}
#[test]
fn the_helper_also_works_on_signatures_with_generics_and_multiline_params() {
let source = "pub fn gamma<'a>(\n metrics: FrameMetrics,\n content: u8,\n) -> u8 {\n content\n}\n";
let gamma = function_body(source, "pub fn gamma");
assert!(gamma.contains("metrics: FrameMetrics"), "{gamma}");
assert!(gamma.trim_end().ends_with('}'), "{gamma}");
}
#[test]
#[should_panic(expected = "找不到签名")]
fn a_missing_signature_fails_loudly() {
let _ = function_body(SAMPLE, "pub fn nope");
}
+5
View File
@@ -71,6 +71,11 @@ pub fn breakable(text: &str) -> String {
/// 去掉 [`breakable`] 插进去的软断点(还原成数据原文)。
///
/// 只给测试与调试用:页面渲染时不需要还原(原文从未被改写)。
///
/// **限界(r1 复审指出)**:它删的是**所有** U+200B,所以原文自带的零宽空格也会被一并删掉
/// (`strip(breakable(x)) == x` 只在「x 本身不含 U+200B」时成立;实测样例里没有这种串)。
/// 这个函数因此只配「与 [`breakable`] 成对使用」的场景,不能当通用的原文还原器用——
/// 产品侧从不调用它,剪贴板/视图模型拿的本来就是没动过的原文。
pub fn strip(text: &str) -> String {
text.chars().filter(|ch| *ch != SOFT_BREAK).collect()
}
@@ -81,3 +81,22 @@ fn breaking_is_reversible_and_idempotent() {
assert_eq!(breakable(&broken), broken, "幂等:再跑一遍不许再插");
}
}
/// **已知限界**(r1 复审 P2):`strip` 删的是**所有** U+200B,原文自带的也会被删。
///
/// 这条不修(产品侧从不调用 `strip`,剪贴板/模型拿的是未改写的原文),但要把行为钉住:
/// 将来谁想拿 `strip` 当通用还原器,这里会告诉他为什么不行。
#[test]
fn strip_is_not_a_general_unbreaker() {
let original_with_zwsp = "a\u{200b}b";
assert_eq!(
breakable(original_with_zwsp),
original_with_zwsp,
"没有分隔符就不插"
);
assert_eq!(
strip(original_with_zwsp),
"ab",
"strip 会连同原文自带的 U+200B 一起删(已知限界,不是 bug 修复点)"
);
}
+75 -6
View File
@@ -81,14 +81,15 @@ fn the_width_comparison_really_changes_the_layout() {
assert!(narrow.secondary.is_none(), "700 不许再有列表列");
}
/// **M102 的对齐判据**:正文块左缘 = 页头标题左缘,块宽 = 可用宽度(不再二次居中)。
/// **M102 的对齐判据**:正文块左缘 = 页头标题左缘(`body_offset == 0`),块宽 = pane 宽(封顶 1440)。
///
/// 页头标题的左缘 = `rail + gutter`(`frame::header_bar` 的 padding 与 `frame::page` 的
/// 内容 padding 是同一个 `metrics.gutter`);正文块宽 = `frame::body_width`。
/// 页头标题的左缘 = `rail + gutter`(`frame::header_bar` 的 padding 与 `frame::page` 的内容
/// padding 是同一个 `metrics.gutter`);正文块宽 = `frame::body_width`。
///
/// **抓得住**:谁把单列页的块宽改回 424(视觉复审 ①:左缘 422 对标题 112),
/// 或把块重新居中(左缘立刻偏离 `rail + gutter`);
/// **抓不住**:真实像素(`pages_demo` 的图与报告里的量测表负责)。
/// **抓得住**:块宽被改回 424(视觉复审 ①:左缘 422 对标题 112)、偏移不再是 0(居中/右对齐)、
/// 上限被挪动;
/// **抓不住**:`frame::body` 到底有没有照这两个数建盒子(那由
/// [`frame_body_is_built_from_the_block_quantities`] 扫源码守)。
#[test]
fn body_block_starts_at_the_header_title_and_fills_the_page() {
for width in [600.0, 840.0, 900.0, 1180.0] {
@@ -100,6 +101,11 @@ fn body_block_starts_at_the_header_title_and_fills_the_page() {
block, metrics.available,
"{width}: 单列页块宽必须是可用宽度"
);
assert_eq!(
layout::block::body_offset(metrics),
0.0,
"{width}: 块必须贴左"
);
assert!(
(title_x + block + metrics.gutter - width).abs() < f32::EPSILON,
"{width}: 块从 {title_x} 起、宽 {block},没铺到右页边距"
@@ -113,12 +119,75 @@ fn body_block_starts_at_the_header_title_and_fills_the_page() {
assert_eq!(layout::rail_width() + wide.gutter, 112.0, "页头标题左缘");
assert_eq!(frame::body_width(wide), 1044.0, "正文块宽度");
// 极宽窗:块宽封顶 1440(tower 拍板),仍然贴左 ⇒ 右侧留白而不是居中
let huge = frame_metrics(PaneKit::Single, 2560.0);
assert_eq!(frame::body_width(huge), layout::block::BODY_MAX_WIDTH);
assert_eq!(frame::body_width(huge), 1440.0);
assert_eq!(layout::block::body_offset(huge), 0.0);
assert!(112.0 + frame::body_width(huge) < 2560.0);
// 两列版面的块宽 = 主列 pane(实例页正文在 panes 的主列里,不铺整页)
let two = frame_metrics(PaneKit::ListDetail, 1180.0);
assert_eq!(frame::body_width(two), two.main_pane);
assert_eq!(two.main_pane, 1180.0 - 88.0 - 48.0 - 336.0 - 24.0);
}
/// **源码结构**:`frame::body` 的盒子必须从「块宽 + 块偏移 + 贴左」这三个数建。
///
/// 为什么读源码:iced 的 `Element` 没有几何内省接口,`frame::body` 换成
/// `centered(metrics.column, content)`(**就是视觉复审 ① 的改前写法**)不会红任何数值断言 ——
/// M102 r1 的变异实测就是这样漏掉的(`pages/tests.rs` 那条只喂 `frame_metrics()` 与
/// `body_width()`)。所以这条盯的是**摆法**:宽度来自 `body_width(metrics)`、左内边距来自
/// `body_offset(metrics)`、对齐 `Start`,且不出现居中/散文列宽。
///
/// **抓得住**:`body()` 被换回居中、`align_x` 改成 `Center`、宽度改回 `metrics.column`;
/// **抓不住**:绕过这段契约的重写(先取局部变量再建盒)——它守的是这 8 行的写法契约。
#[test]
fn frame_body_is_built_from_the_block_quantities() {
let body = layout::source::function_body(include_str!("frame.rs"), "pub fn body<'a>(");
for needle in [
"body_width(metrics)",
"body_offset(metrics)",
"Alignment::Start",
] {
assert!(
body.contains(needle),
"`frame::body` 必须从 {needle} 建盒(贴左 + 块宽 + 零偏移):\n{body}"
);
}
for forbidden in [
"centered(",
"single_column(",
"metrics.column",
"Alignment::Center",
] {
assert!(
!body.contains(forbidden),
"`frame::body` 里出现了 {forbidden:?} —— 那是居中 / 散文列宽的老写法:\n{body}"
);
}
}
/// **源码结构**:`frame::prose` 的宽度必须来自 `prose_width`(= `metrics.column`),不来自块宽。
///
/// M102 把两个量拆开后,「把散文塞进 1044 的块」不会红任何数值断言;这条盯接线。
#[test]
fn frame_prose_is_built_from_the_prose_width() {
let prose = layout::source::function_body(include_str!("frame.rs"), "pub fn prose<'a>(");
assert!(
prose.contains("prose_width(metrics)"),
"`frame::prose` 必须从 prose_width 取宽:\n{prose}"
);
for forbidden in ["body_width(", "metrics.available", "metrics.main_pane"] {
assert!(
!prose.contains(forbidden),
"`frame::prose` 里出现了 {forbidden:?} —— 散文会失去 72 字符行宽承诺:\n{prose}"
);
}
}
/// **M84 的行宽红线**:任何页、任何窗口宽度、任何版面,正文列都不许超过 424px。
///
/// **抓得住**:`frame_metrics` 的 `column`(页面唯一的列宽来源)不会越界;