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 一条)。
298 lines
12 KiB
Rust
298 lines
12 KiB
Rust
//! 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));
|
||
}
|