G13(D32 分层):新增 shape::ComponentRadius(白名单只有 Micro=2)、 MICRO_CONTROL_MAX_SIZE=24 与纯函数 radius_layer_for();微半径走 radius_px(size) -> Option<f32>,尺寸 >24dp 一律 None,靠签名而不是自觉拦。 ShapeScale 与 rank() 一个字未动,2 不进刻度。 G11:LayoutMetrics 加 search_bar_height=56 / nav_rail_width=88 / nav_drawer_width=240 / divider_thickness=1;删除语义相反的 nav_expanded_width=88 / nav_collapsed_width=48(D33:展开 drawer 240 / 折叠 rail 88;值 88 的后继是新名字 nav_rail_width,48 无后继)。 G10:新增 component_map::ComponentMetrics(12 字段 + 4 个命名常量 + indicator_width),入口 Component::metrics();框内几何与 LayoutMetrics 的 骨架值分工写进模块文档,骨架级尺寸在 CM 里留 0 不许重复。 G9:Component 末尾追加 Checkbox / Radio / Search / NavigationDrawer / Divider(20 -> 25),同步 table.rs 槽位、ALL 与 name();ComponentSpec 新增 micro_radius(Some 时 shape 必须是占位 None,由构造器 + 测试钉死)。 G12:新增 spacing::stroke::HAIRLINE = 1.0(crate 根再导出为 unlucky_design::stroke),divider_thickness 直接引用它。 其余:lib.rs 的「OKLCh 近似」文案改回真实口径(真 HCT / ColorSpec2021); component_map 单测拆到 tests.rs 以免 mod.rs 超 400 行;测试 145 -> 165。
288 lines
11 KiB
Rust
288 lines
11 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]);
|
||
assert_eq!(
|
||
ComponentRadius::Micro.dp(),
|
||
2.0,
|
||
"官方 Checkbox RadiusSize = 2.dp"
|
||
);
|
||
// 尺寸闸:>24dp 一律拿不到微半径(D32 的「只允许 ≤24dp 微控件」)。
|
||
assert_eq!(ComponentRadius::Micro.radius_px(20.0), Some(2.0));
|
||
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);
|
||
}
|
||
|
||
#[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");
|
||
assert_eq!(cm.icon_offset, 4.0, "SearchBarIconOffsetX = 4");
|
||
// 可视左缘 16 = 文本框默认 12 + 偏移 4。
|
||
assert_eq!(cm.pad_start, Space::X4.dp());
|
||
assert_eq!(cm.pad_end, Space::X4.dp());
|
||
// 搜索条圆角 = 高/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());
|
||
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));
|
||
}
|