Files
Unlucky-Launcher/crates/design/tests/tokext_pins.rs
T
AskaEth 16bdad1b98 fix(design): M69 r1 —— SEARCH_BAR 内边距语义收口 + ComponentRadius 取值口上闸
P2-1(语义缝隙):`SEARCH_BAR` 的 pad_start / pad_end 原为 16,与
icon_offset = 4 按字面相加得 20,比官方图标可视边 16 多 4px(越过 rubric
±2px 容差);而被加数的 12 不在 CM 里,消费方只能自己写 12.0 —— 正是 G10
要消灭的字面量。改为 12(文本框内容内边距,Space::X3)+ 4,两侧同式:
图标可视边 = pad + icon_offset = 16。字段文档写明求和公式;测试钉死
pad + offset == 16 且 pad != 16(防有人把 16 写回 pad 再叠加)。
顺带给 SEARCH_BAR.gap 补 [估] 出处(官方无图标↔文字 token)。

P2-2(绕过路径):ComponentRadius::dp() 收成 pub(crate)。它不看尺寸、
无条件返回 2.0,公开等于把 D32 的尺寸闸交给自觉。对外唯一取值口是
radius_px(size) -> Option<f32>;ALL 只暴露变体名、不暴露数值。
tokext_pins 原用 dp(),改为紧邻的 radius_px(20.0) == Some(2.0),覆盖不丢。

顺手:修掉本轮新引入的 rustdoc warning(component_map 文档链到私有模块
metrics,改指 ComponentMetrics);Divider 行标注 motion/duration 为占位,
并在 ComponentSpec 的 text / motion 字段文档写明「无文字 / 无动效的组件
这里是占位值,消费方不得据此排版 / 排动效」。

门禁:test 165 全绿、fmt --check、clippy -D warnings、check --workspace
--all-targets、doc(warning 2 → 1,只剩既有的 typography→emphasized 一条)。
2026-11-01 17:05:19 +08:00

298 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! M69(TOKEXT)的数值钉死:G9~G13 五项缺口逐条关门。
//!
//! 期望值全部**独立于实现重写**(照 `research/15` §18~§23 与 `00-decision-log.md` D32/D33 抄),
//! 实现改一个数、这里不改就红。覆盖:
//! - **G13** `ComponentRadius` 白名单 + 尺寸闸(2 不在 `ShapeScale` 里,刻度不许被加宽);
//! - **G11** `LayoutMetrics` 的 4 个新骨架值 + nav 语义修正;
//! - **G10** `ComponentMetrics` 逐字段;
//! - **G9** `Component` 25 个变体与档位表一行对一行;
//! - **G12** 一像素线单一真源。
use unlucky_design::component_map::{Component, ComponentMetrics};
use unlucky_design::elevation::Elevation;
use unlucky_design::icons::IconSize;
use unlucky_design::shape::{radius_layer_for, ComponentRadius, RadiusLayer, ShapeScale};
use unlucky_design::spacing::{stroke, LayoutMetrics, Space};
use unlucky_design::typography::TypeRole;
// ---------- G13:圆角分层(D32) ----------
#[test]
fn shape_scale_is_unchanged_and_never_contains_two() {
// 刻度一个字都不许动(D32 第 1 条:ShapeScale 保持严格)。
let ladder: [(ShapeScale, f32); 9] = [
(ShapeScale::None, 0.0),
(ShapeScale::ExtraSmall, 4.0),
(ShapeScale::Small, 8.0),
(ShapeScale::Medium, 12.0),
(ShapeScale::Large, 16.0),
(ShapeScale::LargeIncreased, 20.0),
(ShapeScale::ExtraLarge, 28.0),
(ShapeScale::ExtraLargeIncreased, 32.0),
(ShapeScale::ExtraExtraLarge, 48.0),
];
for (scale, dp) in ladder {
assert_eq!(scale.dp(), dp, "{scale:?} 的刻度被改动了");
// 2 是官方 Checkbox 的圆角,但它**不在**刻度里——加进来就等于废掉 K1 守门。
assert_ne!(dp, 2.0, "2dp 不许进 ShapeScale(走 ComponentRadius)");
}
assert_eq!(ShapeScale::Full.radius_px(64.0), 32.0);
}
#[test]
fn component_radius_is_a_one_item_whitelist() {
assert_eq!(ComponentRadius::ALL, [ComponentRadius::Micro]);
// 2dp 只能通过带尺寸闸的 `radius_px` 拿到(`dp()` 是 crate 内部口,外部看不见)。
assert_eq!(
ComponentRadius::Micro.radius_px(20.0),
Some(2.0),
"官方 Checkbox RadiusSize = 2.dp"
);
// 尺寸闸:>24dp 一律拿不到微半径(D32 的「只允许 ≤24dp 微控件」)。
assert_eq!(ComponentRadius::Micro.radius_px(24.0), Some(2.0));
assert_eq!(ComponentRadius::Micro.radius_px(25.0), None);
assert_eq!(ComponentRadius::Micro.radius_px(240.0), None);
assert_eq!(ComponentRadius::Micro.radius_px(0.0), None);
// 边长小于两倍半径时按边长一半折算(2dp 元素不能有 2dp 圆角)。
assert_eq!(ComponentRadius::Micro.radius_px(2.0), Some(1.0));
}
#[test]
fn radius_layer_for_routes_by_size() {
assert_eq!(radius_layer_for(20.0), RadiusLayer::Component);
assert_eq!(radius_layer_for(24.0), RadiusLayer::Component);
assert_eq!(radius_layer_for(28.0), RadiusLayer::Scale);
assert_eq!(radius_layer_for(360.0), RadiusLayer::Scale);
// 抽屉/面板这类容器永远不该被判成微控件。
for container in [
LayoutMetrics::DESKTOP.nav_drawer_width,
LayoutMetrics::DESKTOP.ai_panel_width,
] {
assert_eq!(radius_layer_for(container), RadiusLayer::Scale);
}
}
// ---------- G11:骨架值 + nav 语义修正 ----------
#[test]
fn layout_metrics_new_skeleton_values_are_pinned() {
let m = LayoutMetrics::DESKTOP;
// A 级:SearchBarTokens.ContainerHeight = 56。
assert_eq!(m.search_bar_height, 56.0);
// A 级:NavigationDrawer 下限 240(官方 ContainerWidth = 360,1180 窗下过宽,D33 取 240)。
assert_eq!(m.nav_drawer_width, 240.0);
// 12b §5.1 登记偏离:官方 rail 96 → 本项目 88。
assert_eq!(m.nav_rail_width, 88.0);
// A 级:DividerTokens.Thickness = 1.0dp(一像素线,不受 4px 栅格约束)。
assert_eq!(m.divider_thickness, 1.0);
}
#[test]
fn nav_widths_mean_what_their_names_say() {
// D33:展开(drawer)比折叠(rail)宽;旧字段 nav_expanded_width=88 / nav_collapsed_width=48
// 的名字与值相反,已在 M69 改名并删除,这里钉住「新名字不会再次反向」。
let m = LayoutMetrics::DESKTOP;
assert!(
m.nav_drawer_width > m.nav_rail_width,
"展开态(drawer {})必须比折叠态(rail {})宽",
m.nav_drawer_width,
m.nav_rail_width
);
// rail 里 56 宽的选中指示器要放得下(88 − 2×16)。
assert_eq!(m.nav_rail_width - Space::X4.dp() * 2.0, 56.0);
// drawer 里 216 宽的指示器同样由「宽 − 2×12」推出(见 ComponentMetrics::indicator_width)。
assert_eq!(
ComponentMetrics::NAVIGATION_DRAWER.indicator_width(m.nav_drawer_width),
216.0
);
}
// ---------- G10:组件级微度量 ----------
#[test]
fn checkbox_metrics_are_pinned() {
let cm = ComponentMetrics::CHECKBOX;
// 20×20(不是 token 文件里那个死值 18,见 research/15 §26.2 矛盾 ①)。
assert_eq!((cm.width, cm.height), (20.0, 20.0));
assert_eq!(
cm.radius,
Some(ComponentRadius::Micro),
"圆角 2dp 走 D32 第二层"
);
assert_eq!(cm.stroke, 2.0, "CheckboxTokens.UnselectedOutlineWidth = 2");
assert_eq!(cm.state_layer, 40.0, "StateLayerSize = 40");
assert_eq!(cm.inner_dot, 0.0);
assert_eq!(cm.gap, Space::X2.dp(), "框 ↔ 标签 8(团队口径)");
assert_eq!(
Component::Checkbox.metrics(),
Some(ComponentMetrics::CHECKBOX)
);
}
#[test]
fn radio_metrics_are_pinned() {
let cm = ComponentMetrics::RADIO;
assert_eq!(
(cm.width, cm.height),
(20.0, 20.0),
"RadioButtonTokens.IconSize = 20"
);
assert_eq!(cm.stroke, 2.0, "RadioStrokeWidth = 2.dp");
// 内点 10 不是 12:12 是含描边前的名义值(绘制半径 = 6 − 1 = 5)。
assert_eq!(cm.inner_dot, 10.0);
assert_eq!(cm.state_layer, 40.0);
assert_eq!(cm.radius, None, "radio 是真圆,圆角走 ShapeScale::Full");
assert_eq!(Component::Radio.spec().shape, ShapeScale::Full);
}
#[test]
fn search_bar_metrics_are_pinned() {
let cm = ComponentMetrics::SEARCH_BAR;
// 高 56 属骨架级:CM 里必须留 0,不许重复一份。
assert_eq!((cm.width, cm.height), (0.0, 0.0));
assert_eq!(cm.icon, IconSize::Toolbar.px(), "leading/trailing 图标 24");
// A 级(§20.4):图标可视左缘 16 = 文本框内容内边距 12 + `SearchBarIconOffsetX = 4`。
assert_eq!(cm.pad_start, Space::X3.dp(), "内容内边距 12");
assert_eq!(cm.icon_offset, 4.0, "SearchBarIconOffsetX = 4");
assert_eq!(cm.pad_start + cm.icon_offset, 16.0, "图标可视左缘必须是 16");
// 右侧同式:图标右缘也是 16。
assert_eq!(cm.pad_end, Space::X3.dp());
assert_eq!(cm.pad_end + cm.icon_offset, 16.0, "图标可视右缘必须是 16");
// 守门:不许把 pad 本身写成 16(否则 pad + offset = 20,越过 rubric 的 ±2px)。
assert_ne!(cm.pad_start, 16.0);
assert_ne!(cm.pad_end, 16.0);
// 搜索条圆角 = 高/2 = 28,等于官方 SearchBarCornerRadius。
assert_eq!(
ShapeScale::Full.radius_px(LayoutMetrics::DESKTOP.search_bar_height),
28.0
);
}
#[test]
fn navigation_drawer_metrics_are_pinned() {
let cm = ComponentMetrics::NAVIGATION_DRAWER;
assert_eq!((cm.width, cm.height), (0.0, 0.0), "宽/项高属骨架级");
assert_eq!(
cm.inset, 12.0,
"NavigationDrawerItemDefaults.ItemPadding = 12"
);
assert_eq!(cm.pad_start, Space::X4.dp(), "项内 start 16");
assert_eq!(cm.pad_end, Space::X6.dp(), "项内 end 24");
assert_eq!(cm.icon, IconSize::Toolbar.px());
// 与 search bar 同一条公式:图标边 = pad + icon_offset(抽屉的 offset 是 0)。
assert_eq!(cm.pad_start + cm.icon_offset, 16.0, "图标起点 = 项内 start");
assert_eq!(cm.gap, Space::X3.dp(), "图标 ↔ label 12");
// 指示器宽 = 容器宽 − 24;官方 360 − 24 = 336,本项目 240 − 24 = 216。
assert_eq!(cm.indicator_width(360.0), 336.0);
assert_eq!(cm.indicator_width(240.0), 216.0);
}
#[test]
fn component_metrics_never_duplicate_layout_metrics() {
// 同一量只许一份:CM 里出现的骨架级尺寸必须留 0。
let m = LayoutMetrics::DESKTOP;
for component in [Component::Search, Component::NavigationDrawer] {
let cm = component.metrics().expect("这两个控件必须有 CM");
assert_eq!(cm.width, 0.0, "{} 的宽度属 LM", component.name());
assert_eq!(cm.height, 0.0, "{} 的高度属 LM", component.name());
assert_ne!(cm.height, m.search_bar_height);
assert_ne!(cm.width, m.nav_drawer_width);
}
// Divider 的厚度也是 LM(一像素线)→ 它没有 CM。
assert_eq!(Component::Divider.metrics(), None);
assert_eq!(m.divider_thickness, stroke::HAIRLINE);
}
// ---------- G12:一像素线 ----------
#[test]
fn hairline_is_the_single_source_of_one_pixel_lines() {
assert_eq!(stroke::HAIRLINE, 1.0);
// 分隔线厚度直接引用它,不是另写一个字面量。
assert_eq!(LayoutMetrics::DESKTOP.divider_thickness, stroke::HAIRLINE);
// 一像素线不受 4px 栅格约束(栅格测试里显式豁免,这里反向钉一次)。
assert_ne!(stroke::HAIRLINE % unlucky_design::spacing::GRID, 0.0);
}
// ---------- G9:枚举 25 变体 ↔ 表槽位 ----------
#[test]
fn component_enum_is_the_frozen_twenty_five() {
let names = [
"top-app-bar",
"navigation-item",
"primary-button",
"secondary-button",
"text-button",
"icon-button",
"outlined-card",
"filled-card",
"list-item",
"text-field",
"segmented-button",
"chip",
"switch",
"slider",
"progress-indicator",
"dialog",
"menu",
"snackbar",
"tooltip",
"empty-state",
// M69 末尾追加的 5 个
"checkbox",
"radio",
"search",
"navigation-drawer",
"divider",
];
assert_eq!(Component::ALL.len(), names.len(), "M66 的 G9:20 → 25");
for (i, component) in Component::ALL.iter().enumerate() {
assert_eq!(component.name(), names[i], "第 {i} 个组件被换位/改名了");
// 表槽位与枚举判别值一一对应(`spec()` 就是按下标取的)。
assert_eq!(*component as usize, i);
assert_eq!(component.spec().component, *component);
}
}
#[test]
fn new_components_carry_the_official_elevation_and_shape() {
// Checkbox / Radio / Search / NavigationDrawer / Divider:全部 L0(都不是浮层)。
for component in [
Component::Checkbox,
Component::Radio,
Component::Search,
Component::NavigationDrawer,
Component::Divider,
] {
assert_eq!(
component.spec().elevation,
Elevation::Level0,
"{} 不该有阴影(R4:只有浮层允许)",
component.name()
);
}
// Checkbox 的圆角真源是 micro_radius,shape 只是占位 None。
let checkbox = Component::Checkbox.spec();
assert_eq!(checkbox.micro_radius, Some(ComponentRadius::Micro));
assert_eq!(checkbox.shape, ShapeScale::None);
// 其余 4 个不碰微半径层。
for component in [
Component::Radio,
Component::Search,
Component::NavigationDrawer,
Component::Divider,
] {
assert_eq!(component.spec().micro_radius, None, "{}", component.name());
}
// NavigationDrawer = CornerLargeEnd 的内侧角 16;Divider 无圆角。
assert_eq!(Component::NavigationDrawer.spec().shape, ShapeScale::Large);
assert_eq!(Component::Divider.spec().shape, ShapeScale::None);
// Checkbox / Radio 的标签字级是团队口径(官方无「框 + 标签」token)。
assert_eq!(Component::Checkbox.spec().text, TypeRole::BodyLarge);
assert_eq!(Component::Radio.spec().text, TypeRole::BodyLarge);
// Search 的字级:输入 BodyLarge、占位/辅助 BodySmall。
assert_eq!(Component::Search.spec().text, TypeRole::BodyLarge);
assert_eq!(Component::Search.spec().support, Some(TypeRole::BodySmall));
}