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

+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 修复点)"
);
}