225 lines
10 KiB
Rust
225 lines
10 KiB
Rust
//! `instances` 的断言:演示数据、窄窗退化,以及**页面摆法**(源码结构断言)。
|
||
use super::*;
|
||
|
||
/// M120 的文案守门(中文无英文词 / 切语言真的变 / 页面里不许写死英文案·白名单)。
|
||
mod i18n;
|
||
|
||
use super::super::layout::source;
|
||
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass};
|
||
|
||
/// **M102 几何判据**:列表可视区正好放下整数行——末行不会被卡片底边切半行。
|
||
///
|
||
/// 算式里的三个页面真源**直接引用**(不写字面量),所以改页面常量会让这条立刻红:
|
||
/// [`LIST_BOTTOM_INSET`](滚动区底部内缩)、[`ROW_SPACING`](页级行距,`None` = 0)。
|
||
///
|
||
/// ```text
|
||
/// 卡片高 = 760 − 标题栏 48 − 两行页头 84 − 2×gutter 24 = 580
|
||
/// 列头 = 上 12 + LabelMedium 16 + 下 4 = 32
|
||
/// 内缩 = LIST_BOTTOM_INSET = Space::X2 = 8
|
||
/// 可视区 = 580 − 32 − 8 = 540
|
||
/// 行高 = ListItem 两行档 44 + 16 = 60
|
||
/// pitch = 行高 + ROW_SPACING(None ⇒ 0) = 60
|
||
/// 整行数 = 540 / 60 = 9 行整
|
||
/// ```
|
||
///
|
||
/// **抓得住**:页面把内缩换档(`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;
|
||
use unlucky_design::typography::TypeRole;
|
||
|
||
let lm = LayoutMetrics::DESKTOP;
|
||
let gutter = WidthClass::Expanded.gutter();
|
||
|
||
let card = 760.0 - lm.titlebar_height - lm.header_height(true) - 2.0 * gutter;
|
||
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 - LIST_BOTTOM_INSET.dp();
|
||
assert_eq!(viewport, 540.0, "列表滚动区高度必须以页面的内缩真源算");
|
||
|
||
// 页级行距的**唯一真源**:None = 这一层不加间距
|
||
let gap = ROW_SPACING.map_or(0.0, Space::dp);
|
||
let row = lm.row_height + Space::X4.dp();
|
||
let pitch = row + gap;
|
||
assert_eq!(pitch, 60.0, "行节奏 = ListItem 两行档 60(不留页级行距)");
|
||
assert_eq!(
|
||
viewport % pitch,
|
||
0.0,
|
||
"可视区 {viewport} 不是 {pitch} 的整数倍 ⇒ 末行会被切半行(ROW_SPACING={ROW_SPACING:?})"
|
||
);
|
||
|
||
// 同时必须还能滚:12 行内容高过可视区,截图里才看得到独立滚动条
|
||
let content = DEMO.len() as f32 * pitch;
|
||
assert!(
|
||
content > viewport,
|
||
"列表内容 {content} 不比可视区 {viewport} 高,截图里看不到独立滚动条"
|
||
);
|
||
}
|
||
|
||
/// **源码结构**:`list_column()` 里对行容器的间距**只能**来自 [`ROW_SPACING`]。
|
||
///
|
||
/// 为什么读源码:变异实测(M102 r1)把 `.spacing(ui.space(Space::X1))` 加回行容器后,
|
||
/// 几何断言照样绿——它算的是常量,看不见页面又叠了一层。iced 的 Element 又没有内省接口,
|
||
/// 所以只能断言这段代码的形状。
|
||
///
|
||
/// ## 两层判据(r4 复审的 M4 订正)
|
||
///
|
||
/// | 层 | 判据 | 抓的是 |
|
||
/// |---|---|---|
|
||
/// | ① 构建链 | `let mut rows = column(…);` 这条语句链上不许有 `.spacing(` | 直接写在链上的间距(变异 D) |
|
||
/// | ② 语句序列 | `list_column()` 里**每一处** `rows.spacing(…)` 的实参都要**顺绑定追回 `ROW_SPACING`** | **另起一条语句**再叠间距(变异 M4,含「先把令牌挪进一条 `let`」的写法) |
|
||
///
|
||
/// 为什么②要追绑定而不是比字面:合法写法有几种形状——`rows = rows.spacing(ui.space(spacing))`
|
||
/// 直接取自 `if let Some(spacing) = ROW_SPACING`,或先 `let spacing = ROW_SPACING;` 再中转
|
||
/// (变异 H3)。`layout/source.rs::derived_from` 顺 `let` / `Some(…) =` 两种绑定往回追最多三跳,
|
||
/// 于是「真源」放行、「中转了一层 `Space::X1`」判否。
|
||
///
|
||
/// **抓得住**:构建链上的字面量间距(D)、另起语句的行距(M4)、`ROW_SPACING` / `LIST_BOTTOM_INSET`
|
||
/// 两个真源被摘掉;`column![head, scroller]` 上的合法间距不受影响(变异 J,绿);
|
||
/// **抓不住**:把间距算进别处再以非令牌形式传进 `list_row()`(那要改组件几何,代码评审能看出来)、
|
||
/// 以及绕过 `rows.spacing(…)` 这个调用形状的写法。
|
||
#[test]
|
||
fn list_column_spaces_rows_only_from_the_shared_constant() {
|
||
let file = include_str!("../instances.rs");
|
||
let body = source::function_region(file, "fn list_column");
|
||
let rows_chain = source::statement(file, "let mut rows = column(");
|
||
|
||
assert!(
|
||
body.contains("ROW_SPACING"),
|
||
"列表行距必须从 ROW_SPACING 取,不许另写:\n{body}"
|
||
);
|
||
assert!(
|
||
body.contains("LIST_BOTTOM_INSET"),
|
||
"底部内缩必须从 LIST_BOTTOM_INSET 取:\n{body}"
|
||
);
|
||
|
||
// ① 行容器的构建链上不许叠间距(`column![head, scroller]` 上的合法间距不在这条链里)
|
||
assert!(
|
||
!rows_chain.contains(".spacing("),
|
||
"行容器的构建链上又出现了间距(M102 ② 的页级附加量):\n{rows_chain}"
|
||
);
|
||
|
||
// ② 语句序列:每一处 `rows.spacing(…)` 的实参都必须**顺绑定追回 `ROW_SPACING`**
|
||
// (直接用它、或 `let spacing = ROW_SPACING` 再中转都算;先 `let extra = Space::X1;`
|
||
// 再 `rows.spacing(ui.space(extra))` 则红——r4 复审的变异 M4)
|
||
let applied = source::call_arguments(&body, "rows.spacing(");
|
||
assert!(
|
||
!applied.is_empty(),
|
||
"`list_column()` 里没有把 ROW_SPACING 应用到行容器的调用:\n{body}"
|
||
);
|
||
for argument in &applied {
|
||
let ident = argument
|
||
.split_once("ui.space(")
|
||
.map(|(_, rest)| rest)
|
||
.unwrap_or(argument);
|
||
let ident: String = ident
|
||
.chars()
|
||
.take_while(|ch| ch.is_alphanumeric() || *ch == '_')
|
||
.collect();
|
||
|
||
assert!(
|
||
argument.contains("ui.space("),
|
||
"行容器的间距实参必须走令牌栅格(`ui.space(…)`),收到 {argument:?}"
|
||
);
|
||
assert!(
|
||
!argument.contains("Space::"),
|
||
"行容器的间距实参里直接写了令牌档位:{argument:?}\n{body}"
|
||
);
|
||
assert!(
|
||
source::derived_from(&body, &ident, "ROW_SPACING"),
|
||
"行容器的间距不是从 ROW_SPACING 来的(`{ident}` 追不到 ROW_SPACING)——\
|
||
M102 ② 的页级附加量不许另起一条语句塞回来:{argument:?}\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() {
|
||
use super::super::layout::{measure_cap_px, META_CARD_MIN_WIDTH, META_GAP};
|
||
use unlucky_design::spacing::LayoutMetrics;
|
||
|
||
// 实测(Segoe UI 14px):demo 数据里最宽的元信息值是加载器版本串
|
||
const LONGEST_VALUE: &str = "NeoForge 20.4.237";
|
||
const LONGEST_VALUE_PX: f32 = 118.0;
|
||
|
||
let padding = LayoutMetrics::DESKTOP.card_padding;
|
||
assert_eq!(padding, 20.0, "卡内边距来自令牌");
|
||
|
||
// 夹具与 demo 数据不许脱节:这个值必须真在 DEMO 里
|
||
assert!(
|
||
DEMO.iter().any(|entry| entry.loader == LONGEST_VALUE),
|
||
"夹具 {LONGEST_VALUE:?} 已经不在 DEMO 里了,请重新量最长值"
|
||
);
|
||
|
||
// ① 单卡外框减掉两侧内边距,必须放得下这条值(否则 text 会按词折行 → 卡底不齐)
|
||
let inner = META_CARD_MIN_WIDTH - 2.0 * padding;
|
||
assert!(
|
||
inner >= LONGEST_VALUE_PX,
|
||
"卡内可用 {inner}px 装不下 {LONGEST_VALUE_PX}px 的 {LONGEST_VALUE:?}(外框 {META_CARD_MIN_WIDTH} − 2×{padding})"
|
||
);
|
||
|
||
// ② 实际版面里每张卡的内宽(2 列时)也要够
|
||
let two_columns = (measure_cap_px() - META_GAP) / 2.0;
|
||
assert!(
|
||
two_columns - 2.0 * padding >= LONGEST_VALUE_PX,
|
||
"两列布局下卡内可用 {}px 装不下 {LONGEST_VALUE_PX}px",
|
||
two_columns - 2.0 * padding
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn every_demo_entry_has_name_version_and_loader() {
|
||
for entry in &DEMO {
|
||
assert!(!entry.name.is_empty());
|
||
assert!(!entry.version.is_empty());
|
||
assert!(!entry.loader.is_empty());
|
||
}
|
||
assert!(SELECTED < DEMO.len());
|
||
}
|
||
|
||
#[test]
|
||
fn detail_paragraph_is_long_enough_to_wrap() {
|
||
// 行宽上限 ≈ 72 字符/行;写够几行才看得出断行位置
|
||
assert!(NOTE.len() > 200, "正文档太短,看不出行宽上限");
|
||
}
|
||
|
||
#[test]
|
||
fn narrow_widths_switch_the_page_to_one_column() {
|
||
// 1180 / 900 → 两列;700 → 单列(整页切换)
|
||
assert_eq!(
|
||
frame_metrics(PaneKit::ListDetail, 1180.0).layout,
|
||
PageLayout::ListDetail
|
||
);
|
||
assert_eq!(
|
||
frame_metrics(PaneKit::ListDetail, 700.0).layout,
|
||
PageLayout::SingleColumn
|
||
);
|
||
// 窄窗下详情不吃满窗口:正文列仍 ≤ 行宽上限
|
||
let narrow = frame_metrics(PaneKit::ListDetail, 700.0);
|
||
assert!(narrow.column <= measure_cap_px() + f32::EPSILON);
|
||
assert!(narrow.secondary.is_none());
|
||
}
|