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:
1 parent
89d98184c1
commit
824bb36f8e
11 files changed
+469
-67
No files matched your search
@@ -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");
|
||||
}
|
||||
@@ -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 修复点)"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user